Per-session identity for Pi; refuse ambiguous personas; wire session migrate - #376
Conversation
|
Warning Review limit reachedNext included review available in 37 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (16)
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 |
Deploying wireup-landing with
|
| Latest commit: |
7167fe5
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://3939c647.wireup-landing.pages.dev |
| Branch Preview URL: | https://fix-pi-identity-and-persona.wireup-landing.pages.dev |
|
Scope correction (after rebase onto main): this PR no longer touches I had added a commit clearing three clippy lints, because the branch went red with no cause in the branch. Reproduced locally with the runner's toolchain: The underlying thing is still worth someone's decision: with no pinned toolchain, the next stable bump reddens the repo again the same way. Deliberately not taken here. |
Pi (the coding agent) has no MCP client, so every prior Pi story in this repo routed through a third-party adapter: docs/integrations/PI.md told operators to install pi-mcp-adapter, and wire setup's pi_paths() writes ~/.pi/agent/mcp.json, a file Pi proper never reads. On a box with Pi installed, neither the adapter nor that mcp.json existed, so the documented path had never worked. Pi does forward a session id, so wire can key identity to it directly: its bash/powershell tools inject PI_SESSION_ID when they spawn with a session context (core/tools/bash.js resolveSpawnContext, gated on exposeSessionEnvironment, default true). Added PI_SESSION_ID to resolve_session_key at priority 3, labelled `pi`, so `wire whoami` from a Pi shell resolves sessions/by-key/<hash> instead of falling through to the machine default and sharing one inbox with every other session. Priority sits between Claude Code and Codex. Two invariants the tests lock: PI_SESSION_ID beats CODEX/COPILOT/VSCODE so a stray host id cannot steal a Pi session's identity, and home parity, i.e. one id string resolves to one home whether it arrives as PI_SESSION_ID or as WIRE_SESSION_ID. The second is what lets pi-plugin pin the key itself, since Pi does not put PI_SESSION_ID in an extension's own environment and deletes it for context-less shells. Also added PI_SESSION_ID to the env snapshot/restore lists of the three pre-existing adapter tests. Without that they fail whenever the suite runs inside Pi, because the ambient variable outranks the Codex, Copilot and VS Code adapters those tests assert on. pi-plugin/ is the Pi package: 13 native tools over the wire CLI, a wire-pi skill, and /wire-watch for the session-lifetime inbox stream. wire_accept and wire_setup are consent-gated (confirm param plus a UI prompt), so nothing mints a relay claim or grants peer write access on an agent's own initiative. docs/integrations/PI.md rewritten against what was verified; docs/PLUGIN.md gained the Pi section it lacked.
Reported as "cwd naming vs unique session naming", and it turned out to be two separate integrity defects sharing one symptom: the name you are told and the identity you operate as are not the same thing. Collisions. A persona nickname is ADJECTIVES[243] x NOUNS[242] = 58,806 names, seeded from the DID's 8-hex fingerprint suffix, and v0.11 made that nickname the addressable handle, baked into the DID itself. Collision odds hit 50 percent at ~285 identities. This box holds 8,861 initialized session homes and 566 handle groups already shared by two different DIDs, e.g. did:wire:agate-heron-aead0646 and did:wire:agate-heron-4c4d66bc. resolve_local_sister returned the FIRST match in readdir order, so `wire dial <nick>` could pair with, and `wire send <nick>` could write signed events to, whichever of the two the filesystem enumerated first. It now returns Unique or Ambiguous, and resolve_local_sister_unique errors on ambiguity at all four acting call sites (send's auto-pair path, dial's resolution ladder, both wire add branches). Verified live against a real collision. Two details that each would have silently defeated the guard: - list_sessions overrides name to the persona handle, so the two colliding homes arrive with an IDENTICAL name. Dedupe keys on home_dir, not name. - the first remedy the error printed was a dead end: neither the by-key home name nor a full DID was matched by resolve_local_sister or resolve_local_session. Both now match both, the Unique token is the home (unique and accepted downstream), dial looks the sister up by home, and both printed remedies were confirmed to resolve. One identity at two homes collapses to Unique rather than a refusal; one agent is not a choice between agents. Measured first: 8,861 homes, 8,861 distinct DIDs, so that case is prevented, not observed. The lie. `wire session current` printed only the cwd registry's name, but since v0.13 identity never resolves from the registry, and the registry still answers for four display paths. Verified: in a registered cwd it said `slancha-api` while `wire whoami` signed as `cobalt-nettle`, the machine default. It now reports operative_handle, session_source, config_dir, wire_home and agrees alongside the registry name. `agrees` is null when there was nothing to compare rather than claiming agreement unchecked. stdout keeps its historical single-line answer so parsers hold; the disagreement note goes to stderr. Follows f09f360, whose pi-plugin/skills/wire-pi/SKILL.md already documents the `agrees` field this commit adds. Tests: resolver unit test covers home-keyed dedupe, DID and home remedies, and the same-DID collapse; three new unit tests cover resolve_local_session directly; tests/cli.rs locks the new JSON keys and the unchanged stdout contract.
… works
wire setup listed `Pi: ~/.pi/agent/mcp.json` among the hosts it would wire
up, and `--apply` would write it. Pi has no MCP client, so that file is read
only by the third-party pi-mcp-adapter: on a box with Pi installed and no
adapter, setup --apply looked like it had connected Pi and had not.
Kept the target rather than deleting it, since anyone who does run the
adapter still wants the write. Added the note beside it in both the dry-run
listing and the post-apply summary, naming the bridge, pointing at the native
package for everyone else, and flagging that the shared snippet pins
WIRE_SESSION_ID to ${CLAUDE_CODE_SESSION_ID}, the wrong variable under Pi.
Left the snippet as is on purpose: whether pi-mcp-adapter expands ${VAR} at
all is third-party behaviour this commit does not verify, so writing
${PI_SESSION_ID} there would replace one unverified claim with another. The
note says what is wrong without pretending to know the fix. wire's
valid_session_key() guard means an unexpanded ${...} literal is rejected and
falls through rather than hashing into one shared home.
bridge_notes() is a function rather than two inline loops so the selection is
unit-testable without enumerating real host paths. Test locks both that Pi
carries the note and that hosts with a real MCP client do not.
RFC-006 Part A made sessions/by-key/<hash> the one layout, and the readers moved: list_sessions scans by-key only, and session_dir hashes a name into by-key. Nothing reads the top-level sessions/<name> location any more, so a home left there is unreachable even though its keypair is intact. This box still has five of them, one per project, created before v0.13 by `wire session new`. The failure is quiet in a specific way. `wire session current` names the project, the registry entry looks right, and `wire whoami` for that name answers "not initialized" — so the next `wire up` mints a *second* identity for a project that already has one, and the orphan keeps whatever pairings it had. It reads as "the cwd registry is naming a different agent than my session", which is how this was reported. `wire session migrate <name>` moves the home back into the layout every reader understands, so --session-by-name, `session list`, `session env` and `session destroy` reach it again. It is a rename inside one filesystem, and the rollback command is printed. Safety, because this moves live keys: - dry-run by default; `--apply` is what touches the filesystem - refuses when the by-key target already exists: two homes are two identities, and merging picks one keypair and orphans the other's pairings - refuses when the legacy home's daemon pid is alive (platform::process_alive) - verifies both ends after the rename before reporting success - rejects any name that is not a plain single component before touching disk, since the by-key home derives from the sanitized form and a path-shaped name would escape the sessions root Verified end to end in a temp root: before the move `session list` showed nothing; after, the persona is listed, `session env` resolves, `whoami` through the new home returns the SAME handle, a re-run reports "already in the by-key layout", and a planted collision refused with both dirs intact. Tests: two CLI tests (full move + collision refusal, both asserting the keypair survives), one lib test on the name-shape guard.
…--check and clippy -D warnings)
…ngelog The help text said a stranded home is invisible to `--session <name>`. There is no global `--session` flag: it exists on `wire daemon` only. Rewrote it against the surfaces I verified, and recorded the real symptom, which is worse than "not initialized" — `wire session env slancha-api` answers `no session named "slancha-api" on this machine` while that name's keypair sits one directory level up. Same correction in the changelog entry. Also: prose in 99ad8c5/ab8ba22 said `wire --session <name> whoami`, which is not a command either. Left in place since the commits are already made, but this is the corrected version of the claim.
wireEnv() returned early when either WIRE_HOME or WIRE_SESSION_ID was set, so
with WIRE_HOME pinned — the documented configuration in AGENTS.md's MCP example,
and the exact form used by docs/integrations/PI.md's own worked example below —
the extension never pinned WIRE_SESSION_ID. wire then had a root and no session
key, resolved the machine default, and every Pi session sharing that root became
one identity. That is the "every session shows the same persona" symptom v0.13
was filed for, reintroduced by my own package.
The two pins are different axes: WIRE_HOME says which root a fleet lives in
(RFC-008 §C deliberate share); WIRE_SESSION_ID says which session inside it. Only
the latter is an identity claim, so only it suppresses the pin. WIRE_HOME still
passes through untouched.
Found by trying to prove uniqueness rather than assert it: two seeded sessions
under one shared root both answered `initialized: false`, while the same test
with no WIRE_HOME export gave two personas. Measured after the fix, one shared
WIRE_HOME root:
seeded A -> curious-headland pi session A sees: curious-headland
seeded B -> vibrant-flax pi session B sees: vibrant-flax
pi session A again: curious-headland
That also closes a parity question the Rust test could not reach: the home seeded
through WIRE_SESSION_ID=<id> is the same home the extension resolves for
`pi --session-id <id>`, so ctx.sessionManager.getSessionId() is the id string
wire hashes, not a normalized variant of it. Bare `pi -p` runs mint their own
ids (observed: two distinct ULIDs for two runs), so distinct sessions are
distinct keys by construction.
Docs corrected in the same commit: both pi-plugin/README.md and PI.md stated
"an operator WIRE_HOME or WIRE_SESSION_ID wins", which described the defect as
if it were the design.
The question this answers: does `wire up` run inside a Pi session belong to that session. Through the 13 tools, yes (they pin the key). Through Pi's bash tool, no: a keyless `wire` resolves the machine default, which is one shared inbox for every session on the box, and `wire up` there mints an identity nobody can later attribute. Pi intends to supply the key as PI_SESSION_ID (dist/core/tools/bash.js resolveSpawnContext) but sets it only when the bash tool's execute() receives a session ctx. An extension that registers `bash` and delegates without ctx — literally the shape of Pi's own examples/extensions/bash-spawn-hook.ts — deletes it. Measured here with default settings and no extension touching exposeSessionEnvironment: absent in two separate `pi -p` processes. First attempt was to own the tool: createBashTool + spawnHook. Rejected, not shipped — registering a built-in tool name is a hard conflict and the second registration fails to load outright (observed against pi-tool-display), so a package that did this could not be installed next to the display extensions people already run. Using the hook instead: `tool_call` with mutable event.input, which composes (later handlers see the mutation) and cannot displace another extension's tool or bypass a policy hook that blocks earlier. The prefix is `export WIRE_SESSION_ID='<id>'; `, not `VAR=x cmd`, because a prefix binds only the first command of a chain and `cd x && wire up` would still run keyless. It is applied only to commands that invoke `wire`, skipped when the command assigns the variable itself or the operator exported it, and taken from the live ctx per call — never process.env, since an SDK host may serve several sessions in one process and a process-level pin collapses them (65df231). WIRE_PI_NO_BASH_INJECT=1 opts out; WIRE_PI_HOOK_DEBUG=<file> logs decisions. Verified against the installed release binary, which has no `pi` adapter, so this is portable rather than branch-local: two Pi sessions produced 01a05362-…/tinder-palm and 01a05363-…/tidal-cedar from typed commands. PI.md also records a discrepancy found while verifying it: with WIRE_HOME pinned and WIRE_SESSION_ID set, the keyed home lands under the machine default root, not $WIRE_HOME/sessions, while the key itself resolves exactly as by-key/<sha256(key)[..16]>. Not investigated here; it means a WIRE_HOME temp dir does not sandbox a keyed `wire up`, which is how test identities ended up in a live root today.
a51a047 to
7167fe5
Compare
Live probe of goose 1.45: the harness forwards AGENT_SESSION_ID (its sessions.db id, YYYYMMDD_N) to MCP children and bash tools, but the AGENT=goose marker only reaches shells via goose term init. Gating the goose adapter on both left every wire MCP under goose on the per-process minted path — a persona orphaned on every session restart and unbounded by-key home accumulation (8.9k homes on one operator box). is_goose_session_id adds the shape check: AGENT=goose honors the id as-is; otherwise a goose-shaped id keys the session; anything else without the marker is rejected so a foreign host cannot launder a goose persona. Live-verified end to end: goose run resolves the wire MCP to source=goose, resume keeps the persona, parallel sessions get distinct personas. Updates the strict-gate test's foreign-marker case to the refined contract and adds the dual-gate matrix test. Rebased on the post-#376 session.rs union (PI + Codex + goose strict).
) Live probe of goose 1.45: the harness forwards AGENT_SESSION_ID (its sessions.db id, YYYYMMDD_N) to MCP children and bash tools, but the AGENT=goose marker only reaches shells via goose term init. Gating the goose adapter on both left every wire MCP under goose on the per-process minted path — a persona orphaned on every session restart and unbounded by-key home accumulation (8.9k homes on one operator box). is_goose_session_id adds the shape check: AGENT=goose honors the id as-is; otherwise a goose-shaped id keys the session; anything else without the marker is rejected so a foreign host cannot launder a goose persona. Live-verified end to end: goose run resolves the wire MCP to source=goose, resume keeps the persona, parallel sessions get distinct personas. Updates the strict-gate test's foreign-marker case to the refined contract and adds the dual-gate matrix test. Rebased on the post-#376 session.rs union (PI + Codex + goose strict).
Why
Reported as "the cwd registry names a different agent than my session". It turned out to be three separate integrity defects sharing one symptom: the name you are shown and the identity that signs are not the same thing. Plus a missing host: Pi had no working path at all.
Rebased onto
origin/main(27 commits behind at branch time); the two Goose commits that appeared on this branch locally are not included — they live onorigin/feat/goose-session-adapterand belong to their own PR.What changed
feat(pi)PI_SESSION_IDadapter at priority 3 +pi-plugin/, a native Pi package (13 tools, consent-gatedwire_setup/wire_accept)fix(identity)fix(setup)~/.pi/agent/mcp.jsonis read by Pifeat(session)wire session migrate— pre-RFC-006sessions/<name>homes are reachable againcargo fmt+ clippylet-chainThe collision defect
A persona nickname is
243 × 242= 58,806 pairs seeded from the DID's 8-hex suffix, and v0.11 made that nickname the addressable handle inside the DID. Birthday bound passes at ~285 identities. Measured on one box: 8,861 initialized homes, 566 handle groups already shared by two different DIDs (did:wire:agate-heron-aead0646/…-4c4d66bc).resolve_local_sisterreturned the first match inread_dirorder, sowire dial <nick>/wire send <nick>could pair with, or write signed events to, whichever identity the filesystem enumerated first. NowUnique | Ambiguous, refusing at all four acting call sites, with a working remedy per candidate:Both printed forms were verified to resolve. Two details that would each have silently defeated the guard:
list_sessionsoverridesnameto the persona handle, so colliding homes arrive with an identicalname(dedupe keys onhome_dir); and the first remedy printed was a dead end, because neither the by-key home nor a full DID was matched by any resolver.wire session migratesession env slancha-apiansweredno session named "slancha-api" on this machinewhile that name's keypair sat one directory level up — invisible to every reader since RFC-006 Part A. Dry-run by default; refuses when the by-key target exists (two homes are two identities), when the legacy daemon pid is alive, and for names that are not plain single components. Verified in a temp root: invisible before, listed after, same handle through the new home, re-run is a no-op, planted collision refuses.Pi
Pi has no MCP client (
README.md: "No MCP."), so every prior Pi story routed through a third-party adapter andwire setupwrote a file Pi never reads. Pi does forward a session id, so wire can key identity to it directly. Verified through the real tool path — two Pi sessions, one shared root:Two follow-ups caught during verification are fixed in the same PR: a
WIRE_HOMEpin was suppressing the session key (reintroducing the one-persona symptom, and reachable from this repo's own worked example), and atool_callhook now carries the key forwirecommands typed into a Pi shell — via the hook rather than abashoverride, because registering a built-in tool name is a hard conflict that makes the package uninstallable next to display extensions.Deliberately not done
docs/design/2026-07-05-retire-idle-identities.mddecision. The accumulation is reported indocs/integrations/PI.md, not mitigated in code.WIRE_HOMEpinned andWIRE_SESSION_IDset, the keyed home resolves under the machine default root, not$WIRE_HOME/sessions, whilesessions_root()'s docstring says otherwise. The key is honored exactly (by-key/<sha256(key)[..16]>, checked against an independent hash); the root is not. Consequence worth knowing:WIRE_HOME=$(mktemp -d)does not sandbox a keyedwire up.Verification
Live probes, not just tests: ambiguity refusal and both remedies on a real collision; DID/home/collapse resolver paths; migrate end-to-end in a temp root; Pi identity through
pi installand the extension tool path.