From 940066fa67a41388c2ceabb3a433c8acd3997f84 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Sun, 9 Aug 2026 05:39:07 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20CLI-style=20auth=20=E2=80=94=20open=20UR?= =?UTF-8?q?L,=20then=20paste=20Studio=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match `cmd login`: OAuth uses method "code" so OpenCode prompts for the key Studio shows after Authorize (or "ok" if localhost callback already succeeded). Remove the standalone "Enter API key" method. Harden the localhost callback CORS for Chrome Private Network Access. Co-authored-by: Serhii Dziupin --- README.md | 13 ++--- src/auth-login.ts | 123 +++++++++++++++++++++++++++++++++++++++++----- src/index.ts | 56 ++++----------------- test/smoke.ts | 89 +++++++++++++++++++++++++++++++++ 4 files changed, 214 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 8472fb0..0daddb2 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ opencode plugin file://$PWD ## Authenticate -Requires the plugin to be installed (see above). Preferred: **Command Code Go plan ($1/mo)** browser login (same as `cmd login`). You can also paste an API key / session token. +Requires the plugin to be installed (see above). Preferred: **Command Code Go plan ($1/mo)** browser login — same as `cmd login` (open URL → Authorize → paste the key Studio shows). ```bash # Option A — sync from Command Code CLI (recommended) @@ -74,13 +74,10 @@ cmd login # browser → Studio CLI auth (Go plan) opencode auth login --provider command-code # pick "Use existing cmd login session" -# Option B — browser OAuth (Go $1) +# Option B — browser login inside OpenCode (Go $1) opencode auth login --provider command-code # pick "Login with Command Code (Go $1)" - -# Option C — paste API key / session token -opencode auth login --provider command-code -# pick "Enter Command Code API key" and paste into the prompt field +# open the URL, Authorize, then paste the API key (or ok if the browser already finished) ``` Then start OpenCode, pick provider **command-code**, and choose any model from the live catalog (`cmd --list-models`). Laguna S 2.1 free is the default when available: @@ -98,7 +95,7 @@ Laguna S 2.1 free requires an active Go (or higher) account with credits on file | **Gateway proxy** | Talks to `api.commandcode.ai/alpha/generate` — same transport as the `cmd` CLI (`mode=agent`). | | **Live model catalog** | Discovered from `cmd --list-models` (enriched by the installed CLI’s models.md) — not hardcoded. | | **Laguna S 2.1 free** | Default when present — 256k context, $0 while the deal lasts. | -| **Auth options** | Go-plan browser OAuth, sync from `cmd login`, or paste an API key / session token. | +| **Auth options** | Go-plan browser login (URL → paste key, same as `cmd login`) or sync an existing CLI session. | | **Agent-grade tools** | OpenCode tool calls park and resume; MCP-prefixed tools are tracked separately. | | **Attachments** | Images, PDFs, text/binary files from OpenCode. Text-only models get image placeholders (Command Code behavior). | | **Auto-compact** | Context-fraction tips + tiered client compact before the 256k window overflows. | @@ -162,7 +159,7 @@ Local pin refresh after a release: ## FAQ **Do I need a Studio API key?** -No. Prefer Go-plan browser login or sync from `cmd login`. You can also use **Enter Command Code API key** if you already have a session token. +No. Use Go-plan browser login or sync from `cmd login`. After you Authorize in the browser, Studio may show a one-time key to paste into the OpenCode prompt — that key is part of the login flow, not a separate product API key. **Are only Laguna models available?** No. The plugin loads the full live catalog from `cmd --list-models`. Laguna is just the default free model used for testing. diff --git a/src/auth-login.ts b/src/auth-login.ts index 6f1de75..086ccc4 100644 --- a/src/auth-login.ts +++ b/src/auth-login.ts @@ -3,8 +3,9 @@ * * Browser flow matches `cmd login`: * https://commandcode.ai/studio/auth/cli?callback=http://localhost:PORT/callback&state=STATE - * Studio POSTs { apiKey, userId, userName, keyName, state } to the local callback. - * That session credential is what the $1 Go plan uses — no Studio API key needed. + * Studio POSTs { apiKey, userId, userName, keyName, state } to the local callback when + * reachable. If the POST fails, Studio shows the key to copy — paste it into OpenCode + * (`method: "code"`), same as the CLI's "Paste your API key..." field. */ import { createServer, type Server } from "node:http"; import { randomBytes } from "node:crypto"; @@ -58,12 +59,14 @@ const ALLOWED_CORS = [ "http://localhost:3000", "https://staging.commandcode.ai", "https://commandcode.ai", + "https://www.commandcode.ai", ]; let pending: PendingCommandLogin | null = null; let authServer: Server | null = null; let waitForCallback: Promise | null = null; let rejectCallback: ((err: Error) => void) | null = null; +let receivedPayload: BrowserCallbackPayload | null = null; function authJsonPath(): string { const xdg = process.env.XDG_DATA_HOME; @@ -218,6 +221,23 @@ export function resetPendingCommandLogin(): void { pending = null; waitForCallback = null; rejectCallback = null; + receivedPayload = null; +} + +function payloadToTokens(payload: BrowserCallbackPayload): CommandCodeAuthTokens { + return { + access: payload.apiKey, + key: payload.apiKey, + refresh: "browser-oauth", + expires: Date.now() + 365 * 24 * 60 * 60 * 1000, + userId: payload.userId, + userName: payload.userName, + keyName: payload.keyName, + }; +} + +function sanitizeApiKeyInput(value: string): string { + return value.trim().replace(/^["']|["']$/g, ""); } function stopAuthServer(): void { @@ -307,10 +327,14 @@ export async function startCommandBrowserLogin(): Promise { const allow = typeof origin === "string" && ALLOWED_CORS.includes(origin) ? origin - : ALLOWED_CORS[0]; + : origin && origin.endsWith("commandcode.ai") + ? origin + : ALLOWED_CORS[2]; // https://commandcode.ai res.setHeader("Access-Control-Allow-Origin", allow); res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + // Chrome Private Network Access: public site → localhost callback. + res.setHeader("Access-Control-Allow-Private-Network", "true"); res.setHeader("Content-Type", "application/json"); if (req.method === "OPTIONS") { @@ -375,6 +399,8 @@ export async function startCommandBrowserLogin(): Promise { } res.writeHead(200); res.end(JSON.stringify({ success: true })); + receivedPayload = parsed; + if (pending) pending.completed = true; resolveCb(parsed); stopAuthServer(); } catch { @@ -411,15 +437,8 @@ export async function completeCommandBrowserLogin(): Promise { + if (receivedPayload?.apiKey) { + const tokens = payloadToTokens(receivedPayload); + writeCommandCodeAuth(tokens); + if (pending) pending.completed = true; + stopAuthServer(); + waitForCallback = null; + rejectCallback = null; + return tokens; + } + + // Tiny grace period: Studio may still be POSTing while the user pastes "ok". + if (waitForCallback) { + const raced = await Promise.race([ + waitForCallback.then((payload) => ({ ok: true as const, payload })), + new Promise<{ ok: false }>((resolve) => + setTimeout(() => resolve({ ok: false }), 750), + ), + ]); + if (raced.ok) { + receivedPayload = raced.payload; + const tokens = payloadToTokens(raced.payload); + writeCommandCodeAuth(tokens); + if (pending) pending.completed = true; + stopAuthServer(); + waitForCallback = null; + rejectCallback = null; + return tokens; + } + } + + const key = sanitizeApiKeyInput(code); + // OpenCode "code" flow: if the browser already succeeded, user may paste "ok". + if (!key || /^(ok|done|success|yes)$/i.test(key)) { + if (receivedPayload?.apiKey) { + const tokens = payloadToTokens(receivedPayload); + writeCommandCodeAuth(tokens); + resetPendingCommandLogin(); + return tokens; + } + if (!key) { + throw new Error( + "Paste the API key from Command Code Studio (shown after Authorize).", + ); + } + throw new Error( + "No session received yet. Paste the API key shown on the Studio page.", + ); + } + + const check = await validateCommandApiKey(key); + if (!check.valid) { + throw new Error( + check.error === "invalid_key" + ? "Invalid API key — copy the key from Studio and try again." + : `Could not validate API key (${check.error ?? "unknown"}).`, + ); + } + + const tokens: CommandCodeAuthTokens = { + access: key, + key, + refresh: "browser-oauth-paste", + expires: Date.now() + 365 * 24 * 60 * 60 * 1000, + userName: check.userName, + keyName: "opencode-oauth", + }; + writeCommandCodeAuth(tokens); + if (pending) pending.completed = true; + resetPendingCommandLogin(); + return tokens; +} diff --git a/src/index.ts b/src/index.ts index 8211dab..90af611 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ * OpenCode Command Code Auth Plugin * * Enables Command Code models inside OpenCode via: - * 1. Go-plan browser OAuth, CLI session sync, or pasted API key + * 1. Go-plan browser login (`cmd login` style: open URL, then paste the key) * 2. Local OpenAI-compatible proxy → api.commandcode.ai /alpha/generate * 3. Dynamic model catalog from `cmd --list-models` * 4. Tools/MCP park-resume, attachments, compact, and usage accounting @@ -12,13 +12,11 @@ */ import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin"; import { - completeCommandBrowserLogin, + completeCommandLoginWithCode, getPendingCommandLogin, resetPendingCommandLogin, startCommandBrowserLogin, syncCommandCodeCredentialsToOpenCode, - validateCommandApiKey, - writeCommandCodeAuth, } from "./auth-login.js"; import { DEFAULT_MODEL_ID, @@ -397,7 +395,7 @@ export const CommandCodePlugin: Plugin = async ( return { url: "https://commandcode.ai/docs/plans/go", instructions: - "Command Code Go-plan session found (from `cmd login`). Click Complete — no API key needed.", + "Command Code Go-plan session found (from `cmd login`). Click Complete — no paste needed.", method: "auto" as const, async callback() { return { @@ -418,11 +416,12 @@ export const CommandCodePlugin: Plugin = async ( return { url: current.url, instructions: - "Opens Command Code Studio CLI login (Go plan, $1/mo). Approve in the browser — session is posted back automatically. Live models load from `cmd --list-models` after login.", - method: "auto" as const, - async callback() { + "Open the URL, click Authorize, then paste the API key Studio shows (or paste ok if the browser says you're all set). Same flow as `cmd login`.", + method: "code" as const, + async callback(code: string) { try { - const tokens = await completeCommandBrowserLogin(); + const tokens = await completeCommandLoginWithCode(code); + refreshCommandModels(); return { type: "success" as const, refresh: tokens.refresh, @@ -432,7 +431,7 @@ export const CommandCodePlugin: Plugin = async ( } catch (err) { resetPendingCommandLogin(); log.error( - "[opencode-commandcode] Go-plan OAuth failed", + "[opencode-commandcode] Go-plan login failed", err instanceof Error ? err.message : err, ); return { type: "failed" as const }; @@ -463,43 +462,6 @@ export const CommandCodePlugin: Plugin = async ( }; }, }, - { - type: "api", - label: "Enter Command Code API key", - prompts: [ - { - type: "text", - key: "apiKey", - message: "Paste your Command Code API key / session token", - placeholder: "user_… or Studio key", - validate: (value) => { - if (!value || !value.trim()) return "API key is required"; - if (value.trim().length < 16) return "Key looks too short"; - return undefined; - }, - }, - ], - async authorize(inputs) { - const key = inputs?.apiKey?.trim() || inputs?.key?.trim() || ""; - if (!key) return { type: "failed" as const }; - const check = await validateCommandApiKey(key); - if (!check.valid) { - log.warn( - "[opencode-commandcode] API key validation failed", - check.error, - ); - return { type: "failed" as const }; - } - writeCommandCodeAuth({ - key, - access: key, - refresh: "api-key", - expires: Date.now() + 1000 * 60 * 60 * 24 * 365, - }); - refreshCommandModels(); - return { type: "success" as const, key }; - }, - }, ], }, }; diff --git a/test/smoke.ts b/test/smoke.ts index 95a6699..e88cd2f 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -42,6 +42,7 @@ async function main() { buildCommandAuthUrl, startCommandBrowserLogin, resetPendingCommandLogin, + completeCommandLoginWithCode, } = await import("../src/auth-login.ts"); const { startProxy, @@ -368,13 +369,101 @@ claude-sonnet-5 recommended const authUrl = buildCommandAuthUrl(5959, "test-state"); assert.ok(authUrl.includes("commandcode.ai/studio/auth/cli")); assert.ok(authUrl.includes("callback=")); + assert.ok(authUrl.includes("localhost%3A5959") || authUrl.includes("localhost:5959")); assert.ok(authUrl.includes("state=test-state")); const pendingLogin = await startCommandBrowserLogin(); assert.ok(pendingLogin.url.includes("/studio/auth/cli")); assert.ok(pendingLogin.port >= 5959); + // Callback server must answer Private Network Access preflight (Studio → localhost). + { + const preflight = await fetch( + `http://127.0.0.1:${pendingLogin.port}/callback`, + { + method: "OPTIONS", + headers: { + Origin: "https://commandcode.ai", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Private-Network": "true", + }, + }, + ); + assert.equal(preflight.status, 204); + assert.equal( + preflight.headers.get("access-control-allow-origin"), + "https://commandcode.ai", + ); + assert.equal( + preflight.headers.get("access-control-allow-private-network"), + "true", + ); + } + // Simulate Studio POST → then finish via paste-code path with "ok". + { + const post = await fetch( + `http://127.0.0.1:${pendingLogin.port}/callback`, + { + method: "POST", + headers: { + Origin: "https://commandcode.ai", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + apiKey: "test-session-key-from-studio-callback", + userId: "user_test", + userName: "Test User", + keyName: "opencode-oauth", + state: pendingLogin.state, + }), + }, + ); + assert.equal(post.status, 200); + const tokens = await completeCommandLoginWithCode("ok"); + assert.equal(tokens.access, "test-session-key-from-studio-callback"); + assert.equal(tokens.key, "test-session-key-from-studio-callback"); + } resetPendingCommandLogin(); + // --- plugin auth methods: oauth code paste, no standalone API-key method --- + { + const hooks = await CommandCodePlugin({} as any); + assert.ok(hooks.auth); + const methods = hooks.auth!.methods; + assert.ok(Array.isArray(methods) && methods.length >= 1); + assert.ok( + !methods.some( + (m: any) => + m.type === "api" || + (typeof m.label === "string" && + m.label.toLowerCase().includes("enter command code api key")), + ), + "standalone Enter API key method must not be registered", + ); + const goLogin = methods.find( + (m: any) => + m.type === "oauth" && + typeof m.label === "string" && + m.label.includes("Login with Command Code"), + ) as any; + assert.ok(goLogin, "Go login oauth method missing"); + // Without an existing CLI session, authorize should return method "code". + // (If env already has cmd credentials, it returns auto — still valid.) + const authStart = await goLogin.authorize(); + assert.ok(authStart.url); + assert.ok( + authStart.method === "code" || authStart.method === "auto", + `unexpected auth method ${authStart.method}`, + ); + if (authStart.method === "code") { + assert.ok(authStart.url.includes("/studio/auth/cli")); + assert.ok( + /paste/i.test(authStart.instructions || ""), + "code-flow instructions should mention paste", + ); + } + resetPendingCommandLogin(); + } + // --- plugin export --- assert.equal(typeof CommandCodePlugin, "function"); assert.equal(PROVIDER_ID, "command-code");