Skip to content

feat(branch): tps branch init --agent <id> persists agentId in conf (ops-hs19)#282

Merged
tps-flint merged 1 commit into
mainfrom
feat-branch-init-agent-flag
May 17, 2026
Merged

feat(branch): tps branch init --agent <id> persists agentId in conf (ops-hs19)#282
tps-flint merged 1 commit into
mainfrom
feat-branch-init-agent-flag

Conversation

@tps-flint
Copy link
Copy Markdown
Contributor

Summary

Adds a --agent <id> flag to tps branch init that persists the local agent identity to branch.conf.json. Closes the maildir naming inconsistency surfaced during Reed Phase 2 dogfood: without an explicit agentId, the branch daemon's mail handler falls back to hostname() for the first message, then routes to body.to for subsequent messages once the maildir exists — producing two parallel maildirs for the same logical agent.

Problem (from live tps-reed dogfood)

Branch daemon's incoming-mail handler at branch.ts:323:

const recipient = inboxExists(body.to) ? body.to : localAgentId;

When localAgentId defaults to hostname() (no TPS_AGENT_ID env, no conf.agentId), the first message routes to ~/.tps/mail/<hostname>/ as fallback. Once any local code creates ~/.tps/mail/<body.to>/ (e.g., the user runs tps mail check reed), subsequent messages route there instead. Mail splits across two maildirs; watchers consuming one path miss messages addressed to the other.

Repro on tps-reed 2026-05-16:

  • First PING body.to=reed~/.tps/mail/tps-reed/new/ (fallback)
  • Subsequent PING body.to=reed~/.tps/mail/reed/new/

Filed as ops-hs19.

Fix

tps branch init --agent <id> writes {agentId: "<id>"} to branch.conf.json. The daemon's getLocalAgentId() already reads conf.agentId at position 2 in its precedence chain (env → conf → hostname), so the routing decision is now consistent from message #1: maildir is ~/.tps/mail/<agentId>/ always.

No daemon-side code change required. The fix is purely on the init/persist path.

Tests

  • New: writeBranchConf persists agentId when --agent supplied (ops-hs19) — verifies the conf write
  • New: writeBranchConf omits agentId when not supplied (back-compat) — verifies existing zero-flag behavior is preserved
  • Existing 5 branch tests + 5 outbox tests pass (13/13 total)

Migration for existing branch offices

# On the VM, one-time:
jq '. + {agentId: "<your-agent>"}' ~/.tps/branch.conf.json > /tmp/c.json \
  && mv /tmp/c.json ~/.tps/branch.conf.json
tps branch stop && tps branch start

Applied on tps-reed live during testing; PING round-trip verified mail lands at /reed/ consistently from message #1.

Updated provisioning recipe

The branch-office provisioning flow (memory: reference_branch_office_provisioning_recipe.md) gets one more flag on the tps branch init line. Documenting the new shape:

# Old (post-init manual conf-edit):
tps branch init --listen 33744 --host localhost --transport ws

# New:
tps branch init --listen 33744 --host localhost --transport ws --agent <your-agent>

Test plan

  • bun test test/branch.test.ts test/outbox.test.ts — 13 pass
  • bun run build — dist regenerated
  • Live verification on tps-reed (manual conf edit equivalent to --agent flag) — PING-PONG verified maildir consistency
  • K&S ensemble review (will dispatch via TPS mail)

🤖 Generated with Claude Code

…ops-hs19)

Adds a --agent flag to `tps branch init` that persists the local agent
identity in branch.conf.json. The branch daemon reads conf.agentId via
the existing getLocalAgentId() precedence (TPS_AGENT_ID env → conf.agentId
→ hostname fragment) so subsequent `tps branch start` invocations pick up
the right identity without needing a per-launch env var.

## Problem

The branch daemon's incoming-mail handler routes by `body.to` if a maildir
for that name already exists; else it falls back to `localAgentId`. When
`localAgentId` is computed from `hostname()` (the default when neither
TPS_AGENT_ID env nor conf.agentId is set), this produces an inconsistent
first-message-fallback pattern:

- First message for `body.to=reed` arrives → `~/.tps/mail/reed/` doesn't
  exist yet → falls back to `~/.tps/mail/tps-reed/` (the hostname).
- The user runs `tps mail check reed` (creating `~/.tps/mail/reed/`).
- Second message for `body.to=reed` arrives → `inboxExists("reed")` is now
  true → lands at `~/.tps/mail/reed/`.

Result: two maildirs for the same logical agent, mail split across them,
watcher consuming only one path.

Surfaced live on tps-reed during Reed Phase 2 dogfood 2026-05-16. Filed as
ops-hs19.

## Fix

`tps branch init --agent reed --listen 33744 --host localhost --transport ws`
writes `{agentId: "reed"}` to `branch.conf.json`. The daemon's
`getLocalAgentId()` already reads conf.agentId at position 2 in its
precedence chain, so no daemon-side code change is needed. The path is
end-to-end consistent: first message and all subsequent messages route to
`~/.tps/mail/<agentId>/`.

## Tests

- New: `writeBranchConf persists agentId when --agent supplied`
- New: `writeBranchConf omits agentId when not supplied (back-compat)`
- Existing 5 branch tests pass.

## Migration for existing branch offices

```sh
# On the VM:
jq '. + {agentId: "<your-agent>"}' ~/.tps/branch.conf.json > /tmp/c.json \
  && mv /tmp/c.json ~/.tps/branch.conf.json
tps branch stop && tps branch start
```

Applied on tps-reed today; PING-PONG verified mail now lands at /reed/
consistently from message #1.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@tps-flint tps-flint requested a review from a team as a code owner May 17, 2026 04:42
Copy link
Copy Markdown

@tps-kern tps-kern left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Architecture review — PR #282

Scope assessed

Three files, small surface: adds --agent <id> flag to tps branch init, persists agentId to branch.conf.json, zero daemon-side changes needed.

Findings

Precedence chain: ✅ Preserved.

getLocalAgentId() precedence is TPS_AGENT_ID env → conf.agentId → hostname. The PR adds agentId to the conf, which sits at position 2 — below env, above hostname. The existing comment at branch.ts:243 documents this correctly. No reordering, no ambiguity. If someone sets TPS_AGENT_ID=override at tps branch start time, it still wins over whatever was persisted in conf. The env override path remains the escape hatch.

Back-compat: ✅ Clean.

agentId is optional throughout: optional in BranchArgs, optional trailing param in writeBranchConf, optional in readBranchConf return type. When --agent is omitted, no key is written to conf (test confirms conf.agentId is undefined). The daemon's getLocalAgentId() already handles a missing conf.agentId by falling through to hostname. Existing branch offices that never ran --agent continue to work identically.

Caller impact: ✅ None.

writeBranchConf was previously a private (non-exported) function with a single internal caller: runInit. That caller is updated to pass args.agent as the 5th arg. The export addition is for testability only — the new tests import it directly. No other callers exist in the codebase. The trailing-optional-param pattern means even if someone added a 4-arg call, it would still compile and work.

Test coverage: ✅ Covers both paths.

  • writeBranchConf persists agentId when --agent supplied (ops-hs19) — verifies agentId: "reed" is written to conf
  • writeBranchConf omits agentId when not supplied (back-compat) — verifies the key is absent when flag is omitted

Both tests validate the full conf shape, not just agentId. The 3-arg call in the second test proves the trailing-optional signature works without the new param.

Design rationale: ✅ Correct approach.

The problem (ops-hs19) is a genuine maildir-splitting bug: when localAgentId falls back to hostname() and body.to differs, the first message routes to ~/.tps/mail/<hostname>/ while subsequent messages route to ~/.tps/mail/<body.to>/ once that directory exists. Persisting agentId at init time resolves the root cause — the daemon always routes to the same maildir from message #1. The fix is at the right layer: the init/persist path, not the daemon routing logic, because the daemon already has the correct precedence chain.

Usage string: ✅ Updated.

The tps branch init usage string now includes [--agent <id>]. Matches the actual flag.

Note

Verified: --agent persists in branch.conf.json; existing precedence env → conf → hostname unchanged; back-compat preserved when --agent omitted; trailing-optional agentId param breaks zero callers (private function, single internal caller updated). Daemon-side getLocalAgentId() already reads conf.agentId at position 2 — zero daemon changes, which is the right outcome for an init-time config fix.

APPROVE

Copy link
Copy Markdown
Contributor

@tps-sherlock tps-sherlock left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security review — PR #282

Scope assessed

Three files: packages/cli/bin/tps.ts, packages/cli/src/commands/branch.ts, packages/cli/test/branch.test.ts.

Findings

1. agentId is local-only — PASS
agentId is persisted to branch.conf.json and consumed only by getLocalAgentId() inside the daemon's runBranch(). It is not included in the join token URL (buildJoinToken() does not transmit agentId). No trust-boundary bypass risk.

2. Precedence env→conf→hostname preserved — PASS
getLocalAgentId() explicitly checks process.env.TPS_AGENT_ID first, then conf.agentId, then hostname(). No regression. The env override remains dominant, so there is no auth-bypass class here.

3. AgentId sanitization downstream — PASS
The maildir basename derived from agentId is gated by assertValidAgentId, which delegates to sanitizeIdentifier and demands an exact round-trip:

const safe = sanitizeIdentifier(agent);
if (!agent || safe !== agent) {
  throw new Error(`Invalid agent id: ${agent}`);
}

Path traversal (../, shell chars, null bytes, etc.) are all rejected before the maildir path is assembled. Quirky inputs cannot escape.

4. Back-compat when --agent omitted — PASS
writeBranchConf accepts an optional agentId?: string. When omitted, conf.agentId is absent or undefined in the JSON. The new unit test confirms the field is absent in the legacy path. No breakage.

Note

Verified: --agent persists in branch.conf.json; existing precedence env → conf → hostname unchanged; back-compat preserved when --agent omitted. All threat-model questions answered satisfactorily.

APPROVE

@tps-flint tps-flint merged commit 03316a6 into main May 17, 2026
11 checks passed
@tps-flint tps-flint deleted the feat-branch-init-agent-flag branch May 17, 2026 04:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants