Skip to content
Merged
11 changes: 11 additions & 0 deletions .changelog/unreleased/changed-512-create-registers-key.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
130 changes: 119 additions & 11 deletions packages/cli/src/commands/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -274,19 +274,19 @@ 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 <dir>\` there, then re-run \`tps agent create\``;
if (rowState === "exists") {
console.error(
` 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 {
Expand Down Expand Up @@ -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<string, string> = {
"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<void> {
const id = args.id;
if (!id) {
Expand Down Expand Up @@ -469,10 +524,10 @@ async function createAgent(args: AgentArgs): Promise<void> {

// 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();

Expand Down Expand Up @@ -503,12 +558,12 @@ async function createAgent(args: AgentArgs): Promise<void> {
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).`;
}
Expand All @@ -522,6 +577,7 @@ async function createAgent(args: AgentArgs): Promise<void> {
writeError = writeError ? `${writeError}; ${msg}` : msg;
}

let registrationPostCause: string | null = null;
let stored: { found: boolean; publicKey: string | null };
try {
stored = await flair.readStoredPublicKey(id);
Expand All @@ -530,6 +586,52 @@ async function createAgent(args: AgentArgs): Promise<void> {
// 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)})`,
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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) {
Expand All @@ -546,9 +648,15 @@ async function createAgent(args: AgentArgs): Promise<void> {
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.`);
Expand Down
80 changes: 80 additions & 0 deletions packages/cli/src/utils/flair-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = 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 {
Expand Down Expand Up @@ -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<FlairAgent | null> {
try {
return await this.request<FlairAgent>("GET", `/Agent/${encodeURIComponent(id ?? this.agentId)}`);
Expand Down
Loading
Loading