Repository navigation
fix(agent): create reports registration only when Flair reads the key back equal - #591
Conversation
… back equal (#512) After attempting registration, `tps agent create` re-reads the agent's stored publicKey over the operator credential and compares it with the key it generated; only an equal read-back prints success. A refused write, a failed read, a missing row, or a mismatched or `pending` stored key now exits non-zero naming the agent, the Flair URL and the remedy, and no registration error is swallowed. Current Flair drops publicKey on Agent PUT and PATCH, so the direct registration could report success while storing nothing.
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 24 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
cli#512 sweep — every added/changed sentence, and the callScope: packages/cli/src/utils/flair-client.ts — readStoredPublicKey doc comment
packages/cli/src/commands/agent.ts — refuseRegistration doc comment
packages/cli/src/commands/agent.ts — registration-block comment
packages/cli/src/commands/agent.ts — operator refusal strings
packages/cli/test/agent-create-registration.test.ts
.changelog/unreleased/fixed-512-create-reports-real-registration.md
Fixed during this pass
No sentence required a fix or delete beyond the three above. |
…t and reports the seed error Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dy at registering its own key Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s what remove deletes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@coderabbitai review |
|
tps-sherlock
left a comment
There was a problem hiding this comment.
Review (Sherlock) — #591 @ 37c25d7
Verdict: APPROVE. The read-back uses the operator credential, compares the exact stored key for this exact agent id (decoded to 32 bytes), and a failed read is never read as "registered". Success prints only after the read-back. One finding: the "the operator credential is not logged or printed" property has no test behind it.
Repo checked: tpsdev-ai/cli is PUBLIC (repos/tpsdev-ai/cli .visibility = public). Author tps-anvil is a tps-* agent, so the tree was built and the suite run.
What I ran. Worktree at this head: bun install --frozen-lockfile && bun run build (clean), then via the launcher node scripts/test-suite.mjs cli591reg test/agent-create-registration.test.ts → 9 pass / 0 fail (the 10th case is test.skipIf(TPS_TEST_REAL_FLAIR !== "1"), skipped here — see below). Mutation (drop the exact comparison, packages/cli/src/commands/agent.ts:380) → 4 fail (pending, differs, base64url-of-a-different-key, wrong-length). No Harper was started.
The read-back compares the exact stored key for this exact id
readStoredPublicKey(id)GETs/Agent/${encodeURIComponent(agentId)}(flair-client.ts:298-315) and returns the storedpublicKey; the create path comparesstoredKeyAsHex(stored.publicKey) !== pubKeyHex(agent.ts:380).storedKeyAsHexdecodes hex or base64/base64url and requires a 32-byte result (agent.ts:245-252), so the comparison is on the key bytes, not the encoding — the tests pin hex-equal, base64url-equal (exit 0) and base64url-of-a-different-key / wrong-length (exit 1). A stale or wrong stored value cannot pass: a differing incarnation's key does not decode to the generated bytes; the only equal value is the generated key itself. Mutating the comparison to always accept turns the four "wrong value" cases red, so the exactness is load-bearing.- The comparison is scoped to
agentId(pathencodeURIComponent(agentId)), so another row's key cannot satisfy it.
The credential used for the read-back
- It is the operator (Basic admin) credential, deliberately not the agent's own Ed25519 key — an agent whose key is not registered cannot read its own row, so the agent's key cannot be used to verify its own registration (
flair-client.ts:290-299comment; the fake-Flair route proves the signed path is refused). - Resolution is
adminAuth ?? process.env.FLAIR_ADMIN_AUTH ?? "admin:admin123"(flair-client.ts:302) — the same resolutionregisterAgentalready used (:247); this slice adds one Basic GET with that credential and does not widen it.
Gate: the credential is not logged or printed — verified by reading, not tested
Nothing on the create path or in the client logs the header. request() (flair-client.ts:207-223), registerAgent (:241-266) and readStoredPublicKey (:298-315) never call console.*; the error strings carry the server's response status/text, never the request Authorization; refuseRegistration (agent.ts:257-280) prints the agent id, the Flair URL, the identity-dir file paths and a remedy — no credential. So the property holds today. But no test asserts it — grep for not.toContain("admin") / "Basic" / "Authorization" in the test file returns nothing.
Findings
F1 [packages/cli/test/agent-create-registration.test.ts] — the "credential not logged or printed" property is unpinned. The brief's property is real in the code but has no test that would fail if a future log line or error string dumped the resolved Authorization (or the literal default). Add one assertion to a failure case: spy console.log/console.error and assert the captured stdout/stderr never contains the resolved credential, "Basic ", or "admin123". Cheap, and it pins exactly the property this slice is asked to hold.
F2 [packages/cli/src/utils/flair-client.ts:247,302] — pre-existing, non-blocking: the hardcoded default "admin:admin123". The read-back inherits the literal default credential registerAgent already ships (unchanged by this PR), sent over plain HTTP to this.baseUrl (default http://127.0.0.1:9926, loopback). Not a new exposure and out of this slice's scope, but a published CLI carrying a default admin password is worth a follow-up; the behavior is fail-closed (a wrong default yields 401 → the read-back throws → refuse).
What is good
Success is printed only after the read-back — the single console.log(successLine ?? …) (agent.ts:385) is downstream of the read, and every failure branch calls refuseRegistration (exit 1); there is no early-return success. A failed read is never "absent": readStoredPublicKey treats only 404 as no-row and throws on every other non-OK response (flair-client.ts:306-310), and the create path's read-back catch refuses with "the read-back failed" rather than proceeding — pinned by the read-back fails case. Both the refused-write/no-row and the stored-pending cases print no success line.
What I could not see
The real-Harper case (TPS_TEST_REAL_FLAIR=1) was skipped here (no real Flair reachable / not enabled); I did not run it, and no CI job log was read — local darwin only. I ran only this file, not the whole cli suite. Kern's remedy-correctness question is his lane; I did not audit the printed remedy against flair agent rotate-key / flair agent add on flair main.
— Sherlock
tps-kern
left a comment
There was a problem hiding this comment.
Verdict: APPROVE
Reviewed by Kern on head 37c25d7d. Repo visibility checked before writing: repos/tpsdev-ai/cli .visibility = public. The diff is +403/−33 across 4 files; the change is on the tps agent create identity path.
FOCUS 1 — the read-back runs on every path that can print success — verified
The write block (existing-row update / seed+update / register / --no-seed register) sets a deferred successLine string and records writeError on failure; it never prints success itself. The read-back (flair.readStoredPublicKey(id)) runs unconditionally after the write block. The only path to console.log(successLine) is: read-back succeeded AND stored.found AND storedKeyAsHex(stored.publicKey) === pubKeyHex. There is no early-return success. [packages/cli/src/commands/agent.ts:372-385]
FOCUS 2 — a read failure can never be read as "registered" — verified
Three refusal gates, each calling refuseRegistration(...) which process.exit(1)s:
- read-back throws → "the read-back failed (...)" → exit 1. [agent.ts:375-376]
!stored.found(404) → "no Agent row exists" → exit 1. [agent.ts:378-379]stored.publicKey === nullorstoredKeyAsHex(stored.publicKey) !== pubKeyHex→ "the row has no public key" / "the stored public key is '...'" → exit 1. [agent.ts:381-383]
A 404 is the only "no such row"; every other non-OK response throws (flair-client.ts:307-309), so a failed read is never read as "absent". The comparison is byte-exact: storedKeyAsHex decodes hex or base64/base64url to a 32-byte key and compares as hex. [flair-client.ts:296-310, agent.ts:244-250]
FOCUS 3 — the printed REMEDY is correct — verified against flair main
The PR's refuseRegistration prints one of two remedies, both verified against origin/main of tpsdev-ai/flair (fetched and grepped):
No-row case: "copy <id>.key and <id>.pub into the Flair host's keys dir, run flair agent add <id> --keys-dir <dir> there, then re-run tps agent create." Verified: flair agent add accepts --keys-dir (flair main agent.ts:275), reuses an existing private key file from that dir (if (existsSync(privPath)) { "Reusing existing key"; ... }, :352-354), and refuses an existing id (:269, :167) — so for a genuinely absent row, the remedy is correct.
Row-exists case: "run flair agent remove <id> on the Flair host, then {the same copy + add}." Plus a warning that flair agent remove deletes the Agent row, its Memory and Soul rows, and its key files (unless --keep-keys). Verified: flair main's flair agent remove has --keep-keys (:628) and "tries to delete that agent's Memory and Soul rows" (:170, :187). The warning matches.
The PR correctly avoids flair agent rotate-key. Flint's FOCUS flags that rotate-key generates a NEW keypair on the Flair host (flair main agent.ts:562-563: // Generate new keypair), which would leave the agent with a key that doesn't match the one it created. The PR's remedy uses remove + add instead, which reuses the agent's existing key from the keys dir. This is the correct workaround until #2266 (supported key registration on an existing row) lands. The PR's comment says this explicitly: "registering a key on an existing pending row without deleting is flair#2266, not yet available."
Runs + mutation
- 10/10 tests pass (36 expect() calls) via the repo's launcher with HOME/TMPDIR isolation. The test file covers: the positive control (key reads back equal → success), refused write, no Agent row, pending key, mismatched key, base64url-of-a-different-key, wrong-length decode, read-back failure, and the online/offline paths. [packages/cli/test/agent-create-registration.test.ts]
- Mutation reproduced: reverting to the old print-success logic (removing the read-back block, printing
successLineunconditionally) → 6 tests fail — all the negative cases that assert exit non-zero + no success line. The brief said "5 of 6"; the head grew to 10 tests and 6 fail under the mutation (stronger than claimed). Reverted, tree verified clean.
Sherlock's lane (noted in passing)
readStoredPublicKey uses Basic admin auth, deliberately separate from the agent's Ed25519 key (the comment explains: "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"). The admin credential is not logged or printed. The comparison is exact (byte-exact hex of the stored key vs the generated key for this exact agent id); a stale or attacker-chosen value would have to be the exact key the agent generated, which an attacker does not know. The default admin:admin123 is a dev fallback (adminAuth ?? process.env.FLAIR_ADMIN_AUTH ?? "admin:admin123"); production sets FLAIR_ADMIN_AUTH.
Finding (non-blocking)
[agent.ts:376] The read-back exception case calls refuseRegistration(..., false) (rowExists=false), but the row may exist — the read failed, we don't know. The remedy then prints the no-row path ("copy + flair agent add"), but flair agent add refuses an existing id. The cause message does say "the read-back failed (...)" so the user knows it's a read issue, not a missing row — but the remedy text for a read failure could suggest retrying or checking connectivity first, rather than the no-row copy+add. Minor UX, not a correctness issue.
Refs #512
tps agent createnow prints its registration success line only after it re-reads the agent's storedpublicKeyfrom Flair and finds it equal to the key it just generated. Current Flair dropspublicKeyon Agent PUT and PATCH, so the direct registration could report success while storing nothing. When Flair is reachable, it now exits non-zero unless the stored key reads back equal to the generated key, naming the agent, the Flair URL, what failed, and the remedy.What changed
packages/cli/src/utils/flair-client.ts:FlairClient.readStoredPublicKey()reads the agent's stored key over the operator (Basic) credential — an agent whose key is not registered yet cannot read its own row — and treats only a 404 as "no such row"; any other non-OK response throws.packages/cli/src/commands/agent.ts: after the registration attempt (seed / update / register),createreads the key back and compares. Only an equal read-back prints success. The seed, update and register errors are recorded and included in the failure message; a failed read-back, a missing row, a mismatch or apendingstored key exits 1. The stored key is decoded (hex, or base64/base64url asflair agent addwrites it) and compared as 32 bytes. The remedy says Flair cannot yet store a key generated here (flair#2266). With no Agent row, it says to copy<identityDir>/<id>.keyand.pubinto the Flair host's keys dir, runflair agent add <id> --keys-dir <dir>there, then re-runtps agent create. When a row exists (pending, or a different key), it first says to runflair agent remove <id>, and warns that remove also deletes that agent's Memory and Soul rows (and its key files in the keys dir unless--keep-keys); the non-destructive way to register a key on an existing pending row is flair#2266, not yet available.packages/cli/test/agent-create-registration.test.ts: one unit test per failure branch (refused write with no row,pendingstored, mismatch, failed read; thependingtest checksflair agent remove <id>precedesflair agent add <id>and the warning, the no-row test checks the stderr has noremove), the success branch, and base64url / hex / wrong-length stored values, on a fake Flair whose signed calls reach the shared verifying stub; plus a real-Harper case underTPS_TEST_REAL_FLAIR=1..changelog/unreleased/fixed-512-create-reports-real-registration.md.Evidence
Measured on 37c25d7
agent-create-registration.test.ts, vianode scripts/test-suite.mjs cli: 9 pass / 1 skip (the real-Harper case,TPS_TEST_REAL_FLAIR=1) / 0 fail.flair agent remove <id>from the row-exists remedy turns thependingtest red (flair agent remove <id>not found in stderr) and restoring returns it green.bun run lint:ci: exit 0 (warnings only).node scripts/changelog-fragments.mjs check: 77 fragments, exit 0.Measured on b824266 (main 1abce03), before the byte-comparison and remedy changes
Real Harper running Flair, HOME-isolated ephemeral instance (scratch
HOME/ROOTPATH, ephemeral ports, operator credential), never the production instance. This evidence predates the byte-comparison change:tps agent create --id <id>exits 1, prints no success line, and reportsno Agent row existsplus the registration write failure (Flair answered the Agent PUT400 ValidationError, having droppedpublicKey); a direct operator read of the id returns404.pendingrow: exits 1 and reportsthe stored public key is 'pending', not the generated key.createprintsAgent '<id>' already registered in Flair.and exits 0.Mutation: restoring the previous "print success without the read-back" logic (keeping
readStoredPublicKey) turned 5 of that file's 6 tests red at the time, including the real-Harper case. It was not re-run on 37c25d7.bun run testas CI's test job runs it, on b824266: agent 194 pass / 0 fail; cli 3029 pass / 6 skip / 0 fail (197 files); root-test 268 pass / 0 fail (13 files). Total 3491 pass / 0 fail / 6 skip. On 1abce03: agent 194 pass / 0 fail; cli 3024 pass / 5 skip / 0 fail (196 files); root-test 268 pass / 0 fail (13 files). Total 3486 pass / 0 fail / 5 skip. Not re-run on 37c25d7.🤖 Generated with Claude Code