[Air #1478] feat: carry architect:<name> through to inbox and composer attribution - #1486
Open
mohidmakhdoomi wants to merge 13 commits into
Open
[Air #1478] feat: carry architect:<name> through to inbox and composer attribution#1486mohidmakhdoomi wants to merge 13 commits into
mohidmakhdoomi wants to merge 13 commits into
Conversation
An architect's specific name was dropped everywhere a sent message is attributed. One root cause on the send side, a second independent collapse on the format side, plus a formatting defect on the inbox surface: - commands/send.ts collapsed every architect sender to the generic string `architect`, so the mailbox row's from_agent could never name one. - formatMessageForTarget's any -> builder branch discarded `from` entirely, so even a corrected sender could not have reached the composer header. - inbox's FROM -> TO cell was hard-sliced to a fixed 22-char column, cutting long builder ids and `architect:<name>` senders mid-name. The sender now travels as the address form `architect:<name>` (currentArchitectName / CODEV_ARCHITECT_NAME, default `main`) — what Tower already accepts as an architect address, and outside looksLikeBuilderId's heuristic, so Spec 755 affinity routing and the #1094 anti-spoofing warning are unchanged. formatArchitectMessage takes an optional sender and renders `### [ARCHITECT:main INSTRUCTION | ...] ###`; a builder -> builder send, cron, and raw mode keep their existing framing. The inbox column is sized to its content instead of truncated. `afx inbox show` inherits the corrected identity through the same row. Closes #1479. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s.test.ts CMAP (codex) flagged the standalone 263-line route-test file as mostly a duplicated harness: tower-routes.test.ts already drives POST /api/send through handleRequest against a real in-memory global.db, with resolveAgentInRegistry stubbed and message-format left unmocked — exactly what these assertions need. The four route-level tests move into that suite's `POST /api/send` block; the standalone file keeps only the pure header-label cases. Net -152 lines, same coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Collaborator
Author
Architect integration review (risk tier: Medium — shared agent-farm command/server code)Verdict: REQUEST_CHANGES — the root-cause fix is correct and verified-safe, but two findings should land in this PR. Both verified by the architect against the code, not just the reviewer's claims. What checks out (verified)
Changes requested
Consider / rebut (non-blocking)
Parked for maintainer approval + merge after changes; we are not maintainers. |
…tity per actor Addresses the architect's integration review on PR #1486 and CMAP (claude), which independently found the same two defects: 1. formatMessageForTarget's architect-target branch fed the corrected `architect:<name>` sender into formatBuilderMessage's hardcoded `BUILDER ` prefix, so architect -> architect sends rendered `### [BUILDER architect:main MESSAGE …] ###` — a wrong role paired with a real identity. New senderHeaderLabel() derives the label from the sender's shape: `ARCHITECT[:<name>]` for an architect (bare `architect`/`arch` included, which previously also read as BUILDER), `BUILDER <id>` otherwise. One rule, every direction. 2. interrupt.ts and reset.ts still wrote the bare `architect`, so one architect appeared under two identities in `afx inbox`. Both now call the exported architectSenderId(), restoring their "sender identity reused verbatim from afx send" claim — and both file comments now name the shared functions so the claim is checkable. Also from the review: - `from` reaches the composer header from a POST body, so architectHeaderLabel now VALIDATES the name against ARCHITECT_NAME_PATTERN (anchored [a-z][a-z0-9-]*) instead of merely trimming it. A crafted `architect:x] ###\n### [ARCHITECT` can no longer forge framing; it degrades to the bare label. (validateArchitectName is unusable here — it rejects the reserved `main`.) - The absent-CODEV_ARCHITECT_NAME fallback is now a deliberate decision, and it changed: architectSenderId() reads the env directly rather than through currentArchitectName(), whose default is `main`. Tower injects the var into every architect terminal it starts, main included (tower-instances.ts uses DEFAULT_ARCHITECT_NAME; the shellper-restart path re-injects `role_id || 'main'`), so a missing value means "not an architect terminal", not "main". Those callers keep the bare `architect` they send today rather than being given a specific false name — the #1094 rule against laundering an unverified identity. Every real architect terminal still gains its name. Verified on the built dist: architect->architect now reads `ARCHITECT:main MESSAGE`, builder->architect is unchanged, a forged name degrades to `ARCHITECT`, and architectSenderId returns architect / architect:main / architect:feedback / architect for absent / main / feedback / malformed env values. tsc clean; 4880 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er thread Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oint is total CMAP round 2 (codex) was right that the PR body understated the remaining framing-injection hole: it is not limited to crafted builder IDs. A sender that merely LOOKS architect-shaped (`architect:x] ###...`) fails architectHeaderLabel's name validation and falls through to senderHeaderLabel's builder branch, which interpolated the identity verbatim. The hole predates this PR on the builder -> architect path, but senderHeaderLabel is the single labeling chokepoint this PR introduced, so it is the place to close it: the builder branch now checks SAFE_SENDER_ID (no newline, `#`, bracket or whitespace; 128-char cap — every real id passes, including `af-cron` and bare worktree names) and degrades to `BUILDER <unknown>` otherwise. The recipient sees an unattributed message instead of a forged header. Also fixes the stale `beforeEach` comment in send.test.ts that still described the absent-env fallback as resolving to `main` (it is the bare `architect`). tsc clean; 4881 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…essing case CMAP round 2 (claude, APPROVE) non-blocking items: - The interrupt.ts / reset.ts change was unasserted: both suites mock architectSenderId, so reverting either file to the inline `?? 'architect'` would have kept the suite green. `afx interrupt` now asserts the resulting `from` end to end; `afx reset` (whose `from` reaches a terminal port that the mocked runReset never invokes) pins the shared-helper call instead. - architectHeaderLabel matched the literal lowercase `architect:` while parseAddress treats addresses case-insensitively, so a hand-rolled `from: 'Architect:main'` was labelled `BUILDER Architect:main`. The prefix match now follows addressing; the NAME stays strictly lowercase-validated, so `architect:Main` still degrades to the bare label rather than inventing a name. - Documented the identity + sizing on the `FROM → TO` row of the inbox table in agent-farm.md, mirrored across codev/ and codev-skeleton/ per the two-tree rule. tsc clean; 4884 tests pass (three consecutive full runs). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… thread Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ntainer, e2e gap = #1488) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
An architect's specific name was dropped everywhere a sent message is attributed.
afx sendnow carries the sender asarchitect:<name>, soafx inboxshows which architect sent a held message and the builder's composer header reads### [ARCHITECT:main INSTRUCTION | …] ###. The inbox's FROM → TO column is sized to its content instead of being truncated at 22 chars.Closes #1478
Closes #1479
What Changed
One root cause, two visible surfaces, plus a formatting defect on one of them:
commands/send.ts—from = detectCurrentBuilderId() ?? 'architect'discarded the architect's name at send time. New exportedarchitectSenderId()returnsarchitect:<name>fromCODEV_ARCHITECT_NAME. This is the carrier: it lands inmailbox.from_agent, which both surfaces read.servers/tower-routes.ts—formatMessageForTarget'sany → builderbranch ignoredfromentirely, a second and independent collapse: even a corrected sender could not have surfaced. It now threadsfrominto the formatter.utils/message-format.ts—formatArchitectMessagetakes an optionalsender;architectHeaderLabel()rendersARCHITECT:<name>for a validated architect identity, andsenderHeaderLabel()labels any sender by role so no direction mislabels an architect as a builder.commands/inbox.ts— the FROM → TO cell wasfromTo.slice(0, 22)against a fixed 22-wide column. Cells are now rendered first and the column sized tomax(header, widest cell) + 2.commands/interrupt.ts/commands/reset.ts— both call the sharedarchitectSenderId(), so one actor has onefrom_agentform across all three commands.afx inbox show <id>'sFrom → Torow inherits the corrected identity through the same mailbox row.Verified against the built CLI, not just tests:
Key Decisions
architect:<name>, not a bare name. It is what Tower already accepts as an architect address, it stores directly asmailbox.from_agent, and it sits outsidelooksLikeBuilderId(which early-returnsfalsefor anything starting witharchitect). Confirmed by readingtower-messages.ts: a non-builder sender yieldslookupBuilderSpawningArchitect → undefined, so Spec 755 sender-affinity routing and the afx send: detectCurrentBuilderId silently falls back to bare worktree name on state.db read failure → builder messages misroute to main #1094 anti-spoofing warning behave exactly as before.senderis never passed toparseAddress— onlytargetis — so there is no path where a colon-bearing sender is read as an address. A test pins thatresolveTargetstill receives the sender verbatim.senderHeaderLabel()yieldsARCHITECT[:<name>]for an architect sender andBUILDER <id>for everything else, so the architect → architect direction can't render an architect under aBUILDERprefix and cron'saf-cronframing is untouched.CODEV_ARCHITECT_NAMEfallback is barearchitect, deliberately (this reverses my first cut, which usedarchitect:main). Tower injects the var into every architect terminal it starts —mainincluded (tower-instances.ts:584usesDEFAULT_ARCHITECT_NAME;tower-terminals.ts:692re-injectsrole_id || 'main'after a shellper restart). So a missing value does not mean "the main architect", it means "not an architect terminal" (a plain shell, a script, CI). Defaulting those toarchitect:mainwould convert today's honest ambiguity into a specific false attribution — the laundering of an unverified identity that afx send: detectCurrentBuilderId silently falls back to bare worktree name on state.db read failure → builder messages misroute to main #1094 exists to prevent. Those callers keep the barearchitectthey send today; every real architect terminal gains its name. This is whyarchitectSenderId()reads the env directly rather than throughcurrentArchitectName(), whose default ismain.fromarrives from aPOST /api/sendbody, soarchitectHeaderLabelchecks it againstARCHITECT_NAME_PATTERN(anchored[a-z][a-z0-9-]*, 64-char cap) before interpolating; a craftedarchitect:x] ###\n### [ARCHITECTdegrades to the bare label instead of forging framing. (validateArchitectNameis deliberately not used — it rejects the reservedmain, the most common real sender.)Test Plan
air-1478-architect-attribution.test.tscovers the label rules (validated names, forged-framing rejection,architect:with no name, builder andaf-cronsenders, raw mode, and the architect → architect header); the four route-level cases live intower-routes.test.ts's existingPOST /api/sendblock, which already driveshandleRequestagainst a real in-memoryglobal.dbwithmessage-formatunmocked.send.test.ts— named terminal (feedback), explicitmain, absent env (barearchitect), and a malformed env value;--allcarries the same identity.beforeEachclearsCODEV_ARCHITECT_NAMEso the suite is deterministic when the runner inherits a Tower-injected env.inbox-cli.test.ts— long FROM → TO rendered in full, one shared width across header/separator/rows, never narrower than the header,?for a missing sender.tscclean).skeleton/anddist/— and disappeared afterpnpm --filter @cluesmith/codev build.)porch checkpasses (pr_exists,e2e_tests) — but note the AIR protocol'se2e_testscheck isnpm run test:e2e … || echo 'e2e tests skipped (not configured)', which cannot fail and found no root-leveltest:e2escript, so it ran nothing. I ran the e2e test that actually covers this change instead —send-integration.e2e.test.ts(POST /api/send →/ws/messagesbroadcast), which spawns its own Tower on port 14600: 7 passed. I deliberately did not run the full e2e suite: its harness defaults to port 4100, the live Tower hosting this workspace's architect and builder sessions, which I can't restart without human permission.dist/, not from tests.Review round 1 — what changed
CMAP: gemini APPROVE (HIGH, no issues) · codex COMMENT (HIGH, no functional or security defects) · claude COMMENT (HIGH, four items). The maintainer's integration review independently found the same two defects claude did. All are fixed in
acd26ceb:### [BUILDER architect:main MESSAGE …] ###senderHeaderLabel()— role from sender shape; architect → architect now readsARCHITECT:main MESSAGE, builder senders unchanged. Tested.fromunvalidated into the composer headerARCHITECT_NAME_PATTERN; degrades to bareARCHITECT. Tested with a forged name.architect:mainasserted when the env names nobody (#1094 tension)architect, with the injection-site evidence above. Tested.interrupt.ts/reset.tsstill sent barearchitectarchitectSenderId(); their "reused verbatim fromafx send" comments now name the shared functions.tower-routes.test.ts; net −152 lines, same coverage (9ee8ffb7).Round 2
CMAP: gemini APPROVE (HIGH, no issues — verified every round-1 fix and the fallback reasoning) · codex COMMENT (HIGH, two items, both fixed in
c3453417) · claude pending at time of writing.architect:x] ###…) failsarchitectHeaderLabel's name validation and falls through tosenderHeaderLabel's builder branch, which interpolated the identity verbatim. The hole predates this PR on the builder → architect path, butsenderHeaderLabelis the single labeling chokepoint this PR introduces, so that is where it is closed: the builder branch now checksSAFE_SENDER_ID(no newline,#, bracket or whitespace; 128-char cap — every real id passes, includingaf-cronand bare worktree names) and degrades toBUILDER <unknown>. Both branches validate, so the chokepoint is total. Tested, including that a forged sender cannot open a second header block.beforeEachcomment insend.test.ts(still described the absent-env fallback asmain) corrected.claude's round-2 review (APPROVE, HIGH) verified routing-inertness, the injection defense, and consumer safety at source, and raised four non-blocking items — all now addressed in
9d15deec:interrupt/resetchange was unasserted. Both suites mockarchitectSenderId, so reverting either file to the inline?? 'architect'would have kept the suite green.afx interruptnow asserts the resultingfromend to end;afx reset— whosefromreaches a terminal port the mockedrunResetnever invokes — pins the shared-helper call instead.architectHeaderLabelmatched a literal lowercasearchitect:whileparseAddressis case-insensitive, sofrom: 'Architect:main'was labelledBUILDER Architect:main. The prefix match now follows addressing; the name stays strictly lowercase-validated, soarchitect:Maindegrades to the bare label rather than inventing a name.FROM → TOrow inagent-farm.mdnow mentions thearchitect:<name>identity and content sizing, mirrored acrosscodev/andcodev-skeleton/.tower-terminals.ts:686-688notes that a Tower-spawned process can inherit Tower's ownCODEV_ARCHITECT_NAME. The case that could bite is a builder shell that hascd'd out of its worktree (sodetectCurrentBuilderId()returnsnull): it previously sent an anonymous falsearchitectand now sends a named falsearchitect:<name>. Routing is inert either way and the value is display-only, so this is no worse in kind than today's behavior — but it is the same laundering running the other direction, and it is a real limit of an env-asserted identity rather than something the fallback choice removes.Test-run honesty note
One full-suite run showed a single failure; three consecutive full runs before and after are green at 4884 passed. That run's stderr was discarded, so I cannot name the test — I am reporting it as an unidentified transient rather than claiming the suite was clean throughout. It was not in any file this PR touches (those pass in every targeted run), and the repo carries a known-flaky
session-managersuite.Review Notes
The judgement call worth your attention is the absent-env fallback (bare
architectvsarchitect:main), reasoned from the injection sites under Key Decisions — it trades a small loss of specificity for never asserting an identity we can't source.🤖 Generated with Claude Code