diff --git a/src/api-token-store.ts b/src/api-token-store.ts index c9382fd..f81696a 100644 --- a/src/api-token-store.ts +++ b/src/api-token-store.ts @@ -11,7 +11,8 @@ import { /** * Scoped API token stored by `auth bootstrap` (ADR 0056) for - * `execute --local`. Separate from `kody login` OAuth credentials. + * `execute --local`, Open API search/whoami, and token-auth cloud execute. + * Separate from `kody login` OAuth credentials. */ export type StoredApiToken = { version: 1 diff --git a/src/api-token.ts b/src/api-token.ts index 468dd04..813e8b1 100644 --- a/src/api-token.ts +++ b/src/api-token.ts @@ -1,9 +1,21 @@ -import { apiTokenEnvVar } from './defaults.js' +import { loadStoredApiToken } from './api-token-store.js' +import { apiTokenEnvVar, defaultApiUrl } from './defaults.js' +import type { SecretBackend, StoreResolution } from './store.js' /** Platform tracking for login OAuth as CapabilityProxy / package-graph Bearer. */ export const localExecuteOauthPlatformIssueUrl = 'https://github.com/kentcdodds/kody/issues/2812' +export type ResolveScopedApiTokenInput = { + tokenValues?: { token?: string } + env?: NodeJS.ProcessEnv + apiUrl?: string + apiTokenBackend?: SecretBackend + apiTokenResolution?: StoreResolution + /** Test seam. */ + loadApiToken?: typeof loadStoredApiToken +} + /** * Scoped Open API / CapabilityProxy token (`kody_at_…`). Same source for * `execute --local`, token-only cloud execute, and Open API search/whoami. @@ -16,6 +28,20 @@ export function readApiToken( return token.length > 0 ? token : null } +/** + * Resolve a scoped API token without falling back to `kody login` OAuth. + * Priority: `--token` / `KODY_API_TOKEN` → stored bootstrap/API token. + */ +export function resolveScopedApiToken( + input: ResolveScopedApiTokenInput = {}, +): string | null { + const token = readApiToken(input.tokenValues, input.env) + if (token) return token + const apiUrl = input.apiUrl || defaultApiUrl + const loadApi = input.loadApiToken ?? loadStoredApiToken + return loadApi(apiUrl, input.apiTokenBackend, input.apiTokenResolution)?.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_') @@ -83,8 +109,13 @@ export function requireApiToken( values: { token?: string } = {}, env: NodeJS.ProcessEnv = process.env, purpose: string = 'this command', + options: Omit = {}, ): string { - const token = readApiToken(values, env) + const token = resolveScopedApiToken({ + tokenValues: values, + env, + ...options, + }) if (!token) throw new Error(missingApiTokenMessage(purpose)) return token } diff --git a/src/cli.ts b/src/cli.ts index a797976..8d2d4c9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,8 +2,8 @@ import { parseArgs } from 'node:util' import { readFile } from 'node:fs/promises' import { hasExplicitTokenFlag, - readApiToken, requireApiToken, + resolveScopedApiToken, } from './api-token.js' import { deleteStoredApiToken, @@ -25,7 +25,11 @@ import { installSkill } from './skill.js' import { readPackageVersion } from './package-info.js' import { redactError } from './redact.js' -export { readApiToken, requireApiToken as resolveApiToken } from './api-token.js' +export { + readApiToken, + requireApiToken as resolveApiToken, + resolveScopedApiToken, +} from './api-token.js' export { resolveLocalExecuteBearer } from './local-execute-auth.js' export { authBootstrap, redeemBootstrapCode } from './auth-bootstrap.js' @@ -256,7 +260,7 @@ async function dispatch( const scopes = result.stored.scopes?.join(', ') || '(none)' write( [ - `Bootstrap API token stored for execute --local.`, + `Bootstrap API token stored for execute --local, search, whoami, and token-auth cloud execute.`, `api: ${result.stored.apiUrl}`, `token id: ${result.stored.tokenId}`, `scopes: ${scopes}`, @@ -271,15 +275,23 @@ async function dispatch( } case 'whoami': { const tokenValues = tokenFlagValues(parsed.values) - if (shouldUseApiToken({ tokenValues, mcpUrl, allowEnvWithoutLogin: true })) { + const apiUrl = apiUrlFrom({ + apiUrl: + typeof parsed.values['api-url'] === 'string' + ? parsed.values['api-url'] + : undefined, + }) + if ( + shouldUseApiToken({ + tokenValues, + mcpUrl, + apiUrl, + allowEnvWithoutLogin: true, + }) + ) { const identity = await whoamiWithApiToken({ - token: requireApiToken(tokenValues, process.env, 'whoami'), - apiUrl: apiUrlFrom({ - apiUrl: - typeof parsed.values['api-url'] === 'string' - ? parsed.values['api-url'] - : undefined, - }), + token: requireApiToken(tokenValues, process.env, 'whoami', { apiUrl }), + apiUrl, }) if (json) { write(`${JSON.stringify(identity, null, 2)}\n`) @@ -326,15 +338,23 @@ async function dispatch( case 'search': { const query = parsed.positionals.join(' ').trim() const tokenValues = tokenFlagValues(parsed.values) - if (shouldUseApiToken({ tokenValues, mcpUrl, allowEnvWithoutLogin: true })) { + const apiUrl = apiUrlFrom({ + apiUrl: + typeof parsed.values['api-url'] === 'string' + ? parsed.values['api-url'] + : undefined, + }) + if ( + shouldUseApiToken({ + tokenValues, + mcpUrl, + apiUrl, + allowEnvWithoutLogin: true, + }) + ) { const result = await searchWithApiToken({ - token: requireApiToken(tokenValues, process.env, 'search'), - apiUrl: apiUrlFrom({ - apiUrl: - typeof parsed.values['api-url'] === 'string' - ? parsed.values['api-url'] - : undefined, - }), + token: requireApiToken(tokenValues, process.env, 'search', { apiUrl }), + apiUrl, query: query || undefined, entity: typeof parsed.values.entity === 'string' ? parsed.values.entity : undefined, @@ -407,6 +427,7 @@ async function dispatch( shouldUseApiToken({ tokenValues, mcpUrl, + apiUrl, allowEnvWithoutLogin: true, }) const result = local @@ -433,6 +454,7 @@ async function dispatch( tokenValues, process.env, 'execute with an API token', + { apiUrl }, ), apiUrl, }) @@ -543,21 +565,36 @@ function tokenFlagValues(values: ReturnType['values']): { /** * Prefer a scoped API token when the user passed `--token`, or when - * `KODY_API_TOKEN` is set and there is no stored `kody login` session. - * Logged-in MCP OAuth still wins over an env-only token so a leftover - * `KODY_API_TOKEN` does not hijack cloud MCP commands. + * `KODY_API_TOKEN` / a stored bootstrap token is available and there is no + * stored `kody login` session. Logged-in MCP OAuth still wins over an + * env-only or stored token so a leftover token does not hijack cloud MCP + * commands. */ export function shouldUseApiToken(input: { tokenValues: { token?: string } mcpUrl: string allowEnvWithoutLogin: boolean env?: NodeJS.ProcessEnv + apiUrl?: string /** Override session detection (tests). */ hasSession?: boolean + /** Test seam for stored bootstrap/API token lookup. */ + loadApiToken?: NonNullable< + Parameters[0] + >['loadApiToken'] }): boolean { if (hasExplicitTokenFlag(input.tokenValues)) return true if (!input.allowEnvWithoutLogin) return false - if (!readApiToken(input.tokenValues, input.env)) return false + if ( + !resolveScopedApiToken({ + tokenValues: input.tokenValues, + env: input.env, + apiUrl: input.apiUrl, + loadApiToken: input.loadApiToken, + }) + ) { + return false + } const loggedIn = input.hasSession ?? loadCredentials(input.mcpUrl) != null return !loggedIn diff --git a/src/help.ts b/src/help.ts index 649ef78..2d379e7 100644 --- a/src/help.ts +++ b/src/help.ts @@ -27,20 +27,24 @@ Usage: auth bootstrap Redeem a one-shot \`kody_bc_…\` from MCP \`cliCredentialBootstrap\` (POST /v1/tokens/bootstrap/redeem, no Authorization header). - Stores the resulting \`kody_at_…\` for \`execute --local\` without - printing the token. Prefer this over tokenCreate for agents - already on MCP. Interactive humans can use \`kody login\` instead. + Stores the resulting \`kody_at_…\` for \`execute --local\`, + search, whoami, and token-auth cloud execute without printing + the token. Prefer this over tokenCreate for agents already on + MCP. Interactive humans can use \`kody login\` instead. --token / ${apiTokenEnvVar} Scoped API token (preferred via env). With no \`kody login\` session, search / whoami / execute use the Open API and CapabilityProxy — including cloud execute without --local. + Auth priority matches \`execute --local\`: \`--token\` / + ${apiTokenEnvVar}; stored bootstrap/API token from + \`auth bootstrap\`; then \`kody login\` where applicable. Mint with the MCP \`api\` tool \`tokenCreate\` (include \`local-execute\` plus the capability scopes you need), or use \`auth bootstrap\` after \`cliCredentialBootstrap\`. - For \`execute --local\`, auth priority is: \`--token\` / - ${apiTokenEnvVar}; stored bootstrap/API token; then a valid - \`kody login\` session as Bearer (no tokenCreate exchange). + For \`execute --local\`, a valid \`kody login\` session can also + supply Bearer when no scoped token is available (no tokenCreate + exchange). --local Run the execute module on this machine (workerd, Linux/macOS). Requires Node.js 22 or newer. Auth: \`--token\` / diff --git a/src/local-execute-auth.ts b/src/local-execute-auth.ts index b39de8d..f09d81d 100644 --- a/src/local-execute-auth.ts +++ b/src/local-execute-auth.ts @@ -1,11 +1,10 @@ import { ensureFreshCredentials } from './auth.js' import { missingLocalExecuteAuthMessage, - readApiToken, + resolveScopedApiToken, + type ResolveScopedApiTokenInput, } from './api-token.js' -import { loadStoredApiToken } from './api-token-store.js' -import { defaultApiUrl } from './defaults.js' -import type { SecretBackend, StoreResolution } from './store.js' +import type { SecretBackend } from './store.js' /** * Bearer for CapabilityProxy / package-graph under `execute --local`. @@ -18,30 +17,20 @@ import type { SecretBackend, StoreResolution } from './store.js' * No under-the-hood `tokenCreate` exchange. Do not scavenge host MCP tokens * (ADR 0053). */ -export async function resolveLocalExecuteBearer(input: { - tokenValues?: { token?: string } - env?: NodeJS.ProcessEnv - mcpUrl?: string - apiUrl?: string - backend?: SecretBackend - apiTokenBackend?: SecretBackend - apiTokenResolution?: StoreResolution - fetchFn?: typeof fetch - now?: number - purpose?: string - /** Test seam. */ - ensureCredentials?: typeof ensureFreshCredentials - /** Test seam. */ - loadApiToken?: typeof loadStoredApiToken -}): Promise { - const token = readApiToken(input.tokenValues, input.env) +export async function resolveLocalExecuteBearer( + input: ResolveScopedApiTokenInput & { + mcpUrl?: string + backend?: SecretBackend + fetchFn?: typeof fetch + now?: number + purpose?: string + /** Test seam. */ + ensureCredentials?: typeof ensureFreshCredentials + }, +): Promise { + const token = resolveScopedApiToken(input) if (token) return token - const apiUrl = input.apiUrl || defaultApiUrl - const loadApi = input.loadApiToken ?? loadStoredApiToken - const stored = loadApi(apiUrl, input.apiTokenBackend, input.apiTokenResolution) - if (stored?.token) return stored.token - const ensure = input.ensureCredentials ?? ensureFreshCredentials try { const credentials = await ensure({ diff --git a/test/auth-bootstrap.test.ts b/test/auth-bootstrap.test.ts index 26ae5d2..d06cc29 100644 --- a/test/auth-bootstrap.test.ts +++ b/test/auth-bootstrap.test.ts @@ -222,6 +222,148 @@ test('resolveLocalExecuteBearer still prefers --token over stored bootstrap', as assert.equal(token, 'kody_at_flag') }) +test('whoami uses stored bootstrap token without env, --token, or login', async () => { + const home = mkdtempSync(join(tmpdir(), 'kody-cli-whoami-store-')) + const previousXdg = process.env.XDG_CONFIG_HOME + const previousHome = process.env.HOME + const previousToken = process.env.KODY_API_TOKEN + const previousMcpUrl = process.env.KODY_MCP_URL + process.env.XDG_CONFIG_HOME = home + process.env.HOME = home + delete process.env.KODY_API_TOKEN + process.env.KODY_MCP_URL = 'http://127.0.0.1:9/unreachable-mcp' + const storedToken = 'kody_at_whoami_stored' + saveStoredApiToken({ + version: 1, + apiUrl, + token: storedToken, + tokenId: 'tok_whoami_stored', + scopes: ['account:read', 'local-execute'], + createdVia: 'cli-bootstrap', + expiresAt: '2026-10-02T12:00:00.000Z', + }) + const authHeaders: Array = [] + const previousFetch = globalThis.fetch + globalThis.fetch = (async (input: Parameters[0], init?: RequestInit) => { + const url = String(input) + const headers = new Headers(init?.headers) + authHeaders.push(headers.get('authorization')) + if (url.includes('/v1/tokens/current')) { + return new Response( + JSON.stringify({ + id: 'tok_whoami_stored', + name: 'kody-cli-bootstrap', + scopes: ['account:read', 'local-execute'], + expires_at: '2026-10-02T12:00:00.000Z', + max_expires_at: null, + }), + { status: 200 }, + ) + } + if (url.includes('/v1/me')) { + return new Response( + JSON.stringify({ + user_id: 'user_1', + email: 'agent@example.com', + display_name: 'Agent', + }), + { status: 200 }, + ) + } + return new Response('not found', { status: 404 }) + }) as typeof fetch + let stdout = '' + let stderr = '' + try { + const code = await runCli(['whoami', '--api-url', apiUrl], { + stdout: (text) => { + stdout += text + }, + stderr: (text) => { + stderr += text + }, + }) + assert.equal(code, 0, stderr) + } finally { + globalThis.fetch = previousFetch + if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME + else process.env.XDG_CONFIG_HOME = previousXdg + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + if (previousToken === undefined) delete process.env.KODY_API_TOKEN + else process.env.KODY_API_TOKEN = previousToken + if (previousMcpUrl === undefined) delete process.env.KODY_MCP_URL + else process.env.KODY_MCP_URL = previousMcpUrl + } + assert.match(stdout, /tok_whoami_stored/) + assert.match(stdout, /Agent /) + assert.doesNotMatch(stdout, /kody_at_/) + assert.doesNotMatch(stderr, /Not logged in/) + assert.ok(authHeaders.some((header) => header === `Bearer ${storedToken}`)) +}) + +test('search uses stored bootstrap token without env, --token, or login', async () => { + const home = mkdtempSync(join(tmpdir(), 'kody-cli-search-store-')) + const previousXdg = process.env.XDG_CONFIG_HOME + const previousHome = process.env.HOME + const previousToken = process.env.KODY_API_TOKEN + const previousMcpUrl = process.env.KODY_MCP_URL + process.env.XDG_CONFIG_HOME = home + process.env.HOME = home + delete process.env.KODY_API_TOKEN + process.env.KODY_MCP_URL = 'http://127.0.0.1:9/unreachable-mcp' + const storedToken = 'kody_at_search_stored' + saveStoredApiToken({ + version: 1, + apiUrl, + token: storedToken, + tokenId: 'tok_search_stored', + scopes: ['local-execute'], + createdVia: 'cli-bootstrap', + }) + const authHeaders: Array = [] + const previousFetch = globalThis.fetch + globalThis.fetch = (async (input: Parameters[0], init?: RequestInit) => { + const url = String(input) + const headers = new Headers(init?.headers) + authHeaders.push(headers.get('authorization')) + if (url.includes('/v1/search')) { + return new Response( + JSON.stringify({ results: [{ id: 'guide:open_api', title: 'Open API' }] }), + { status: 200 }, + ) + } + return new Response('not found', { status: 404 }) + }) as typeof fetch + let stdout = '' + let stderr = '' + try { + const code = await runCli(['search', 'open api', '--api-url', apiUrl], { + stdout: (text) => { + stdout += text + }, + stderr: (text) => { + stderr += text + }, + }) + assert.equal(code, 0, stderr) + } finally { + globalThis.fetch = previousFetch + if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME + else process.env.XDG_CONFIG_HOME = previousXdg + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + if (previousToken === undefined) delete process.env.KODY_API_TOKEN + else process.env.KODY_API_TOKEN = previousToken + if (previousMcpUrl === undefined) delete process.env.KODY_MCP_URL + else process.env.KODY_MCP_URL = previousMcpUrl + } + assert.match(stdout, /guide:open_api/) + assert.doesNotMatch(stdout, /kody_at_/) + assert.doesNotMatch(stderr, /Not logged in/) + assert.ok(authHeaders.some((header) => header === `Bearer ${storedToken}`)) +}) + test('parseStoredApiToken rejects non-kody_at payloads', () => { assert.throws( () => diff --git a/test/cli.test.ts b/test/cli.test.ts index 0b33f7c..1c7280b 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -13,6 +13,7 @@ import { runCli, shouldUseApiToken, } from '../src/cli.js' +import type { StoredApiToken } from '../src/api-token-store.js' import { modernMcpProtocolVersion } from '../src/defaults.js' import { formatToolResult, listKodyTools } from '../src/mcp.js' import { redact } from '../src/redact.js' @@ -169,6 +170,52 @@ test('shouldUseApiToken prefers explicit --token and env token when not logged i ) }) +test('shouldUseApiToken treats stored bootstrap token like env when not logged in', () => { + const stored: StoredApiToken = { + version: 1, + apiUrl: 'https://api.kody.codes', + token: 'kody_at_stored', + tokenId: 'tok_stored', + } + assert.equal( + shouldUseApiToken({ + tokenValues: {}, + mcpUrl: 'https://kody.codes/mcp', + allowEnvWithoutLogin: true, + env: {}, + hasSession: false, + loadApiToken: () => stored, + }), + true, + ) + assert.equal( + shouldUseApiToken({ + tokenValues: {}, + mcpUrl: 'https://kody.codes/mcp', + allowEnvWithoutLogin: true, + env: {}, + hasSession: true, + loadApiToken: () => stored, + }), + false, + ) +}) + +test('resolveApiToken falls back to a stored bootstrap token', () => { + const stored: StoredApiToken = { + version: 1, + apiUrl: 'https://api.kody.codes', + token: 'kody_at_stored', + tokenId: 'tok_stored', + } + assert.equal( + resolveApiToken({}, {}, 'this command', { + loadApiToken: () => stored, + }), + 'kody_at_stored', + ) +}) + test('execute with --token (no --local) uses CapabilityProxy and never requires login', async () => { const previousMcpUrl = process.env.KODY_MCP_URL process.env.KODY_MCP_URL = 'http://127.0.0.1:9/unreachable-mcp'