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.

346 lines (319 loc) 14.9 kB
/** * # cli/commands/register — `kestrel register` (kestrel-markets-0t0 + gaz). * * The CLI half of autonomous-agent self-registration (PLAT-ADR-0021 tier 1 — the * middle rung between anon and a human account, NO human in the loop). It: * * 1. reads git `user.name` / `user.email` (unless `--no-git-identity`); * 2. DISCLOSES to stderr exactly what it will send + that the identity is UNVERIFIED * + how to opt out (AX doctrine, bead gaz — the CLI never sends claimed identity * silently); * 3. POSTs to the managed platform's `POST /agents/register`; * 4. stores the returned durable capability in `~/.kestrel/credentials.json` (0600) * so subsequent `--api` calls present it instead of self-minting an anon trial. * * The claimed git identity is FREELY SPOOFABLE and the platform treats it as unverified * attribution only (bead gaz) — this command discloses that plainly rather than implying * the registration is authenticated. * * Dependency-light (node built-ins + fetch): no `bun:sqlite`, so it runs under bun-less * node like the other light verbs. */ import { execFileSync } from "node:child_process"; import type { GlobalFlags, OutputCtx } from "../context.ts"; import { parseArgs } from "../args.ts"; import { CliError, EXIT } from "../errors.ts"; import { defaultCredentialsPath, defaultKeypairPath, loadCredential, saveCredential, saveKeypair, type StoredCredential, type StoredKeypair, } from "../credentials.ts"; import { generateAgentKeypair } from "../keypair.ts"; /** The canonical managed API base (mirrors backend/remote.ts DEFAULT_API). */ export const DEFAULT_API = "https://api.kestrel.markets"; type FetchLike = (url: string, init?: RequestInit) => Promise<Response>; /** Claimed (UNVERIFIED) git identity — freely spoofable, never trusted (bead gaz). */ export interface GitIdentity { readonly name?: string; readonly email?: string; } /** Injected seams so the command is hermetically testable (no network, no real git/home). */ export interface RegisterDeps { readonly fetch?: FetchLike; /** Read the local git identity; default shells out to `git config`. */ readonly gitIdentity?: () => GitIdentity; /** Where to persist the credential; default `~/.kestrel/credentials.json`. */ readonly credentialsPath?: string; /** Where to persist the enrolled keypair (--keypair); default `~/.kestrel/agent-key.json`. */ readonly keypairPath?: string; /** Environment (for KESTREL_API); default `process.env`. */ readonly env?: Record<string, string | undefined>; } /** The registration response body (mirrors the platform's AgentRegistrationBody). */ interface RegistrationResponse { readonly agent_id: string; readonly subject?: string; readonly capability: string; readonly token_type: string; readonly scopes: readonly string[]; readonly expiry: string; readonly upgrade?: { readonly verify_email_hint?: string; readonly pm_authority_hint?: string }; } /** The enroll-key response body (mirrors the platform's EnrollmentBody). */ interface EnrollmentResponse { readonly public_key_thumbprint: string; readonly granted_scopes: readonly string[]; readonly jwt: { readonly audience: string; readonly max_ttl_seconds: number }; } function usageErr(message: string): CliError { return new CliError({ code: "USAGE", exit: EXIT.USAGE, message }); } /** Read one git config value, or undefined if git is absent / unset / not a repo. */ function readGitConfig(key: string): string | undefined { try { const out = execFileSync("git", ["config", "--get", key], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); const v = out.trim(); return v.length > 0 ? v : undefined; } catch { return undefined; } } /** The default git-identity reader — `git config user.name` / `user.email`. */ export function defaultGitIdentity(): GitIdentity { const name = readGitConfig("user.name"); const email = readGitConfig("user.email"); return { ...(name !== undefined ? { name } : {}), ...(email !== undefined ? { email } : {}) }; } /** Resolve the managed API base from `--api` > `KESTREL_API` > the canonical default. */ function resolveApiBase(globals: GlobalFlags, env: Record<string, string | undefined>): string { const raw = globals.api ?? env.KESTREL_API; if (raw === undefined || raw === "" || raw === "default") return DEFAULT_API; return raw.replace(/\/+$/, ""); } /** * `register [--name <n>] [--scopes <s,s>] [--no-git-identity]`. * * Sends the git identity as CLAIMED attribution unless `--no-git-identity`. On success, * persists the durable capability and prints a summary (stdout is the payload channel; * disclosure goes to stderr). */ export async function registerCommand( argv: readonly string[], ctx: OutputCtx, globals: GlobalFlags, deps: RegisterDeps = {}, ): Promise<number> { const { flags, bools } = parseArgs(argv, new Set(["no-git-identity", "keypair"]), new Set(["name", "scopes"])); const env = deps.env ?? (process.env as Record<string, string | undefined>); 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 register", }); } const base = resolveApiBase(globals, env); const withGit = !bools.has("no-git-identity"); const identity: GitIdentity = withGit ? (deps.gitIdentity ?? defaultGitIdentity)() : {}; const name = flags.get("name"); // `--scopes a,b` → a normalized scope list. It is forwarded as the COMMA-SEPARATED STRING the // live `POST /agents/register` accepts on `scope_request` (verified against prod: a JSON ARRAY // 422s `scope_unknown` on any element the platform doesn't recognize; the comma-separated string // is the accepted shape). Serializing the array was the documented flag's 100%-broken bug (kestrel-wlym). const scopes = flags.get("scopes")?.split(",").map((s) => s.trim()).filter((s) => s.length > 0); // ── DISCLOSURE (AX doctrine, bead gaz) — say what we send, BEFORE we send it ── disclose(ctx, base, withGit, identity); const body: Record<string, unknown> = {}; if (name !== undefined) body.name = name; if (withGit && (identity.name !== undefined || identity.email !== undefined)) { body.claimed_git = { ...(identity.name !== undefined ? { name: identity.name } : {}), ...(identity.email !== undefined ? { email: identity.email } : {}), }; } if (scopes !== undefined && scopes.length > 0) body.scope_request = scopes.join(","); const res = await fetchImpl(`${base}/agents/register`, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify(body), }); if (res.status === 403) { const detail = await problemDetail(res); throw usageErr(`registration refused: ${detail}`); } if (res.status === 429) { throw new CliError({ code: "HTTP_429", exit: EXIT.GENERIC, message: "registration rate-limited — retry later or reuse the credential you already hold", }); } if (!res.ok) { // Surface the server's problem+json `detail`/`title` verbatim (kestrel-wlym): the old opaque // `registration failed (HTTP 422)` hid the one line that says HOW to fix it (e.g. an unknown // scope literal + "Remove it and retry"). A 422 is a client-shaped request the caller can act on. const detail = await problemDetail(res, `registration failed (HTTP ${res.status})`); throw new CliError({ code: `HTTP_${res.status}`, exit: res.status >= 500 ? EXIT.RUNTIME_UNAVAILABLE : EXIT.GENERIC, message: `registration failed (HTTP ${res.status}): ${detail}`, ...(res.status === 422 ? { hint: "--scopes is a COMMA-SEPARATED scope request (e.g. --scopes data,sim); an unknown scope literal is rejected — remove it and retry" } : {}), }); } const reg = (await res.json()) as RegistrationResponse; const credPath = deps.credentialsPath ?? defaultCredentialsPath(env); // ── optional keypair enrollment (--keypair, kestrel-markets-0t0 slice 2) ── // Generate an Ed25519 keypair, enroll the PUBLIC half (authenticated by the durable // capability we just received), and store the PRIVATE half 0600. Thereafter the CLI // authenticates by minting short-lived JWTs signed with the private key — a possession // proof distinct from replaying the bearer. The kcap path stays fully working regardless. const wantKeypair = bools.has("keypair"); let enrolled: { keyPath: string; thumbprint: string } | undefined; if (wantKeypair) { enrolled = await enrollKeypair(ctx, fetchImpl, base, reg, deps, env); } const credential: StoredCredential = { version: 1, api: base, agent_id: reg.agent_id, capability: reg.capability, token_type: reg.token_type, scopes: reg.scopes, expiry: reg.expiry, ...(reg.subject !== undefined ? { subject: reg.subject } : {}), ...(enrolled !== undefined ? { keypair_enrolled: true } : {}), ...(withGit && (identity.name !== undefined || identity.email !== undefined) ? { claimed: { ...(identity.name !== undefined ? { name: identity.name } : {}), ...(identity.email !== undefined ? { email: identity.email } : {}) } } : {}), }; saveCredential(credential, credPath); emitResult(ctx, reg, credPath, enrolled); return 0; } /** * Generate + enroll an Ed25519 keypair for the freshly-registered agent. Returns the * keyfile path + thumbprint on success, or `undefined` (with a stderr note) when the * platform did not return a `subject` (older platform) or the enroll leg refused — the * registration itself still succeeded, so this is a soft degrade, never a hard failure. */ async function enrollKeypair( ctx: OutputCtx, fetchImpl: FetchLike, base: string, reg: RegistrationResponse, deps: RegisterDeps, env: Record<string, string | undefined>, ): Promise<{ keyPath: string; thumbprint: string } | undefined> { if (reg.subject === undefined) { process.stderr.write("disclose: --keypair skipped — the platform did not return a subject to bind the key to\n"); return undefined; } const { publicJwk, privateJwk } = await generateAgentKeypair(); process.stderr.write("disclose: generated a local Ed25519 keypair; enrolling the PUBLIC half (the private key never leaves this machine)\n"); const enrollRes = await fetchImpl(`${base}/agents/enroll-key`, { method: "POST", headers: { "content-type": "application/json", accept: "application/json", authorization: `Bearer ${reg.capability}` }, body: JSON.stringify({ public_key: publicJwk }), }); if (!enrollRes.ok) { const detail = await problemDetail(enrollRes); process.stderr.write(`disclose: --keypair enrollment failed (HTTP ${enrollRes.status}: ${detail}); the durable capability still works\n`); return undefined; } const body = (await enrollRes.json()) as EnrollmentResponse; const keyPath = deps.keypairPath ?? defaultKeypairPath(env); const keypair: StoredKeypair = { version: 1, api: base, subject: reg.subject, public_jwk: publicJwk, private_jwk: privateJwk, audience: body.jwt.audience, max_ttl_seconds: body.jwt.max_ttl_seconds, enrolled_at: new Date().toISOString(), }; saveKeypair(keypair, keyPath); return { keyPath, thumbprint: body.public_key_thumbprint }; } /** Disclose (to stderr) exactly what will be sent + the unverified caveat + opt-out. */ function disclose(ctx: OutputCtx, base: string, withGit: boolean, identity: GitIdentity): void { if (ctx.mode === "json") { // In json mode the disclosure rides in the machine record too (see emitResult); still // echo a one-line stderr note so a human watching the stream sees it. process.stderr.write( JSON.stringify({ disclosure: { endpoint: `${base}/agents/register`, sends_claimed_git_identity: withGit, claimed: withGit ? identity : null, note: "claimed git identity is UNVERIFIED (freely spoofable); pass --no-git-identity to omit it", }, }) + "\n", ); return; } const lines: string[] = []; lines.push(`registering with ${base}/agents/register`); if (withGit && (identity.name !== undefined || identity.email !== undefined)) { lines.push(`will send CLAIMED git identity (UNVERIFIED): ${identity.name ?? "(no name)"} <${identity.email ?? "no-email"}>`); lines.push("this identity is freely spoofable and is NOT trusted as authentication; pass --no-git-identity to omit it"); } else if (withGit) { lines.push("no git user.name/user.email found — sending no claimed identity"); } else { lines.push("--no-git-identity: sending NO claimed git identity"); } for (const l of lines) process.stderr.write(`disclose: ${l}\n`); } /** Print the success summary to stdout (the payload channel). */ function emitResult( ctx: OutputCtx, reg: RegistrationResponse, credPath: string, enrolled?: { keyPath: string; thumbprint: string }, ): void { if (ctx.mode === "json") { process.stdout.write( JSON.stringify({ schema: "kestrel.register/v1", agent_id: reg.agent_id, token_type: reg.token_type, scopes: reg.scopes, expiry: reg.expiry, credential_path: credPath, keypair: enrolled ? { enrolled: true, keyfile_path: enrolled.keyPath, thumbprint: enrolled.thumbprint } : null, upgrade: reg.upgrade ?? null, }) + "\n", ); return; } if (ctx.mode === "text") { const kp = enrolled ? `\tkeyfile=${enrolled.keyPath}` : ""; process.stdout.write(`registered\tagent_id=${reg.agent_id}\tscopes=${reg.scopes.join(",")}\texpiry=${reg.expiry}\tcredential=${credPath}${kp}\n`); return; } process.stdout.write(`registered as ${reg.agent_id}\n`); process.stdout.write(` scopes: ${reg.scopes.join(", ")}\n`); process.stdout.write(` expiry: ${reg.expiry}\n`); process.stdout.write(` credential: ${credPath}\n`); if (enrolled) { process.stdout.write(` keyfile: ${enrolled.keyPath} (0600 — Ed25519 private key; JWT auth)\n`); process.stdout.write(` key id: ${enrolled.thumbprint}\n`); } if (reg.upgrade?.verify_email_hint) process.stdout.write(` upgrade: ${reg.upgrade.verify_email_hint}\n`); } /** Extract a human-readable detail from a problem+json body, fail-soft to `fallback`. */ async function problemDetail(res: Response, fallback = "scope not mintable at registration"): Promise<string> { try { const b = (await res.json()) as { detail?: string; title?: string }; return b.detail ?? b.title ?? fallback; } catch { return fallback; } }