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");