diff --git a/README.md b/README.md index a57a5b9..59455d0 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,16 @@ npx @kodycodes/cli whoami - Neither `kody login` nor a token → the error tells you to mint one with the MCP `api` tool `tokenCreate` (scopes: `local-execute` plus the capability - scopes the module will call) and pass `--token` / `KODY_API_TOKEN`. - `execute --local` never reads stored CLI OAuth, so login is not a substitute - there. -- Wrong/expired token → 401 with a mint-fresh-token message. + scopes the module will call) and pass `--token` / `KODY_API_TOKEN`, or run + `kody login` (for cloud MCP commands and for login-backed `execute --local`). +- For `execute --local` specifically: `--token` / `KODY_API_TOKEN` wins when + set; otherwise the CLI uses the stored `kody login` OAuth access token as + the Bearer (no under-the-hood `tokenCreate`). The Open API must accept that + OAuth bearer on CapabilityProxy / package-graph + ([kentcdodds/kody#2812](https://github.com/kentcdodds/kody/issues/2812)); + until then mint a scoped `kody_at_…` token. +- Wrong/expired token → 401 with a mint-fresh-token message (or the OAuth + platform-gap message when the bearer is login OAuth). - Wrong scopes → the error includes `insufficient_scope` and the required scope when Kody sends one. - Account flag off → the error includes `feature_disabled` and the @@ -105,19 +111,29 @@ with `--params`) on this machine instead of in Kody's cloud sandbox. Local CPU is free; every `kody:runtime` call (`kody.*`, `kody.mcp.*`, `workflows.create`) is proxied to Kody's CapabilityProxy and metered like a cloud hop. Static `kody:@scope/package/export` imports are **resolved into the local workerd -bundle** via `POST /v1/local-execute/package-graph` (same Open API token — -never hosted MCP `execute`, and never a whole-module CapabilityProxy → -`kody.execute` defer). There is no author-facing `packages.invoke`. +bundle** via `POST /v1/local-execute/package-graph` (same Bearer as +CapabilityProxy — never hosted MCP `execute`, and never a whole-module +CapabilityProxy → `kody.execute` defer). There is no author-facing +`packages.invoke`. ```bash -export KODY_API_TOKEN=… # scoped token minted through the Kody `api` tool +# Prefer login when already signed in (no temporary API token to paste): +npx @kodycodes/cli login +npx @kodycodes/cli execute --local --file ./task.js --params '{"to":"me@example.com"}' + +# Or a scoped API token (still wins over login when set): +export KODY_API_TOKEN=… # minted through the Kody `api` tool npx @kodycodes/cli execute --local --file ./task.js --params '{"to":"me@example.com"}' ``` -- **Auth:** a scoped API token from `--token` or `KODY_API_TOKEN` (prefer the - env var so the token stays out of shell history and `ps`). No `kody login`, - and the CLI never reads MCP OAuth tokens from other hosts. The token stays in - the CLI process; the sandbox only talks to a loopback bridge. +- **Auth:** `--token` / `KODY_API_TOKEN` when set; else a valid `kody login` + session (OAuth access token as Bearer — never printed, never exchanged via + `tokenCreate`). Prefer the env var for API tokens so they stay out of shell + history and `ps`. The bearer stays in the CLI process; the sandbox only + talks to a loopback bridge. Host MCP OAuth from other clients is never + read. Until [kentcdodds/kody#2812](https://github.com/kentcdodds/kody/issues/2812) + ships, login-only Bearer is rejected by the Open API — use a `kody_at_…` + token in that case. - **Node.js:** 22 or newer (`package.json` `engines` is `>=22`). Older Node fails immediately with that requirement, before workerd is downloaded or started. @@ -167,8 +183,9 @@ If the keychain is unavailable (common on headless Linux), the CLI writes a Access tokens refresh automatically on expiry or HTTP 401. -`execute --local` and token-only cloud execute do not use stored CLI -credentials; they only read `--token` / `KODY_API_TOKEN`. +`execute --local` prefers `--token` / `KODY_API_TOKEN` when set; otherwise it +uses stored CLI credentials from `kody login`. Token-only cloud execute still +only reads `--token` / `KODY_API_TOKEN`. ## Releases diff --git a/skills/kody/SKILL.md b/skills/kody/SKILL.md index cec65d0..f1aa935 100644 --- a/skills/kody/SKILL.md +++ b/skills/kody/SKILL.md @@ -78,15 +78,22 @@ Modules with static `kody:@…` imports keep `--local`: the CLI fetches stamped package modules via `POST /v1/local-execute/package-graph` and embeds them in local workerd (CapabilityProxy only for per-call `kody:runtime` hops — not a whole-module cloud defer). If that package-graph API is missing, `--local` -fails clearly rather than silently billing a remote execute. Mint the token -with the MCP `api` tool `tokenCreate` — include `local-execute` plus the -capability scopes the module calls — and pass `--token` or `KODY_API_TOKEN`. -If neither login nor a token is set, the CLI error says that. -`insufficient_scope` means the token is missing a scope; `feature_disabled` -means the account `local-execute` flag is off. Never read MCP OAuth tokens -from the host. +fails clearly rather than silently billing a remote execute. + +For `execute --local` auth: `--token` / `KODY_API_TOKEN` wins when set; +otherwise use a valid `kody login` session (OAuth access token as Bearer — +never print it, never mint via `tokenCreate` under the hood). Until the Open +API accepts MCP OAuth on CapabilityProxy / package-graph +(https://github.com/kentcdodds/kody/issues/2812), mint a scoped token with +the MCP `api` tool `tokenCreate` — include `local-execute` plus the capability +scopes the module calls — and pass `--token` or `KODY_API_TOKEN`. If neither +login nor a token is set, the CLI error says that. `insufficient_scope` means +the token is missing a scope; `feature_disabled` means the account +`local-execute` flag is off. Never read MCP OAuth tokens from the host. ```bash -KODY_API_TOKEN=… npx @kodycodes/cli execute --file ./task.js --params '{"q":"email"}' +npx @kodycodes/cli login +npx @kodycodes/cli execute --local --file ./task.js --params '{"q":"email"}' +# Or with an explicit scoped token (wins over login): KODY_API_TOKEN=… npx @kodycodes/cli execute --local --file ./task.js --params '{"q":"email"}' ``` diff --git a/src/api-token.ts b/src/api-token.ts index 8cc4645..b9bc295 100644 --- a/src/api-token.ts +++ b/src/api-token.ts @@ -1,5 +1,9 @@ import { apiTokenEnvVar } from './defaults.js' +/** Platform tracking for login OAuth as CapabilityProxy / package-graph Bearer. */ +export const localExecuteOauthPlatformIssueUrl = + 'https://github.com/kentcdodds/kody/issues/2812' + /** * Scoped Open API / CapabilityProxy token (`kody_at_…`). Same source for * `execute --local`, token-only cloud execute, and Open API search/whoami. @@ -12,6 +16,11 @@ export function readApiToken( return token.length > 0 ? token : null } +/** True when the bearer looks like a minted Open API token (not MCP OAuth). */ +export function isScopedApiToken(token: string): boolean { + return token.startsWith('kody_at_') +} + /** * How to mint the scoped token this CLI already accepts. There is no second * auth flow here — callers use the MCP `api` tool they already have. @@ -20,14 +29,28 @@ export function apiTokenMintInstructions(): string { return `Mint one with the Kody MCP \`api\` tool \`tokenCreate\` (include the \`local-execute\` scope plus the capability scopes this command needs) and pass --token or set ${apiTokenEnvVar}.` } -/** `execute --local` never reads stored CLI OAuth, so login is not a substitute. */ +/** Token-only Open API paths (search / whoami / cloud token execute) with no token. */ export function missingApiTokenMessage(purpose: string): string { - return `${purpose} needs a scoped Kody API token. ${apiTokenMintInstructions()} Stored \`kody login\` credentials are not used on this path.` + return `${purpose} needs a scoped Kody API token. ${apiTokenMintInstructions()}` +} + +/** + * `execute --local` with neither `--token` / `KODY_API_TOKEN` nor `kody login`. + */ +export function missingLocalExecuteAuthMessage( + purpose: string = 'execute --local', +): string { + return `${purpose} needs auth. Run \`kody login\`, or ${apiTokenMintInstructions()}` } /** Cloud search / whoami / execute when the process has neither a session nor a token. */ export function missingCliAuthMessage(): string { - return `Not logged in, and no API token is set. ${apiTokenMintInstructions()} Or run \`kody login\` for browser OAuth (search, whoami, and cloud execute).` + return `Not logged in, and no API token is set. ${apiTokenMintInstructions()} Or run \`kody login\` for browser OAuth (search, whoami, cloud execute, and login-backed \`execute --local\`).` +} + +/** 401 when CapabilityProxy rejected a non-`kody_at_` bearer (typically CLI OAuth). */ +export function rejectedOauthBearerMessage(): string { + return `Kody rejected the bearer from \`kody login\`: the Open API still accepts only scoped \`kody_at_…\` API tokens on CapabilityProxy / package-graph (not MCP OAuth). See ${localExecuteOauthPlatformIssueUrl}. Until that lands, ${apiTokenMintInstructions()}` } export function insufficientScopeMessage(input: { diff --git a/src/capability-proxy.ts b/src/capability-proxy.ts index eb81620..3b48bf0 100644 --- a/src/capability-proxy.ts +++ b/src/capability-proxy.ts @@ -2,6 +2,8 @@ import { apiTokenMintInstructions, featureDisabledMessage, insufficientScopeMessage, + isScopedApiToken, + rejectedOauthBearerMessage, } from './api-token.js' import { apiTokenEnvVar, cliName } from './defaults.js' import { describeNetworkError } from './network-error.js' @@ -10,7 +12,8 @@ import { readPackageVersion } from './package-info.js' /** * HTTP contract for Kody's CapabilityProxy (Open API `/v1`). Local execute * runs user code in workerd and forwards every `kody:runtime` call here with - * a scoped API token. + * a Bearer from `--token` / `KODY_API_TOKEN` or (when unset) `kody login` + * OAuth access token. * * - `GET /v1/capability-proxy/session` validates the token before workerd * starts. 200 → `{ scopes?: string[], expiresAt?: string }`. @@ -20,8 +23,8 @@ import { readPackageVersion } from './package-info.js' * is the positional argument list. 200 → `{ result }`. * * Errors use `{ error: { code, message } }` (a bare string is accepted too). - * 401 means the token is expired/revoked; 403 `feature_disabled` means the - * `local-execute` flag is off for the account. + * 401 means the token is expired/revoked (or MCP OAuth until the platform + * accepts it); 403 `feature_disabled` means the `local-execute` flag is off. */ export const capabilityProxySessionPath = 'v1/capability-proxy/session' export const capabilityProxyCallPath = 'v1/capability-proxy/call' @@ -88,7 +91,7 @@ export async function openCapabilityProxySession( const response = await send(input, url, { method: 'GET' }) const body = await readJson(response) if (!response.ok) { - throw describeFailure(response.status, body, url, 'session') + throw describeFailure(response.status, body, url, 'session', input.token) } const record = isRecord(body) ? body : {} return { @@ -112,7 +115,7 @@ export async function callCapabilityProxy( }) const body = await readJson(response) if (!response.ok) { - throw describeFailure(response.status, body, url, 'call') + throw describeFailure(response.status, body, url, 'call', input.token) } const failure = readErrorBody(body) if (failure) { @@ -174,15 +177,16 @@ function describeFailure( body: unknown, url: URL, stage: 'session' | 'call', + token: string, ): CapabilityProxyError { const failure = readErrorBody(body) const code = failure?.code ?? null const detail = failure?.message ? ` Server said: ${failure.message}` : '' if (status === 401) { - return new CapabilityProxyError( - `Kody rejected the API token (expired, revoked, or malformed). Mint a fresh scoped token and pass it with --token or ${apiTokenEnvVar}.${detail}`, - { status, code }, - ) + const message = isScopedApiToken(token) + ? `Kody rejected the API token (expired, revoked, or malformed). Mint a fresh scoped token and pass it with --token or ${apiTokenEnvVar}.${detail}` + : `${rejectedOauthBearerMessage()}${detail}` + return new CapabilityProxyError(message, { status, code }) } if (code === 'feature_disabled') { return new CapabilityProxyError(`${featureDisabledMessage()}${detail}`, { status, code }) diff --git a/src/cli.ts b/src/cli.ts index 637f162..d7aa515 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,7 @@ import { ensureFreshCredentials, login } from './auth.js' import { deleteCredentials, loadCredentials } from './store.js' import { callKodyTool, formatToolResult, listKodyTools } from './mcp.js' import { runInstall } from './install.js' +import { resolveLocalExecuteBearer } from './local-execute-auth.js' import { runLocalExecute } from './local-execute.js' import { assertLocalExecuteNodeEngine } from './node-engine.js' import { searchWithApiToken, whoamiWithApiToken } from './open-api-client.js' @@ -20,6 +21,7 @@ import { readPackageVersion } from './package-info.js' import { redactError } from './redact.js' export { readApiToken, requireApiToken as resolveApiToken } from './api-token.js' +export { resolveLocalExecuteBearer } from './local-execute-auth.js' export type CommandName = | 'login' @@ -330,7 +332,11 @@ async function dispatch( code: args.code as string, params: args.params, conversationId: args.conversationId as string | undefined, - token: requireApiToken(tokenValues, process.env, 'execute --local'), + token: await resolveLocalExecuteBearer({ + tokenValues, + mcpUrl, + purpose: 'execute --local', + }), apiUrl, onStatus: (message) => writeErr(`${message}\n`), }) diff --git a/src/help.ts b/src/help.ts index e311538..5e4ec9a 100644 --- a/src/help.ts +++ b/src/help.ts @@ -29,16 +29,19 @@ Usage: CapabilityProxy — including cloud execute without --local. Mint with the MCP \`api\` tool \`tokenCreate\` (include \`local-execute\` plus the capability scopes you need). - --local still runs the module on this machine (workerd). + For \`execute --local\`, \`--token\` / ${apiTokenEnvVar} wins + when set; otherwise a valid \`kody login\` session is used as + the Bearer (no tokenCreate exchange). --local Run the execute module on this machine (workerd, Linux/macOS). - Requires Node.js 22 or newer and a token with the local-execute - scope. Static kody:@… imports are fetched via - POST /v1/local-execute/package-graph and embedded in local - workerd (CapabilityProxy only for per-call kody:runtime hops — - never a whole-module cloud kody.execute defer). Fails clearly - when that package-graph API is unavailable. Cloud token execute - (no --local) uses CapabilityProxy → kody.execute for every module. + Requires Node.js 22 or newer. Auth: \`--token\` / + ${apiTokenEnvVar}, or else \`kody login\`. Static kody:@… + imports are fetched via POST /v1/local-execute/package-graph + and embedded in local workerd (CapabilityProxy only for + per-call kody:runtime hops — never a whole-module cloud + kody.execute defer). Fails clearly when that package-graph + API is unavailable. Cloud token execute (no --local) uses + CapabilityProxy → kody.execute for every module. Environment: KODY_MCP_URL Override the default MCP URL (${defaultMcpUrl}) diff --git a/src/local-execute-auth.ts b/src/local-execute-auth.ts new file mode 100644 index 0000000..a156a2b --- /dev/null +++ b/src/local-execute-auth.ts @@ -0,0 +1,44 @@ +import { ensureFreshCredentials } from './auth.js' +import { + missingLocalExecuteAuthMessage, + readApiToken, +} from './api-token.js' +import type { SecretBackend } from './store.js' + +/** + * Bearer for CapabilityProxy / package-graph under `execute --local`. + * + * Priority: `--token` / `KODY_API_TOKEN`, else a fresh `kody login` OAuth + * access token (never printed). No under-the-hood `tokenCreate` exchange. + * + * Platform must accept MCP/user OAuth on those Open API routes + * (https://github.com/kentcdodds/kody/issues/2812); until then a login-only + * bearer gets 401 and the CLI surfaces that gap. + */ +export async function resolveLocalExecuteBearer(input: { + tokenValues?: { token?: string } + env?: NodeJS.ProcessEnv + mcpUrl?: string + backend?: SecretBackend + fetchFn?: typeof fetch + now?: number + purpose?: string + /** Test seam. */ + ensureCredentials?: typeof ensureFreshCredentials +}): Promise { + const token = readApiToken(input.tokenValues, input.env) + if (token) return token + + const ensure = input.ensureCredentials ?? ensureFreshCredentials + try { + const credentials = await ensure({ + mcpUrl: input.mcpUrl, + backend: input.backend, + fetchFn: input.fetchFn, + now: input.now, + }) + return credentials.accessToken + } catch { + throw new Error(missingLocalExecuteAuthMessage(input.purpose ?? 'execute --local')) + } +} diff --git a/src/local-package-graph.ts b/src/local-package-graph.ts index 90041be..62d0682 100644 --- a/src/local-package-graph.ts +++ b/src/local-package-graph.ts @@ -3,6 +3,7 @@ import { capabilityProxyUrl, type CapabilityProxyClientInput, } from './capability-proxy.js' +import { isScopedApiToken, rejectedOauthBearerMessage } from './api-token.js' import { cliName } from './defaults.js' import { describeNetworkError } from './network-error.js' import { readPackageVersion } from './package-info.js' @@ -122,7 +123,7 @@ export async function fetchLocalPackageGraph( const body = await readJson(response) if (!response.ok) { - throw describePackageGraphFailure(response.status, body, url, imports) + throw describePackageGraphFailure(response.status, body, url, imports, input.token) } const graph = parsePackageGraphBody(body, imports) @@ -180,6 +181,7 @@ function describePackageGraphFailure( body: unknown, url: URL, imports: Array, + token: string, ): LocalPackageGraphError { const failure = readErrorBody(body) const code = failure?.code ?? null @@ -195,10 +197,10 @@ function describePackageGraphFailure( ) } if (status === 401) { - return new LocalPackageGraphError( - `Kody rejected the API token while fetching the local package graph.${detail}`, - { status, code }, - ) + const message = isScopedApiToken(token) + ? `Kody rejected the API token while fetching the local package graph.${detail}` + : `${rejectedOauthBearerMessage()}${detail}` + return new LocalPackageGraphError(message, { status, code }) } if (code === 'feature_disabled') { return new LocalPackageGraphError( diff --git a/test/capability-proxy.test.ts b/test/capability-proxy.test.ts index e4646de..d5764ac 100644 --- a/test/capability-proxy.test.ts +++ b/test/capability-proxy.test.ts @@ -7,7 +7,7 @@ import { openCapabilityProxySession, } from '../src/capability-proxy.js' -const token = 'kody_tok_secret_value' +const token = 'kody_at_secret_value' function respondWith(status: number, body: unknown) { const requests: Array<{ url: string; method: string; headers: Headers; body: string | null }> = @@ -67,6 +67,23 @@ test('openCapabilityProxySession explains a rejected token without echoing it', ) }) +test('openCapabilityProxySession explains rejected login OAuth and names the platform gap', async () => { + const oauth = 'oauth-access-from-login' + const { fetchFn } = respondWith(401, { + error: { code: 'unauthorized', message: 'Invalid API token.' }, + }) + await assert.rejects( + () => openCapabilityProxySession({ apiUrl: 'https://api.kody.codes', token: oauth, fetchFn }), + (error: Error) => { + assert.match(error.message, /kody login/) + assert.match(error.message, /kentcdodds\/kody\/issues\/2812/) + assert.match(error.message, /kody_at_/) + assert.equal(error.message.includes(oauth), false) + return true + }, + ) +}) + test('openCapabilityProxySession names the local-execute flag when it is off', async () => { const { fetchFn } = respondWith(403, { error: { code: 'feature_disabled', message: 'local-execute is off' }, diff --git a/test/cli.test.ts b/test/cli.test.ts index 470d514..1182c8b 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -9,13 +9,37 @@ import { executeSourcesConflict, resolveApiToken, resolveCommand, + resolveLocalExecuteBearer, runCli, shouldUseApiToken, } from '../src/cli.js' import { modernMcpProtocolVersion } from '../src/defaults.js' import { formatToolResult, listKodyTools } from '../src/mcp.js' import { redact } from '../src/redact.js' -import { createFileBackend, saveCredentials } from '../src/store.js' +import { + createFileBackend, + fileStorePath, + saveCredentials, + type StoredCredentials, +} from '../src/store.js' + +function sampleLoginCredentials( + overrides: Partial = {}, +): StoredCredentials { + return { + version: 1, + mcpUrl: 'https://kody.codes/mcp', + resource: 'https://kody.codes/mcp', + authorizationServerUrl: 'https://kody.codes', + clientId: 'client-1', + accessToken: 'oauth-access-from-login', + refreshToken: 'refresh-1', + tokenType: 'bearer', + expiresAt: Date.now() + 120_000, + scope: 'profile email', + ...overrides, + } +} test('resolveCommand maps subcommands and flags', () => { assert.equal(resolveCommand(['search', 'what can you do']).command, 'search') @@ -58,6 +82,40 @@ test('resolveApiToken prefers --token, falls back to KODY_API_TOKEN, and require ) }) +test('resolveLocalExecuteBearer prefers API token over login OAuth', async () => { + const token = await resolveLocalExecuteBearer({ + tokenValues: { token: 'kody_at_flag' }, + env: { KODY_API_TOKEN: 'kody_at_env' }, + ensureCredentials: async () => { + throw new Error('login should not be consulted when a token is set') + }, + }) + assert.equal(token, 'kody_at_flag') +}) + +test('resolveLocalExecuteBearer uses login OAuth when no API token is set', async () => { + const token = await resolveLocalExecuteBearer({ + tokenValues: {}, + env: {}, + ensureCredentials: async () => sampleLoginCredentials(), + }) + assert.equal(token, 'oauth-access-from-login') +}) + +test('resolveLocalExecuteBearer fails clearly when neither login nor token is available', async () => { + await assert.rejects( + () => + resolveLocalExecuteBearer({ + tokenValues: {}, + env: {}, + ensureCredentials: async () => { + throw new Error('Not logged in') + }, + }), + /execute --local needs auth[\s\S]*kody login[\s\S]*KODY_API_TOKEN/, + ) +}) + test('apiUrlFrom defaults to api.kody.codes', () => { assert.equal(apiUrlFrom({}, {}), 'https://api.kody.codes') assert.equal(apiUrlFrom({}, { KODY_API_URL: 'http://localhost:8787' }), 'http://localhost:8787') @@ -179,8 +237,11 @@ test('execute without token or login prompts clearly', async () => { assert.match(stderr, /--token/) }) -test('execute --local without a token names tokenCreate and does not pretend login is enough', async () => { +test('execute --local without token or login fails clearly', async () => { const previousToken = process.env.KODY_API_TOKEN + const previousXdg = process.env.XDG_CONFIG_HOME + const xdg = mkdtempSync(join(tmpdir(), 'kody-cli-no-login-')) + process.env.XDG_CONFIG_HOME = xdg delete process.env.KODY_API_TOKEN let stderr = '' try { @@ -192,15 +253,132 @@ test('execute --local without a token names tokenCreate and does not pretend log } finally { if (previousToken === undefined) delete process.env.KODY_API_TOKEN else process.env.KODY_API_TOKEN = previousToken + if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME + else process.env.XDG_CONFIG_HOME = previousXdg } - assert.match(stderr, /execute --local needs a scoped Kody API token/) - assert.match(stderr, /tokenCreate/) - assert.match(stderr, /local-execute/) + assert.match(stderr, /execute --local needs auth/) + assert.match(stderr, /kody login/) + assert.match(stderr, /tokenCreate|KODY_API_TOKEN/) assert.match(stderr, /pass --token or set KODY_API_TOKEN/) - assert.match(stderr, /not used on this path/) assert.doesNotMatch(stderr, /workerd did not start|Could not start workerd/) }) +test('execute --local with login (no API token) sends OAuth access token as Bearer', async () => { + const previousToken = process.env.KODY_API_TOKEN + const previousXdg = process.env.XDG_CONFIG_HOME + const previousMcpUrl = process.env.KODY_MCP_URL + const xdg = mkdtempSync(join(tmpdir(), 'kody-cli-login-local-')) + process.env.XDG_CONFIG_HOME = xdg + delete process.env.KODY_API_TOKEN + const mcpUrl = 'https://login-local.test/mcp' + process.env.KODY_MCP_URL = mcpUrl + const credentials = sampleLoginCredentials({ mcpUrl, resource: mcpUrl }) + saveCredentials(credentials, createFileBackend(fileStorePath(mcpUrl))) + + const bearers: Array = [] + const previousFetch = globalThis.fetch + globalThis.fetch = (async (input: Parameters[0], init?: RequestInit) => { + const headers = new Headers(init?.headers) + bearers.push(headers.get('authorization') ?? '') + return new Response( + JSON.stringify({ + error: { code: 'feature_disabled', message: 'local-execute is off' }, + }), + { status: 403 }, + ) + }) as typeof fetch + + let stderr = '' + try { + const code = await runCli( + [ + 'execute', + '--local', + '--mcp-url', + mcpUrl, + '--api-url', + 'https://api.kody.codes', + '--code', + 'export default () => 1', + ], + { stdout: () => {}, stderr: (text) => (stderr += text) }, + ) + assert.equal(code, 1) + } finally { + globalThis.fetch = previousFetch + if (previousToken === undefined) delete process.env.KODY_API_TOKEN + else process.env.KODY_API_TOKEN = previousToken + if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME + else process.env.XDG_CONFIG_HOME = previousXdg + if (previousMcpUrl === undefined) delete process.env.KODY_MCP_URL + else process.env.KODY_MCP_URL = previousMcpUrl + } + assert.deepEqual(bearers, [`Bearer ${credentials.accessToken}`]) + assert.match(stderr, /feature_disabled[\s\S]*local-execute/) + assert.equal(stderr.includes(credentials.accessToken), false) +}) + +test('execute --local prefers --token over a stored login session', async () => { + const previousToken = process.env.KODY_API_TOKEN + const previousXdg = process.env.XDG_CONFIG_HOME + const previousMcpUrl = process.env.KODY_MCP_URL + const xdg = mkdtempSync(join(tmpdir(), 'kody-cli-token-wins-')) + process.env.XDG_CONFIG_HOME = xdg + delete process.env.KODY_API_TOKEN + const mcpUrl = 'https://token-wins.test/mcp' + process.env.KODY_MCP_URL = mcpUrl + saveCredentials( + sampleLoginCredentials({ + mcpUrl, + resource: mcpUrl, + accessToken: 'oauth-should-not-win', + }), + createFileBackend(fileStorePath(mcpUrl)), + ) + + const bearers: Array = [] + const previousFetch = globalThis.fetch + globalThis.fetch = (async (_input: Parameters[0], init?: RequestInit) => { + bearers.push(new Headers(init?.headers).get('authorization') ?? '') + return new Response( + JSON.stringify({ + error: { code: 'feature_disabled', message: 'local-execute is off' }, + }), + { status: 403 }, + ) + }) as typeof fetch + + let stderr = '' + try { + const code = await runCli( + [ + 'execute', + '--local', + '--token', + 'kody_at_explicit', + '--mcp-url', + mcpUrl, + '--api-url', + 'https://api.kody.codes', + '--code', + 'export default () => 1', + ], + { stdout: () => {}, stderr: (text) => (stderr += text) }, + ) + assert.equal(code, 1) + } finally { + globalThis.fetch = previousFetch + if (previousToken === undefined) delete process.env.KODY_API_TOKEN + else process.env.KODY_API_TOKEN = previousToken + if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME + else process.env.XDG_CONFIG_HOME = previousXdg + if (previousMcpUrl === undefined) delete process.env.KODY_MCP_URL + else process.env.KODY_MCP_URL = previousMcpUrl + } + assert.deepEqual(bearers, ['Bearer kody_at_explicit']) + assert.match(stderr, /feature_disabled/) +}) + test('execute token paths surface feature_disabled and insufficient_scope', async () => { const previousToken = process.env.KODY_API_TOKEN delete process.env.KODY_API_TOKEN diff --git a/test/local-execute.test.ts b/test/local-execute.test.ts index 64ff86f..12b875f 100644 --- a/test/local-execute.test.ts +++ b/test/local-execute.test.ts @@ -376,7 +376,7 @@ test('runLocalExecute stops before starting workerd when the token or flag is re () => runLocalExecute({ code: 'export default () => 1', - token: 'kody_tok_revoked', + token: 'kody_at_revoked', apiUrl, workerdPath: neverRuns, }), @@ -400,7 +400,7 @@ test('runLocalExecute stops before starting workerd when the token or flag is re }) test( - 'kody execute --local uses the token only and never touches CLI login', + 'kody execute --local with --token never requires MCP login', { timeout: 180_000 }, async () => { reset() diff --git a/test/remote-execute.test.ts b/test/remote-execute.test.ts index 7f4a202..8091b9b 100644 --- a/test/remote-execute.test.ts +++ b/test/remote-execute.test.ts @@ -107,7 +107,7 @@ test('runRemoteExecuteWithToken surfaces missing-token session 401 clearly', asy () => runRemoteExecuteWithToken({ code: 'export default () => 1', - token: 'bad', + token: 'kody_at_bad', apiUrl: 'https://api.kody.codes', fetchFn, }),