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
2 changes: 1 addition & 1 deletion src/api-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export function apiTokenMintInstructions(): string {

/** 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 <kody_bc_…>\``
return `From MCP, call \`cliCredentialBootstrap\` (MCP \`api\` / \`kody.cliCredentialBootstrap\`), then run \`npx @kodycodes/cli auth bootstrap --code <kody_bc_…> --lifetime short\``
}

/** Token-only Open API paths (search / whoami / cloud token execute) with no token. */
Expand Down
189 changes: 187 additions & 2 deletions src/auth-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,50 @@ const bootstrapCodePattern = /^kody_bc_([a-z0-9]{16})_([A-Za-z0-9_-]{32})$/

export const bootstrapRedeemPath = 'v1/tokens/bootstrap/redeem'

/** ADR 0056 idle / absolute lifetime caps (seconds). */
export const cliTokenLifetimePolicy = {
minIdleTtlSeconds: 60,
/** Idle timeout at most 14 days. */
maxIdleTtlSeconds: 14 * 24 * 60 * 60,
/** Absolute lifetime at most 3 months. */
maxMaxLifetimeSeconds: 90 * 24 * 60 * 60,
} as const

/**
* Input aliases for required token lifetimes. Sugar only: never stored on the
* token. `short` suits single-task agents; `long` is the policy maximum.
*/
export const cliTokenLifetimeAliases = {
short: {
idleTtlSeconds: 60 * 60,
maxLifetimeSeconds: 24 * 60 * 60,
},
long: {
idleTtlSeconds: cliTokenLifetimePolicy.maxIdleTtlSeconds,
maxLifetimeSeconds: cliTokenLifetimePolicy.maxMaxLifetimeSeconds,
},
} as const

export type CliTokenLifetimeAlias = keyof typeof cliTokenLifetimeAliases

/**
* Resolved lifetime for redeem. Alias form keeps the label only for the
* redeem JSON body (`lifetime`); explicit form sends idle/max seconds.
* Neither label nor alias is persisted with the stored API token.
*/
export type ResolvedCliTokenLifetime =
| {
kind: 'alias'
lifetime: CliTokenLifetimeAlias
idleTtlSeconds: number
maxLifetimeSeconds: number
}
| {
kind: 'explicit'
idleTtlSeconds: number
maxLifetimeSeconds: number
}

export type BootstrapRedeemResponse = {
token: string
token_type?: string
Expand All @@ -29,6 +73,14 @@ export type BootstrapRedeemResponse = {
created_via?: string
}

export type BootstrapRedeemRequestBody =
| { code: string; lifetime: CliTokenLifetimeAlias }
| {
code: string
idle_ttl_seconds: number
max_lifetime_seconds: number
}

export function parseCliBootstrapCode(value: string): { codeId: string; secret: string } | null {
const match = bootstrapCodePattern.exec(value.trim())
if (!match) return null
Expand All @@ -47,16 +99,125 @@ export function assertCliBootstrapCode(code: string): string {
return trimmed
}

/** Exact CLI flag syntax for a missing lifetime (ADR 0056). */
export function cliTokenLifetimeMissingError(): string {
return (
'Token lifetime is required. Pass --lifetime short|long, or both ' +
`--idle-ttl-seconds <n> and --max-lifetime-seconds <n> ` +
`(idle ${cliTokenLifetimePolicy.minIdleTtlSeconds}-${cliTokenLifetimePolicy.maxIdleTtlSeconds}s, ` +
`max age up to ${cliTokenLifetimePolicy.maxMaxLifetimeSeconds}s). ` +
'Single-task agents should use --lifetime short.'
)
}

/**
* POST /v1/tokens/bootstrap/redeem with JSON `{ code }` and **no** Authorization
* Resolve a required lifetime choice. Aliases expand to idle/max seconds for
* validation; the redeem body still sends the alias or explicit seconds only.
*/
export function resolveCliTokenLifetime(input: {
lifetime?: string | null
idleTtlSeconds?: number
maxLifetimeSeconds?: number
}): ResolvedCliTokenLifetime {
const aliasRaw = typeof input.lifetime === 'string' ? input.lifetime.trim() : ''
const hasAlias = aliasRaw.length > 0
const hasIdle = input.idleTtlSeconds !== undefined
const hasMax = input.maxLifetimeSeconds !== undefined

if (!hasAlias && !hasIdle && !hasMax) {
throw new Error(cliTokenLifetimeMissingError())
}
if (hasAlias && (hasIdle || hasMax)) {
throw new Error(
'Pass --lifetime short|long, or both --idle-ttl-seconds and --max-lifetime-seconds, not both forms.',
)
}
if (hasAlias) {
if (!(aliasRaw in cliTokenLifetimeAliases)) {
throw new Error(
`--lifetime must be short or long (got ${JSON.stringify(aliasRaw)}).`,
)
}
const lifetime = aliasRaw as CliTokenLifetimeAlias
const resolved = cliTokenLifetimeAliases[lifetime]
return {
kind: 'alias',
lifetime,
idleTtlSeconds: resolved.idleTtlSeconds,
maxLifetimeSeconds: resolved.maxLifetimeSeconds,
}
}
if (!hasIdle || !hasMax) {
throw new Error(
'When not using --lifetime short|long, both --idle-ttl-seconds and --max-lifetime-seconds are required.',
)
}
const idleTtlSeconds = readRequiredInteger({
value: input.idleTtlSeconds!,
min: cliTokenLifetimePolicy.minIdleTtlSeconds,
max: cliTokenLifetimePolicy.maxIdleTtlSeconds,
field: '--idle-ttl-seconds',
})
const maxLifetimeSeconds = readRequiredInteger({
value: input.maxLifetimeSeconds!,
min: idleTtlSeconds,
max: cliTokenLifetimePolicy.maxMaxLifetimeSeconds,
field: '--max-lifetime-seconds',
})
return {
kind: 'explicit',
idleTtlSeconds,
maxLifetimeSeconds,
}
}

/** Build the redeem JSON body (alias or explicit seconds — never both). */
export function bootstrapRedeemRequestBody(
code: string,
lifetime: ResolvedCliTokenLifetime,
): BootstrapRedeemRequestBody {
if (lifetime.kind === 'alias') {
return { code, lifetime: lifetime.lifetime }
}
return {
code,
idle_ttl_seconds: lifetime.idleTtlSeconds,
max_lifetime_seconds: lifetime.maxLifetimeSeconds,
}
}

/** Parse a CLI `--idle-ttl-seconds` / `--max-lifetime-seconds` string flag. */
export function parseCliLifetimeSecondsFlag(
raw: string | undefined,
flag: '--idle-ttl-seconds' | '--max-lifetime-seconds',
): number | undefined {
if (raw === undefined) return undefined
const trimmed = raw.trim()
if (!/^-?\d+$/.test(trimmed)) {
throw new Error(`${flag} must be an integer.`)
}
return Number(trimmed)
}

/**
* POST /v1/tokens/bootstrap/redeem with JSON `{ code, lifetime }` or
* `{ code, idle_ttl_seconds, max_lifetime_seconds }` and **no** Authorization
* header (ADR 0056). Returns the minted `kody_at_…` once.
*/
export async function redeemBootstrapCode(input: {
code: string
lifetime?: string | null
idleTtlSeconds?: number
maxLifetimeSeconds?: number
apiUrl?: string
fetchFn?: typeof fetch
}): Promise<BootstrapRedeemResponse> {
const code = assertCliBootstrapCode(input.code)
const lifetime = resolveCliTokenLifetime({
lifetime: input.lifetime,
idleTtlSeconds: input.idleTtlSeconds,
maxLifetimeSeconds: input.maxLifetimeSeconds,
})
const apiUrl = input.apiUrl || defaultApiUrl
assertTokenSafeApiUrl(apiUrl)
const url = capabilityProxyUrl(apiUrl, bootstrapRedeemPath)
Expand All @@ -70,7 +231,7 @@ export async function redeemBootstrapCode(input: {
'content-type': 'application/json',
'user-agent': `${cliName}/${readPackageVersion()}`,
},
body: JSON.stringify({ code }),
body: JSON.stringify(bootstrapRedeemRequestBody(code, lifetime)),
})
} catch (error) {
const reason = describeNetworkError(error)
Expand Down Expand Up @@ -113,6 +274,9 @@ export function storedApiTokenFromRedeem(input: {
*/
export async function authBootstrap(input: {
code: string
lifetime?: string | null
idleTtlSeconds?: number
maxLifetimeSeconds?: number
apiUrl?: string
fetchFn?: typeof fetch
backend?: SecretBackend
Expand All @@ -125,6 +289,9 @@ export async function authBootstrap(input: {
const apiUrl = input.apiUrl || defaultApiUrl
const redeemed = await redeemBootstrapCode({
code: input.code,
lifetime: input.lifetime,
idleTtlSeconds: input.idleTtlSeconds,
maxLifetimeSeconds: input.maxLifetimeSeconds,
apiUrl,
fetchFn: input.fetchFn,
})
Expand All @@ -137,6 +304,24 @@ export async function authBootstrap(input: {
}
}

function readRequiredInteger(input: {
value: number
min: number
max: number
field: string
}): number {
if (
!Number.isInteger(input.value) ||
input.value < input.min ||
input.value > input.max
) {
throw new Error(
`${input.field} must be an integer between ${input.min} and ${input.max}.`,
)
}
return input.value
}

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.')
Expand Down
51 changes: 47 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import {
deleteStoredApiToken,
loadStoredApiToken,
} from './api-token-store.js'
import { authBootstrap } from './auth-bootstrap.js'
import {
authBootstrap,
parseCliLifetimeSecondsFlag,
resolveCliTokenLifetime,
} from './auth-bootstrap.js'
import { defaultApiUrl, defaultMcpUrl, modernMcpProtocolVersion } from './defaults.js'
import { usage } from './help.js'
import { ensureFreshCredentials, login } from './auth.js'
Expand All @@ -35,7 +39,16 @@ export {
resolveScopedApiToken,
} from './api-token.js'
export { resolveLocalExecuteBearer } from './local-execute-auth.js'
export { authBootstrap, redeemBootstrapCode } from './auth-bootstrap.js'
export {
authBootstrap,
bootstrapRedeemRequestBody,
cliTokenLifetimeAliases,
cliTokenLifetimeMissingError,
cliTokenLifetimePolicy,
parseCliLifetimeSecondsFlag,
redeemBootstrapCode,
resolveCliTokenLifetime,
} from './auth-bootstrap.js'

export type CommandName =
| 'login'
Expand Down Expand Up @@ -84,6 +97,9 @@ function parseKnown(args: Array<string>) {
'allow-private-network': { type: 'boolean' },
token: { type: 'string' },
'api-url': { type: 'string' },
lifetime: { type: 'string' },
'idle-ttl-seconds': { type: 'string' },
'max-lifetime-seconds': { type: 'string' },
project: { type: 'boolean' },
'no-browser': { type: 'boolean' },
clients: { type: 'string' },
Expand Down Expand Up @@ -253,7 +269,7 @@ async function dispatch(
const action = parsed.positionals[0]
if (action !== 'bootstrap') {
throw new Error(
'Usage: kody auth bootstrap --code <kody_bc_…> [--api-url <url>]',
'Usage: kody auth bootstrap --code <kody_bc_…> (--lifetime short|long | --idle-ttl-seconds <n> --max-lifetime-seconds <n>) [--api-url <url>]',
)
}
const code =
Expand All @@ -263,13 +279,40 @@ async function dispatch(
'Provide --code <kody_bc_…> from cliCredentialBootstrap (MCP api / kody.cliCredentialBootstrap).',
)
}
const lifetime = resolveCliTokenLifetime({
lifetime:
typeof parsed.values.lifetime === 'string'
? parsed.values.lifetime
: undefined,
idleTtlSeconds: parseCliLifetimeSecondsFlag(
typeof parsed.values['idle-ttl-seconds'] === 'string'
? parsed.values['idle-ttl-seconds']
: undefined,
'--idle-ttl-seconds',
),
maxLifetimeSeconds: parseCliLifetimeSecondsFlag(
typeof parsed.values['max-lifetime-seconds'] === 'string'
? parsed.values['max-lifetime-seconds']
: undefined,
'--max-lifetime-seconds',
),
})
const apiUrl = apiUrlFrom({
apiUrl:
typeof parsed.values['api-url'] === 'string'
? parsed.values['api-url']
: undefined,
})
const result = await authBootstrap({ code, apiUrl })
const result = await authBootstrap({
code,
apiUrl,
...(lifetime.kind === 'alias'
? { lifetime: lifetime.lifetime }
: {
idleTtlSeconds: lifetime.idleTtlSeconds,
maxLifetimeSeconds: lifetime.maxLifetimeSeconds,
}),
})
const scopes = result.stored.scopes?.join(', ') || '(none)'
write(
[
Expand Down
6 changes: 5 additions & 1 deletion src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Usage:
kody login [--mcp-url <url>] [--no-browser]
kody logout [--mcp-url <url>] [--api-url <url>]
kody status [--mcp-url <url>] [--api-url <url>]
kody auth bootstrap --code <kody_bc_…> [--api-url <url>]
kody auth bootstrap --code <kody_bc_…> (--lifetime short|long | --idle-ttl-seconds <n> --max-lifetime-seconds <n>) [--api-url <url>]
kody whoami [--mcp-url <url>] [--token <token>] [--api-url <url>] [--json]
kody search [query] [--entity <ref>] [--domain <id>] [--limit <n>] [--token <token>] [--api-url <url>] [--json]
kody api <operationId> [--params <json>] [--token <token>] [--api-url <url>] [--json]
Expand All @@ -28,6 +28,10 @@ Usage:
auth bootstrap
Redeem a one-shot \`kody_bc_…\` from MCP \`cliCredentialBootstrap\`
(POST /v1/tokens/bootstrap/redeem, no Authorization header).
Lifetime is required: \`--lifetime short|long\` (\`short\` = 1h
idle / 24h max; \`long\` = 14d idle / 3mo max), or both
\`--idle-ttl-seconds\` and \`--max-lifetime-seconds\`.
Single-task agents should use \`--lifetime short\`.
Stores the resulting \`kody_at_…\` for \`execute --local\`,
search, whoami, api, and token-auth cloud execute without
printing the token. Prefer this over tokenCreate for agents
Expand Down
Loading
Loading