UNPKG

kestrel.markets

Version:

A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.

285 lines (262 loc) 12.7 kB
/** * # cli/commands/self — `kestrel whoami` + `kestrel refresh` (kestrel-p7th). * * The CLI is one of the FOUR EQUAL FACES (HTTP/SDK/CLI/MCP). The credential lifecycle — inspect * who you are, renew before the 1h durable capability lapses — existed only over MCP/HTTP * (`capability_refresh`); a CLI-only fleet operator had to `cat ~/.kestrel/credentials.json` to see * their identity and had NO renewal path, so CLI-only credentials silently aged out. This projects * that lifecycle onto the CLI face: * * - `whoami` → read the stored credential (+ any enrolled keypair) and print identity, scopes, * expiry (with a CLI-side clock STATUS: valid / expiring / EXPIRED — display only, outside any * Session), the API host it is bound to, and the on-disk file path. Pure-local: zero network. * - `refresh` → re-mint the durable capability BEFORE it expires via the platform's existing * `POST /capabilities/refresh` primitive (authenticated by the current capability), then * overwrite the stored credential in place. The SERVER mints the fresh token — the CLI adds NO * client-side RNG and no wall-clock into the token (determinism doctrine); the only clock use is * the human-facing expiry STATUS, which is outside the Episode (OSS-ADR-0055). * * Dependency-light (node built-ins + fetch): no `bun:sqlite`, so both run under bun-less node like * the other light verbs. */ import type { GlobalFlags, OutputCtx } from "../context.ts"; import { parseArgs } from "../args.ts"; import { CliError, EXIT } from "../errors.ts"; import { defaultCredentialsPath, defaultKeypairPath, loadCredential, loadKeypair, saveCredential, type StoredCredential, } from "../credentials.ts"; type FetchLike = (url: string, init?: RequestInit) => Promise<Response>; /** Injected seams so both verbs are hermetically testable (no real network, home, or clock). */ export interface SelfDeps { readonly fetch?: FetchLike; /** Where the credential is read/written; default `~/.kestrel/credentials.json`. */ readonly credentialsPath?: string; /** Where the enrolled keypair is read; default `~/.kestrel/agent-key.json`. */ readonly keypairPath?: string; /** Environment (for KESTREL_HOME); default `process.env`. */ readonly env?: Record<string, string | undefined>; /** The DISPLAY clock for the expiry status line (never enters the token). Default `Date.now`. */ readonly now?: () => number; } function notRegistered(credPath: string): CliError { return new CliError({ code: "NOT_FOUND", exit: EXIT.NOT_FOUND, message: `no stored credential at ${credPath} — this CLI is anonymous (no registered identity)`, hint: "run `kestrel register` to self-register and mint a durable capability (proof-before-account: `sim`/`prove` still work anonymously)", }); } /* ────────────────────────────────── whoami ─────────────────────────────────── */ /** The expiry STATUS derived from the DISPLAY clock (never the token). */ interface ExpiryStatus { readonly state: "valid" | "expiring" | "expired" | "unknown"; readonly remaining_ms: number | null; readonly label: string; } /** The "expiring soon" display threshold — a durable capability lives 1h, so flag the last 5 min. */ const EXPIRING_SOON_MS = 5 * 60 * 1000; /** Derive the human expiry status from `expiry` (ISO-8601) vs the display clock. Display only — * this clock never enters a token or a Session. An unparseable expiry is `unknown`, never a crash. */ export function expiryStatus(expiry: string, nowMs: number): ExpiryStatus { const at = Date.parse(expiry); if (Number.isNaN(at)) return { state: "unknown", remaining_ms: null, label: "unknown (unparseable expiry)" }; const remaining = at - nowMs; if (remaining <= 0) return { state: "expired", remaining_ms: remaining, label: `EXPIRED ${fmtDuration(-remaining)} ago — run \`kestrel refresh\`` }; if (remaining <= EXPIRING_SOON_MS) return { state: "expiring", remaining_ms: remaining, label: `expiring in ${fmtDuration(remaining)} — run \`kestrel refresh\`` }; return { state: "valid", remaining_ms: remaining, label: `valid, expires in ${fmtDuration(remaining)}` }; } /** A compact, deterministic mm/ss / hh:mm duration for the status label (display only). */ function fmtDuration(ms: number): string { const totalSec = Math.floor(ms / 1000); const h = Math.floor(totalSec / 3600); const m = Math.floor((totalSec % 3600) / 60); const s = totalSec % 60; if (h > 0) return `${h}h${String(m).padStart(2, "0")}m`; return `${m}m${String(s).padStart(2, "0")}s`; } /** * `whoami` — introspect the stored credential without hand-parsing credentials.json. Prints the * identity, scopes, expiry + status, the API host, whether a keypair is enrolled, and the file path. * No stored credential → a typed NOT_FOUND naming how to register. Zero network. */ export function whoamiCommand(argv: readonly string[], ctx: OutputCtx, deps: SelfDeps = {}): number { parseArgs(argv, new Set(), new Set()); // no flags — reject stray ones fail-closed const env = deps.env ?? (process.env as Record<string, string | undefined>); const credPath = deps.credentialsPath ?? defaultCredentialsPath(env); const cred = loadCredential(credPath); if (cred === undefined) throw notRegistered(credPath); const keyPath = deps.keypairPath ?? defaultKeypairPath(env); const keypair = loadKeypair(keyPath); const nowMs = (deps.now ?? Date.now)(); const status = expiryStatus(cred.expiry, nowMs); if (ctx.mode === "json") { process.stdout.write( JSON.stringify({ schema: "kestrel.whoami/v1", agent_id: cred.agent_id, subject: cred.subject ?? null, api: cred.api, token_type: cred.token_type, scopes: cred.scopes, expiry: cred.expiry, expiry_status: status.state, expiry_remaining_ms: status.remaining_ms, keypair_enrolled: keypair !== undefined, credential_path: credPath, ...(keypair !== undefined ? { keyfile_path: keyPath } : {}), ...(cred.claimed !== undefined ? { claimed_unverified: cred.claimed } : {}), }) + "\n", ); return 0; } if (ctx.mode === "text") { const lines = [ `agent_id\t${cred.agent_id}`, `subject\t${cred.subject ?? ""}`, `api\t${cred.api}`, `token_type\t${cred.token_type}`, `scopes\t${cred.scopes.join(",")}`, `expiry\t${cred.expiry}`, `expiry_status\t${status.state}`, `keypair_enrolled\t${keypair !== undefined}`, `credential_path\t${credPath}`, ]; process.stdout.write(lines.join("\n") + "\n"); return 0; } // human const out = [ `agent_id ${cred.agent_id}`, `subject ${cred.subject ?? "(none)"}`, `api ${cred.api}`, `token_type ${cred.token_type}`, `scopes ${cred.scopes.join(", ")}`, `expiry ${cred.expiry} (${status.label})`, `keypair ${keypair !== undefined ? `enrolled — ${keyPath}` : "not enrolled"}`, `credential ${credPath}`, ]; if (cred.claimed !== undefined) out.push(`claimed ${cred.claimed.name ?? "(no name)"} <${cred.claimed.email ?? "no-email"}> (UNVERIFIED)`); process.stdout.write(out.join("\n") + "\n"); return 0; } /* ────────────────────────────────── refresh ────────────────────────────────── */ /** The `POST /capabilities/refresh` response (mirrors the live platform primitive). */ interface RefreshResponse { readonly capability: string; readonly token_type?: string; readonly subject?: string; readonly family?: string; readonly scopes?: readonly string[]; readonly expiry?: string; readonly ttl_ms?: number; } /** Extract a human-readable detail from a problem+json body, fail-soft to `fallback`. */ async function problemDetail(res: Response, fallback: string): Promise<string> { try { const b = (await res.json()) as { detail?: string; title?: string }; return b.detail ?? b.title ?? fallback; } catch { return fallback; } } /** * `refresh` — renew the stored durable capability BEFORE it lapses, via the platform's existing * `POST /capabilities/refresh` primitive (authenticated by the current capability). The fresh token * is minted SERVER-SIDE (no client RNG, no client wall-clock in the token) and written back in place, * preserving the durable identity (agent_id / subject / keypair enrollment / claimed attribution). * * Host-scoped: it always refreshes against the credential's OWN `api` host — a credential is bound to * the host that minted it. No stored credential → a typed NOT_FOUND naming how to register; a server * refusal (an already-expired capability past its refresh window, a revoked family) surfaces the * server's problem+json detail, never a bare status. */ export async function refreshCommand( argv: readonly string[], ctx: OutputCtx, _globals: GlobalFlags, deps: SelfDeps = {}, ): Promise<number> { parseArgs(argv, new Set(), new Set()); // no flags — reject stray ones fail-closed const env = deps.env ?? (process.env as Record<string, string | undefined>); const credPath = deps.credentialsPath ?? defaultCredentialsPath(env); const cred = loadCredential(credPath); if (cred === undefined) throw notRegistered(credPath); const fetchImpl = deps.fetch ?? (globalThis.fetch as FetchLike | undefined); if (fetchImpl === undefined) throw new CliError({ code: "RUNTIME_UNAVAILABLE", exit: EXIT.RUNTIME_UNAVAILABLE, message: "global fetch unavailable — need node ≥18 or bun to refresh", }); const base = cred.api.replace(/\/+$/, ""); const res = await fetchImpl(`${base}/capabilities/refresh`, { method: "POST", headers: { "content-type": "application/json", accept: "application/json", authorization: `Bearer ${cred.capability}` }, body: JSON.stringify({}), }); if (!res.ok) { const detail = await problemDetail(res, `capability refresh failed (HTTP ${res.status})`); throw new CliError({ code: `HTTP_${res.status}`, exit: res.status >= 500 ? EXIT.RUNTIME_UNAVAILABLE : EXIT.GENERIC, message: `capability refresh failed (HTTP ${res.status}): ${detail}`, ...(res.status === 401 || res.status === 403 ? { hint: "the current capability may already be past its refresh window — run `kestrel register` to mint a fresh durable capability" } : {}), }); } const body = (await res.json()) as RefreshResponse; if (typeof body.capability !== "string" || body.capability.length === 0) throw new CliError({ code: "HTTP_502", exit: EXIT.RUNTIME_UNAVAILABLE, message: "capability refresh returned no usable capability token", }); // Re-mint IN PLACE: the fresh server-signed token + its scopes/expiry, preserving the durable // identity fields the refresh response does not (re)issue. NO client RNG, no local clock in the token. const next: StoredCredential = { ...cred, capability: body.capability, ...(body.token_type !== undefined ? { token_type: body.token_type } : {}), ...(body.subject !== undefined ? { subject: body.subject } : {}), ...(body.scopes !== undefined ? { scopes: body.scopes } : {}), ...(body.expiry !== undefined ? { expiry: body.expiry } : {}), }; saveCredential(next, credPath); emitRefreshResult(ctx, next, credPath); return 0; } /** Print the refresh summary (stdout is the payload channel). */ function emitRefreshResult(ctx: OutputCtx, cred: StoredCredential, credPath: string): void { if (ctx.mode === "json") { process.stdout.write( JSON.stringify({ schema: "kestrel.refresh/v1", agent_id: cred.agent_id, subject: cred.subject ?? null, api: cred.api, token_type: cred.token_type, scopes: cred.scopes, expiry: cred.expiry, credential_path: credPath, }) + "\n", ); return; } if (ctx.mode === "text") { process.stdout.write( `refreshed\tagent_id=${cred.agent_id}\tscopes=${cred.scopes.join(",")}\texpiry=${cred.expiry}\tcredential=${credPath}\n`, ); return; } process.stdout.write(`refreshed ${cred.agent_id}\n`); process.stdout.write(` scopes: ${cred.scopes.join(", ")}\n`); process.stdout.write(` expiry: ${cred.expiry}\n`); process.stdout.write(` credential: ${credPath}\n`); }