Skip to content

Per-session identity for Pi; refuse ambiguous personas; wire session migrate - #376

Merged
laulpogan merged 9 commits into
mainfrom
fix/pi-identity-and-persona-collisions
Aug 31, 2026
Merged

Per-session identity for Pi; refuse ambiguous personas; wire session migrate#376
laulpogan merged 9 commits into
mainfrom
fix/pi-identity-and-persona-collisions

Conversation

@laulpogan

Copy link
Copy Markdown
Collaborator

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 on origin/feat/goose-session-adapter and belong to their own PR.

What changed

commit what
feat(pi) PI_SESSION_ID adapter at priority 3 + pi-plugin/, a native Pi package (13 tools, consent-gated wire_setup/wire_accept)
fix(identity) persona collisions refuse to guess; DID + by-key-home remedies that actually resolve
fix(setup) Pi target labelled bridge-only instead of implying ~/.pi/agent/mcp.json is read by Pi
feat(session) wire session migrate — pre-RFC-006 sessions/<name> homes are reachable again
+ docs/style changelog, corrected surface names, cargo fmt + clippy let-chain

The 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_sister returned the first match in read_dir order, so wire dial <nick> / wire send <nick> could pair with, or write signed events to, whichever identity the filesystem enumerated first. Now Unique | Ambiguous, refusing at all four acting call sites, with a working remedy per candidate:

error: `agate-heron` is not unique: 2 local sessions answer to that name. …
  wire dial did:wire:agate-heron-aead0646   # or: wire add 88d7e1b95b24bea4 --local-sister
  wire dial did:wire:agate-heron-4c4d66bc   # or: wire add c3404ee2aa53602c --local-sister

Both printed forms were verified to resolve. Two details that would each have silently defeated the guard: list_sessions overrides name to the persona handle, so colliding homes arrive with an identical name (dedupe keys on home_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 migrate

session env slancha-api answered no session named "slancha-api" on this machine while 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 and wire setup wrote 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:

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

Two follow-ups caught during verification are fixed in the same PR: a WIRE_HOME pin was suppressing the session key (reintroducing the one-persona symptom, and reachable from this repo's own worked example), and a tool_call hook now carries the key for wire commands typed into a Pi shell — via the hook rather than a bash override, because registering a built-in tool name is a hard conflict that makes the package uninstallable next to display extensions.

Deliberately not done

  • No auto-retire / no auto-brake on identity minting — respects the operator-ratified docs/design/2026-07-05-retire-idle-identities.md decision. The accumulation is reported in docs/integrations/PI.md, not mitigated in code.
  • No provenance file in session homes — that touches the RFC-006 Part A format freeze; schema proposed in the docs only.
  • Nickname capacity is unchanged. Widening 58,806 pairs means a DID format bump, which is a versioned decision.
  • One discrepancy documented, not fixed: with WIRE_HOME pinned and WIRE_SESSION_ID set, the keyed home resolves under the machine default root, not $WIRE_HOME/sessions, while sessions_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 keyed wire up.

Verification

fmt --check OK · clippy --all-targets -D warnings clean
--lib 707 · --test cli 77 · --test plugin_contract 4
e2e_bilateral 4 · e2e_identity 2 · e2e_handle_pair 3 · e2e_mesh 6 · e2e_group 1 · stress_within_system 11

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 install and the extension tool path.

@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 37 minutes.

View limit details

Limit 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.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 087b64c8-5e55-4523-a18f-025ddf5bc274

📥 Commits

Reviewing files that changed from the base of the PR and between de6608b and 7167fe5.

📒 Files selected for processing (16)
  • CHANGELOG.md
  • README.md
  • docs/AGENT_INTEGRATION.md
  • docs/PLUGIN.md
  • docs/integrations/PI.md
  • pi-plugin/README.md
  • pi-plugin/extensions/wire.ts
  • pi-plugin/package.json
  • pi-plugin/skills/wire-pi/SKILL.md
  • src/cli/comms.rs
  • src/cli/mod.rs
  • src/cli/pairing.rs
  • src/cli/session.rs
  • src/cli/setup.rs
  • src/session.rs
  • tests/cli.rs

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 31, 2026

Copy link
Copy Markdown

Deploying wireup-landing with  Cloudflare Pages  Cloudflare Pages

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

View logs

@laulpogan

laulpogan commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Scope correction (after rebase onto main): this PR no longer touches src/relay_server.rs, src/pull.rs, or tests/heartbeat_body_roundtrip.rs.

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: origin/main fails cargo +1.98.0 clippy --all-targets -D warnings on those spots — CI's clippy job floats to stable with no rust-toolchain.toml pin and stable moved 1.95 → 1.98 (last green main CI run was 2026-08-11). #375 landed that fix first, so my commit was dropped on rebase rather than duplicating it. Verified: 1.98 clippy is clean on this branch without it.

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.
…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.
@laulpogan
laulpogan force-pushed the fix/pi-identity-and-persona-collisions branch from a51a047 to 7167fe5 Compare August 31, 2026 17:41
@laulpogan
laulpogan merged commit 3631337 into main Aug 31, 2026
10 checks passed
laulpogan added a commit that referenced this pull request Aug 31, 2026
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).
laulpogan added a commit that referenced this pull request Aug 31, 2026
)

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).
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.

1 participant