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
3 changes: 2 additions & 1 deletion src/api-token-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 33 additions & 2 deletions src/api-token.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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_')
Expand Down Expand Up @@ -83,8 +109,13 @@ export function requireApiToken(
values: { token?: string } = {},
env: NodeJS.ProcessEnv = process.env,
purpose: string = 'this command',
options: Omit<ResolveScopedApiTokenInput, 'tokenValues' | 'env'> = {},
): string {
const token = readApiToken(values, env)
const token = resolveScopedApiToken({
tokenValues: values,
env,
...options,
})
if (!token) throw new Error(missingApiTokenMessage(purpose))
return token
}
Expand Down
83 changes: 60 additions & 23 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'

Expand Down Expand Up @@ -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}`,
Expand All @@ -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`)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -407,6 +427,7 @@ async function dispatch(
shouldUseApiToken({
tokenValues,
mcpUrl,
apiUrl,
allowEnvWithoutLogin: true,
})
const result = local
Expand All @@ -433,6 +454,7 @@ async function dispatch(
tokenValues,
process.env,
'execute with an API token',
{ apiUrl },
),
apiUrl,
})
Expand Down Expand Up @@ -543,21 +565,36 @@ function tokenFlagValues(values: ReturnType<typeof parseKnown>['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<typeof resolveScopedApiToken>[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
Expand Down
16 changes: 10 additions & 6 deletions src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\` /
Expand Down
41 changes: 15 additions & 26 deletions src/local-execute-auth.ts
Original file line number Diff line number Diff line change
@@ -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`.
Expand All @@ -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<string> {
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<string> {
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({
Expand Down
Loading
Loading