From b8853ab0d69cc5e99244448c607cb76a48bca1bb Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 5 May 2026 14:48:06 +0200 Subject: [PATCH] Add Command Code browser login fallback --- README.md | 28 ++++++--- index.ts | 2 +- package.json | 3 +- src/auth-server.ts | 105 +++++++++++++++++++++++++------ src/core.ts | 2 +- src/oauth.ts | 118 ++++++++++++++++++++++++++++------- tests/test-oauth.ts | 147 ++++++++++++++++++++++++++++++-------------- 7 files changed, 307 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index af110ed..53cd4a9 100644 --- a/README.md +++ b/README.md @@ -40,19 +40,33 @@ Then reload pi: Set your Command Code API key using one of these methods: -### 1. Environment variable +### 1. Browser login (recommended) -```sh -export COMMANDCODE_API_KEY="cc-..." +In pi, run: + +```txt +/login ``` -### 2. Auth file (recommended) +Then select **Command Code** from the provider list. + +This opens Command Code in your browser and stores the returned API key in pi's auth file. If the browser shows "Copy your API key" because automatic transfer failed, copy that key and paste it into the pi terminal prompt. + +> Note: `/login commandcode` is not supported by pi currently; use interactive `/login` and select Command Code. + +### 2. Environment variable + +```sh +export COMMANDCODE_API_KEY="user_..." +``` + +### 3. Auth file Create `~/.commandcode/auth.json`: ```json { - "apiKey": "cc-..." + "apiKey": "user_..." } ``` @@ -60,7 +74,7 @@ Or use pi's auth file at `~/.pi/agent/auth.json`: ```json { - "commandcode": "cc-..." + "commandcode": "user_..." } ``` @@ -69,7 +83,7 @@ Or use pi's auth file at `~/.pi/agent/auth.json`: After installing and setting your API key, select a Command Code model in pi: ```txt -/model claude-sonnet-4-6 +/model deepseek/deepseek-v4-flash ``` Any query will then use the Command Code API. You can list available models: diff --git a/index.ts b/index.ts index a4a9745..c56fdef 100644 --- a/index.ts +++ b/index.ts @@ -4,7 +4,7 @@ * Connects pi to Command Code's API (https://api.commandcode.ai/alpha/generate). * * Authentication (pick one): - * 1. Run `/login commandcode` — opens browser to commandcode.ai, auto-stores API key + * 1. Run `/login`, then select Command Code — opens browser to commandcode.ai, auto-stores API key * 2. Set COMMANDCODE_API_KEY environment variable * 3. Place API key in `~/.commandcode/auth.json` or `~/.pi/agent/auth.json` * as {"apiKey": "user_..."} or {"commandcode": "user_..."} diff --git a/package.json b/package.json index 5d2d727..2757d5b 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,12 @@ "LICENSE" ], "scripts": { - "test": "npm run typecheck && tsx tests/test-pure-functions.ts && tsx tests/test-abort.ts && tsx tests/test-stream.ts && node tests/test-pi-local.mjs", + "test": "npm run typecheck && tsx tests/test-pure-functions.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-stream.ts && node tests/test-pi-local.mjs", "typecheck": "tsc --noEmit", "format:check": "prettier --check '**/*.{ts,mjs,json,md}'", "format": "prettier --write '**/*.{ts,mjs,json,md}'", "test:unit": "tsx tests/test-pure-functions.ts", + "test:oauth": "tsx tests/test-oauth.ts", "test:abort": "tsx tests/test-abort.ts", "test:stream": "tsx tests/test-stream.ts", "test:pi-local": "node tests/test-pi-local.mjs", diff --git a/src/auth-server.ts b/src/auth-server.ts index 5d5290d..e815c10 100644 --- a/src/auth-server.ts +++ b/src/auth-server.ts @@ -1,13 +1,16 @@ /** * Local HTTP callback server for the Command Code browser auth flow. * - * Starts a one-shot server on a random port. The Command Code Studio - * website POSTs the user's API key to /callback after they authenticate. + * Starts a one-shot server on a CLI-compatible localhost port. The Command Code + * Studio website POSTs the user's API key to /callback after they authenticate. */ import { createServer, type Server } from "node:http" import type { AddressInfo } from "node:net" +const DEFAULT_PORT = 5959 +const DEFAULT_PORT_RANGE = 10 + export interface AuthCallback { apiKey: string state: string @@ -22,15 +25,65 @@ export interface AuthServer { waitForCallback: Promise } +export interface AuthServerOptions { + startPort?: number + portRange?: number +} + +function listenOnAvailablePort( + server: Server, + startPort = DEFAULT_PORT, + range = DEFAULT_PORT_RANGE, +): Promise { + return new Promise((resolve, reject) => { + let offset = 0 + + const tryListen = () => { + const useFallbackPort = startPort === 0 || offset >= range + const port = useFallbackPort ? 0 : startPort + offset + + const onError = (err: NodeJS.ErrnoException) => { + server.off("listening", onListening) + if (err.code === "EADDRINUSE" && !useFallbackPort) { + offset += 1 + tryListen() + return + } + reject(err) + } + + const onListening = () => { + server.off("error", onError) + const address = server.address() as AddressInfo + resolve(address.port) + } + + server.once("error", onError) + server.once("listening", onListening) + server.listen(port, "127.0.0.1") + } + + tryListen() + }) +} + +function closeServer(server: Server) { + server.close((err: NodeJS.ErrnoException | undefined) => { + if (err && err.code !== "ERR_SERVER_NOT_RUNNING") { + // There is nowhere useful to report this during auth cleanup. + } + }) +} + /** * Start a local HTTP server that listens for the Command Code Studio * to POST the API key after the user authenticates in their browser. * * The server accepts exactly one valid POST to /callback and then closes. */ -export function startAuthServer(): Promise { - let resolveCallback: (value: AuthCallback) => void - let rejectCallback: (error: Error) => void +export async function startAuthServer(options: AuthServerOptions = {}): Promise { + let resolveCallback!: (value: AuthCallback) => void + let rejectCallback!: (error: Error) => void const waitForCallback = new Promise((resolve, reject) => { resolveCallback = resolve @@ -38,7 +91,7 @@ export function startAuthServer(): Promise { }) const server = createServer((req, res) => { - // CORS: allow requests from Command Code domains and localhost for dev + // CORS: allow requests from Command Code domains and localhost for dev. const origin = req.headers.origin || "" const allowedOrigins = [ "http://localhost:3000", @@ -46,13 +99,22 @@ export function startAuthServer(): Promise { "https://commandcode.ai", ] const responseOrigin = allowedOrigins.includes(origin) ? origin : allowedOrigins[0] + const requestedHeaders = req.headers["access-control-request-headers"] res.setHeader("Access-Control-Allow-Origin", responseOrigin) res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS") - res.setHeader("Access-Control-Allow-Headers", "Content-Type") + res.setHeader( + "Access-Control-Allow-Headers", + typeof requestedHeaders === "string" && requestedHeaders.length > 0 + ? requestedHeaders + : "Content-Type", + ) + // Chrome's Private Network Access preflight may require this for an HTTPS + // page posting to a localhost HTTP callback. + res.setHeader("Access-Control-Allow-Private-Network", "true") res.setHeader("Content-Type", "application/json") - // Handle CORS preflight + // Handle CORS preflight. if (req.method === "OPTIONS") { res.writeHead(204) res.end() @@ -98,7 +160,7 @@ export function startAuthServer(): Promise { } else { rejectCallback(new Error(description || String(parsed.error))) } - server.close() + closeServer(server) return } @@ -123,7 +185,7 @@ export function startAuthServer(): Promise { res.end(JSON.stringify({ success: true })) resolveCallback({ apiKey, state, userId, userName, keyName }) - server.close() + closeServer(server) } catch { res.writeHead(400) res.end(JSON.stringify({ success: false, error: "Invalid JSON" })) @@ -136,14 +198,17 @@ export function startAuthServer(): Promise { }) }) - return new Promise((resolve) => { - server.on("error", (err: NodeJS.ErrnoException) => { - rejectCallback(new Error(`Failed to start auth server: ${err.message}`)) - }) - - server.listen(0, "127.0.0.1", () => { - const address = server.address() as AddressInfo - resolve({ server, port: address.port, waitForCallback }) - }) - }) + try { + const port = await listenOnAvailablePort( + server, + options.startPort ?? DEFAULT_PORT, + options.portRange ?? DEFAULT_PORT_RANGE, + ) + return { server, port, waitForCallback } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + const error = new Error(`Failed to start auth server: ${message}`) + rejectCallback(error) + throw error + } } diff --git a/src/core.ts b/src/core.ts index 8d523ea..1373073 100644 --- a/src/core.ts +++ b/src/core.ts @@ -129,7 +129,7 @@ export function createStreamCommandCode(deps: CoreDependencies) { usage: defaultUsage(), stopReason: "error", errorMessage: - "No Command Code API key. Run /login commandcode, set COMMANDCODE_API_KEY env var, or configure ~/.commandcode/auth.json or ~/.pi/agent/auth.json.", + "No Command Code API key. Run /login and select Command Code, set COMMANDCODE_API_KEY env var, or configure ~/.commandcode/auth.json or ~/.pi/agent/auth.json.", timestamp: now(), } stream.push({ type: "error", reason: "error", error: msg }) diff --git a/src/oauth.ts b/src/oauth.ts index aa20e99..be77391 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -2,11 +2,12 @@ * Command Code OAuth provider for pi's /login flow. * * Implements a browser-assisted API key retrieval flow: - * 1. Starts a local HTTP server on a random port + * 1. Starts a local HTTP server on a Command Code CLI-compatible port * 2. Opens the Command Code Studio auth page in the browser * 3. The user authenticates on the Command Code website * 4. The website POSTs the API key back to the local server - * 5. The API key is stored in pi's auth.json as OAuth credentials + * 5. If browser transfer fails, the user can paste the API key manually + * 6. The API key is stored in pi's auth.json as OAuth credentials * * Since Command Code API keys don't expire, we store them as * OAuth credentials with a far-future expiry. @@ -17,6 +18,7 @@ import { startAuthServer } from "./auth-server.ts" const STUDIO_BASE_URL = "https://commandcode.ai" const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000 // API keys don't expire +const DEFAULT_AUTH_TIMEOUT_MS = 15_000 export interface OAuthLoginCallbacks { onAuth(params: { url: string }): void @@ -29,10 +31,76 @@ export interface OAuthCredentials { expires: number } +class AuthTimeoutError extends Error { + constructor() { + super("Browser authentication timed out") + this.name = "AuthTimeoutError" + } +} + function generateStateToken(): string { return randomBytes(32).toString("base64url") } +function getAuthTimeoutMs(): number { + const raw = process.env.COMMANDCODE_AUTH_TIMEOUT_MS + if (!raw) return DEFAULT_AUTH_TIMEOUT_MS + + const parsed = Number(raw) + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_AUTH_TIMEOUT_MS +} + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new AuthTimeoutError()), timeoutMs) + + promise.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (error) => { + clearTimeout(timer) + reject(error) + }, + ) + }) +} + +function credentialsFromApiKey(apiKey: string): OAuthCredentials { + return { + refresh: apiKey, + access: apiKey, + expires: Date.now() + TEN_YEARS_MS, + } +} + +/** + * Remove common terminal paste wrappers/control chars and surrounding whitespace. + */ +export function sanitizeApiKey(input: string): string { + const esc = String.fromCharCode(27) + return Array.from( + input + .replaceAll(`${esc}[200~`, "") + .replaceAll(`${esc}[201~`, "") + .replaceAll("[200~", "") + .replaceAll("[201~", ""), + ) + .filter((char) => { + const code = char.charCodeAt(0) + return code > 31 && code !== 127 + }) + .join("") + .trim() +} + +async function promptForApiKey(callbacks: OAuthLoginCallbacks, message: string) { + const apiKey = sanitizeApiKey(await callbacks.onPrompt({ message })) + if (!apiKey) throw new Error("No Command Code API key provided") + return credentialsFromApiKey(apiKey) +} + /** * Starts the browser-based login flow for Command Code. * @@ -40,37 +108,47 @@ function generateStateToken(): string { * The keys don't expire, so we set a far-future expiry. */ export async function login(callbacks: OAuthLoginCallbacks): Promise { - const authServer = await startAuthServer() + let authServer + try { + authServer = await startAuthServer() + } catch { + return promptForApiKey( + callbacks, + "Could not start browser auth. Paste your Command Code API key:", + ) + } + const stateToken = generateStateToken() + const callbackUrl = `http://localhost:${authServer.port}/callback` + const authUrl = `${STUDIO_BASE_URL}/studio/auth/cli?callback=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(stateToken)}` - const authUrl = `${STUDIO_BASE_URL}/studio/auth/cli?callback=${encodeURIComponent(`http://localhost:${authServer.port}/callback`)}&state=${encodeURIComponent(stateToken)}` - - // Tell pi to open the browser + // Tell pi to open the browser. callbacks.onAuth({ url: authUrl }) - // Wait for the Command Code Studio to POST the API key back + // Wait for the Command Code Studio to POST the API key back. If the browser + // cannot reach localhost (Command Code shows "Copy your API key"), fall back + // to pi's prompt so the user can paste the key from the browser. let callback: { apiKey: string; state: string } try { - callback = await authServer.waitForCallback + callback = await withTimeout(authServer.waitForCallback, getAuthTimeoutMs()) } catch (error) { - // Clean up server on error authServer.server.close() + if (error instanceof AuthTimeoutError) { + return promptForApiKey( + callbacks, + "Automatic transfer failed or timed out. Paste your Command Code API key:", + ) + } throw error } - // Validate state token to prevent CSRF + // Validate state token to prevent CSRF. if (callback.state !== stateToken) { authServer.server.close() throw new Error("State token mismatch. Authentication may have been tampered with.") } - // Return as OAuth credentials. Since CC API keys don't expire, - // we set a far-future expiry and use the API key as both access and refresh. - return { - refresh: callback.apiKey, - access: callback.apiKey, - expires: Date.now() + TEN_YEARS_MS, - } + return credentialsFromApiKey(callback.apiKey) } /** @@ -78,11 +156,7 @@ export async function login(callbacks: OAuthLoginCallbacks): Promise { - return { - refresh: credentials.refresh, - access: credentials.access, - expires: Date.now() + TEN_YEARS_MS, - } + return credentialsFromApiKey(credentials.refresh) } /** diff --git a/tests/test-oauth.ts b/tests/test-oauth.ts index 5fb5cef..0ff0fab 100644 --- a/tests/test-oauth.ts +++ b/tests/test-oauth.ts @@ -9,11 +9,24 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { startAuthServer, type AuthCallback } from "../src/auth-server.ts" -import { getApiKey, login, refreshToken } from "../src/oauth.ts" +import { getApiKey, login, refreshToken, sanitizeApiKey } from "../src/oauth.ts" + +/** + * Helper: wait for an HTTP server to close, or resolve immediately if already closed. + */ +function waitForClose(server: { + listening: boolean + on(event: "close", cb: () => void): void +}): Promise { + return new Promise((resolve) => { + if (!server.listening) return resolve(undefined) + server.on("close", resolve) + }) +} describe("startAuthServer()", () => { - it("starts on a random port and accepts a valid callback POST", async () => { - const { server, port, waitForCallback } = await startAuthServer() + it("starts on a localhost port and accepts a valid callback POST", async () => { + const { server, port, waitForCallback } = await startAuthServer({ startPort: 0 }) const callbackData: AuthCallback = { apiKey: "user_testKey123", @@ -26,10 +39,7 @@ describe("startAuthServer()", () => { // Simulate the Command Code Studio posting the API key back const response = await fetch(`http://127.0.0.1:${port}/callback`, { method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "https://commandcode.ai", - }, + headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" }, body: JSON.stringify(callbackData), }) @@ -44,47 +54,40 @@ describe("startAuthServer()", () => { assert.equal(result.userName, "Test User") assert.equal(result.keyName, "test-key") - // Server closes itself after callback; ensure it's done - await new Promise((resolve) => { - if (!server.listening) return resolve(undefined) - server.on("close", resolve) - }) + await waitForClose(server) }) it("rejects when the callback indicates access_denied", async () => { - const { server, port, waitForCallback } = await startAuthServer() + const { server, port, waitForCallback } = await startAuthServer({ startPort: 0 }) + + // Attach rejection handler before posting to avoid unhandled rejection + const errorPromise: Promise = waitForCallback.then( + () => { + throw new Error("Expected callback to reject") + }, + (e: Error) => e.message, + ) const response = await fetch(`http://127.0.0.1:${port}/callback`, { method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "https://commandcode.ai", - }, - body: JSON.stringify({ - error: "access_denied", - error_description: "User cancelled", - }), + headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" }, + body: JSON.stringify({ error: "access_denied", error_description: "User cancelled" }), }) assert.equal(response.status, 200) - await assert.rejects(() => waitForCallback, /User cancelled/) + const errorMsg = await errorPromise + assert.match(errorMsg, /User cancelled/) - await new Promise((resolve) => { - if (!server.listening) return resolve(undefined) - server.on("close", resolve) - }) + await waitForClose(server) }) it("returns 400 for missing required fields", async () => { - const { server, port } = await startAuthServer() + const { server, port } = await startAuthServer({ startPort: 0 }) const response = await fetch(`http://127.0.0.1:${port}/callback`, { method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "https://commandcode.ai", - }, + headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" }, body: JSON.stringify({ apiKey: "key", state: "s" }), }) @@ -94,22 +97,32 @@ describe("startAuthServer()", () => { server.close() }) - it("handles CORS preflight OPTIONS request", async () => { - const { server, port } = await startAuthServer() + it("handles CORS and private-network preflight OPTIONS request", async () => { + const { server, port } = await startAuthServer({ startPort: 0 }) const response = await fetch(`http://127.0.0.1:${port}/callback`, { method: "OPTIONS", - headers: { Origin: "https://commandcode.ai" }, + headers: { + Origin: "https://commandcode.ai", + "Access-Control-Request-Headers": "content-type,x-requested-with", + "Access-Control-Request-Private-Network": "true", + }, }) assert.equal(response.status, 204) + assert.equal(response.headers.get("access-control-allow-origin"), "https://commandcode.ai") + assert.equal( + response.headers.get("access-control-allow-headers"), + "content-type,x-requested-with", + ) + assert.equal(response.headers.get("access-control-allow-private-network"), "true") await new Promise((resolve) => setTimeout(resolve, 100)) server.close() }) it("returns 404 for non-callback paths", async () => { - const { server, port } = await startAuthServer() + const { server, port } = await startAuthServer({ startPort: 0 }) const response = await fetch(`http://127.0.0.1:${port}/other`, { method: "POST", @@ -124,7 +137,7 @@ describe("startAuthServer()", () => { }) it("returns 405 for GET on /callback", async () => { - const { server, port } = await startAuthServer() + const { server, port } = await startAuthServer({ startPort: 0 }) const response = await fetch(`http://127.0.0.1:${port}/callback`, { method: "GET", @@ -159,6 +172,10 @@ describe("OAuth functions", () => { assert.equal(result.refresh, "my-api-key") assert.ok(result.expires > Date.now(), "expiry should be in the future") }) + + it("sanitizeApiKey removes paste markers, control chars, and whitespace", () => { + assert.equal(sanitizeApiKey("\u001b[200~ user_manualKey\n\u001b[201~"), "user_manualKey") + }) }) describe("login()", () => { @@ -168,7 +185,7 @@ describe("login()", () => { onAuth(params: { url: string }) { authUrl = params.url }, - onPrompt(params: { message: string }): Promise { + onPrompt(_params: { message: string }): Promise { throw new Error("onPrompt should not be called in browser flow") }, } @@ -176,10 +193,13 @@ describe("login()", () => { // Start login in the background const loginPromise = login(callbacks) - // Verify the auth URL was passed to callbacks + // Wait for onAuth to be called (it fires asynchronously after the auth server starts) + while (!authUrl) await new Promise((resolve) => setTimeout(resolve, 10)) + + // Verify the auth URL was passed to callbacks (callback URL is encoded) assert.match( authUrl, - /^https:\/\/commandcode\.ai\/studio\/auth\/cli\?callback=http:\/\/localhost:\d+\/callback&state=/, + /^https:\/\/commandcode\.ai\/studio\/auth\/cli\?callback=http%3A%2F%2Flocalhost%3A\d+%2Fcallback&state=/, ) // Extract port and state from the URL @@ -214,18 +234,55 @@ describe("login()", () => { assert.ok(result.expires > Date.now(), "expiry should be far in the future") }) + it("prompts for a manual API key if browser transfer times out", async () => { + const originalTimeout = process.env.COMMANDCODE_AUTH_TIMEOUT_MS + process.env.COMMANDCODE_AUTH_TIMEOUT_MS = "1" + + let authUrl = "" + let promptMessage = "" + + try { + const result = await login({ + onAuth(params: { url: string }) { + authUrl = params.url + }, + async onPrompt(params: { message: string }): Promise { + promptMessage = params.message + return "\u001b[200~ user_manualApiKey\n\u001b[201~" + }, + }) + + assert.match(authUrl, /^https:\/\/commandcode\.ai\/studio\/auth\/cli\?/) + assert.match(promptMessage, /Paste your Command Code API key/) + assert.equal(result.access, "user_manualApiKey") + assert.equal(result.refresh, "user_manualApiKey") + assert.ok(result.expires > Date.now(), "expiry should be far in the future") + } finally { + if (originalTimeout === undefined) delete process.env.COMMANDCODE_AUTH_TIMEOUT_MS + else process.env.COMMANDCODE_AUTH_TIMEOUT_MS = originalTimeout + } + }) + it("rejects on state token mismatch", async () => { let authUrl = "" const callbacks = { onAuth(params: { url: string }) { authUrl = params.url }, - onPrompt(params: { message: string }): Promise { + onPrompt(_params: { message: string }): Promise { throw new Error("should not prompt") }, } - const loginPromise = login(callbacks) + const loginPromise: Promise = login(callbacks).then( + () => { + throw new Error("Expected login to reject") + }, + (e: Error) => e.message, + ) + + // Wait for onAuth to be called asynchronously + while (!authUrl) await new Promise((resolve) => setTimeout(resolve, 10)) const url = new URL(authUrl) const port = parseInt(url.searchParams.get("callback")?.match(/localhost:(\d+)/)?.[1] ?? "0") @@ -233,10 +290,7 @@ describe("login()", () => { // Post back with a wrong state token await fetch(`http://127.0.0.1:${port}/callback`, { method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "https://commandcode.ai", - }, + headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" }, body: JSON.stringify({ apiKey: "user_badState", state: "wrong-state-token", @@ -246,6 +300,7 @@ describe("login()", () => { }), }) - await assert.rejects(() => loginPromise, /State token mismatch/) + const errorMsg = await loginPromise + assert.match(errorMsg, /State token mismatch/) }) })