Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 31 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down
23 changes: 15 additions & 8 deletions skills/kody/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"}'
```
29 changes: 26 additions & 3 deletions src/api-token.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.
Expand All @@ -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: {
Expand Down
22 changes: 13 additions & 9 deletions src/capability-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 }`.
Expand All @@ -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'
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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 })
Expand Down
8 changes: 7 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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`),
})
Expand Down
19 changes: 11 additions & 8 deletions src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
44 changes: 44 additions & 0 deletions src/local-execute-auth.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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'))
}
}
12 changes: 7 additions & 5 deletions src/local-package-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -180,6 +181,7 @@ function describePackageGraphFailure(
body: unknown,
url: URL,
imports: Array<string>,
token: string,
): LocalPackageGraphError {
const failure = readErrorBody(body)
const code = failure?.code ?? null
Expand All @@ -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(
Expand Down
19 changes: 18 additions & 1 deletion test/capability-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> =
Expand Down Expand Up @@ -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' },
Expand Down
Loading
Loading