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
text/typescript
/**
* # 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`);
}