diff --git a/README.md b/README.md index 02769e6..9a9c39e 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Copilot Bridge lets you access your personal Copilot session locally through an - SSE streaming for incremental responses - Real-time model discovery via VS Code Language Model API - 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 --- @@ -73,35 +73,51 @@ The author collects no data and has no access to user prompts or completions. ### Installation 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”. -3. Check status anytime with “Copilot Bridge: Status”. -4. Keep VS Code open — the bridge runs only while the editor is active. +2. Set **Copilot Bridge › Token** to a secret value (Settings UI or JSON). Requests without this token receive `401 Unauthorized`. +3. Open the **Command Palette** → “Copilot Bridge: Enable” to start the bridge. +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 -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="" +``` List models: + ```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: + ```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"}]}' \ http://127.0.0.1:$PORT/v1/chat/completions ``` Use with OpenAI SDK: + ```ts 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({ 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({ @@ -128,13 +144,15 @@ Responses stream back via SSE with concurrency controls for editor stability. |----------|----------|-------------| | `bridge.enabled` | false | Start automatically with VS Code | | `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.maxConcurrent` | 1 | Max concurrent requests | | `bridge.verbose` | false | Enable verbose logging | > ℹ️ 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 @@ -151,14 +169,15 @@ Responses stream back via SSE with concurrency controls for editor stability. > Never expose the endpoint to external networks. - Loopback-only binding (non-configurable) -- Optional bearer token enforcement +- Mandatory bearer token gating (requests rejected without the correct header) - No persistent storage or telemetry --- ## 🧾 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.0.0** – Modular core, OpenAI typings, tool-calling support - **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. For compliance or takedown inquiries, please open a GitHub issue. ---- \ No newline at end of file +--- diff --git a/package.json b/package.json index a070344..43c3729 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "icon": "images/icon.png", "name": "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", "publisher": "thinkability", "repository": { @@ -18,6 +18,12 @@ "extensionKind": [ "ui" ], + "capabilities": { + "untrustedWorkspaces": { + "supported": false + }, + "virtualWorkspaces": false + }, "categories": [ "AI" ], @@ -46,7 +52,7 @@ "bridge.enabled": { "type": "boolean", "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": { "type": "number", @@ -56,18 +62,18 @@ "bridge.token": { "type": "string", "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": { "type": "number", "default": 3, - "minimum": 0, "description": "Number of user/assistant turns to include (system message is kept separately)." }, "bridge.maxConcurrent": { "type": "number", "default": 1, "minimum": 1, + "maximum": 4, "description": "Maximum concurrent /v1/chat/completions requests. Excess requests return 429." }, "bridge.verbose": { diff --git a/src/extension.ts b/src/extension.ts index 11cf00b..45737fb 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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 { 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 { 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 { } finally { state.server = undefined; state.modelCache = undefined; - if (state.statusBarItem) { - state.statusBarItem.text = 'Copilot Bridge: Disabled'; - } + updateStatus('disabled'); verbose('Stopped'); } } diff --git a/src/http/auth.ts b/src/http/auth.ts index 9a14786..a5bf49b 100644 --- a/src/http/auth.ts +++ b/src/http/auth.ts @@ -9,8 +9,12 @@ let cachedAuthHeader = ''; * Caches the full "Bearer " 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; diff --git a/src/http/server.ts b/src/http/server.ts index 9a3a27a..b2739ea 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -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 => { 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; } diff --git a/src/http/utils.ts b/src/http/utils.ts index 437e59d..9b4d84d 100644 --- a/src/http/utils.ts +++ b/src/http/utils.ts @@ -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). */ diff --git a/src/status.ts b/src/status.ts index d519cde..57ee940 100644 --- a/src/status.ts +++ b/src/status.ts @@ -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 `.'); + } 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); };