mirror of
https://github.com/larsbaunwall/vscode-copilot-bridge.git
synced 2025-10-05 22:22:59 +00:00
Mandatory authorization: Always use a bearer token
This commit is contained in:
parent
11e0b9cb37
commit
778e93cfc1
7 changed files with 147 additions and 31 deletions
|
|
@ -3,7 +3,7 @@ import type { AddressInfo } from 'net';
|
|||
import { getBridgeConfig } from './config';
|
||||
import { state } from './state';
|
||||
import { ensureOutput, verbose } from './log';
|
||||
import { ensureStatusBar } from './status';
|
||||
import { ensureStatusBar, updateStatus } from './status';
|
||||
import { startServer, stopServer } from './http/server';
|
||||
import { getModel } from './models';
|
||||
|
||||
|
|
@ -31,10 +31,27 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
|
|||
const config = getBridgeConfig();
|
||||
const hasToken = config.token.length > 0;
|
||||
vscode.window.showInformationMessage(
|
||||
`Copilot Bridge: ${state.running ? 'Enabled' : 'Disabled'} | Bound: ${bound} | Token: ${hasToken ? 'Set' : 'None'}`
|
||||
`Copilot Bridge: ${state.running ? 'Enabled' : 'Disabled'} | Bound: ${bound} | Token: ${hasToken ? 'Set (required)' : 'Missing (requests will 401)'}`
|
||||
);
|
||||
}));
|
||||
|
||||
ctx.subscriptions.push(vscode.workspace.onDidChangeConfiguration((event) => {
|
||||
if (!event.affectsConfiguration('bridge.token')) {
|
||||
return;
|
||||
}
|
||||
if (!state.statusBarItem) {
|
||||
return;
|
||||
}
|
||||
const kind: 'start' | 'error' | 'success' | 'disabled' = !state.running
|
||||
? 'disabled'
|
||||
: state.modelCache
|
||||
? 'success'
|
||||
: state.modelAttempted
|
||||
? 'error'
|
||||
: 'start';
|
||||
updateStatus(kind, { suppressLog: true });
|
||||
}));
|
||||
|
||||
const config = getBridgeConfig();
|
||||
if (config.enabled) {
|
||||
await startBridge();
|
||||
|
|
@ -52,7 +69,8 @@ async function startBridge(): Promise<void> {
|
|||
await startServer();
|
||||
} catch (error) {
|
||||
state.running = false;
|
||||
state.statusBarItem!.text = 'Copilot Bridge: Error';
|
||||
state.lastReason = 'startup_failed';
|
||||
updateStatus('error', { suppressLog: true });
|
||||
if (error instanceof Error) {
|
||||
verbose(error.stack || error.message);
|
||||
} else {
|
||||
|
|
@ -70,9 +88,7 @@ async function stopBridge(): Promise<void> {
|
|||
} finally {
|
||||
state.server = undefined;
|
||||
state.modelCache = undefined;
|
||||
if (state.statusBarItem) {
|
||||
state.statusBarItem.text = 'Copilot Bridge: Disabled';
|
||||
}
|
||||
updateStatus('disabled');
|
||||
verbose('Stopped');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,12 @@ let cachedAuthHeader = '';
|
|||
* Caches the full "Bearer <token>" header to optimize hot path.
|
||||
*/
|
||||
export const isAuthorized = (req: IncomingMessage, token: string): boolean => {
|
||||
if (!token) return true;
|
||||
|
||||
if (!token) {
|
||||
cachedToken = '';
|
||||
cachedAuthHeader = '';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update cache if token changed
|
||||
if (token !== cachedToken) {
|
||||
cachedToken = token;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { isAuthorized } from './auth';
|
|||
import { handleHealthCheck } from './routes/health';
|
||||
import { handleModelsRequest } from './routes/models';
|
||||
import { handleChatCompletion } from './routes/chat';
|
||||
import { writeErrorResponse, writeNotFound, writeRateLimit, writeUnauthorized } from './utils';
|
||||
import { writeErrorResponse, writeNotFound, writeRateLimit, writeTokenRequired, writeUnauthorized } from './utils';
|
||||
import { ensureOutput, verbose } from '../log';
|
||||
import { updateStatus } from '../status';
|
||||
|
||||
|
|
@ -36,7 +36,15 @@ export const startServer = async (): Promise<void> => {
|
|||
if (path === '/health') {
|
||||
return next();
|
||||
}
|
||||
if (!isAuthorized(req, config.token)) {
|
||||
const token = getBridgeConfig().token;
|
||||
if (!token) {
|
||||
if (config.verbose) {
|
||||
verbose('401 unauthorized: missing auth token');
|
||||
}
|
||||
writeTokenRequired(res);
|
||||
return;
|
||||
}
|
||||
if (!isAuthorized(req, token)) {
|
||||
writeUnauthorized(res);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@ const UNAUTHORIZED_ERROR = JSON.stringify({
|
|||
},
|
||||
});
|
||||
|
||||
const TOKEN_REQUIRED_ERROR = JSON.stringify({
|
||||
error: {
|
||||
message: 'auth token required',
|
||||
type: 'invalid_request_error',
|
||||
code: 'auth_token_required',
|
||||
},
|
||||
});
|
||||
|
||||
const NOT_FOUND_ERROR = JSON.stringify({
|
||||
error: {
|
||||
message: 'not found',
|
||||
|
|
@ -49,6 +57,11 @@ export const writeUnauthorized = (res: ServerResponse): void => {
|
|||
res.end(UNAUTHORIZED_ERROR);
|
||||
};
|
||||
|
||||
export const writeTokenRequired = (res: ServerResponse): void => {
|
||||
res.writeHead(401, JSON_HEADERS);
|
||||
res.end(TOKEN_REQUIRED_ERROR);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fast-path not found response (pre-serialized).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,42 +1,92 @@
|
|||
import * as vscode from 'vscode';
|
||||
import { AddressInfo } from 'net';
|
||||
import { state } from './state';
|
||||
import { getBridgeConfig } from './config';
|
||||
import { LOOPBACK_HOST, getBridgeConfig } from './config';
|
||||
import { info } from './log';
|
||||
|
||||
const formatEndpoint = (addr: AddressInfo | null, port: number): string => {
|
||||
if (addr) {
|
||||
const address = addr.address === '::' ? LOOPBACK_HOST : addr.address;
|
||||
return `${address}:${addr.port}`;
|
||||
}
|
||||
const normalizedPort = port === 0 ? 'auto' : port;
|
||||
return `${LOOPBACK_HOST}:${normalizedPort}`;
|
||||
};
|
||||
|
||||
const buildTooltip = (status: string, endpoint: string, tokenConfigured: boolean, reason?: string): vscode.MarkdownString => {
|
||||
const tooltip = new vscode.MarkdownString();
|
||||
tooltip.supportThemeIcons = true;
|
||||
tooltip.isTrusted = true;
|
||||
tooltip.appendMarkdown(`**Copilot Bridge**\n\n`);
|
||||
tooltip.appendMarkdown(`Status: ${status}\n\n`);
|
||||
tooltip.appendMarkdown(`Endpoint: \`http://${endpoint}\`\n\n`);
|
||||
|
||||
if (tokenConfigured) {
|
||||
tooltip.appendMarkdown('Auth token: ✅ configured. Requests must include `Authorization: Bearer <token>`.');
|
||||
} else {
|
||||
tooltip.appendMarkdown('Auth token: ⚠️ not configured — all API requests return **401 Unauthorized** until you set `bridge.token`.');
|
||||
tooltip.appendMarkdown('\n\n[Configure token](command:workbench.action.openSettings?%22bridge.token%22)');
|
||||
}
|
||||
|
||||
if (reason) {
|
||||
tooltip.appendMarkdown(`\n\nLast reason: \`${reason}\``);
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
};
|
||||
|
||||
export const ensureStatusBar = (): void => {
|
||||
if (!state.statusBarItem) {
|
||||
state.statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
|
||||
state.statusBarItem.text = 'Copilot Bridge: Disabled';
|
||||
state.statusBarItem.command = 'bridge.status';
|
||||
state.statusBarItem.show();
|
||||
updateStatus('disabled');
|
||||
}
|
||||
};
|
||||
|
||||
export type BridgeStatusKind = 'start' | 'error' | 'success';
|
||||
export type BridgeStatusKind = 'start' | 'error' | 'success' | 'disabled';
|
||||
|
||||
export const updateStatus = (kind: BridgeStatusKind): void => {
|
||||
interface UpdateStatusOptions {
|
||||
readonly suppressLog?: boolean;
|
||||
}
|
||||
|
||||
export const updateStatus = (kind: BridgeStatusKind, options: UpdateStatusOptions = {}): void => {
|
||||
const cfg = getBridgeConfig();
|
||||
const addr = state.server?.address() as AddressInfo | null;
|
||||
const shown = addr ? `${addr.address}:${addr.port}` : `${cfg.host}:${cfg.port}`;
|
||||
const shown = formatEndpoint(addr, cfg.port);
|
||||
const tokenConfigured = cfg.token.length > 0;
|
||||
|
||||
if (!state.statusBarItem) return;
|
||||
|
||||
let statusLabel: string;
|
||||
switch (kind) {
|
||||
case 'start': {
|
||||
const availability = state.modelCache ? 'OK' : (state.modelAttempted ? 'Unavailable' : 'Pending');
|
||||
state.statusBarItem.text = `Copilot Bridge: ${availability} @ ${shown}`;
|
||||
info(`Started at http://${shown} | Copilot: ${state.modelCache ? 'ok' : (state.modelAttempted ? 'unavailable' : 'pending')}`);
|
||||
if (!options.suppressLog) {
|
||||
info(`Started at http://${shown} | Copilot: ${state.modelCache ? 'ok' : (state.modelAttempted ? 'unavailable' : 'pending')}`);
|
||||
}
|
||||
statusLabel = availability;
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
state.statusBarItem.text = `Copilot Bridge: Unavailable @ ${shown}`;
|
||||
statusLabel = 'Unavailable';
|
||||
break;
|
||||
case 'success':
|
||||
state.statusBarItem.text = `Copilot Bridge: OK @ ${shown}`;
|
||||
statusLabel = 'OK';
|
||||
break;
|
||||
case 'disabled':
|
||||
state.statusBarItem.text = 'Copilot Bridge: Disabled';
|
||||
statusLabel = 'Disabled';
|
||||
break;
|
||||
default:
|
||||
// Exhaustive check in case of future extension
|
||||
const _never: never = kind;
|
||||
return _never;
|
||||
}
|
||||
|
||||
state.statusBarItem.tooltip = buildTooltip(statusLabel, shown, tokenConfigured, state.lastReason);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue