Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +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.
133 changes: 100 additions & 33 deletions packages/cli/src/commands/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,46 @@ async function loadSoulFile(filePath: string): Promise<Record<string, string>> {
return result;
}

/** Flair stores base64url (`flair agent add`) and accepts hex or base64/base64url; only a 32-byte decode is a key. */
function storedKeyAsHex(stored: string): string | null {
let bytes: Buffer;
if (/^[0-9a-fA-F]{64}$/.test(stored)) bytes = Buffer.from(stored, "hex");
else if (/^[A-Za-z0-9+/_-]+={0,2}$/.test(stored)) bytes = Buffer.from(stored, "base64");
else return null;
return bytes.length === 32 ? bytes.toString("hex") : null;
}

/**
* cli#512 — when Flair is reachable, `create` exits non-zero here unless the
* stored key reads back equal to the generated key.
*/
function refuseRegistration(
id: string,
flairUrl: string,
identityDir: string,
detail: string,
writeError: string | null,
rowExists: boolean,
): never {
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).`,
);
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 (rowExists) {
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.`,
);
} else {
console.error(` Until then: ${copy}.`);
}
process.exit(1);
}

async function createAgent(args: AgentArgs): Promise<void> {
const id = args.id;
if (!id) {
Expand Down Expand Up @@ -274,48 +314,75 @@ async function createAgent(args: AgentArgs): Promise<void> {
console.log(` Keys saved to ${identityDir}/`);
}

// 2. Register in Flair
// 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.
const flair = createFlairClient(id, flairUrl, keyPath);
const online = await flair.ping();

if (!online) {
console.warn(` ⚠️ Flair not reachable at ${flairUrl} — skipping registration.`);
console.warn(` Run setup-harper.sh and retry: tps agent create --id ${id}`);
} else {
const existing = await flair.getAgent(id);
if (existing) {
console.log(` Agent '${id}' already registered in Flair.`);
// Still register real public key if record has placeholder
if (existing.publicKey === "pending") {
await flair.updateAgent(id, { publicKey: pubKeyHex }).catch(() => {});
}
} else if (!args.noSeed) {
// Seed agent with soul + starter memories
const soulTemplate = args.soulFile
? await loadSoulFile(args.soulFile)
: undefined;
const starterMemories = args.starterMemories;
try {
const seeded = await flair.seedAgent({
agentId: id,
displayName: name,
role: "agent",
soulTemplate,
starterMemories,
});
// Update the public key on the agent record that AgentSeed created
await flair.updateAgent(id, { publicKey: pubKeyHex }).catch(() => {});
console.log(` Agent seeded: ${seeded.soulEntries.length} soul entries, ${seeded.memories.length} memories.`);
} catch (_e: any) {
// AgentSeed requires admin auth — fall back to direct registration
await flair.registerAgent(name, pubKeyHex).catch(() => {});
console.log(` Agent '${id}' registered in Flair (no seed — not admin).`);
let writeError: string | null = null;
let successLine: string | null = null;
try {
const existing = await flair.getAgent(id);
if (existing) {
if (storedKeyAsHex(existing.publicKey ?? "") !== pubKeyHex) {
await flair.updateAgent(id, { publicKey: pubKeyHex });
}
successLine = ` Agent '${id}' already registered in Flair.`;
} else if (!args.noSeed) {
// Seed agent with soul + starter memories
const soulTemplate = args.soulFile
? await loadSoulFile(args.soulFile)
: undefined;
const starterMemories = args.starterMemories;
try {
const seeded = await flair.seedAgent({
agentId: id,
displayName: name,
role: "agent",
soulTemplate,
starterMemories,
});
// Update the public key on the agent record that AgentSeed created
await flair.updateAgent(id, { publicKey: pubKeyHex });
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)}`;
await flair.registerAgent(name, pubKeyHex);
successLine = ` Agent '${id}' registered in Flair (no seed — not admin).`;
}
} else {
// --no-seed: just register
await flair.registerAgent(name, pubKeyHex);
successLine = ` Agent '${id}' registered in Flair (seeding skipped).`;
}
} else {
// --no-seed: just register
await flair.registerAgent(name, pubKeyHex);
console.log(` Agent '${id}' registered in Flair (seeding skipped).`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
writeError = writeError ? `${writeError}; ${msg}` : msg;
}

let stored: { found: boolean; publicKey: string | null };
try {
stored = await flair.readStoredPublicKey(id);
} catch (e) {
refuseRegistration(id, flairUrl, identityDir, `the read-back failed (${e instanceof Error ? e.message : String(e)})`, writeError, false);
}
if (!stored.found) {
refuseRegistration(id, flairUrl, identityDir, "no Agent row exists", writeError, false);
} else if (stored.publicKey === null || storedKeyAsHex(stored.publicKey) !== pubKeyHex) {
const found =
stored.publicKey === null ? "the row has no public key" : `the stored public key is '${stored.publicKey}'`;
refuseRegistration(id, flairUrl, identityDir, `${found}, not the generated key`, writeError, true);
}
console.log(successLine ?? ` Agent '${id}' registered in Flair.`);
}

// 3. Write agent config
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/src/utils/flair-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,31 @@ export class FlairClient {
}
}

/**
* Read the public key Flair actually stores for `agentId`, over the operator
* (Basic admin) credential. cli#512: the read-back that decides whether a
* registration happened must not use the key it is verifying — an agent
* whose key is not registered yet cannot read its own row. A 404 is the only
* "no such row"; every other non-OK response throws, so a failed read is
* never read as "absent".
*/
async readStoredPublicKey(
agentId: string = this.agentId,
adminAuth?: string,
): Promise<{ found: boolean; publicKey: string | null }> {
const auth = adminAuth ?? process.env.FLAIR_ADMIN_AUTH ?? "admin:admin123";
const res = await fetch(`${this.baseUrl}/Agent/${encodeURIComponent(agentId)}`, {
headers: { Authorization: `Basic ${Buffer.from(auth).toString("base64")}` },
});
if (res.status === 404) return { found: false, publicKey: null };
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new FlairRequestError(`Flair GET /Agent/${agentId} → ${res.status}: ${text}`, res.status);
}
const record = (await res.json()) as FlairAgent | null;
return { found: true, publicKey: typeof record?.publicKey === "string" ? record.publicKey : null };
}

async listAgents(): Promise<FlairAgent[]> {
try {
return await this.request<FlairAgent[]>("GET", "/Agent/");
Expand Down
Loading
Loading