diff --git a/.changelog/unreleased/changed-512-create-registers-key.md b/.changelog/unreleased/changed-512-create-registers-key.md
new file mode 100644
index 00000000..f4182ca7
--- /dev/null
+++ b/.changelog/unreleased/changed-512-create-registers-key.md
@@ -0,0 +1,11 @@
+- **`tps agent create` registers a `pending` agent's key through Flair's `POST
+ /AgentKeyRegister` when the route accepts it and the stored key reads back
+ equal.** It sends the generated key through that route over its operator
+ credential. Definitive refusals exit non-zero with the agent, the Flair URL,
+ the refusal name and a remedy; an error response with no recognized refusal,
+ or an unreadable error body, remains unconfirmed with its HTTP status; an
+ unrecognized 404 or 405 names the minimum Flair
+ version that serves the route as the likely cause. After a successful or
+ ambiguous POST, a read-back that fails or does not return the generated key
+ reports this attempt unconfirmed with a re-run-create remedy. Flair
+ drops `publicKey` on `Agent` PUT and PATCH.
diff --git a/.changelog/unreleased/fixed-512-create-reports-real-registration.md b/.changelog/unreleased/fixed-512-create-reports-real-registration.md
index cee05f2e..b15df982 100644
--- a/.changelog/unreleased/fixed-512-create-reports-real-registration.md
+++ b/.changelog/unreleased/fixed-512-create-reports-real-registration.md
@@ -1 +1 @@
-- **`tps agent create` prints success only after the generated key reads back equal from Flair.** It re-reads the agent's stored `publicKey` over the operator credential; when Flair is reachable, it exits non-zero unless the stored key reads back equal to the generated key, naming the agent, the Flair URL and the remedy.
+- **When Flair is reachable, `tps agent create` reports registration only after the generated key reads back equal.** It re-reads the agent's stored `publicKey` over the operator credential; it exits non-zero unless the stored key reads back equal to the generated key, naming the agent, the Flair URL and the remedy.
diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts
index ff3b9e7a..ba716b5f 100644
--- a/packages/cli/src/commands/agent.ts
+++ b/packages/cli/src/commands/agent.ts
@@ -13,7 +13,7 @@
import { AgentRuntime, loadAgentConfig } from "@tpsdev-ai/agent";
import yaml from "js-yaml";
import { generateKeyPair, saveKeyPair, loadKeyPair, verify } from "../utils/identity.js";
-import { createFlairClient, isEncryptedPrivateKeyPem, loadFlairSigningKey } from "../utils/flair-client.js";
+import { createFlairClient, FlairAgentKeyRegisterError, FlairRequestError, isEncryptedPrivateKeyPem, loadFlairSigningKey } from "../utils/flair-client.js";
import { applyRole, loadPlugins } from "../plugins/index.js";
import { createInterface as createPromptInterface } from "node:readline/promises";
import { accessSync, constants, createReadStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, watch, writeFileSync } from "node:fs";
@@ -274,7 +274,7 @@ function refuseRegistration(
const cause = writeError ? `${detail}; the registration write failed: ${writeError}` : detail;
console.error(`❌ Agent '${id}' is not registered in Flair at ${flairUrl} — ${cause}.`);
console.error(
- ` Flair cannot store a key generated here yet (it drops publicKey on Agent PUT/PATCH; a supported operation is flair#2266).`,
+ ` Flair drops publicKey on Agent PUT/PATCH; the operation that stores a key on an existing pending row is POST /AgentKeyRegister (flair#2266).`,
);
const copy = `copy ${join(identityDir, `${id}.key`)} and ${join(identityDir, `${id}.pub`)} into the Flair host's keys dir, run \`flair agent add ${id} --keys-dir
\` there, then re-run \`tps agent create\``;
if (rowState === "exists") {
@@ -282,11 +282,11 @@ function refuseRegistration(
` Until then: run \`flair agent remove ${id}\` on the Flair host, then ${copy}.`,
);
console.error(
- ` ⚠️ \`flair agent remove\` deletes the agent's Agent row, its Memory and Soul rows, and its key files in the keys dir (unless --keep-keys); registering a key on an existing pending row without deleting is flair#2266, not yet available.`,
+ ` ⚠️ \`flair agent remove\` tries to delete the agent's Agent, Memory and Soul rows and its key files in the keys dir (unless --keep-keys); it can stop on a failure.`,
);
} else if (rowState === "unknown") {
console.error(
- ` Until then: if \`flair agent show ${id}\` finds the agent, it already exists: run \`flair agent remove ${id}\` on the Flair host first (this also deletes that agent's Memory and Soul rows), then ${copy}.`,
+ ` Until then: if \`flair agent show ${id}\` finds the agent, it already exists: run \`flair agent remove ${id}\` on the Flair host first (this tries to delete that agent's Memory and Soul rows and can stop on a failure), then ${copy}.`,
);
console.error(` Otherwise: ${copy}.`);
} else {
@@ -398,6 +398,61 @@ function refuseUnloadableIdentity(id: string, identityDir: string, err: unknown)
process.exit(1);
}
+/**
+ * cli#512 — the first Flair release that serves POST /AgentKeyRegister, the
+ * operation `create` uses to store a locally generated key on a `pending` row.
+ */
+const MIN_FLAIR_VERSION_FOR_KEY_REGISTER = "0.62.0";
+
+/** Flair's stored `publicKey` value for an Agent row seeded but not yet registered (flair#2266). */
+const PENDING_PUBLIC_KEY = "pending";
+
+/** `refusal` is null when the body is unreadable or names no recognized refusal. */
+function refuseKeyRegistration(id: string, flairUrl: string, status: number, refusal: string | null): never {
+ if (refusal === null && (status === 404 || status === 405)) {
+ console.error(
+ `❌ Agent '${id}' — this attempt to register a key in Flair at ${flairUrl} was not confirmed: POST /AgentKeyRegister answered ${status} with no refusal this client recognizes, so whether a key was stored is unknown; this Flair may not serve the route.`,
+ );
+ console.error(
+ ` Remedy: if this Flair predates the route, upgrade it to ${MIN_FLAIR_VERSION_FOR_KEY_REGISTER} or newer, then re-run \`tps agent create --id ${id}\`.`,
+ );
+ process.exit(1);
+ }
+ const remedies: Record = {
+ "authentication required": "the admin credential is empty or invalid; set FLAIR_ADMIN_AUTH to this Flair's admin credential",
+ forbidden: "the credential is not this Flair's admin; set FLAIR_ADMIN_AUTH to its admin credential",
+ registration_request_invalid: "the agent id or public key was refused as malformed",
+ agent_not_found: `no Agent row exists for '${id}'; create the agent's row on this Flair instance, then re-run \`tps agent create --id ${id}\``,
+ agent_not_local: `'${id}' did not originate on this instance; register its key on the agent's home instance`,
+ agent_inactive: `'${id}' is deactivated; reactivate it before registering a key`,
+ agent_key_not_pending: `the stored key for '${id}' is not 'pending', so this operation does not apply; replacing a key is the key-rotation path, not create`,
+ registration_conflict: "another write to this agent is in progress or already committed; re-run `tps agent create`",
+ };
+ if (refusal === null) {
+ console.error(`❌ Agent '${id}' — this attempt to register a key in Flair at ${flairUrl} was not confirmed: POST /AgentKeyRegister answered HTTP ${status} with no refusal this client recognizes; whether a key was stored is unknown.`);
+ console.error(" Remedy: check that Flair serves this route and its version.");
+ process.exit(1);
+ }
+ const remedy = remedies[refusal] ?? "the endpoint refused the registration; re-run `tps agent create`";
+ console.error(`❌ Agent '${id}' — this attempt to register a key in Flair at ${flairUrl} was not confirmed: POST /AgentKeyRegister refused the registration (${refusal}) with HTTP ${status}.`);
+ console.error(` Remedy: ${remedy}.`);
+ process.exit(1);
+}
+
+/**
+ * cli#512 — a read-back after POST /AgentKeyRegister did not confirm the key.
+ * `cause` is the client's own bounded account of the POST request; a refusal name is
+ * never invented. `observed` is the read-back result or failure.
+ */
+function refuseKeyRegistrationUnconfirmed(id: string, flairUrl: string, cause: string, observed: string): never {
+ console.error(`❌ Agent '${id}' — this attempt to register a key in Flair at ${flairUrl} was not confirmed: ${cause}.`);
+ console.error(` The read-back after the POST ${observed}.`);
+ console.error(
+ ` Remedy: re-run \`tps agent create --id ${id}\`; it retries registration if the row is still pending and checks the stored key.`,
+ );
+ process.exit(1);
+}
+
async function createAgent(args: AgentArgs): Promise {
const id = args.id;
if (!id) {
@@ -469,10 +524,10 @@ async function createAgent(args: AgentArgs): Promise {
// 2. Register in Flair, then verify the key actually landed.
//
- // cli#512: registration is reported only when Flair reads the key back equal
- // to the key just generated. Current Flair drops `publicKey` on Agent PUT and
- // PATCH, so neither can store it. The exit decision rests on the read-back;
- // the write errors recorded in writeError are reported when it fails.
+ // cli#512: the Flair registration line is printed only when Flair reads the
+ // key back equal to the key just generated. Current Flair drops `publicKey` on
+ // Agent PUT and PATCH, so neither can store it; a `pending` row is completed
+ // through POST /AgentKeyRegister.
const flair = createFlairClient(id, flairUrl, keyPath);
const online = await flair.ping();
@@ -503,12 +558,12 @@ async function createAgent(args: AgentArgs): Promise {
soulTemplate,
starterMemories,
});
- // Update the public key on the agent record that AgentSeed created
- await flair.updateAgent(id, { publicKey: pubKeyHex });
+ // cli#512: the seed creates a `pending` row if absent; an existing
+ // row stays unchanged. The read below determines the stored key.
successLine = ` Agent seeded: ${seeded.soulEntries.length} soul entries, ${seeded.memories.length} memories.`;
} catch (seedErr) {
// AgentSeed requires admin auth — fall back to direct registration
- writeError = `seed or key update failed: ${seedErr instanceof Error ? seedErr.message : String(seedErr)}`;
+ writeError = `seed failed: ${seedErr instanceof Error ? seedErr.message : String(seedErr)}`;
await flair.registerAgent(name, pubKeyHex);
successLine = ` Agent '${id}' registered in Flair (no seed — not admin).`;
}
@@ -522,6 +577,7 @@ async function createAgent(args: AgentArgs): Promise {
writeError = writeError ? `${writeError}; ${msg}` : msg;
}
+ let registrationPostCause: string | null = null;
let stored: { found: boolean; publicKey: string | null };
try {
stored = await flair.readStoredPublicKey(id);
@@ -530,6 +586,52 @@ async function createAgent(args: AgentArgs): Promise {
// the remedy must name the remove branch as well as the add branch (cli#593).
refuseRegistration(id, flairUrl, identityDir, `the read-back failed (${e instanceof Error ? e.message : String(e)})`, writeError, "unknown");
}
+ if (stored.found && stored.publicKey === PENDING_PUBLIC_KEY) {
+ // cli#512 — register the `pending` row's key, then read it back after
+ // success, a transport failure, a success body that is not JSON, or
+ // `registration_unavailable`. Definitive refusals exit before that read.
+ const publicKeyB64url = Buffer.from(kp.signing.publicKey).toString("base64url");
+ let unconfirmed: string | null = null;
+ try {
+ await flair.registerAgentKey(id, publicKeyB64url);
+ } catch (e) {
+ if (e instanceof FlairAgentKeyRegisterError) {
+ // Flair returns `registration_unavailable` both before committing
+ // (nothing changed) and after committing when its own read-back
+ // fails, so whether the key is stored is unknown here: the read-back
+ // below decides, never as a refusal and never as unregistered.
+ if (e.refusal === "registration_unavailable") {
+ unconfirmed = "POST /AgentKeyRegister answered registration_unavailable, which Flair returns both before committing (nothing changed) and after committing when its own read-back fails";
+ } else {
+ refuseKeyRegistration(id, flairUrl, e.status, e.refusal);
+ }
+ } else if (e instanceof FlairRequestError && e.status !== 0) {
+ unconfirmed = `the registration response carried status ${e.status} but no readable JSON body — a response-parsing failure (${e.message})`;
+ } else {
+ unconfirmed = `the registration request received no response — a transport failure (${e instanceof Error ? e.message : String(e)})`;
+ }
+ }
+ // Read after success or an ambiguous outcome; definitive refusals exited above.
+ try {
+ stored = await flair.readStoredPublicKey(id);
+ } catch (e) {
+ refuseKeyRegistrationUnconfirmed(
+ id,
+ flairUrl,
+ unconfirmed ?? "POST /AgentKeyRegister succeeded",
+ `failed (${e instanceof Error ? e.message : String(e)})`,
+ );
+ }
+ registrationPostCause = unconfirmed ?? "POST /AgentKeyRegister succeeded";
+ if (!(stored.found && storedKeyAsHex(stored.publicKey ?? "") === pubKeyHex)) {
+ const observed = !stored.found
+ ? "found no Agent row"
+ : stored.publicKey === null
+ ? "found no stored public key"
+ : `found the stored public key '${stored.publicKey}', not the generated key`;
+ refuseKeyRegistrationUnconfirmed(id, flairUrl, registrationPostCause, observed);
+ }
+ }
if (!stored.found) {
refuseRegistration(id, flairUrl, identityDir, "no Agent row exists", writeError, "absent");
} else if (stored.publicKey === null || storedKeyAsHex(stored.publicKey) !== pubKeyHex) {
@@ -546,9 +648,15 @@ async function createAgent(args: AgentArgs): Promise {
try {
confirmed = await flair.request<{ publicKey?: string }>("GET", `/Agent/${encodeURIComponent(id)}`);
} catch (e) {
+ if (registrationPostCause !== null) {
+ refuseKeyRegistrationUnconfirmed(id, flairUrl, registrationPostCause, `failed on the signed request (${e instanceof Error ? e.message : String(e)})`);
+ }
refuseRegistration(id, flairUrl, identityDir, `a request signed with the private key was refused (${e instanceof Error ? e.message : String(e)})`, writeError, "exists");
}
if (storedKeyAsHex(confirmed?.publicKey ?? "") !== pubKeyHex) {
+ if (registrationPostCause !== null) {
+ refuseKeyRegistrationUnconfirmed(id, flairUrl, registrationPostCause, "did not confirm the registered key on the signed request");
+ }
refuseRegistration(id, flairUrl, identityDir, "the signed request was accepted but its response did not confirm the registered key", writeError, "exists");
}
console.log(successLine ?? ` Agent '${id}' registered in Flair.`);
diff --git a/packages/cli/src/utils/flair-client.ts b/packages/cli/src/utils/flair-client.ts
index d84c455a..e1bd2bfc 100644
--- a/packages/cli/src/utils/flair-client.ts
+++ b/packages/cli/src/utils/flair-client.ts
@@ -299,6 +299,45 @@ function responseError(entry: string, method: string, path: string, status: numb
return `Flair ${entry} ${method} ${path} → ${status}: ${code}`;
}
+/** Recognized POST /AgentKeyRegister refusal names. */
+const AGENT_KEY_REGISTER_REFUSALS: readonly string[] = [
+ "authentication required",
+ "forbidden",
+ "registration_request_invalid",
+ "agent_not_found",
+ "agent_not_local",
+ "agent_inactive",
+ "agent_key_not_pending",
+ "registration_conflict",
+ "registration_unavailable",
+];
+
+/** Membership — module-private, so nothing outside this module can change how a refusal is named. */
+const AGENT_KEY_REGISTER_REFUSAL_SET: ReadonlySet = new Set(AGENT_KEY_REGISTER_REFUSALS);
+
+/** Parse a recognized refusal name from the body. */
+function agentKeyRegisterRefusal(body: string): string | null {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(body);
+ } catch {
+ return null;
+ }
+ const code = (parsed as { error?: unknown } | null)?.error;
+ return typeof code === "string" && AGENT_KEY_REGISTER_REFUSAL_SET.has(code) ? code : null;
+}
+
+/** An HTTP error from POST /AgentKeyRegister; null means an unreadable body or no recognized refusal. */
+export class FlairAgentKeyRegisterError extends Error {
+ constructor(
+ public readonly status: number,
+ public readonly refusal: string | null,
+ ) {
+ super(`Flair registerAgentKey POST /AgentKeyRegister → ${status}: ${refusal ?? UNRECOGNIZED_ERROR}`);
+ this.name = "FlairAgentKeyRegisterError";
+ }
+}
+
/** The `code` of an object-valued failure or cause; other candidates, including callables, are ignored. */
function errorCode(err: unknown): string {
try {
@@ -563,6 +602,47 @@ export class FlairClient {
}
}
+ /**
+ * POST /AgentKeyRegister — register `publicKey` (the canonical base64url
+ * encoding of 32 bytes) for the `pending` Agent row `agentId`, over the
+ * operator (Basic admin) credential. cli#512: this is Flair's supported
+ * operation for a row whose key Flair's Agent PUT and PATCH do not store. A
+ * non-OK response throws a FlairAgentKeyRegisterError carrying the status and
+ * the endpoint's refusal name (null for an unreadable body or no recognized
+ * refusal). A transport failure throws a FlairRequestError (status 0).
+ */
+ async registerAgentKey(
+ agentId: string,
+ publicKey: string,
+ adminAuth?: string,
+ ): Promise<{ agentId: string; publicKey: string; updatedAt: string; registrationId: string }> {
+ const url = `${this.baseUrl}/AgentKeyRegister`;
+ const route = requestedPath(url);
+ const auth = adminAuth ?? process.env.FLAIR_ADMIN_AUTH ?? "admin:admin123";
+ let res: Response;
+ try {
+ res = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Basic ${Buffer.from(auth).toString("base64")}`,
+ },
+ body: JSON.stringify({ agentId, publicKey }),
+ });
+ } catch (err) {
+ throw new FlairRequestError(transportError("registerAgentKey", "POST", route, err), 0);
+ }
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ throw new FlairAgentKeyRegisterError(res.status, agentKeyRegisterRefusal(text));
+ }
+ try {
+ return (await res.json()) as { agentId: string; publicKey: string; updatedAt: string; registrationId: string };
+ } catch {
+ throw new FlairRequestError(parseError("registerAgentKey", "POST", route, res.status), res.status);
+ }
+ }
+
async getAgent(id?: string): Promise {
try {
return await this.request("GET", `/Agent/${encodeURIComponent(id ?? this.agentId)}`);
diff --git a/packages/cli/test/agent-create-registration.test.ts b/packages/cli/test/agent-create-registration.test.ts
index 9c9f6ae8..0d330f04 100644
--- a/packages/cli/test/agent-create-registration.test.ts
+++ b/packages/cli/test/agent-create-registration.test.ts
@@ -4,14 +4,11 @@ const patchShared = createPatchShared();
* cli#512 — `tps agent create` reports registration only when Flair reads the
* local key back equal. cli#607 — and only when the local key pair is
* consistent and Flair accepts a request signed with the private key. These
- * tests drive the create path against a fake Flair: signed calls reach the
- * shared verifying stub (cli#554), and the operator-credentialed calls the
- * create flow makes — the Agent PUT and the read-back GET, both Basic — reach a
- * route this file controls. Every fixture's Agent PUT obeys Flair: it never
+ * tests drive the create path against a fake Flair. Every fixture's Agent PUT obeys Flair: it never
* stores `publicKey` (see `refusePut`).
*/
import { afterEach, expect, mock, spyOn, test } from "bun:test";
-import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, writeFileSync } from "node:fs";
+import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
import { createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID } from "node:crypto";
import { homedir } from "node:os";
import { join } from "node:path";
@@ -19,9 +16,11 @@ import { format } from "node:util";
import { runAgent } from "../src/commands/agent.js";
import { generateKeyPair, saveKeyPair } from "../src/utils/identity.js";
import { flairTarget } from "./helpers/scratch-flair.js";
-import { stubFlairHandler } from "./helpers/stub-flair.js";
+import { requestSigner, stubFlairHandler } from "./helpers/stub-flair.js";
const AGENT = "create-registration-agent";
+const ADMIN_AUTH = "admin:admin123";
+const ADMIN_HEADER = `Basic ${Buffer.from(ADMIN_AUTH).toString("base64")}`;
// cli#555: a file that names `spyOn` carries the top-level teardown; each
// runCreate restores its own spies in a finally as well.
@@ -136,6 +135,24 @@ function expectNoKeyBytes(output: string, bytes: Uint8Array): void {
}
}
+/**
+ * The registration POST happened, and no Agent PUT/PATCH for `id` follows it: the
+ * refusal branches send no fallback write. The registered request list is the
+ * server's own record, so a fallback write that lands after the POST shows here.
+ */
+function expectNoAgentWriteAfterRegistration(requests: Array<{ method: string; path: string }>, id: string): void {
+ const post = requests.findIndex((req) => req.method === "POST" && req.path === "/AgentKeyRegister");
+ expect(post).toBeGreaterThan(-1);
+ expect(requests.slice(post + 1).filter((req) => (req.method === "PUT" || req.method === "PATCH") && req.path === `/Agent/${id}`)).toEqual([]);
+}
+
+/** The registration POST happened, and a read-back `GET /Agent/` follows it. */
+function expectReadBackAfterRegistration(requests: Array<{ method: string; path: string }>, id: string): void {
+ const post = requests.findIndex((req) => req.method === "POST" && req.path === "/AgentKeyRegister");
+ expect(post).toBeGreaterThan(-1);
+ expect(requests.slice(post + 1).some((req) => req.method === "GET" && req.path === `/Agent/${id}`)).toBe(true);
+}
+
test("refused write and no Agent row: exits non-zero, prints no success, names agent, URL and remedy", async () => {
const id = `${AGENT}-no-row`;
const fake = startFakeFlair(async (req, url) => (req.method === "PUT" ? refusePut(url, id) : undefined));
@@ -146,7 +163,7 @@ test("refused write and no Agent row: exits non-zero, prints no success, names a
expect(res.stderr).toContain(id);
expect(res.stderr).toContain(fake.url);
expect(res.stderr).toContain("no Agent row exists");
- expect(res.stderr).toContain("seed or key update failed");
+ expect(res.stderr).toContain("seed failed");
expect(res.stderr).toContain("flair#2266");
expect(res.stderr).toContain(`flair agent add ${id} --keys-dir`);
expect(res.stderr).not.toContain("remove");
@@ -158,8 +175,8 @@ test("refused write and no Agent row: exits non-zero, prints no success, names a
}
});
-test("the stored key is still `pending`: exits non-zero naming the stored value", async () => {
- const id = `${AGENT}-pending`;
+test("a pending row and an unrecognized 404 from POST /AgentKeyRegister: exits non-zero naming the minimum version as the likely cause, prints no success", async () => {
+ const id = `${AGENT}-pending-no-route`;
const fake = startFakeFlair(async (req, url) => {
if (req.method === "PUT") return refusePut(url, id);
if (url.pathname === `/Agent/${id}`) return Response.json({ id, name: id, publicKey: "pending" });
@@ -169,11 +186,481 @@ test("the stored key is still `pending`: exits non-zero naming the stored value"
const res = await runCreate(fake.url, id);
expect(res.exitCode).toBe(1);
expect(res.stdout).not.toContain("registered in Flair");
- expect(res.stderr).toContain("the stored public key is 'pending'");
- const remove = res.stderr.indexOf(`flair agent remove ${id}`);
- expect(remove).toBeGreaterThan(-1);
- expect(remove).toBeLessThan(res.stderr.indexOf(`flair agent add ${id}`));
- expect(res.stderr).toContain("Memory and Soul rows");
+ expect(res.stderr).toContain(`Agent '${id}'`);
+ expect(res.stderr).toContain(`this attempt to register a key in Flair at ${fake.url} was not confirmed`);
+ // the response is not asserted to have a cause: only that this client cannot
+ // recognize it as the endpoint's refusal
+ expect(res.stderr).toContain("POST /AgentKeyRegister answered 404 with no refusal this client recognizes");
+ expect(res.stderr).toContain("this Flair may not serve the route");
+ expect(res.stderr).toContain("upgrade it to 0.62.0 or newer");
+ // no fallback write after the registration POST: the POST is last, and no
+ // Agent PUT/PATCH follows it
+ expectNoAgentWriteAfterRegistration(fake.requests, id);
+ expect(fake.requests.at(-1)).toEqual({ method: "POST", path: "/AgentKeyRegister", auth: expect.stringContaining("Basic ") });
+ } finally {
+ fake.stop();
+ }
+});
+
+test("a 405 on the registration route: exits non-zero naming the minimum version as the likely cause, and sends no fallback write", async () => {
+ const id = `${AGENT}-pending-method`;
+ const fake = startFakeFlair(async (req, url) => {
+ if (req.method === "PUT") return refusePut(url, id);
+ if (req.method === "POST" && url.pathname === "/AgentKeyRegister") return new Response("method not allowed", { status: 405 });
+ if (url.pathname === `/Agent/${id}`) return Response.json({ id, name: id, publicKey: "pending" });
+ return undefined;
+ });
+ try {
+ const res = await runCreate(fake.url, id);
+ expect(res.exitCode).toBe(1);
+ expect(res.stdout).not.toContain("registered in Flair");
+ expect(res.stderr).toContain("POST /AgentKeyRegister answered 405 with no refusal this client recognizes");
+ expect(res.stderr).toContain("this Flair may not serve the route");
+ expect(res.stderr).toContain("upgrade it to 0.62.0 or newer");
+ expectNoAgentWriteAfterRegistration(fake.requests, id);
+ } finally {
+ fake.stop();
+ }
+});
+
+interface RegistrarFlair {
+ url: string;
+ stop(): void;
+ readonly row: { id: string; name: string; publicKey: string; updatedAt?: string };
+ readonly requests: Array<{ method: string; path: string; auth: string }>;
+ readonly registerBodies: Array>;
+ fetch(req: Request): Response | Promise;
+}
+
+function startRegistrarFlair(
+ id: string,
+ publicKey: string,
+ opts: {
+ fetchOnly?: boolean;
+ route?: "serve" | "missing" | "method" | "transport" | "unparseable" | "unavailable";
+ refusal?: { status: number; error: string };
+ store?: boolean;
+ commit?: boolean;
+ } = {},
+): RegistrarFlair {
+ const route = opts.route ?? "serve";
+ const row: RegistrarFlair["row"] = { id, name: id, publicKey: "pending" };
+ const requests: RegistrarFlair["requests"] = [];
+ const registerBodies: Array> = [];
+ let registrations = 0;
+ const routes = async (req: Request, url: URL): Promise => {
+ if (req.method === "POST" && url.pathname === "/AgentKeyRegister") {
+ if (route === "missing") return undefined;
+ if (route === "method") return new Response("method not allowed", { status: 405 });
+ let body: Record = {};
+ try {
+ body = (await req.json()) as Record;
+ } catch {
+ /* no body */
+ }
+ registerBodies.push(body);
+ // `commit: true` models a write Flair made before the unconfirmed
+ // response: the row then holds the key although the client cannot confirm
+ // the response, and the read-back after the POST reads it equal.
+ const commit = opts.commit === true;
+ // A transport failure: the client follows this redirect to a closed port and
+ // its fetch rejects, so no response body reaches the caller.
+ if (route === "transport") {
+ if (commit) row.publicKey = String(body.publicKey);
+ return new Response(null, { status: 302, headers: { Location: "http://127.0.0.1:1/nope" } });
+ }
+ // A success status whose body is not JSON: the parse path, not a refusal.
+ if (route === "unparseable") {
+ if (commit) row.publicKey = String(body.publicKey);
+ return new Response("not json", { status: 200 });
+ }
+ // The committed write, then the endpoint reports the registration
+ // unavailable (as Flair does after committing when its read-back fails).
+ if (route === "unavailable") {
+ if (commit) row.publicKey = String(body.publicKey);
+ return Response.json({ error: "registration_unavailable" }, { status: 503 });
+ }
+ if (opts.refusal) return Response.json({ error: opts.refusal.error }, { status: opts.refusal.status });
+ registrations += 1;
+ if (opts.store === false) {
+ return Response.json({ agentId: id, publicKey: String(body.publicKey), updatedAt: new Date().toISOString(), registrationId: `reg-${registrations}` });
+ }
+ row.publicKey = String(body.publicKey);
+ return Response.json({ agentId: id, publicKey: row.publicKey, updatedAt: new Date().toISOString(), registrationId: `reg-${registrations}` });
+ }
+ if (req.method === "GET" && url.pathname === `/Agent/${id}`) {
+ return Response.json({ ...row });
+ }
+ if (req.method === "PUT" || req.method === "PATCH") return refusePut(url, id);
+ return undefined;
+ };
+ const signedRoutes = (req: Request, url: URL) =>
+ req.method === "POST" && url.pathname === "/AgentKeyRegister"
+ ? Response.json({ error: "forbidden" }, { status: 403 })
+ : routes(req, url);
+ const signedStub = stubFlairHandler({ [id]: publicKey }, signedRoutes);
+ const pendingStub = stubFlairHandler({ [id]: "pending" }, signedRoutes);
+ const handler = async (req: Request): Promise => {
+ const url = new URL(req.url);
+ if (url.pathname === "/Health") return new Response("ok");
+ const auth = req.headers.get("Authorization") ?? "";
+ requests.push({ method: req.method, path: url.pathname, auth });
+ if (auth.startsWith("Basic ")) {
+ if (auth !== ADMIN_HEADER) return Response.json({ error: "authentication required" }, { status: 401 });
+ return (await routes(req, url)) ?? new Response("not found", { status: 404 });
+ }
+ return (row.publicKey === "pending" ? pendingStub : signedStub)(req);
+ };
+ const server = opts.fetchOnly ? undefined : Bun.serve({ port: 0, fetch: handler });
+ return { url: server ? `http://127.0.0.1:${server.port}` : "http://flair.test", stop: () => server?.stop(true), row, requests, registerBodies, fetch: handler };
+}
+
+test("the registrar rejects other Basic credentials, a signed pending GET and a non-admin signed registration POST", async () => {
+ const id = `${AGENT}-auth-boundary`;
+ const keyPair = generateKeyPair();
+ const publicKey = Buffer.from(keyPair.signing.publicKey).toString("base64url");
+ const fake = startRegistrarFlair(id, publicKey, { fetchOnly: true });
+ try {
+ const path = `/Agent/${id}`;
+ const admin = await fake.fetch(new Request(`${fake.url}${path}`, { headers: { Authorization: ADMIN_HEADER } }));
+ expect(admin.status).toBe(200);
+ expect((await admin.json()).publicKey).toBe("pending");
+ const wrong = await fake.fetch(new Request(`${fake.url}/AgentKeyRegister`, {
+ method: "POST",
+ headers: { Authorization: `Basic ${Buffer.from("admin:wrong").toString("base64")}`, "Content-Type": "application/json" },
+ body: JSON.stringify({ agentId: id, publicKey }),
+ }));
+ expect(wrong.status).toBe(401);
+ expect(await wrong.json()).toEqual({ error: "authentication required" });
+ expect(fake.registerBodies).toEqual([]);
+ const sign = requestSigner(id, Buffer.from(keyPair.signing.privateKey));
+ const pending = await fake.fetch(new Request(`${fake.url}${path}`, { headers: { Authorization: sign("GET", path) } }));
+ expect(pending.status).toBe(401);
+ expect(await pending.json()).toEqual({ error: "signature_verification_failed" });
+ expect(fake.row.publicKey).toBe("pending");
+ const register = await fake.fetch(new Request(`${fake.url}/AgentKeyRegister`, {
+ method: "POST",
+ headers: { Authorization: ADMIN_HEADER, "Content-Type": "application/json" },
+ body: JSON.stringify({ agentId: id, publicKey }),
+ }));
+ expect(register.status).toBe(200);
+ expect(fake.row.publicKey).toBe(publicKey);
+ const signedRegister = await fake.fetch(new Request(`${fake.url}/AgentKeyRegister`, {
+ method: "POST",
+ headers: { Authorization: sign("POST", "/AgentKeyRegister"), "Content-Type": "application/json" },
+ body: JSON.stringify({ agentId: id, publicKey: Buffer.alloc(32, 7).toString("base64url") }),
+ }));
+ expect(signedRegister.status).toBe(403);
+ expect(await signedRegister.json()).toEqual({ error: "forbidden" });
+ expect(fake.row.publicKey).toBe(publicKey);
+ expect(fake.registerBodies).toEqual([{ agentId: id, publicKey }]);
+ } finally {
+ fake.stop();
+ }
+});
+
+for (const body of ["unrecognized", "unreadable"] as const) {
+ test(`an ${body} registration error body leaves the outcome unconfirmed with its HTTP status`, async () => {
+ const id = `${AGENT}-${body}-error`;
+ const keyPair = generateKeyPair();
+ const publicKey = Buffer.from(keyPair.signing.publicKey).toString("base64url");
+ const fake = startRegistrarFlair(id, publicKey, { fetchOnly: true });
+ const restore = patchShared(globalThis, "fetch", (async (input, init) => {
+ const req = new Request(input, init);
+ if (req.method === "POST" && new URL(req.url).pathname === "/AgentKeyRegister") {
+ const response = Response.json({ error: "unexpected_server_error" }, { status: 502 });
+ if (body === "unreadable") response.text = async () => { throw new Error("body read failed"); };
+ return response;
+ }
+ return fake.fetch(req);
+ }) as typeof fetch);
+ try {
+ const res = await runCreate(fake.url, id, { keyPair, credential: ADMIN_AUTH });
+ expect(res.exitCode).toBe(1);
+ expect(res.stdout).not.toContain("registered in Flair");
+ expect(res.stderr).toContain("was not confirmed");
+ expect(res.stderr).toContain("POST /AgentKeyRegister answered HTTP 502");
+ expect(res.stderr).toContain("whether a key was stored is unknown");
+ expect(res.stderr).not.toContain("refused the registration");
+ } finally {
+ restore();
+ fake.stop();
+ }
+ });
+}
+
+test("a pending row and a Flair that serves POST /AgentKeyRegister: the generated key is registered and read back equal, then success", async () => {
+ const id = `${AGENT}-registers`;
+ const keyPair = generateKeyPair();
+ const publicKey = Buffer.from(keyPair.signing.publicKey).toString("base64url");
+ const fake = startRegistrarFlair(id, publicKey);
+ try {
+ const res = await runCreate(fake.url, id, { keyPair, inspectError: true });
+ expect(res.hasError).toBe(false);
+ expect(res.exitCode).toBeUndefined();
+ expect(res.stdout).toContain("registered in Flair");
+ // the key registered is the base64url of the generated public key
+ expect(fake.registerBodies).toEqual([{ agentId: id, publicKey }]);
+ expect(fake.row.publicKey).toBe(publicKey);
+ // the registration is over Basic admin; the signed verification follows it
+ const registration = fake.requests.findIndex((req) => req.method === "POST" && req.path === "/AgentKeyRegister");
+ expect(registration).toBeGreaterThan(-1);
+ expect(fake.requests[registration]!.auth).toStartWith("Basic ");
+ expect(fake.requests.at(-1)).toEqual({ method: "GET", path: `/Agent/${id}`, auth: expect.stringContaining("TPS-Ed25519 ") });
+ } finally {
+ fake.stop();
+ }
+});
+
+for (const route of ["serve", "unavailable"] as const) {
+ test(`a ${route === "serve" ? "successful" : "registration_unavailable"} POST followed by a read-back exception: unconfirmed with a re-run remedy and no removal advice`, async () => {
+ const id = `${AGENT}-${route}-read-fail`;
+ const keyPair = generateKeyPair();
+ const publicKey = Buffer.from(keyPair.signing.publicKey).toString("base64url");
+ const fake = startRegistrarFlair(id, publicKey, { fetchOnly: true, route, commit: true });
+ const restore = patchShared(globalThis, "fetch", (async (input, init) => {
+ const req = new Request(input, init);
+ const response = await fake.fetch(req);
+ if (req.method === "GET" && new URL(req.url).pathname === `/Agent/${id}` && fake.registerBodies.length > 0) {
+ throw new Error("post-registration read failed");
+ }
+ return response;
+ }) as typeof fetch);
+ try {
+ const res = await runCreate(fake.url, id, { keyPair, credential: ADMIN_AUTH });
+ expect(fake.registerBodies).toEqual([{ agentId: id, publicKey }]);
+ expect(fake.row.publicKey).toBe(publicKey);
+ expectReadBackAfterRegistration(fake.requests, id);
+ expect(res.exitCode).toBe(1);
+ expect(res.stdout).not.toContain("registered in Flair");
+ expect(res.stderr).toContain(`this attempt to register a key in Flair at ${fake.url} was not confirmed`);
+ expect(res.stderr).toContain(route === "serve" ? "POST /AgentKeyRegister succeeded" : "POST /AgentKeyRegister answered registration_unavailable");
+ expect(res.stderr).toContain("The read-back after the POST failed (");
+ expect(res.stderr).toContain("transport error: network failure");
+ expect(res.stderr).toContain(`Remedy: re-run \`tps agent create --id ${id}\``);
+ expect(res.stderr).not.toContain("flair agent remove");
+ expect(res.stderr).not.toContain("is not registered in Flair");
+ expectNoAgentWriteAfterRegistration(fake.requests, id);
+ } finally {
+ restore();
+ fake.stop();
+ }
+ });
+}
+
+for (const observed of ["pending", "different", "absent", "no-key"] as const) {
+ test(`a successful POST followed by a ${observed} read-back: unconfirmed with a re-run remedy and no removal advice`, async () => {
+ const id = `${AGENT}-accepted-${observed}`;
+ const keyPair = generateKeyPair();
+ const publicKey = Buffer.from(keyPair.signing.publicKey).toString("base64url");
+ const fake = startRegistrarFlair(id, publicKey, { fetchOnly: true });
+ const storedKey = observed === "pending" ? "pending" : "de".repeat(32);
+ const restore = patchShared(globalThis, "fetch", (async (input, init) => {
+ const req = new Request(input, init);
+ const response = await fake.fetch(req);
+ if (req.method === "GET" && new URL(req.url).pathname === `/Agent/${id}` && fake.registerBodies.length > 0) {
+ if (observed === "absent") return new Response("not found", { status: 404 });
+ return Response.json({ id, name: id, ...(observed === "no-key" ? {} : { publicKey: storedKey }) });
+ }
+ return response;
+ }) as typeof fetch);
+ try {
+ const res = await runCreate(fake.url, id, { keyPair, credential: ADMIN_AUTH });
+ expect(fake.registerBodies).toEqual([{ agentId: id, publicKey }]);
+ expectReadBackAfterRegistration(fake.requests, id);
+ expect(res.exitCode).toBe(1);
+ expect(res.stdout).not.toContain("registered in Flair");
+ expect(res.stderr).toContain(`this attempt to register a key in Flair at ${fake.url} was not confirmed`);
+ expect(res.stderr).toContain("POST /AgentKeyRegister succeeded");
+ expect(res.stderr).toContain(observed === "absent" ? "found no Agent row" : observed === "no-key" ? "found no stored public key" : `found the stored public key '${storedKey}', not the generated key`);
+ expect(res.stderr).toContain(`Remedy: re-run \`tps agent create --id ${id}\``);
+ expect(res.stderr).not.toContain("flair agent remove");
+ expect(res.stderr).not.toContain("is not registered in Flair");
+ expectNoAgentWriteAfterRegistration(fake.requests, id);
+ } finally {
+ restore();
+ fake.stop();
+ }
+ });
+}
+
+for (const observed of ["failure", "different", "absent"] as const) {
+ test(`a successful POST and equal admin read-back followed by a signed ${observed}: unconfirmed with no removal advice`, async () => {
+ const id = `${AGENT}-signed-${observed}`;
+ const keyPair = generateKeyPair();
+ const publicKey = Buffer.from(keyPair.signing.publicKey).toString("base64url");
+ const fake = startRegistrarFlair(id, publicKey, { fetchOnly: true });
+ const restore = patchShared(globalThis, "fetch", (async (input, init) => {
+ const req = new Request(input, init);
+ const response = await fake.fetch(req);
+ if (req.method === "GET" && new URL(req.url).pathname === `/Agent/${id}` && fake.registerBodies.length > 0 && req.headers.get("Authorization")?.startsWith("TPS-Ed25519 ")) {
+ if (observed === "failure") throw new Error("signed read failed");
+ if (observed === "absent") return new Response("not found", { status: 404 });
+ return Response.json({ id, name: id, publicKey: "de".repeat(32) });
+ }
+ return response;
+ }) as typeof fetch);
+ try {
+ const res = await runCreate(fake.url, id, { keyPair, credential: ADMIN_AUTH });
+ expect(fake.registerBodies).toEqual([{ agentId: id, publicKey }]);
+ expect(fake.requests.at(-1)?.auth).toStartWith("TPS-Ed25519 ");
+ expect(res.exitCode).toBe(1);
+ expect(res.stdout).not.toContain("registered in Flair");
+ expect(res.stderr).toContain(`this attempt to register a key in Flair at ${fake.url} was not confirmed`);
+ expect(res.stderr).toContain(`Remedy: re-run \`tps agent create --id ${id}\``);
+ expect(res.stderr).not.toContain("flair agent remove");
+ } finally {
+ restore();
+ fake.stop();
+ }
+ });
+}
+
+/** A refusal from POST /AgentKeyRegister: the status the endpoint answers and the remedy its refusal names. */
+const KEY_REGISTRATION_REFUSALS: ReadonlyArray<{ status: number; error: string; remedy: string }> = [
+ { status: 400, error: "registration_request_invalid", remedy: "the agent id or public key was refused as malformed" },
+ { status: 401, error: "authentication required", remedy: "set FLAIR_ADMIN_AUTH" },
+ { status: 403, error: "forbidden", remedy: "set FLAIR_ADMIN_AUTH to its admin credential" },
+ { status: 403, error: "agent_not_local", remedy: "register its key on the agent's home instance" },
+ { status: 403, error: "agent_inactive", remedy: "reactivate it before registering a key" },
+ { status: 404, error: "agent_not_found", remedy: "no Agent row exists" },
+ { status: 409, error: "agent_key_not_pending", remedy: "key-rotation path" },
+ { status: 409, error: "registration_conflict", remedy: "another write to this agent is in progress" },
+];
+
+for (const refusal of KEY_REGISTRATION_REFUSALS) {
+ test(`POST /AgentKeyRegister refusal ${refusal.error}: exits non-zero naming the agent, URL, refusal and remedy, and sends no fallback write`, async () => {
+ const id = `${AGENT}-refuse-${refusal.error.replace(/\W/g, "-")}`;
+ const keyPair = generateKeyPair();
+ const publicKey = Buffer.from(keyPair.signing.publicKey).toString("base64url");
+ const fake = startRegistrarFlair(id, publicKey, { refusal: { status: refusal.status, error: refusal.error } });
+ try {
+ const res = await runCreate(fake.url, id, { keyPair });
+ expect(res.exitCode).toBe(1);
+ expect(res.stdout).not.toContain("registered in Flair");
+ expect(res.stderr).toContain(`Agent '${id}'`);
+ expect(res.stderr).toContain(`this attempt to register a key in Flair at ${fake.url} was not confirmed`);
+ expect(res.stderr).toContain(`POST /AgentKeyRegister refused the registration (${refusal.error})`);
+ expect(res.stderr).toContain(refusal.remedy);
+ // the endpoint's own refusal, never the unrecognized-response case
+ expect(res.stderr).not.toContain("this Flair may not serve the route");
+ // no Agent PUT/PATCH follows the refused registration POST
+ expectNoAgentWriteAfterRegistration(fake.requests, id);
+ } finally {
+ fake.stop();
+ }
+ });
+}
+
+test("a transport failure after the POST was sent, but the key reads back equal: success, and the read-back runs after the POST", async () => {
+ const id = `${AGENT}-transport-equal`;
+ const keyPair = generateKeyPair();
+ const publicKey = Buffer.from(keyPair.signing.publicKey).toString("base64url");
+ const fake = startRegistrarFlair(id, publicKey, { route: "transport", commit: true });
+ try {
+ const res = await runCreate(fake.url, id, { keyPair, inspectError: true });
+ expect(res.hasError).toBe(false);
+ expect(res.exitCode).toBeUndefined();
+ expect(res.stdout).toContain("registered in Flair");
+ expectReadBackAfterRegistration(fake.requests, id);
+ } finally {
+ fake.stop();
+ }
+});
+
+test("a transport failure after the POST and a pending read-back: this attempt was not confirmed, naming the observed state", async () => {
+ const id = `${AGENT}-transport-pending`;
+ const keyPair = generateKeyPair();
+ const publicKey = Buffer.from(keyPair.signing.publicKey).toString("base64url");
+ const fake = startRegistrarFlair(id, publicKey, { route: "transport" });
+ try {
+ const res = await runCreate(fake.url, id, { keyPair });
+ expect(res.exitCode).toBe(1);
+ expect(res.stdout).not.toContain("registered in Flair");
+ expect(res.stderr).toContain(`this attempt to register a key in Flair at ${fake.url} was not confirmed`);
+ expect(res.stderr).toContain("transport failure");
+ // the transport failure's own bounded reason, not a refusal name
+ expect(res.stderr).toContain("connection refused");
+ expect(res.stderr).toContain("The read-back after the POST found the stored public key 'pending', not the generated key");
+ expect(res.stderr).not.toContain("refused the registration");
+ expect(res.stderr).not.toContain("not registered");
+ expectReadBackAfterRegistration(fake.requests, id);
+ expectNoAgentWriteAfterRegistration(fake.requests, id);
+ } finally {
+ fake.stop();
+ }
+});
+
+test("an unparseable success body, but the key reads back equal: success, and the read-back runs after the POST", async () => {
+ const id = `${AGENT}-unparseable-equal`;
+ const keyPair = generateKeyPair();
+ const publicKey = Buffer.from(keyPair.signing.publicKey).toString("base64url");
+ const fake = startRegistrarFlair(id, publicKey, { route: "unparseable", commit: true });
+ try {
+ const res = await runCreate(fake.url, id, { keyPair, inspectError: true });
+ expect(res.hasError).toBe(false);
+ expect(res.exitCode).toBeUndefined();
+ expect(res.stdout).toContain("registered in Flair");
+ expectReadBackAfterRegistration(fake.requests, id);
+ } finally {
+ fake.stop();
+ }
+});
+
+test("an unparseable success body and a pending read-back: this attempt was not confirmed, a parsing failure distinct from a transport one", async () => {
+ const id = `${AGENT}-unparseable-pending`;
+ const keyPair = generateKeyPair();
+ const publicKey = Buffer.from(keyPair.signing.publicKey).toString("base64url");
+ const fake = startRegistrarFlair(id, publicKey, { route: "unparseable" });
+ try {
+ const res = await runCreate(fake.url, id, { keyPair });
+ expect(res.exitCode).toBe(1);
+ expect(res.stdout).not.toContain("registered in Flair");
+ expect(res.stderr).toContain(`this attempt to register a key in Flair at ${fake.url} was not confirmed`);
+ expect(res.stderr).toContain("response-parsing failure");
+ expect(res.stderr).toContain("malformed response body");
+ expect(res.stderr).toContain("The read-back after the POST found the stored public key 'pending', not the generated key");
+ expect(res.stderr).not.toContain("refused the registration");
+ expect(res.stderr).not.toContain("not registered");
+ expectReadBackAfterRegistration(fake.requests, id);
+ expectNoAgentWriteAfterRegistration(fake.requests, id);
+ } finally {
+ fake.stop();
+ }
+});
+
+test("registration_unavailable after a committed write and an equal read-back: success, and the read-back runs after the POST", async () => {
+ const id = `${AGENT}-unavailable-equal`;
+ const keyPair = generateKeyPair();
+ const publicKey = Buffer.from(keyPair.signing.publicKey).toString("base64url");
+ const fake = startRegistrarFlair(id, publicKey, { route: "unavailable", commit: true });
+ try {
+ const res = await runCreate(fake.url, id, { keyPair, inspectError: true });
+ expect(res.hasError).toBe(false);
+ expect(res.exitCode).toBeUndefined();
+ expect(res.stdout).toContain("registered in Flair");
+ expectReadBackAfterRegistration(fake.requests, id);
+ } finally {
+ fake.stop();
+ }
+});
+
+test("registration_unavailable without a committed write and a pending read-back: this attempt was not confirmed, naming the observed state", async () => {
+ const id = `${AGENT}-unavailable-pending`;
+ const keyPair = generateKeyPair();
+ const publicKey = Buffer.from(keyPair.signing.publicKey).toString("base64url");
+ const fake = startRegistrarFlair(id, publicKey, { route: "unavailable" });
+ try {
+ const res = await runCreate(fake.url, id, { keyPair });
+ expect(res.exitCode).toBe(1);
+ expect(res.stdout).not.toContain("registered in Flair");
+ expect(res.stderr).toContain(`this attempt to register a key in Flair at ${fake.url} was not confirmed`);
+ expect(res.stderr).toContain("registration_unavailable");
+ expect(res.stderr).toContain("The read-back after the POST found the stored public key 'pending', not the generated key");
+ expect(res.stderr).not.toContain("not registered");
+ expect(res.stderr).not.toContain("refused the registration");
+ expectReadBackAfterRegistration(fake.requests, id);
+ expectNoAgentWriteAfterRegistration(fake.requests, id);
} finally {
fake.stop();
}
@@ -377,13 +864,14 @@ for (const suffix of [".seed", ".key"]) {
});
}
-// chmod 000 does not stop root from reading, so the case cannot be built as root.
-test.skipIf(process.getuid?.() === 0)("an existing private key file that cannot be read: a named refusal before any Flair request, not a throw", async () => {
+test("a directory at the private key path: refusal before any Flair request, not a throw", async () => {
const id = `${AGENT}-unreadable-key`;
const keyPair = generateKeyPair();
- const fake = startFakeFlair(async (req, url) => (req.method === "PUT" ? refusePut(url, id) : undefined));
+ const fake = startRegistrarFlair(id, Buffer.from(keyPair.signing.publicKey).toString("base64url"), { fetchOnly: true });
+ const restore = patchShared(globalThis, "fetch", (async (input, init) => fake.fetch(new Request(input, init))) as typeof fetch);
const { keyPath } = writeIdentity(id, keyPair);
- chmodSync(keyPath, 0o000);
+ unlinkSync(keyPath);
+ mkdirSync(keyPath);
try {
const res = await runCreate(fake.url, id, { inspectError: true });
expect(res.hasError).toBe(false);
@@ -391,13 +879,12 @@ test.skipIf(process.getuid?.() === 0)("an existing private key file that cannot
expect(res.exitCode).toBe(1);
expect(res.stdout).not.toContain("registered");
expect(res.stderr).toContain(`Agent '${id}' cannot load its identity at`);
- expect(res.stderr).toContain(`${keyPath} cannot be read (EACCES)`);
- expect(res.stderr).toContain("make that file readable by the user running tps");
+ expect(res.stderr).toMatch(/cannot be read \(EISDIR\)|loading the identity files failed/);
expect(res.stderr).toContain(`tps agent create --id ${id}`);
expectNoKeyBytes(res.stdout + res.stderr, keyPair.signing.privateKey);
expect(fake.requests).toEqual([]);
} finally {
- chmodSync(keyPath, 0o600);
+ restore();
fake.stop();
}
});
@@ -749,6 +1236,88 @@ test.skipIf(process.env.TPS_TEST_REAL_FLAIR !== "1")(
240_000,
);
+test.skipIf(process.env.TPS_TEST_REAL_FLAIR !== "1")(
+ "against a real Flair, create on a pending row completes registration when the instance serves POST /AgentKeyRegister and the stored key reads back equal; when the route is absent it refuses naming the minimum version",
+ async () => {
+ const { baseUrl, opsUrl, admin } = flairTarget();
+ const id = `create-pending-${randomUUID()}`;
+ const wire = Buffer.from(admin).toString("base64");
+ const headers = { Authorization: `Basic ${wire}`, "Content-Type": "application/json" };
+ const inserted = await fetch(opsUrl, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ operation: "insert",
+ database: "flair",
+ table: "Agent",
+ records: [{ id, name: id, publicKey: "pending", createdAt: new Date().toISOString() }],
+ }),
+ });
+ expect(inserted.ok).toBe(true);
+ const keyPair = generateKeyPair();
+ const publicKey = Buffer.from(keyPair.signing.publicKey).toString("base64url");
+ try {
+ const path = `/Agent/${id}`;
+ const adminRead = await fetch(`${baseUrl}${path}`, { headers });
+ expect(adminRead.status).toBe(200);
+ expect((await adminRead.json()).publicKey).toBe("pending");
+ const wrong = await fetch(`${baseUrl}${path}`, {
+ headers: { Authorization: `Basic ${Buffer.from("admin:wrong").toString("base64")}` },
+ });
+ expect(wrong.ok).toBe(false);
+ const sign = requestSigner(id, Buffer.from(keyPair.signing.privateKey));
+ const pending = await fetch(`${baseUrl}${path}`, { headers: { Authorization: sign("GET", path) } });
+ expect(pending.status).toBe(401);
+ expect((await pending.json()).error).toBe("signature_verification_failed");
+
+ // Whether this Flair serves POST /AgentKeyRegister: a missing route answers
+ // 404, a present one refuses an empty body 400. Measured once, before the run.
+ const probe = await fetch(`${baseUrl}/AgentKeyRegister`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({}),
+ });
+ const capable = probe.status !== 404 && probe.status !== 405;
+
+ const res = await runCreate(baseUrl, id, { keyPair, credential: admin, inspectError: true });
+ if (capable) {
+ expect(res.exitCode).toBeUndefined();
+ expect(res.hasError).toBe(false);
+ expect(res.stdout).toContain("registered in Flair");
+ const record = await fetch(`${baseUrl}/Agent/${id}`, { headers });
+ expect(record.ok).toBe(true);
+ expect((await record.json() as { publicKey: string }).publicKey).toBe(publicKey);
+ const signedRegister = await fetch(`${baseUrl}/AgentKeyRegister`, {
+ method: "POST",
+ headers: { Authorization: sign("POST", "/AgentKeyRegister"), "Content-Type": "application/json" },
+ body: JSON.stringify({ agentId: id, publicKey: Buffer.alloc(32, 7).toString("base64url") }),
+ });
+ expect(signedRegister.status).toBe(403);
+ const unchanged = await fetch(`${baseUrl}${path}`, { headers });
+ expect(unchanged.status).toBe(200);
+ expect((await unchanged.json()).publicKey).toBe(publicKey);
+ } else {
+ expect(res.exitCode).toBe(1);
+ expect(res.stdout).not.toContain("registered in Flair");
+ expect(res.stderr).toContain("this Flair may not serve the route");
+ expect(res.stderr).toContain("0.62.0");
+ }
+ for (const stream of [res.stdout, res.stderr, format(res.error)]) {
+ expect(stream).not.toContain(admin);
+ expect(stream).not.toContain(wire);
+ }
+ } finally {
+ const deleted = await fetch(opsUrl, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ operation: "delete", database: "flair", table: "Agent", hash_values: [id] }),
+ });
+ expect(deleted.ok).toBe(true);
+ }
+ },
+ 240_000,
+);
+
test("selected create cases omit the credential marker and Basic encoding from captured output and errors", async () => {
const marker = `credential-593-${randomUUID()}-${"z".repeat(40)}`;
const credential = `operator:${marker}`;
diff --git a/packages/cli/test/helpers/scratch-flair.ts b/packages/cli/test/helpers/scratch-flair.ts
index a90b3136..aa3dcc6a 100644
--- a/packages/cli/test/helpers/scratch-flair.ts
+++ b/packages/cli/test/helpers/scratch-flair.ts
@@ -14,6 +14,13 @@ import { generateKeyPair } from "../../src/utils/identity.js";
export const FLAIR_PACKAGE = "@tpsdev-ai/flair";
export const FLAIR_VERSION = "0.58.0";
+/**
+ * The npm spec the scratch instance installs. Defaults to the pinned release;
+ * `TPS_TEST_FLAIR_SPEC` overrides it, so the lane can run against a Flair build
+ * that is not on npm yet (a tarball packed from a checkout, say).
+ */
+const FLAIR_INSTALL_SPEC = process.env.TPS_TEST_FLAIR_SPEC ?? `${FLAIR_PACKAGE}@${FLAIR_VERSION}`;
+
/** The operator (Harper admin) credential the helper boots and seeds with. */
export const ADMIN_AUTH = "admin:test123";
@@ -248,7 +255,7 @@ async function bootScratchFlair(instance: ScratchProcess, signal: AbortSignal) {
// npm cache (HOME is the scratch home).
await execFileAsync(
"npm",
- ["install", "--prefix", prefix, "--no-audit", "--no-fund", "--loglevel", "error", `${FLAIR_PACKAGE}@${FLAIR_VERSION}`],
+ ["install", "--prefix", prefix, "--no-audit", "--no-fund", "--loglevel", "error", FLAIR_INSTALL_SPEC],
{ env: { PATH: process.env.PATH ?? "", HOME: home, npm_config_cache: join(home, ".npm") }, signal, timeout: 300_000 },
);
diff --git a/scripts/real-flair-lane.ts b/scripts/real-flair-lane.ts
index b487c552..8021ad61 100644
--- a/scripts/real-flair-lane.ts
+++ b/scripts/real-flair-lane.ts
@@ -18,6 +18,7 @@ export const EXPECTED_CASES: Record = {
"agent-create-registration.test.ts": [
"against a real Flair, create refuses when the generated key cannot be registered",
"against a real Flair, create succeeds with an existing Agent and matching local key",
+ "against a real Flair, create on a pending row completes registration when the instance serves POST /AgentKeyRegister and the stored key reads back equal; when the route is absent it refuses naming the minimum version",
],
"mail-flair-read-auth.test.ts": ["real Flair Agent read guards and refused-read mail recovery"],
"presence-flair-contract.test.ts": [