diff --git a/README.md b/README.md index 59455d0..3f51002 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,9 @@ Or run via `npx @kodycodes/cli` without a global install. | `kody install` | Detect running local MCP clients, write their config, and start host OAuth. **Recommended long-term path.** | | `kody skill install` | Copies the getting-started skill into Claude Code / Cursor / Agents. | | `kody login` | Browser OAuth (CIMD + PKCE) for the CLI itself. Stores access and refresh tokens. | -| `kody logout` | Deletes stored CLI credentials. | -| `kody status` | Shows CLI login state without printing secrets. | +| `kody logout` | Deletes stored CLI OAuth credentials and any stored bootstrap/API token. | +| `kody status` | Shows CLI login / stored API token state without printing secrets. | +| `kody auth bootstrap --code` | Redeems a one-shot `kody_bc_…` from MCP `cliCredentialBootstrap` and stores the resulting `kody_at_…` for `execute --local` (never prints the token). | | `kody whoami` | Confirms the CLI MCP connection and lists tools. With a scoped API token (and no login), shows token identity via the Open API. | | `kody search [query]` | Calls Kody `search` from the CLI (prefer the host MCP tool). Token-only auth uses Open API `GET /v1/search`. | | `kody execute` | Calls Kody `execute` from the CLI (`--invoke`, `--code`, `--file`, or stdin via `--file -`). With a scoped API token and no login (or with `--token`), cloud execute goes through CapabilityProxy → `kody.execute` — no `kody login`. Add `--local` to run the module (and static `kody:@…` package modules) on this machine instead. | @@ -70,13 +71,21 @@ Or run via `npx @kodycodes/cli` without a global install. ## Token-authenticated execute (no `kody login`) -Scoped API tokens (`kody_at_…`, from the MCP `api` tool `tokenCreate` or -`POST /v1/tokens`) authenticate the Open API and CapabilityProxy. They never -replace MCP OAuth on `/mcp`. The CLI uses that token path when you pass -`--token` / `KODY_API_TOKEN` and are not logged in (or when you pass `--token` -explicitly): +Agents already on Kody MCP should prefer `cliCredentialBootstrap` → +`kody auth bootstrap --code …` (ADR 0056) so a one-shot `kody_bc_…` seeds the +CLI store without pasting `kody_at_…` into chat. Interactive humans can use +`kody login`. Scoped API tokens (`kody_at_…`, from bootstrap redeem, +`tokenCreate`, or `POST /v1/tokens`) authenticate the Open API and +CapabilityProxy. They never replace MCP OAuth on `/mcp`. The CLI uses that +token path when you pass `--token` / `KODY_API_TOKEN` and are not logged in +(or when you pass `--token` explicitly): ```bash +# Preferred for agents on MCP (no tokenCreate, no second OAuth): +# 1. MCP api / kody.cliCredentialBootstrap → { bootstrap_code, cli_command } +npx @kodycodes/cli auth bootstrap --code 'kody_bc_…' +npx @kodycodes/cli execute --local --file ./task.js + export KODY_API_TOKEN=… # scopes: local-execute (+ search:read for search) # Cloud execute — module runs in Kody's sandbox via CapabilityProxy → kody.execute npx @kodycodes/cli execute --code 'export default async () => ({ ok: true })' @@ -86,16 +95,16 @@ npx @kodycodes/cli search "what can you do" 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`, or run - `kody login` (for cloud MCP commands and for login-backed `execute --local`). +- Neither bootstrap store, `kody login`, nor a token → the error prefers + `cliCredentialBootstrap` → `auth bootstrap`, then `kody login`, then + `tokenCreate` for CI/headless (`--token` / `KODY_API_TOKEN`). - 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 + set; else a stored bootstrap/API token from `auth bootstrap`; else the + stored `kody login` OAuth access token as 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. + until then use bootstrap or 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 @@ -103,6 +112,7 @@ npx @kodycodes/cli whoami - Account flag off → the error includes `feature_disabled` and the `local-execute` feature flag. Another token does not bypass that flag. - Prefer the env var so the token stays out of shell history and `ps`. +- Never scavenge host MCP OAuth tokens from disk (ADR 0053). ## Local execute @@ -121,19 +131,24 @@ CapabilityProxy → `kody.execute` defer). There is no author-facing 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): +# Agents on MCP: bootstrap code → store (no tokenCreate, no second OAuth): +npx @kodycodes/cli auth bootstrap --code 'kody_bc_…' +npx @kodycodes/cli execute --local --file ./task.js --params '{"to":"me@example.com"}' + +# Or a scoped API token (still wins over login / bootstrap store 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:** `--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. +- **Auth:** `--token` / `KODY_API_TOKEN` when set; else stored `auth bootstrap` + API token; 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 bootstrap or 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. diff --git a/skills/kody/SKILL.md b/skills/kody/SKILL.md index f1aa935..b07d60c 100644 --- a/skills/kody/SKILL.md +++ b/skills/kody/SKILL.md @@ -81,19 +81,28 @@ whole-module cloud defer). If that package-graph API is missing, `--local` 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 +otherwise a stored token from `kody auth bootstrap --code` (after MCP +`cliCredentialBootstrap`); otherwise a valid `kody login` session (OAuth +access token as Bearer — never print it, never mint via `tokenCreate` under +the hood). Prefer bootstrap over `tokenCreate` for agents already on MCP so +`kody_at_…` never appears in chat. Until the Open API accepts MCP OAuth on +CapabilityProxy / package-graph +(https://github.com/kentcdodds/kody/issues/2812), use bootstrap or 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 none of those are available, the CLI error says that +(bootstrap → login → tokenCreate for CI). `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 +# Agents on MCP: +# cliCredentialBootstrap → { bootstrap_code, cli_command } +npx @kodycodes/cli auth bootstrap --code 'kody_bc_…' +npx @kodycodes/cli execute --local --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): +# Or with an explicit scoped token (wins over login / bootstrap store): KODY_API_TOKEN=… npx @kodycodes/cli execute --local --file ./task.js --params '{"q":"email"}' ``` diff --git a/src/api-token-store.ts b/src/api-token-store.ts new file mode 100644 index 0000000..c9382fd --- /dev/null +++ b/src/api-token-store.ts @@ -0,0 +1,172 @@ +import { Entry } from '@napi-rs/keyring' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { defaultApiUrl, keyringService } from './defaults.js' +import { + createFileBackend, + secretServiceKeyringOptions, + type SecretBackend, + type StoreResolution, +} from './store.js' + +/** + * Scoped API token stored by `auth bootstrap` (ADR 0056) for + * `execute --local`. Separate from `kody login` OAuth credentials. + */ +export type StoredApiToken = { + version: 1 + apiUrl: string + token: string + tokenId: string + name?: string + scopes?: Array + expiresAt?: string | null + maxExpiresAt?: string | null + createdVia?: string +} + +export function accountForApiUrl(apiUrl: string): string { + return `cli-api-token:${new URL(apiUrl).origin}` +} + +export function apiTokenFileStorePath( + apiUrl: string, + home: string = homedir(), +): string { + const origin = new URL(apiUrl).host.replace(/[^a-zA-Z0-9.-]/g, '_') + const base = + process.platform === 'win32' + ? join(process.env.APPDATA || join(home, 'AppData', 'Roaming'), 'kody') + : process.platform === 'darwin' + ? join(home, 'Library', 'Application Support', 'kody') + : join(process.env.XDG_CONFIG_HOME || join(home, '.config'), 'kody') + return join(base, `api-token-${origin}.json`) +} + +export function createApiTokenKeyringBackend(apiUrl: string): SecretBackend { + const entry = new Entry( + keyringService, + accountForApiUrl(apiUrl), + secretServiceKeyringOptions, + ) + return { + kind: 'keyring', + get() { + try { + return entry.getPassword() + } catch { + return null + } + }, + set(value: string) { + entry.setPassword(value) + }, + delete() { + try { + return entry.deleteCredential() + } catch { + return false + } + }, + } +} + +function fileBackendFor( + apiUrl: string, + resolution?: StoreResolution, +): SecretBackend { + const path = + resolution?.fileStorePath?.(apiUrl) ?? apiTokenFileStorePath(apiUrl) + return createFileBackend(path) +} + +function resolveApiTokenBackend( + apiUrl: string, + preferred?: SecretBackend, + resolution?: StoreResolution, +): SecretBackend { + if (preferred) return preferred + try { + return (resolution?.createKeyring ?? createApiTokenKeyringBackend)(apiUrl) + } catch { + return fileBackendFor(apiUrl, resolution) + } +} + +export function parseStoredApiToken(raw: string): StoredApiToken { + const parsed = JSON.parse(raw) as StoredApiToken + if ( + parsed.version !== 1 || + typeof parsed.token !== 'string' || + !parsed.token.startsWith('kody_at_') || + typeof parsed.apiUrl !== 'string' || + typeof parsed.tokenId !== 'string' + ) { + throw new Error( + 'Stored Kody API token is invalid. Run `kody auth bootstrap --code …` again.', + ) + } + return parsed +} + +function readParsed(store: SecretBackend): StoredApiToken | null { + const raw = store.get() + if (!raw) return null + return parseStoredApiToken(raw) +} + +export function loadStoredApiToken( + apiUrl: string = defaultApiUrl, + backend?: SecretBackend, + resolution?: StoreResolution, +): StoredApiToken | null { + const store = resolveApiTokenBackend(apiUrl, backend, resolution) + try { + const loaded = readParsed(store) + if (loaded) return loaded + } catch (error) { + if (!(store.kind === 'keyring' && !backend)) throw error + } + if (store.kind === 'keyring' && !backend) { + return readParsed(fileBackendFor(apiUrl, resolution)) + } + return null +} + +export function saveStoredApiToken( + credentials: StoredApiToken, + backend?: SecretBackend, + resolution?: StoreResolution, +): { backend: SecretBackend } { + const store = resolveApiTokenBackend(credentials.apiUrl, backend, resolution) + try { + store.set(JSON.stringify(credentials)) + return { backend: store } + } catch (error) { + if (store.kind === 'keyring' && !backend) { + const fallback = fileBackendFor(credentials.apiUrl, resolution) + fallback.set(JSON.stringify(credentials)) + return { backend: fallback } + } + throw error + } +} + +export function deleteStoredApiToken( + apiUrl: string = defaultApiUrl, + backend?: SecretBackend, + resolution?: StoreResolution, +): { deleted: boolean; backend: SecretBackend } { + const store = resolveApiTokenBackend(apiUrl, backend, resolution) + let deleted = false + try { + deleted = store.delete() + } catch { + deleted = false + } + if (store.kind === 'keyring' && !backend) { + const file = fileBackendFor(apiUrl, resolution) + deleted = file.delete() || deleted + } + return { deleted, backend: store } +} diff --git a/src/api-token.ts b/src/api-token.ts index b9bc295..468dd04 100644 --- a/src/api-token.ts +++ b/src/api-token.ts @@ -22,30 +22,36 @@ export function isScopedApiToken(token: string): boolean { } /** - * 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. + * How to mint a scoped token for CI/headless. Interactive agents on MCP should + * prefer `cliCredentialBootstrap` → `auth bootstrap` instead. */ 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}.` } +/** Preferred interactive path for agents already on Kody MCP (ADR 0056). */ +export function cliBootstrapInstructions(): string { + return `From MCP, call \`cliCredentialBootstrap\` (MCP \`api\` / \`kody.cliCredentialBootstrap\`), then run \`npx @kodycodes/cli auth bootstrap --code \`` +} + /** 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()}` + return `${purpose} needs a scoped Kody API token. ${cliBootstrapInstructions()}, or ${apiTokenMintInstructions()}` } /** - * `execute --local` with neither `--token` / `KODY_API_TOKEN` nor `kody login`. + * `execute --local` with neither env/`--token`, stored bootstrap token, nor + * `kody login`. Prefer bootstrap (MCP) → login → tokenCreate (CI). */ export function missingLocalExecuteAuthMessage( purpose: string = 'execute --local', ): string { - return `${purpose} needs auth. Run \`kody login\`, or ${apiTokenMintInstructions()}` + return `${purpose} needs auth. ${cliBootstrapInstructions()}; or run \`kody login\`; or for CI/headless, ${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, cloud execute, and login-backed \`execute --local\`).` + return `Not logged in, and no API token is set. ${cliBootstrapInstructions()}; or run \`kody login\` for browser OAuth (search, whoami, cloud execute, and login-backed \`execute --local\`); or for CI/headless, ${apiTokenMintInstructions()}` } /** 401 when CapabilityProxy rejected a non-`kody_at_` bearer (typically CLI OAuth). */ diff --git a/src/auth-bootstrap.ts b/src/auth-bootstrap.ts new file mode 100644 index 0000000..3a34827 --- /dev/null +++ b/src/auth-bootstrap.ts @@ -0,0 +1,206 @@ +import { assertTokenSafeApiUrl, capabilityProxyUrl } from './capability-proxy.js' +import { cliName, defaultApiUrl } from './defaults.js' +import { describeNetworkError } from './network-error.js' +import { readPackageVersion } from './package-info.js' +import { + saveStoredApiToken, + type StoredApiToken, +} from './api-token-store.js' +import type { SecretBackend, StoreResolution } from './store.js' + +/** ADR 0056 one-shot bootstrap code prefix (`kody_bc_…`). */ +export const cliBootstrapCodePrefix = 'kody_bc_' + +/** Platform shape: `kody_bc_<16 alnum>_<32 base64url>`. */ +const bootstrapCodePattern = /^kody_bc_([a-z0-9]{16})_([A-Za-z0-9_-]{32})$/ + +export const bootstrapRedeemPath = 'v1/tokens/bootstrap/redeem' + +export type BootstrapRedeemResponse = { + token: string + token_type?: string + id: string + name?: string | null + scopes?: Array + status?: string + idle_ttl_seconds?: number + expires_at?: string | null + max_expires_at?: string | null + created_via?: string +} + +export function parseCliBootstrapCode(value: string): { codeId: string; secret: string } | null { + const match = bootstrapCodePattern.exec(value.trim()) + if (!match) return null + const [, codeId, secret] = match + if (!codeId || !secret) return null + return { codeId, secret } +} + +export function assertCliBootstrapCode(code: string): string { + const trimmed = code.trim() + if (!parseCliBootstrapCode(trimmed)) { + throw new Error( + `Invalid bootstrap code. Expected a one-shot ${cliBootstrapCodePrefix}… from cliCredentialBootstrap (MCP api / kody.cliCredentialBootstrap).`, + ) + } + return trimmed +} + +/** + * POST /v1/tokens/bootstrap/redeem with JSON `{ code }` and **no** Authorization + * header (ADR 0056). Returns the minted `kody_at_…` once. + */ +export async function redeemBootstrapCode(input: { + code: string + apiUrl?: string + fetchFn?: typeof fetch +}): Promise { + const code = assertCliBootstrapCode(input.code) + const apiUrl = input.apiUrl || defaultApiUrl + assertTokenSafeApiUrl(apiUrl) + const url = capabilityProxyUrl(apiUrl, bootstrapRedeemPath) + const fetchFn = input.fetchFn ?? fetch + let response: Response + try { + response = await fetchFn(url, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + 'user-agent': `${cliName}/${readPackageVersion()}`, + }, + body: JSON.stringify({ code }), + }) + } catch (error) { + const reason = describeNetworkError(error) + throw new Error( + `Could not reach the Kody API at ${url.origin} (${reason}). Check --api-url / KODY_API_URL and your network.`, + ) + } + const body = await readJson(response) + if (!response.ok) { + throw describeRedeemFailure(response.status, body, url) + } + return parseRedeemResponse(body) +} + +export function storedApiTokenFromRedeem(input: { + apiUrl: string + redeemed: BootstrapRedeemResponse +}): StoredApiToken { + const token = input.redeemed.token.trim() + if (!token.startsWith('kody_at_')) { + throw new Error('Bootstrap redeem did not return a scoped kody_at_… API token.') + } + return { + version: 1, + apiUrl: input.apiUrl, + token, + tokenId: input.redeemed.id, + ...(input.redeemed.name ? { name: input.redeemed.name } : {}), + ...(Array.isArray(input.redeemed.scopes) ? { scopes: input.redeemed.scopes } : {}), + expiresAt: input.redeemed.expires_at ?? null, + maxExpiresAt: input.redeemed.max_expires_at ?? null, + createdVia: input.redeemed.created_via ?? 'cli-bootstrap', + } +} + +/** + * Redeem a one-shot `kody_bc_…` and persist the resulting API token for + * `execute --local`. Never returns the token string to callers that print + * success output — use `stored.tokenId` / backend kind only. + */ +export async function authBootstrap(input: { + code: string + apiUrl?: string + fetchFn?: typeof fetch + backend?: SecretBackend + resolution?: StoreResolution +}): Promise<{ + stored: StoredApiToken + backendKind: SecretBackend['kind'] + backendPath?: string +}> { + const apiUrl = input.apiUrl || defaultApiUrl + const redeemed = await redeemBootstrapCode({ + code: input.code, + apiUrl, + fetchFn: input.fetchFn, + }) + const stored = storedApiTokenFromRedeem({ apiUrl, redeemed }) + const saved = saveStoredApiToken(stored, input.backend, input.resolution) + return { + stored, + backendKind: saved.backend.kind, + ...(saved.backend.path ? { backendPath: saved.backend.path } : {}), + } +} + +function parseRedeemResponse(body: unknown): BootstrapRedeemResponse { + if (!isRecord(body) || typeof body.token !== 'string' || typeof body.id !== 'string') { + throw new Error('Bootstrap redeem returned an unexpected response.') + } + return { + token: body.token, + token_type: typeof body.token_type === 'string' ? body.token_type : undefined, + id: body.id, + name: typeof body.name === 'string' ? body.name : null, + scopes: Array.isArray(body.scopes) + ? body.scopes.filter((scope): scope is string => typeof scope === 'string') + : undefined, + status: typeof body.status === 'string' ? body.status : undefined, + idle_ttl_seconds: + typeof body.idle_ttl_seconds === 'number' ? body.idle_ttl_seconds : undefined, + expires_at: typeof body.expires_at === 'string' ? body.expires_at : null, + max_expires_at: typeof body.max_expires_at === 'string' ? body.max_expires_at : null, + created_via: typeof body.created_via === 'string' ? body.created_via : undefined, + } +} + +function describeRedeemFailure(status: number, body: unknown, url: URL): Error { + const failure = readErrorBody(body) + const detail = failure?.message ? ` Server said: ${failure.message}` : '' + if (status === 400 || status === 401 || status === 403 || status === 404) { + return new Error( + `Bootstrap redeem failed (HTTP ${status}). The code may be invalid, expired, or already used. Call cliCredentialBootstrap again for a fresh code.${detail}`, + ) + } + return new Error( + failure?.message ?? + `Bootstrap redeem failed with HTTP ${status} (${url.pathname}).${detail}`, + ) +} + +async function readJson(response: Response): Promise { + const text = await response.text() + if (!text) return null + try { + return JSON.parse(text) + } catch { + return text + } +} + +function readErrorBody(body: unknown): { code: string | null; message: string } | null { + if (!isRecord(body) || body.error == null) { + if (typeof body === 'string' && body.trim()) return { code: null, message: body } + if (isRecord(body) && typeof body.message === 'string') { + return { code: null, message: body.message } + } + return null + } + const { error } = body + if (typeof error === 'string') return { code: null, message: error } + if (isRecord(error)) { + const code = typeof error.code === 'string' ? error.code : null + const message = + typeof error.message === 'string' ? error.message : code ?? JSON.stringify(error) + return { code, message } + } + return { code: null, message: String(error) } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} diff --git a/src/cli.ts b/src/cli.ts index d7aa515..a797976 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,6 +5,11 @@ import { readApiToken, requireApiToken, } from './api-token.js' +import { + deleteStoredApiToken, + loadStoredApiToken, +} from './api-token-store.js' +import { authBootstrap } from './auth-bootstrap.js' import { defaultApiUrl, defaultMcpUrl, modernMcpProtocolVersion } from './defaults.js' import { usage } from './help.js' import { ensureFreshCredentials, login } from './auth.js' @@ -22,11 +27,13 @@ import { redactError } from './redact.js' export { readApiToken, requireApiToken as resolveApiToken } from './api-token.js' export { resolveLocalExecuteBearer } from './local-execute-auth.js' +export { authBootstrap, redeemBootstrapCode } from './auth-bootstrap.js' export type CommandName = | 'login' | 'logout' | 'status' + | 'auth' | 'whoami' | 'search' | 'execute' @@ -93,6 +100,7 @@ export function resolveCommand(argv: Array): { case 'login': case 'logout': case 'status': + case 'auth': case 'whoami': case 'search': case 'execute': @@ -162,26 +170,100 @@ async function dispatch( return 0 } case 'logout': { - const result = deleteCredentials(mcpUrl) - write(result.deleted ? 'Logged out.\n' : 'No stored credentials.\n') + const apiUrl = apiUrlFrom({ + apiUrl: + typeof parsed.values['api-url'] === 'string' + ? parsed.values['api-url'] + : undefined, + }) + const oauth = deleteCredentials(mcpUrl) + const apiToken = deleteStoredApiToken(apiUrl) + if (!oauth.deleted && !apiToken.deleted) { + write('No stored credentials.\n') + return 0 + } + const parts: Array = [] + if (oauth.deleted) parts.push('Logged out of kody login.') + if (apiToken.deleted) parts.push('Cleared stored bootstrap/API token.') + write(`${parts.join(' ')}\n`) return 0 } case 'status': { + const apiUrl = apiUrlFrom({ + apiUrl: + typeof parsed.values['api-url'] === 'string' + ? parsed.values['api-url'] + : undefined, + }) const credentials = loadCredentials(mcpUrl) - if (!credentials) { + const storedApi = loadStoredApiToken(apiUrl) + if (!credentials && !storedApi) { write('Not logged in.\n') return 1 } - const expires = credentials.expiresAt - ? new Date(credentials.expiresAt).toISOString() - : 'unknown' - write( - [ + const lines: Array = [] + if (credentials) { + const expires = credentials.expiresAt + ? new Date(credentials.expiresAt).toISOString() + : 'unknown' + lines.push( `mcp: ${credentials.mcpUrl}`, `logged in: yes`, `access token expires: ${expires}`, `refresh token: ${credentials.refreshToken ? 'yes' : 'no'}`, `scope: ${credentials.scope ?? 'unknown'}`, + ) + } else { + lines.push(`mcp: ${mcpUrl}`, `logged in: no`) + } + if (storedApi) { + const scopes = storedApi.scopes?.join(', ') || '(unknown)' + lines.push( + `api: ${storedApi.apiUrl}`, + `stored API token: yes (${storedApi.tokenId})`, + `API token scopes: ${scopes}`, + `API token expires: ${storedApi.expiresAt ?? 'unknown'}`, + `API token via: ${storedApi.createdVia ?? 'unknown'}`, + ) + } else { + lines.push(`api: ${apiUrl}`, `stored API token: no`) + } + lines.push('') + write(lines.join('\n')) + return 0 + } + case 'auth': { + const action = parsed.positionals[0] + if (action !== 'bootstrap') { + throw new Error( + 'Usage: kody auth bootstrap --code [--api-url ]', + ) + } + const code = + typeof parsed.values.code === 'string' ? parsed.values.code.trim() : '' + if (!code) { + throw new Error( + 'Provide --code from cliCredentialBootstrap (MCP api / kody.cliCredentialBootstrap).', + ) + } + const apiUrl = apiUrlFrom({ + apiUrl: + typeof parsed.values['api-url'] === 'string' + ? parsed.values['api-url'] + : undefined, + }) + const result = await authBootstrap({ code, apiUrl }) + const scopes = result.stored.scopes?.join(', ') || '(none)' + write( + [ + `Bootstrap API token stored for execute --local.`, + `api: ${result.stored.apiUrl}`, + `token id: ${result.stored.tokenId}`, + `scopes: ${scopes}`, + `expires: ${result.stored.expiresAt ?? 'unknown'}`, + result.backendKind === 'file' && result.backendPath + ? `OS keychain was unavailable; token saved at ${result.backendPath} (mode 0600).` + : 'Token stored in the OS keychain.', '', ].join('\n'), ) @@ -335,6 +417,7 @@ async function dispatch( token: await resolveLocalExecuteBearer({ tokenValues, mcpUrl, + apiUrl, purpose: 'execute --local', }), apiUrl, diff --git a/src/help.ts b/src/help.ts index 5e4ec9a..649ef78 100644 --- a/src/help.ts +++ b/src/help.ts @@ -8,8 +8,9 @@ Install Kody as a remote MCP server in local agents, or use this CLI as a local Usage: kody login [--mcp-url ] [--no-browser] - kody logout [--mcp-url ] - kody status [--mcp-url ] + kody logout [--mcp-url ] [--api-url ] + kody status [--mcp-url ] [--api-url ] + kody auth bootstrap --code [--api-url ] kody whoami [--mcp-url ] [--token ] [--api-url ] [--json] kody search [query] [--entity ] [--domain ] [--limit ] [--token ] [--api-url ] [--json] kody execute [--invoke | --code | --file ] [--params ] [--conversation-id ] [--json] @@ -23,25 +24,33 @@ Usage: --clients Comma-separated ids: ${hostIds.join(', ')} + 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. + --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. Mint with the MCP \`api\` tool \`tokenCreate\` (include - \`local-execute\` plus the capability scopes you need). - For \`execute --local\`, \`--token\` / ${apiTokenEnvVar} wins - when set; otherwise a valid \`kody login\` session is used as - the Bearer (no tokenCreate exchange). + \`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). --local Run the execute module on this machine (workerd, Linux/macOS). 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. + ${apiTokenEnvVar}, else stored \`auth bootstrap\` token, 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 index a156a2b..b39de8d 100644 --- a/src/local-execute-auth.ts +++ b/src/local-execute-auth.ts @@ -3,32 +3,45 @@ import { missingLocalExecuteAuthMessage, readApiToken, } from './api-token.js' -import type { SecretBackend } from './store.js' +import { loadStoredApiToken } from './api-token-store.js' +import { defaultApiUrl } from './defaults.js' +import type { SecretBackend, StoreResolution } 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. + * Priority: + * 1. `--token` / `KODY_API_TOKEN` + * 2. Stored bootstrap/API token from `auth bootstrap --code` + * 3. Fresh `kody login` OAuth access token (never printed) * - * 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. + * 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) 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/src/redact.ts b/src/redact.ts index f2af3b5..95710b0 100644 --- a/src/redact.ts +++ b/src/redact.ts @@ -1,8 +1,13 @@ const secretPattern = /(access_token|refresh_token|client_secret|authorization)["']?\s*[:=]\s*["']?[^\s"',}]+/gi +/** Never leak minted API tokens or one-shot bootstrap codes in CLI output. */ +const kodySecretPattern = /\bkody_(?:at|bc)_[A-Za-z0-9_-]+\b/g + export function redact(value: string): string { - return value.replace(secretPattern, '$1=[redacted]') + return value + .replace(secretPattern, '$1=[redacted]') + .replace(kodySecretPattern, 'kody_[redacted]') } export function redactError(error: unknown): Error { diff --git a/test/auth-bootstrap.test.ts b/test/auth-bootstrap.test.ts new file mode 100644 index 0000000..26ae5d2 --- /dev/null +++ b/test/auth-bootstrap.test.ts @@ -0,0 +1,253 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync } from 'node:fs' +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { after, before, test } from 'node:test' +import { + assertCliBootstrapCode, + authBootstrap, + parseCliBootstrapCode, + redeemBootstrapCode, +} from '../src/auth-bootstrap.js' +import { + loadStoredApiToken, + parseStoredApiToken, + saveStoredApiToken, + type StoredApiToken, +} from '../src/api-token-store.js' +import { resolveLocalExecuteBearer, runCli } from '../src/cli.js' +import { createFileBackend } from '../src/store.js' +import { redact } from '../src/redact.js' + +/** Matches platform `kody_bc_<16>_<32 base64url>`. */ +const goodCode = `kody_bc_${'a'.repeat(16)}_${'B'.repeat(32)}` +const goodToken = 'kody_at_test_secret_value_do_not_print' + +type RedeemRequest = { + method: string + url: string + authorization: string | null + body: unknown +} + +const requests: Array = [] +let redeemStatus = 200 +let redeemBody: unknown = { + token: goodToken, + token_type: 'Bearer', + id: 'tok_bootstrap_1', + name: 'kody-cli-bootstrap', + scopes: ['account:read', 'local-execute'], + status: 'active', + idle_ttl_seconds: 3600, + expires_at: '2026-10-02T12:00:00.000Z', + max_expires_at: '2026-10-08T12:00:00.000Z', + created_via: 'cli-bootstrap', +} +let apiUrl = '' +let server: Server + +before(async () => { + server = createServer(async (request, response) => { + let raw = '' + for await (const chunk of request) raw += chunk + const body = raw ? JSON.parse(raw) : null + requests.push({ + method: request.method ?? '', + url: request.url ?? '', + authorization: request.headers.authorization ?? null, + body, + }) + response.writeHead(redeemStatus, { 'content-type': 'application/json' }) + response.end(JSON.stringify(redeemBody)) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}` +}) + +after(async () => { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) +}) + +function reset() { + requests.length = 0 + redeemStatus = 200 + redeemBody = { + token: goodToken, + token_type: 'Bearer', + id: 'tok_bootstrap_1', + name: 'kody-cli-bootstrap', + scopes: ['account:read', 'local-execute'], + status: 'active', + idle_ttl_seconds: 3600, + expires_at: '2026-10-02T12:00:00.000Z', + max_expires_at: '2026-10-08T12:00:00.000Z', + created_via: 'cli-bootstrap', + } +} + +function tempBackend() { + const path = join(mkdtempSync(join(tmpdir(), 'kody-cli-boot-')), 'api-token.json') + return createFileBackend(path) +} + +test('parseCliBootstrapCode accepts platform-shaped codes', () => { + assert.deepEqual(parseCliBootstrapCode(goodCode), { + codeId: 'a'.repeat(16), + secret: 'B'.repeat(32), + }) + assert.equal(parseCliBootstrapCode('kody_at_not_a_bootstrap'), null) + assert.throws(() => assertCliBootstrapCode('nope'), /cliCredentialBootstrap/) +}) + +test('redeemBootstrapCode POSTs JSON code with no Authorization header', async () => { + reset() + const redeemed = await redeemBootstrapCode({ + code: goodCode, + apiUrl, + fetchFn: fetch, + }) + assert.equal(requests.length, 1) + assert.equal(requests[0]?.method, 'POST') + assert.equal(requests[0]?.url, '/v1/tokens/bootstrap/redeem') + assert.equal(requests[0]?.authorization, null) + assert.deepEqual(requests[0]?.body, { code: goodCode }) + assert.equal(redeemed.token, goodToken) + assert.equal(redeemed.id, 'tok_bootstrap_1') + assert.equal(redeemed.created_via, 'cli-bootstrap') +}) + +test('authBootstrap redeems and stores without exposing the token in returned metadata fields used for printing', async () => { + reset() + const backend = tempBackend() + const result = await authBootstrap({ + code: goodCode, + apiUrl, + backend, + fetchFn: fetch, + }) + assert.equal(result.backendKind, 'file') + assert.equal(result.stored.tokenId, 'tok_bootstrap_1') + assert.equal(result.stored.createdVia, 'cli-bootstrap') + assert.deepEqual(result.stored.scopes, ['account:read', 'local-execute']) + const loaded = loadStoredApiToken(apiUrl, backend) + assert.equal(loaded?.token, goodToken) + assert.equal(loaded?.tokenId, 'tok_bootstrap_1') + assert.ok(backend.path) + const onDisk = JSON.parse(readFileSync(backend.path, 'utf8')) as StoredApiToken + assert.equal(onDisk.token, goodToken) + assert.equal(onDisk.version, 1) +}) + +test('runCli auth bootstrap redeems and never prints the kody_at_ token', async () => { + reset() + const home = mkdtempSync(join(tmpdir(), 'kody-cli-home-')) + const previousXdg = process.env.XDG_CONFIG_HOME + const previousHome = process.env.HOME + process.env.XDG_CONFIG_HOME = home + process.env.HOME = home + let stdout = '' + try { + const code = await runCli( + ['auth', 'bootstrap', '--code', goodCode, '--api-url', apiUrl], + { + stdout: (text) => { + stdout += text + }, + }, + ) + assert.equal(code, 0) + assert.match(stdout, /Bootstrap API token stored/) + assert.match(stdout, /tok_bootstrap_1/) + assert.match(stdout, /local-execute/) + assert.doesNotMatch(stdout, /kody_at_/) + assert.doesNotMatch(stdout, new RegExp(goodToken.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) + assert.equal(requests[0]?.authorization, null) + } finally { + 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 + } +}) + +test('resolveLocalExecuteBearer prefers stored bootstrap token over login OAuth', async () => { + const backend = tempBackend() + saveStoredApiToken( + { + version: 1, + apiUrl: 'https://api.kody.codes', + token: 'kody_at_stored_bootstrap', + tokenId: 'tok_stored', + scopes: ['local-execute'], + createdVia: 'cli-bootstrap', + }, + backend, + ) + const token = await resolveLocalExecuteBearer({ + tokenValues: {}, + env: {}, + apiUrl: 'https://api.kody.codes', + apiTokenBackend: backend, + ensureCredentials: async () => { + throw new Error('login should not be consulted when bootstrap token is stored') + }, + }) + assert.equal(token, 'kody_at_stored_bootstrap') +}) + +test('resolveLocalExecuteBearer still prefers --token over stored bootstrap', async () => { + const backend = tempBackend() + saveStoredApiToken( + { + version: 1, + apiUrl: 'https://api.kody.codes', + token: 'kody_at_stored_bootstrap', + tokenId: 'tok_stored', + }, + backend, + ) + const token = await resolveLocalExecuteBearer({ + tokenValues: { token: 'kody_at_flag' }, + env: {}, + apiUrl: 'https://api.kody.codes', + apiTokenBackend: backend, + ensureCredentials: async () => { + throw new Error('login should not run') + }, + }) + assert.equal(token, 'kody_at_flag') +}) + +test('parseStoredApiToken rejects non-kody_at payloads', () => { + assert.throws( + () => + parseStoredApiToken( + JSON.stringify({ + version: 1, + apiUrl: 'https://api.kody.codes', + token: 'oauth-looking', + tokenId: 'x', + }), + ), + /invalid/i, + ) +}) + +test('redact strips kody_at_ and kody_bc_ secrets from messages', () => { + assert.match(redact(`got ${goodToken} and ${goodCode}`), /kody_\[redacted]/) + assert.doesNotMatch(redact(`got ${goodToken}`), /kody_at_test/) +}) + +test('redeemBootstrapCode surfaces already-used codes clearly', async () => { + reset() + redeemStatus = 400 + redeemBody = { error: { code: 'invalid_request', message: 'already redeemed' } } + await assert.rejects( + () => redeemBootstrapCode({ code: goodCode, apiUrl, fetchFn: fetch }), + /already used|expired|invalid/i, + ) +}) diff --git a/test/cli.test.ts b/test/cli.test.ts index 1182c8b..0b33f7c 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -44,6 +44,8 @@ function sampleLoginCredentials( test('resolveCommand maps subcommands and flags', () => { assert.equal(resolveCommand(['search', 'what can you do']).command, 'search') assert.equal(resolveCommand(['install', '--yes']).command, 'install') + assert.equal(resolveCommand(['auth', 'bootstrap', '--code', 'kody_bc_x']).command, 'auth') + assert.equal(resolveCommand(['auth', 'bootstrap']).positionals[0], 'bootstrap') assert.equal(resolveCommand(['--help']).command, 'help') assert.equal(resolveCommand(['--version']).command, 'version') assert.equal( @@ -78,7 +80,7 @@ test('resolveApiToken prefers --token, falls back to KODY_API_TOKEN, and require assert.equal(resolveApiToken({}, { KODY_API_TOKEN: ' env ' }), 'env') assert.throws( () => resolveApiToken({}, {}), - /tokenCreate[\s\S]*local-execute[\s\S]*pass --token or set KODY_API_TOKEN/, + /cliCredentialBootstrap[\s\S]*tokenCreate[\s\S]*local-execute[\s\S]*pass --token or set KODY_API_TOKEN/, ) }) @@ -97,6 +99,7 @@ test('resolveLocalExecuteBearer uses login OAuth when no API token is set', asyn const token = await resolveLocalExecuteBearer({ tokenValues: {}, env: {}, + loadApiToken: () => null, ensureCredentials: async () => sampleLoginCredentials(), }) assert.equal(token, 'oauth-access-from-login') @@ -108,11 +111,12 @@ test('resolveLocalExecuteBearer fails clearly when neither login nor token is av resolveLocalExecuteBearer({ tokenValues: {}, env: {}, + loadApiToken: () => null, ensureCredentials: async () => { throw new Error('Not logged in') }, }), - /execute --local needs auth[\s\S]*kody login[\s\S]*KODY_API_TOKEN/, + /execute --local needs auth[\s\S]*cliCredentialBootstrap[\s\S]*auth bootstrap[\s\S]*kody login[\s\S]*tokenCreate/, ) }) @@ -231,6 +235,7 @@ test('execute without token or login prompts clearly', async () => { else process.env.KODY_API_TOKEN = previousToken } assert.match(stderr, /Not logged in, and no API token is set/) + assert.match(stderr, /cliCredentialBootstrap|auth bootstrap/) assert.match(stderr, /tokenCreate/) assert.match(stderr, /local-execute/) assert.match(stderr, /KODY_API_TOKEN/) @@ -257,6 +262,7 @@ test('execute --local without token or login fails clearly', async () => { else process.env.XDG_CONFIG_HOME = previousXdg } assert.match(stderr, /execute --local needs auth/) + assert.match(stderr, /cliCredentialBootstrap|auth bootstrap/) assert.match(stderr, /kody login/) assert.match(stderr, /tokenCreate|KODY_API_TOKEN/) assert.match(stderr, /pass --token or set KODY_API_TOKEN/) diff --git a/test/skill.test.ts b/test/skill.test.ts index 6ade8de..d3ed7fe 100644 --- a/test/skill.test.ts +++ b/test/skill.test.ts @@ -19,6 +19,7 @@ test('installSkill writes the bundled skill to user host directories', async () assert.match(body, /Install the MCP server \(recommended\)/) assert.match(body, /npx @kodycodes\/cli install/) assert.match(body, /kody login/) + assert.match(body, /auth bootstrap|cliCredentialBootstrap/) } })