Mandatory authorization: Always use a bearer token

This commit is contained in:
Lars Baunwall 2025-10-05 14:00:04 +02:00
parent 11e0b9cb37
commit 778e93cfc1
No known key found for this signature in database
7 changed files with 147 additions and 31 deletions

View file

@ -18,7 +18,7 @@ Copilot Bridge lets you access your personal Copilot session locally through an
- SSE streaming for incremental responses - SSE streaming for incremental responses
- Real-time model discovery via VS Code Language Model API - Real-time model discovery via VS Code Language Model API
- Concurrency and rate limits to keep VS Code responsive - Concurrency and rate limits to keep VS Code responsive
- Optional bearer token authentication - Mandatory bearer token authentication with `HTTP 401 Unauthorized` protection
- Lightweight Polka-based server integrated directly with the VS Code runtime - Lightweight Polka-based server integrated directly with the VS Code runtime
--- ---
@ -73,35 +73,51 @@ The author collects no data and has no access to user prompts or completions.
### Installation ### Installation
1. Install from the [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=thinkability.copilot-bridge) or load the `.vsix`. 1. Install from the [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=thinkability.copilot-bridge) or load the `.vsix`.
2. Launch VS Code and open the **Command Palette** → “Copilot Bridge: Enable”. 2. Set **Copilot Bridge Token** to a secret value (Settings UI or JSON). Requests without this token receive `401 Unauthorized`.
3. Check status anytime with “Copilot Bridge: Status”. 3. Open the **Command Palette** → “Copilot Bridge: Enable” to start the bridge.
4. Keep VS Code open — the bridge runs only while the editor is active. 4. Check status anytime with “Copilot Bridge: Status” or by hovering the status bar item (it links directly to the token setting when missing).
5. Keep VS Code open — the bridge runs only while the editor is active.
--- ---
## 📡 Using the Bridge ## 📡 Using the Bridge
Replace `PORT` with the one shown in “Copilot Bridge: Status”. Replace `PORT` with the one shown in “Copilot Bridge: Status”. Use the same token value you configured in VS Code:
```bash
export PORT=12345 # Replace with the port from the status command
export BRIDGE_TOKEN="<your-copilot-bridge-token>"
```
List models: List models:
```bash ```bash
curl http://127.0.0.1:$PORT/v1/models curl -H "Authorization: Bearer $BRIDGE_TOKEN" \
http://127.0.0.1:$PORT/v1/models
``` ```
Stream a completion: Stream a completion:
```bash ```bash
curl -N -H "Content-Type: application/json" \ curl -N \
-H "Authorization: Bearer $BRIDGE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-copilot","messages":[{"role":"user","content":"hello"}]}' \ -d '{"model":"gpt-4o-copilot","messages":[{"role":"user","content":"hello"}]}' \
http://127.0.0.1:$PORT/v1/chat/completions http://127.0.0.1:$PORT/v1/chat/completions
``` ```
Use with OpenAI SDK: Use with OpenAI SDK:
```ts ```ts
import OpenAI from "openai"; import OpenAI from "openai";
if (!process.env.BRIDGE_TOKEN) {
throw new Error("Set BRIDGE_TOKEN to the same token configured in VS Code settings (bridge.token).");
}
const client = new OpenAI({ const client = new OpenAI({
baseURL: `http://127.0.0.1:${process.env.PORT}/v1`, baseURL: `http://127.0.0.1:${process.env.PORT}/v1`,
apiKey: process.env.BRIDGE_TOKEN || "unused", apiKey: process.env.BRIDGE_TOKEN,
}); });
const rsp = await client.chat.completions.create({ const rsp = await client.chat.completions.create({
@ -128,13 +144,15 @@ Responses stream back via SSE with concurrency controls for editor stability.
|----------|----------|-------------| |----------|----------|-------------|
| `bridge.enabled` | false | Start automatically with VS Code | | `bridge.enabled` | false | Start automatically with VS Code |
| `bridge.port` | 0 | Ephemeral port | | `bridge.port` | 0 | Ephemeral port |
| `bridge.token` | "" | Optional bearer token | | `bridge.token` | "" | Bearer token required for every request (leave empty to block API access) |
| `bridge.historyWindow` | 3 | Retained conversation turns | | `bridge.historyWindow` | 3 | Retained conversation turns |
| `bridge.maxConcurrent` | 1 | Max concurrent requests | | `bridge.maxConcurrent` | 1 | Max concurrent requests |
| `bridge.verbose` | false | Enable verbose logging | | `bridge.verbose` | false | Enable verbose logging |
> The bridge always binds to `127.0.0.1` and cannot be exposed to other interfaces. > The bridge always binds to `127.0.0.1` and cannot be exposed to other interfaces.
> 💡 Hover the status bar item to confirm the token status; missing tokens show a warning link that opens the relevant setting.
--- ---
## 🪶 Logging & Diagnostics ## 🪶 Logging & Diagnostics
@ -151,14 +169,15 @@ Responses stream back via SSE with concurrency controls for editor stability.
> Never expose the endpoint to external networks. > Never expose the endpoint to external networks.
- Loopback-only binding (non-configurable) - Loopback-only binding (non-configurable)
- Optional bearer token enforcement - Mandatory bearer token gating (requests rejected without the correct header)
- No persistent storage or telemetry - No persistent storage or telemetry
--- ---
## 🧾 Changelog ## 🧾 Changelog
- **v1.2.0** Locked the HTTP server to localhost for improved safety - **v1.2.0** Authentication token now mandatory; status bar hover warns when missing
- **v1.1.1** Locked the HTTP server to localhost for improved safety
- **v1.1.0** Performance improvements (~30%) - **v1.1.0** Performance improvements (~30%)
- **v1.0.0** Modular core, OpenAI typings, tool-calling support - **v1.0.0** Modular core, OpenAI typings, tool-calling support
- **v0.2.2** Polka integration, improved model family selection - **v0.2.2** Polka integration, improved model family selection
@ -179,4 +198,4 @@ Apache 2.0 © 2025 [Lars Baunwall]
Independent project — not affiliated with GitHub or Microsoft. Independent project — not affiliated with GitHub or Microsoft.
For compliance or takedown inquiries, please open a GitHub issue. For compliance or takedown inquiries, please open a GitHub issue.
--- ---

View file

@ -3,7 +3,7 @@
"icon": "images/icon.png", "icon": "images/icon.png",
"name": "copilot-bridge", "name": "copilot-bridge",
"displayName": "Copilot Bridge", "displayName": "Copilot Bridge",
"description": "Local OpenAI-compatible chat endpoint (inference) bridging to GitHub Copilot via the VS Code Language Model API.", "description": "Local OpenAI-compatible interface built on the public VS Code Language Model API (vscode.lm).",
"version": "1.2.0", "version": "1.2.0",
"publisher": "thinkability", "publisher": "thinkability",
"repository": { "repository": {
@ -18,6 +18,12 @@
"extensionKind": [ "extensionKind": [
"ui" "ui"
], ],
"capabilities": {
"untrustedWorkspaces": {
"supported": false
},
"virtualWorkspaces": false
},
"categories": [ "categories": [
"AI" "AI"
], ],
@ -46,7 +52,7 @@
"bridge.enabled": { "bridge.enabled": {
"type": "boolean", "type": "boolean",
"default": false, "default": false,
"description": "Start the Copilot Bridge automatically when VS Code starts." "description": "Start the Copilot Bridge automatically when VS Code starts. Uses only the public `vscode.lm` Language Model API."
}, },
"bridge.port": { "bridge.port": {
"type": "number", "type": "number",
@ -56,18 +62,18 @@
"bridge.token": { "bridge.token": {
"type": "string", "type": "string",
"default": "", "default": "",
"description": "Optional bearer token required in Authorization header. Leave empty to disable." "description": "Bearer token required in every Authorization header. Leave empty to block access."
}, },
"bridge.historyWindow": { "bridge.historyWindow": {
"type": "number", "type": "number",
"default": 3, "default": 3,
"minimum": 0,
"description": "Number of user/assistant turns to include (system message is kept separately)." "description": "Number of user/assistant turns to include (system message is kept separately)."
}, },
"bridge.maxConcurrent": { "bridge.maxConcurrent": {
"type": "number", "type": "number",
"default": 1, "default": 1,
"minimum": 1, "minimum": 1,
"maximum": 4,
"description": "Maximum concurrent /v1/chat/completions requests. Excess requests return 429." "description": "Maximum concurrent /v1/chat/completions requests. Excess requests return 429."
}, },
"bridge.verbose": { "bridge.verbose": {

View file

@ -3,7 +3,7 @@ import type { AddressInfo } from 'net';
import { getBridgeConfig } from './config'; import { getBridgeConfig } from './config';
import { state } from './state'; import { state } from './state';
import { ensureOutput, verbose } from './log'; import { ensureOutput, verbose } from './log';
import { ensureStatusBar } from './status'; import { ensureStatusBar, updateStatus } from './status';
import { startServer, stopServer } from './http/server'; import { startServer, stopServer } from './http/server';
import { getModel } from './models'; import { getModel } from './models';
@ -31,10 +31,27 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
const config = getBridgeConfig(); const config = getBridgeConfig();
const hasToken = config.token.length > 0; const hasToken = config.token.length > 0;
vscode.window.showInformationMessage( 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(); const config = getBridgeConfig();
if (config.enabled) { if (config.enabled) {
await startBridge(); await startBridge();
@ -52,7 +69,8 @@ async function startBridge(): Promise<void> {
await startServer(); await startServer();
} catch (error) { } catch (error) {
state.running = false; state.running = false;
state.statusBarItem!.text = 'Copilot Bridge: Error'; state.lastReason = 'startup_failed';
updateStatus('error', { suppressLog: true });
if (error instanceof Error) { if (error instanceof Error) {
verbose(error.stack || error.message); verbose(error.stack || error.message);
} else { } else {
@ -70,9 +88,7 @@ async function stopBridge(): Promise<void> {
} finally { } finally {
state.server = undefined; state.server = undefined;
state.modelCache = undefined; state.modelCache = undefined;
if (state.statusBarItem) { updateStatus('disabled');
state.statusBarItem.text = 'Copilot Bridge: Disabled';
}
verbose('Stopped'); verbose('Stopped');
} }
} }

View file

@ -9,8 +9,12 @@ let cachedAuthHeader = '';
* Caches the full "Bearer <token>" header to optimize hot path. * Caches the full "Bearer <token>" header to optimize hot path.
*/ */
export const isAuthorized = (req: IncomingMessage, token: string): boolean => { export const isAuthorized = (req: IncomingMessage, token: string): boolean => {
if (!token) return true; if (!token) {
cachedToken = '';
cachedAuthHeader = '';
return false;
}
// Update cache if token changed // Update cache if token changed
if (token !== cachedToken) { if (token !== cachedToken) {
cachedToken = token; cachedToken = token;

View file

@ -6,7 +6,7 @@ import { isAuthorized } from './auth';
import { handleHealthCheck } from './routes/health'; import { handleHealthCheck } from './routes/health';
import { handleModelsRequest } from './routes/models'; import { handleModelsRequest } from './routes/models';
import { handleChatCompletion } from './routes/chat'; 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 { ensureOutput, verbose } from '../log';
import { updateStatus } from '../status'; import { updateStatus } from '../status';
@ -36,7 +36,15 @@ export const startServer = async (): Promise<void> => {
if (path === '/health') { if (path === '/health') {
return next(); 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); writeUnauthorized(res);
return; return;
} }

View file

@ -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({ const NOT_FOUND_ERROR = JSON.stringify({
error: { error: {
message: 'not found', message: 'not found',
@ -49,6 +57,11 @@ export const writeUnauthorized = (res: ServerResponse): void => {
res.end(UNAUTHORIZED_ERROR); 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). * Fast-path not found response (pre-serialized).
*/ */

View file

@ -1,42 +1,92 @@
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { AddressInfo } from 'net'; import { AddressInfo } from 'net';
import { state } from './state'; import { state } from './state';
import { getBridgeConfig } from './config'; import { LOOPBACK_HOST, getBridgeConfig } from './config';
import { info } from './log'; 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 => { export const ensureStatusBar = (): void => {
if (!state.statusBarItem) { if (!state.statusBarItem) {
state.statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); state.statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
state.statusBarItem.text = 'Copilot Bridge: Disabled'; state.statusBarItem.text = 'Copilot Bridge: Disabled';
state.statusBarItem.command = 'bridge.status';
state.statusBarItem.show(); 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 cfg = getBridgeConfig();
const addr = state.server?.address() as AddressInfo | null; 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; if (!state.statusBarItem) return;
let statusLabel: string;
switch (kind) { switch (kind) {
case 'start': { case 'start': {
const availability = state.modelCache ? 'OK' : (state.modelAttempted ? 'Unavailable' : 'Pending'); const availability = state.modelCache ? 'OK' : (state.modelAttempted ? 'Unavailable' : 'Pending');
state.statusBarItem.text = `Copilot Bridge: ${availability} @ ${shown}`; 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; break;
} }
case 'error': case 'error':
state.statusBarItem.text = `Copilot Bridge: Unavailable @ ${shown}`; state.statusBarItem.text = `Copilot Bridge: Unavailable @ ${shown}`;
statusLabel = 'Unavailable';
break; break;
case 'success': case 'success':
state.statusBarItem.text = `Copilot Bridge: OK @ ${shown}`; state.statusBarItem.text = `Copilot Bridge: OK @ ${shown}`;
statusLabel = 'OK';
break;
case 'disabled':
state.statusBarItem.text = 'Copilot Bridge: Disabled';
statusLabel = 'Disabled';
break; break;
default: default:
// Exhaustive check in case of future extension // Exhaustive check in case of future extension
const _never: never = kind; const _never: never = kind;
return _never; return _never;
} }
state.statusBarItem.tooltip = buildTooltip(statusLabel, shown, tokenConfigured, state.lastReason);
}; };