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