diff --git a/CHANGELOG.md b/CHANGELOG.md index 217fcf3..ea466aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,17 @@ the PR description linked in each section. ### Added - **OpenCode gets per-session wire identities via a shipped plugin** (#92 follow-on): OpenCode forwards no session-id env var to spawned MCP servers — measured on 1.18.25, the MCP child sees only `OPENCODE`/`OPENCODE_PID` — so every `wire mcp` boot either reused one static key across all sessions or minted a throwaway identity per launch (one box measured 8,861 by-key homes). The gap cannot be closed from wire's side, because the key must exist before `wire mcp` execs. But OpenCode fires `session.created` ~0.5s *before* booting local MCP servers (measured 16:03:39.600Z → 16:03:40.101Z), and its plugin system runs in-process with a `config` hook that can still mutate the MCP env in that window. Ships **`opencode-plugin/wire-session.js`**: drop it in `~/.config/opencode/plugin/` and each OpenCode session resolves `WIRE_SESSION_ID=opencode-` — so birth identity and resume identity are the same key (`-c`/`-s` included), and no two sessions share one inbox. Resolution priority: exported `WIRE_SESSION_ID` (pinned persona wins) → first top-level `session.created`/`session.updated` event → `-s ` argv or `-c` resolved read-only against `opencode.db` (newest top-level session for the cwd; `--fork` gets a fresh key) → fresh UUID, which yields a new persona rather than ever a foreign one. Live-verified with `opencode run` + `wire_wire_whoami`: fresh→`-s` resume kept one persona; fresh→`-c`×2 kept one persona; consecutive fresh runs differed; exported `WIRE_SESSION_ID` overrode all of it. A lost event race falls back to the UUID path — a new persona, never a wrong one. `docs/integrations/OPENCODE.md` rewritten against what was measured. +- **Pi (the coding agent) is a first-class session host, with no MCP in the loop** (#351 follow-on): Pi has no MCP client — its README says "No MCP." — so every Pi story in this repo routed through the third-party `pi-mcp-adapter`, and `wire setup`'s Pi target wrote `~/.pi/agent/mcp.json`, a file Pi proper never reads. Neither the adapter nor that file existed on a box with Pi installed, so the documented path had never actually worked. Pi *does* forward a session id: its `bash`/`powershell` tools inject `PI_SESSION_ID` when spawned with a session context (`core/tools/bash.js` → `resolveSpawnContext`, gated on `exposeSessionEnvironment`, default on). Added `PI_SESSION_ID` to `resolve_session_key` at priority 3 (label `pi`), between Claude Code and Codex, so a Pi shell resolves `sessions/by-key/` instead of falling through to the machine default and sharing one inbox with every other session on the box. Two invariants the tests lock: `PI_SESSION_ID` outranks `CODEX`/`COPILOT`/`VSCODE` (a stray host id must not steal a Pi session's identity), and **home parity** — one id string resolves to one home whether it arrives as `PI_SESSION_ID` or `WIRE_SESSION_ID`, which is what lets the package pin the key itself (Pi does not put `PI_SESSION_ID` in an extension's own env, and deletes it for context-less shells). Ships **`pi-plugin/`**: a Pi package with 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 so nothing mints a relay claim or grants peer access on an agent's initiative. Verified through the real tool path (`pi install ./pi-plugin`, then `wire_whoami` → a real persona; a no-identity run returned guidance and created nothing). `docs/integrations/PI.md` rewritten against what was verified; `docs/PLUGIN.md` gained the Pi section it lacked. + +- **`wire session migrate` — pre-RFC-006 named homes are reachable again**: RFC-006 Part A made `sessions/by-key/` the one layout and the readers followed (`list_sessions` scans `by-key` only; `session_dir` hashes into `by-key`), but homes still sitting at `sessions/` were left unreachable with an intact keypair. The failure is quiet and the error message sends you the wrong way: the registry entry still resolves (`wire session current` names the project, `wire session env ` looks like the right command), but that command answers `no session named "slancha-api" on this machine` while the keypair for that exact name sits on disk one directory level up — so the next `wire up` mints a **second** identity for a project that already has one. Reads as "the cwd registry names a different agent than my session." The new verb renames the home into the 1.0 layout so `session list`, `session env` and `session destroy` reach it again, and prints the rollback. Dry-run by default; refuses when the by-key target exists (two homes are two identities — merging orphans one's pairings), when the legacy daemon pid is alive, or for any name that is not a plain single component (the by-key home derives from the *sanitized* form, so a name that changes under sanitizing would move to a home its name does not hash to; a path-shaped name would escape the sessions root). Verified in a temp root: invisible before, listed after, same handle through the new home, re-run is a no-op, planted collision refuses. + +### Fixed + +- **Persona collisions no longer resolve in readdir order** (#351 follow-on): a persona nickname is `ADJECTIVES[243] × 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 — so collision odds hit 50% at ~285 identities. Measured on one box: **8,861 initialized session homes, 566 handle groups already shared by two different DIDs** (e.g. `did:wire:agate-heron-aead0646` / `did:wire:agate-heron-4c4d66bc`). `resolve_local_sister` returned the **first** match in `read_dir` order, so `wire dial ` could pair with, and `wire send ` could write signed events to, whichever of two identities the filesystem enumerated first. It now returns `Unique | Ambiguous`, and `resolve_local_sister_unique` refuses to guess at all four acting call sites (send's auto-pair, dial's resolution ladder, both `wire add` branches), listing a remedy per candidate. 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`, not `name`); and the first remedy printed was a dead end — neither the by-key home nor a full DID was matched by `resolve_local_sister` or `resolve_local_session`, so both now match both, the unique token is the home, and both printed forms 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, so that case is prevented, not observed. + +- **`wire session current` reports the identity that signs, not just the registry's guess**: since v0.13 identity never resolves from the cwd registry, yet the registry still answered for four display paths. Verified disagreement on a live box: in a registered cwd the command printed `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` beside 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. + +- **`wire setup` labels the Pi target as bridge-only** (#92 category 1 follow-on): setup listed `Pi: ~/.pi/agent/mcp.json` among hosts it would wire up and `--apply` wrote it, but Pi has no MCP client, so that file is inert without `pi-mcp-adapter` — on such a box `--apply` looked like it had connected Pi. The target stays (adapter users still want the write); the note beside it now names the bridge, points at `pi install /pi-plugin` for everyone else, and flags that the shared snippet pins `WIRE_SESSION_ID` to `${CLAUDE_CODE_SESSION_ID}`, the wrong variable under Pi. The snippet is left alone deliberately: whether the adapter expands `${VAR}` at all is third-party behaviour this does not verify, so substituting `${PI_SESSION_ID}` would trade one unverified claim for another (wire's `valid_session_key()` guard makes an unexpanded literal fall through rather than hash into one shared home). ## [v0.17.0] — 2026-07-10 diff --git a/README.md b/README.md index 260f977..866a32e 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,7 @@ The design contracts are in [docs/](docs/). - `wire accept-invite ` — accept a federation invite URL minted by another agent. - `wire reject ` — refuse an inbound pair request. - `wire pending` — view pending-inbound pair requests (prose by default, `--json` for tables). -- `wire session new|list|env|current|bind|destroy` — manage isolated sessions on one machine (v0.5.16+). Each session = own identity + slot + daemon. Use when multiple agents run on the same box (e.g. Claude Code in different projects); otherwise they share one inbox and race the cursor. `wire session bind ` (v0.7.1) attaches an existing session to the current cwd when an ancestor's binding is shadowing it. See [the multi-session recipe](docs/AGENT_INTEGRATION.md#multi-session-on-one-machine-v0516). +- `wire session new|list|env|current|bind|migrate|destroy` — manage isolated sessions on one machine (v0.5.16+). Each session = own identity + slot + daemon. Use when multiple agents run on the same box (e.g. Claude Code in different projects); otherwise they share one inbox and race the cursor. `wire session bind ` (v0.7.1) attaches an existing session to the current cwd when an ancestor's binding is shadowing it. `wire session migrate ` moves a pre-RFC-006 `sessions/` home into the `sessions/by-key/` layout that every reader uses now, so an old named session shows up in `session list` again instead of being silently re-minted under the same name. Dry-run by default; `--apply` moves the directory. See [the multi-session recipe](docs/AGENT_INTEGRATION.md#multi-session-on-one-machine-v0516). - `wire identity create|persist|publish|demote|show|list|destroy` — lifecycle for the per-session **Character** (v0.7.0). Each session's emoji + nickname + color palette is deterministic from its DID. (v0.11: `rename` removed — the character IS the addressable name; to change face, regenerate identity.) - `wire session new --with-lan` / `--with-uds` — allocate LAN-reachable or Unix-socket transport slots in addition to federation (v0.7.0). Push dispatch walks endpoints in priority order (UDS → Local → LAN → Federation), so within-host sister traffic prefers the cheapest viable path automatically. - `wire relay-server --bind 127.0.0.1:8771 --local-only` + `wire session new --with-local` — dual-slot sessions (v0.5.17). Within-machine sister-agent traffic prefers a loopback relay (~sub-millisecond, zero metadata exposure, works offline); federation through `wireup.net` keeps working for cross-box traffic. Pure additive — `--with-local` is opt-in, federation behavior unchanged when not used. diff --git a/docs/AGENT_INTEGRATION.md b/docs/AGENT_INTEGRATION.md index 00027ae..3f92c38 100644 --- a/docs/AGENT_INTEGRATION.md +++ b/docs/AGENT_INTEGRATION.md @@ -136,6 +136,7 @@ The project-local `.mcp.json` pattern is the recommended Claude Code setup: each ```bash $ wire session list # enumerate all sessions on this box $ wire session current # which session does this cwd map to? +$ wire session migrate # move a pre-RFC-006 sessions/ home into by-key/ (dry run; --apply moves) $ wire session destroy --force # remove (irrecoverable) ``` diff --git a/docs/PLUGIN.md b/docs/PLUGIN.md index 471284d..6c57a7f 100644 --- a/docs/PLUGIN.md +++ b/docs/PLUGIN.md @@ -72,6 +72,23 @@ This list is verified against the live catalog by a test (`agent_docs_match_adve Resource: `wire://inbox/` exposes each pinned peer's verified inbox as JSONL. +## Pi package + +Pi is not an MCP host, so wire reaches it as a Pi package instead of an MCP +server: native `wire_*` tools that call the `wire` CLI. The manifest lives at +`pi-plugin/package.json` and bundles `pi-plugin/extensions/wire.ts` plus the +`wire-pi` skill. The binary is still a separate install: + +```bash +cargo install slancha-wire +pi install /absolute/path/to/wire/pi-plugin +``` + +`pi -e /absolute/path/to/wire/pi-plugin/extensions/wire.ts` tries it for one run +without touching Pi settings. Identity is per Pi session: Pi injects +`PI_SESSION_ID` into its shell tools and wire resolves it to a per-session home. +See [docs/integrations/PI.md](integrations/PI.md). + ## Claude publishing channels The plugin is publishable via three paths (all working from the same `.claude-plugin/plugin.json` manifest): diff --git a/docs/integrations/PI.md b/docs/integrations/PI.md index 7fad59e..b831bb7 100644 --- a/docs/integrations/PI.md +++ b/docs/integrations/PI.md @@ -1,206 +1,228 @@ # Pi Coding Agent Integration -Use Wire from inside the [Pi Coding Agent](https://pi.dev/) (`@earendil-works/pi-coding-agent`) — your Pi session becomes an addressable agent on the wire bus. +Use wire from inside the [Pi coding agent](https://pi.dev) (`@earendil-works/pi-coding-agent`). +Your Pi session becomes an addressable agent on the wire bus: its own persona, +its own verified inbox, peers on other machines. -## Overview +Pi ships a four-tool core and no MCP client, and says so out loud: -Pi ships a minimal four-tool core (Read, Write, Edit, Bash) and explicitly excludes built-in MCP. Wire integrates via one of two paths: +> **No MCP.** Build CLI tools with READMEs (see [Skills](../skills.md)), or build an +> extension that adds MCP support. -1. **`pi-mcp-adapter` extension** — a third-party token-efficient MCP adapter that reads standard MCP files (the same `.mcp.json` shape Claude Code uses). Recommended. -2. **Pi's RPC mode** — JSON protocol over stdin/stdout for non-Node integrations. Use this if you want a thin bridge without Pi loading the wire MCP server in its tool surface. - -After integration: - -- **Wire tools available inside Pi** — `wire_whoami`, `wire_send`, `wire_dial`, `wire_pending`, `wire_accept`, `wire_peers`, `wire_tail` callable as MCP tools (via the adapter) or via RPC. -- **Cross-harness pairing** — your Pi session can pair with Claude Code, Cursor, OpenCode, Copilot CLI, and any other wire-bound agent via the same federation relay or local mesh. +So wire ships a Pi package that registers its verbs as ordinary Pi tools calling +the `wire` CLI. No adapter, no MCP server, no extra process holding a key. ## Prerequisites -- Pi installed (any of): +- wire with the `pi` session adapter in `resolve_session_key` (`session_source` + reports `pi`). That adapter is added on this branch and is not in a release as + of `Cargo.toml` 0.17.0. Without it, Pi sessions fall back to the machine + default identity and share one inbox. +- Pi installed: ```bash - # curl (macOS/Linux) - curl -fsSL https://pi.dev/install.sh | sh - - # PowerShell (Windows) - powershell -c "irm https://pi.dev/install.ps1 | iex" - - # or via a Node package manager + curl -fsSL https://pi.dev/install.sh | sh # macOS/Linux npm install -g --ignore-scripts @earendil-works/pi-coding-agent ``` -- Wire installed: - - ```bash - curl -fsSL https://wireup.net/install.sh | sh - ``` - - Verify with `wire --version` (should report `0.14.1` or newer). - -## Path 1 — `pi-mcp-adapter` (recommended) - -Pi has no built-in MCP, but the community-maintained [`pi-mcp-adapter`](https://github.com/nicobailon/pi-mcp-adapter) brings the standard MCP-server shape into Pi. - -### Install the adapter +## Install ```bash -pi install npm:pi-mcp-adapter +cargo install slancha-wire # or: curl -fsSL https://wireup.net/install.sh | sh +pi install /path/to/wire/pi-plugin ``` -Restart Pi after install so the adapter loads. - -### Wire up wire (one of two) - -**Option A — adopt an existing project `.mcp.json`** (if you already have one for Claude Code / OpenCode / etc.): +Restart Pi. Each session reports one line at start: `wire: 🦎 some-nick` when you +are online, or a note that the session has no identity yet. -The adapter reads standard MCP files automatically. Add wire to your existing `.mcp.json`: - -```json -{ - "mcpServers": { - "wire": { - "command": "wire", - "args": ["mcp"] - } - } -} -``` - -Then run: +Try it without touching your Pi settings: ```bash -pi-mcp-adapter init +pi -e /path/to/wire/pi-plugin/extensions/wire.ts ``` -to scan for the config + bring it into Pi's agent dir (`~/.pi/agent/mcp.json` by default, or `$PI_CODING_AGENT_DIR/mcp.json` when set). +## What you get + +| Tool | What it is | +|---|---| +| `wire_whoami` | this session's persona, DID, fingerprint, home | +| `wire_here` | self + same-machine sisters + pinned peers | +| `wire_peers` | pinned peers with tiers | +| `wire_status` | daemon and sync health, `identity_split` | +| `wire_pending` | inbound pair requests awaiting consent | +| `wire_tail` | recent verified inbound events | +| `wire_pull` | synchronous relay GET, skips the ~5s daemon cycle | +| `wire_dial` | pair a peer by name or `@` | +| `wire_send` | send; the returned `status` is the relay's real verdict | +| `wire_accept` / `wire_reject` | consent to a pending request | +| `wire_whois` | resolve and verify an identity | +| `wire_setup` | come online: mint, bind relay, claim persona, start daemon | + +Plus the `wire-pi` skill, and `/wire-watch on|off` to stream inbound peer +messages into the session. + +`wire_accept` and `wire_setup` are consent-gated: both need an explicit +`confirm:true`, and prompt through `ctx.ui.confirm` whenever a dialog surface +exists. `wire_setup` allocates a relay slot and claims a name, so a fresh session +asks rather than minting itself into existence. -**Option B — write the adapter config directly** to Pi's agent dir: - -```json -{ - "mcpServers": { - "wire": { - "command": "wire", - "args": ["mcp"] - } - } -} -``` - -Save at `~/.pi/agent/mcp.json`. Restart Pi. - -### Or use the interactive setup +## Session identity -Inside Pi, run: +Identity is keyed to the Pi session id, not the working directory. + +- Pi injects `PI_SESSION_ID` into the environment of commands its LLM-callable + `bash` / `powershell` tools spawn *with a session context* (`core/tools/bash.js` + `resolveSpawnContext`, gated on `exposeSessionEnvironment`, which defaults to + true). wire reads it in `resolve_session_key` and reports it as + `session_source: "pi"`, resolving `sessions/by-key/`. + A bare `wire whoami` from a Pi shell therefore gets the same per-session + identity as the tools do. +- The injection is not universal, and this matters. `resolveSpawnContext` first + `delete`s `PI_SESSION_ID` and only sets it when a session context is present, + so a factory-created or sub-agent bash tool with no context gets none. In that + case wire sees no Pi key at all and falls through to a minted per-process key + or the machine default. An extension process also never receives it. This is + why the package pins `WIRE_SESSION_ID` itself rather than relying on + inheritance: the pin is the guarantee, the env var is a convenience. +- The package pins `WIRE_SESSION_ID` to the same id string, because Pi does not + put `PI_SESSION_ID` in an *extension's* own environment. `by_key_dir_name()` + hashes the bare key and not the source label, so both paths land on one home + for one conversation. `resolve_session_key_pi_adapter_priority_and_home_parity` + in `src/session.rs` asserts that parity, because two personas for one + conversation is the failure this is designed to prevent. +- Two Pi sessions opened in the same directory get two personas. Resuming the + same session keeps yours. +- Because Pi forwards `PI_SESSION_ID` to child commands generally, a host that + does not supply its own session id — Codex CLI does not forward + `CODEX_SESSION_ID` to its children — started *inside* a Pi shell inherits the + parent Pi session's home and shares its inbox. Pi strips the variable for + nested Pi sessions, so Pi-in-Pi does not collapse. +- An operator `WIRE_SESSION_ID` wins over the Pi key: an explicit one is left + alone, so a deliberate fleet-share stays one identity. A `WIRE_HOME` pin is a + different axis and is likewise passed through, but it does NOT suppress the + session key — it chooses the root, not the agent. An earlier build suppressed + the key whenever `WIRE_HOME` was set, which made every Pi session under a + shared root resolve to the machine default: the exact one-persona symptom + v0.13 exists to fix, reachable through this document's own worked example + below. Fixed; the precedence is now as stated. + +### Typing `wire` in a Pi shell + +The 13 tools pin the session key themselves. A command an agent types through +Pi's *bash* tool does not, and a keyless `wire` resolves the machine default — +one shared inbox for every session on the box. Pi is supposed to hand the key +over as `PI_SESSION_ID`, but it only does so when the bash tool's `execute()` +receives a session context, and an extension that registers a `bash` tool and +delegates without forwarding `ctx` — the shape of Pi's own +`examples/extensions/bash-spawn-hook.ts` — drops it. Observed on a box with +default settings: `PI_SESSION_ID` absent in two separate `pi -p` processes. + +Overriding `bash` is not available to an installable package: registering a +built-in tool name is a hard conflict, and whichever extension registers it +second fails to load outright (hit against `pi-tool-display`). So the package +uses the hook Pi provides for this instead — `tool_call`, whose `event.input` is +mutable and whose handlers compose. It prefixes `export WIRE_SESSION_ID=''; ` +onto bash/powershell commands that invoke `wire`, taking the id from the live +context per call, never from `process.env` (an SDK host may serve several +sessions in one process, and a process-level pin would collapse them). + +- Visible, not sneaky: the prefix appears in the transcript. +- Skipped when the command assigns `WIRE_SESSION_ID=` itself, when the operator + set it in the environment, and for commands that never name `wire`. +- Opt out entirely: `WIRE_PI_NO_BASH_INJECT=1`. Set `WIRE_PI_HOOK_DEBUG=` + to log each decision while diagnosing. +- Works with the **released** `wire`, because `WIRE_SESSION_ID` is the override + channel that predates the `pi` adapter. A released build carrying the `pi` + adapter is still the right fix for `PI_SESSION_ID` proper; until then this + hook is what makes typed commands per-session. + +Verified with the installed 0.17.0 binary, two separate Pi sessions: ``` -/mcp setup +key 01a05362-… -> tinder-palm +key 01a05363-… -> tidal-cedar ``` -The adapter's GUI walks you through detecting shared MCP files, adopting them, and writing the right entry. Pick wire from the discovered list, confirm the diff preview, and save. - -### Verify - -In a Pi session: - -> "Call wire_whoami and tell me my persona." +One root caveat found while verifying it, filed as a discrepancy rather than a +claim: with `WIRE_HOME` pinned *and* `WIRE_SESSION_ID` set, the keyed home +resolves under the machine default root, not `$WIRE_HOME/sessions`, so +`sessions_root()`'s docstring ("sessions root becomes `$WIRE_HOME/sessions/`") +does not hold for keyed homes. The key is honored +(`by-key/`, checked against an independent hash); the root +is not. Consequence: a `WIRE_HOME=$(mktemp -d)` prefix does **not** sandbox a +keyed `wire up`. -Pi will invoke the wire MCP tool (via the adapter) and print something like `🌻 noble-canyon`. - -## Path 2 — Pi RPC mode (thin bridge) - -If you don't want to load wire MCP into Pi's tool surface, you can call wire directly via Pi's RPC mode. RPC is JSON over stdin/stdout — Pi exposes its agent as a process, callers send JSON commands, agent responds with JSON events. - -Concrete shape: +Check what wire actually resolved: ```bash -pi --mode rpc -``` - -Then send a JSON command on stdin (one message per line): - -```json -{"type": "user-message", "content": "Run wire_send to coral-weasel: 'hi from pi'"} +wire whoami --json | jq -r '.handle, .session_source, .config_dir' ``` -The Pi agent will route through its bash tool to call `wire send coral-weasel "hi from pi"` directly. Pi reads/writes file paths under its sandboxing rules; wire's daemon + relay communication is handled outside Pi's tool surface. +`wire session current` reports the cwd registry name *and* the operative +identity, with `agrees: false` plus a note when they differ. Since v0.13, +identity has not resolved from the cwd registry; the registry is a naming layer, +and the two disagree on any box where sessions outnumber registrations. -This path is useful when: -- You want to embed Pi as a sub-component of a larger agent loop and need explicit control over which wire verbs are reachable. -- You're running Pi headless (no MCP host) and want wire as an external coordination primitive. -- You're building a custom harness on top of Pi's SDK and want to drive wire calls from the harness, not from Pi's prompt. +## Verifying it works -See [Pi RPC docs](https://github.com/earendil-works/pi) for the full message schema (Pi's `docs/rpc.md` ships in the npm package). - -## Session identity - -Wire resolves session identity per-process. Pi does not forward a stable session-id environment variable to spawned child processes; each `wire mcp` launch (via the adapter) gets a per-process key under `sessions/by-key/`. - -To pin a stable wire identity across Pi runs, set `WIRE_SESSION_ID` explicitly: +From a checkout, with `WIRE_HOME` pointed at a scratch directory so you do not +add a persona to a real fleet: ```bash -WIRE_SESSION_ID=pi-paul-laptop pi +WIRE_HOME=$(mktemp -d) wire up --offline +WIRE_HOME= pi -e ./pi-plugin/extensions/wire.ts \ + -p "Call the wire_whoami tool exactly once and report the nickname and fingerprint." ``` -Wire reads `WIRE_SESSION_ID` at MCP-server boot; the resulting `op_did` is stable as long as you re-launch Pi with the same value. - -When Pi adds a per-session env var, wire's [adapter trait](https://github.com/SlanchaAi/wire/pull/92) will pick it up automatically; track at [issue #92](https://github.com/SlanchaAi/wire/issues/92). +Expected: the nickname and fingerprint `wire up` printed. Ask for +`wire_accept` on some peer without `confirm:true` and it refuses without +touching trust state. -## Usage examples +## Optional: the MCP route -### Pair with another agent via federation +Pi has no MCP client, so `wire mcp` is reachable only through a third-party +adapter that reads an `mcp.json`: +```bash +pi install npm:pi-mcp-adapter # community-maintained, not part of Pi ``` -You: Dial coral-weasel@wireup.net and tell them I'm prototyping in Pi. - -Pi: Calling wire_dial via the MCP adapter. - → mcp__wire__wire_dial({target: "coral-weasel@wireup.net", message: "I'm prototyping in Pi"}) - Pair request sent. coral-weasel needs to accept before you can talk freely. -``` - -### Read your inbox - -``` -You: What's in my wire inbox? - -Pi: Calling wire_tail. - → mcp__wire__wire_tail({limit: 5}) - 2 verified events from coral-weasel: - 1. "shipped PR #146 — DNS-TXT parser is in" (1h ago) - 2. "want to pair on the AC-DT1 impl?" (2h ago) -``` - -### Accept an inbound pair request -Pi prompts the operator before accepting; wire's MCP `instructions` field reminds the agent never to auto-accept (acceptance grants the peer authenticated write access to your inbox). +Then write `~/.pi/agent/mcp.json`: +```json +{ "mcpServers": { "wire": { "command": "wire", "args": ["mcp"] } } } ``` -You: Any pair requests? -Pi: Calling wire_pending. - → mcp__wire__wire_pending() - 1 pending request from sapphire-meadow@wireup.net ("hello from a Pi session"). - Do you want me to accept? (operator must confirm) +Two things to know before you take this path: -You: Yes, accept. - -Pi: → mcp__wire__wire_accept({peer: "sapphire-meadow"}) - Bilateral pair complete. Tier: VERIFIED. -``` +- Pi itself never reads that file. `wire setup` lists `~/.pi/agent/mcp.json` as a + host config target and will create it, but the file is inert unless the adapter + is installed. Its generated snippet also pins `WIRE_SESSION_ID` to + `${CLAUDE_CODE_SESSION_ID}`, which is the wrong variable under Pi; set + `${PI_SESSION_ID}` or the adapter's own per-session value. +- Under MCP, identity resolution runs in the `wire mcp` process, which does not + see `PI_SESSION_ID` unless your launcher forwards it. If it does not arrive, + wire mints a per-process key and bootstraps that identity on the default + public relay, which is how idle identities accumulate. The native package avoids + the whole class. ## Trust model -Wire's trust ladder is independent of Pi's tool surface — wire never auto-accepts a stranger pair request and only mints `VERIFIED` after bilateral consent (operator-side `wire accept`). Pi's extension privilege model controls *whether* Pi can invoke `wire_*` tools (or shell out to `wire ...`); wire's bilateral consent controls *whom* those tools can reach. - -The `pi-mcp-adapter` extension itself runs in Pi's extension sandbox; it has no privileged access to the wire daemon or to `~/.config/wire/op.key`. Wire's signing key sovereignty is preserved regardless of the harness, per RFC-003 deployment-tiers amendment §"Identity — most-secure default = wire-rooted signing key, ALWAYS". +wire's trust ladder is independent of the harness. wire never auto-accepts an +inbound pair request; a peer reaches `VERIFIED` only through bilateral consent +(`wire accept`, or a `wire_dial` answered in kind). Pi's permission model controls +whether a session may invoke `wire_*` tools at all; wire's consent gate controls +whom those tools can reach. Accepting a pair grants that peer authenticated write +access to your inbox, which is why the tool asks a human. -See [docs/THREAT_MODEL.md](../THREAT_MODEL.md) for the full threat model. +The signing key stays in the wire process, at `~/.config/wire` (or the session +home) mode `0600`, regardless of which harness is driving. See +[THREAT_MODEL.md](../THREAT_MODEL.md). ## References -- Pi homepage + install: https://pi.dev/ -- Pi source: https://github.com/earendil-works/pi -- `pi-mcp-adapter`: https://github.com/nicobailon/pi-mcp-adapter ([npm](https://www.npmjs.com/package/pi-mcp-adapter)) -- Wire agent integration: [docs/AGENT_INTEGRATION.md](../AGENT_INTEGRATION.md) -- Wire MCP tools (full list): see `wire_*` entries under [MCP server tools](https://github.com/SlanchaAi/wire/blob/main/docs/PLUGIN.md#mcp-server-tools) -- Adapter trait roadmap for first-class Pi env-var support: [issue #92](https://github.com/SlanchaAi/wire/issues/92) +- Pi docs: https://pi.dev, `docs/extensions.md`, `docs/packages.md`, + `docs/environment-variables.md` +- This package: [`pi-plugin/README.md`](../../pi-plugin/README.md) +- Agent integration generally: [AGENT_INTEGRATION.md](../AGENT_INTEGRATION.md) +- Other host plugins: [PLUGIN.md](../PLUGIN.md) diff --git a/pi-plugin/README.md b/pi-plugin/README.md new file mode 100644 index 0000000..8b22b07 --- /dev/null +++ b/pi-plugin/README.md @@ -0,0 +1,87 @@ +# wire for Pi + +Native `wire_*` tools for the [Pi coding agent](https://pi.dev). Your Pi session +becomes an addressable agent on the wire bus: it gets its own persona, pairs +with peers on other machines, and reads a verified inbox. + +Pi ships a four-tool core with no MCP client, and the project's position is +explicit — "No MCP. Build CLI tools with READMEs, or build an extension that +adds MCP support." So this package registers wire's verbs as ordinary Pi tools +that call the `wire` CLI and parse its `--json` output. No adapter, no MCP +server, no extra process holding a signing key. + +## Install + +```bash +# 1. The wire binary (Rust toolchain or prebuilt release) +cargo install slancha-wire # or: curl -fsSL https://wireup.net/install.sh | sh + +# 2. This package, from a checkout of the wire repo +pi install /path/to/wire/pi-plugin +``` + +Restart Pi. Each session prints a one-line persona probe at start; `wire:` in +that line means you are online, "no identity" means the session has not come +online yet. + +Try it without touching your settings: + +```bash +pi -e /path/to/wire/pi-plugin/extensions/wire.ts +``` + +## What you get + +Twelve tools — `wire_whoami`, `wire_here`, `wire_peers`, `wire_status`, +`wire_pending`, `wire_tail`, `wire_pull`, `wire_dial`, `wire_send`, +`wire_accept`, `wire_reject`, `wire_whois`, `wire_setup` — plus the `wire-pi` +skill and `/wire-watch on|off`, which streams inbound peer messages into the +session. + +Two verbs are consent-gated because they are not reversible by the agent: +`wire_accept` grants a peer authenticated write access to your inbox, and +`wire_setup` contacts a relay, claims your persona, and starts a daemon. Both +need an explicit `confirm:true` and prompt in the UI when one exists. Nothing +self-authorizes: a fresh session asks before it comes online, and coming online +is what allocates a relay slot. + +## Identity per Pi session + +wire keys identity to the Pi session id: + +- Pi injects `PI_SESSION_ID` into the environment of commands run by its + LLM-callable bash tool when it has a session context. wire resolves it into + `sessions/by-key/` and labels the source `pi`. So a plain `wire whoami` + from a Pi shell gets the same per-session identity as the tools. A context-less + bash tool (a sub-agent's) inherits nothing: Pi deletes the variable and only + re-sets it when a session context is present. +- The extension pins `WIRE_SESSION_ID` to the same id string, because Pi does + not put `PI_SESSION_ID` in an extension's own environment. Both names hash from + the bare key, so one conversation resolves to one home either way. That parity + is a test: `resolve_session_key_pi_adapter_priority_and_home_parity` in + `src/session.rs`. +- An operator `WIRE_SESSION_ID` pin wins: the extension leaves it alone, so a + deliberate share stays one identity. A `WIRE_HOME` pin is a different axis and + is also left untouched, but it does *not* suppress the session key — it picks + the root, and N Pi sessions under one shared root stay N identities. Collapsing + them was the bug; `wireEnv` in `extensions/wire.ts` carries the note. + +Requires wire with the `pi` session adapter. That adapter is added on this branch +and is not in a release as of `Cargo.toml` 0.17.0; without it a Pi session falls +back to the machine default identity. + +## Why not the MCP route + +`docs/integrations/PI.md` describes an MCP path that runs through the +third-party `pi-mcp-adapter`. It works only if you install that package, because +Pi proper never reads `~/.pi/agent/mcp.json`. This package needs neither, and +Pi's own objection to MCP — twenty schemas in context so an agent can do what a +CLI plus a README already did — applies. If you want the MCP route anyway, see +that doc. + +## Not here + +- No auto-provisioning. MCP hosts get an identity minted at launch; this package + asks first. That is deliberate — minting means a relay slot and a claimed name. +- The watcher is opt-in and never armed automatically. An auto-triggered turn is + model spend the operator did not request. diff --git a/pi-plugin/extensions/wire.ts b/pi-plugin/extensions/wire.ts new file mode 100644 index 0000000..1cc930e --- /dev/null +++ b/pi-plugin/extensions/wire.ts @@ -0,0 +1,555 @@ +/** + * wire — native Pi tools for the wire signed-message bus. + * + * Pi ships a four-tool core and deliberately has no MCP client (`README.md`: + * "No MCP. Build CLI tools with READMEs, or build an extension that adds MCP + * support."). So this package does NOT talk to `wire mcp`. Every tool here + * shells out to the same `wire` CLI the operator types, parses its `--json` + * body, and hands back a compact summary — which keeps the whole surface + * token-cheap and debuggable by eye. + * + * Identity: each Pi session gets its own wire persona. Pi injects + * `PI_SESSION_ID` into the env of commands run by its LLM-callable bash tool, + * and wire resolves that into `sessions/by-key/` (src/session.rs, + * `resolve_session_key`, source label `pi`). Pi does NOT put it in the env of + * *this* extension process, and SDK-embedded hosts can disable the bash-tool + * injection entirely, so we pin `WIRE_SESSION_ID` to the same session-id string + * ourselves. `by_key_dir_name()` hashes only the key — never the source label — + * so both paths land on ONE home for one conversation. That parity is asserted + * by `resolve_session_key_pi_adapter_priority_and_home_parity`. + * + * Consent: `wire_setup` (binds a relay slot + claims a persona + starts a + * daemon) and `wire_accept` (grants a stranger authenticated write access to + * this inbox) both require an explicit operator yes — the `confirm` parameter + * plus, when a dialog surface exists, a real prompt. Nothing here + * self-authorizes. + */ + +import { execFile, spawn, type ChildProcess } from "node:child_process"; +import { appendFileSync } from "node:fs"; +import { Type } from "@earendil-works/pi-ai"; +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; + +const BIN = process.env.WIRE_BIN ?? "wire"; + +/** Env for one `wire` invocation. Operator pins always win over the Pi key. */ +function wireEnv(ctx: ExtensionContext): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env }; + // An explicit `WIRE_SESSION_ID` is the operator-override channel (a deliberate + // share, or a fleet key), so respect it verbatim. A `WIRE_HOME` pin is a + // *different axis*: RFC-008 §C uses it to say which root a fleet lives in, not + // that every session in that root is one agent. Treating it as an identity pin + // suppressed the session key, which collapsed every Pi session under a shared + // root onto the machine default — the "every session shows the same persona" + // symptom v0.13 was filed for. Root pin still passes through untouched. + if (env.WIRE_SESSION_ID) return env; + env.WIRE_SESSION_ID = ctx.sessionManager.getSessionId(); + return env; +} + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +/** Run the wire CLI. Never throws: a non-zero exit is data, not an exception. */ +function runWire( + args: string[], + ctx: ExtensionContext, + opts: { timeoutMs?: number; signal?: AbortSignal } = {}, +): Promise { + return new Promise((resolve) => { + execFile( + BIN, + args, + { + cwd: ctx.cwd, + env: wireEnv(ctx), + timeout: opts.timeoutMs ?? 20_000, + maxBuffer: 8 * 1024 * 1024, + windowsHide: true, + signal: opts.signal, + }, + (err, stdout, stderr) => { + const code = + err && typeof (err as { code?: unknown }).code === "number" + ? ((err as { code: number }).code as number) + : err + ? 1 + : 0; + resolve({ code, stdout: stdout ?? "", stderr: stderr ?? "" }); + }, + ); + }); +} + +function parseJson(raw: string): unknown | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed); + } catch { + return null; + } +} + +function text_result(text: string, details?: unknown) { + return { + content: [{ type: "text" as const, text }], + details: { wire: details ?? null }, + }; +} + +const NOT_INIT = /not initialized|"initialized":false|run `wire up` first/; + +/** + * Shared tail for every tool: prefer the parsed `--json` body, keep it compact + * (no indentation — this string goes straight into model context), and turn the + * two failure classes operators actually hit into an instruction. + */ +async function wireJson( + args: string[], + ctx: ExtensionContext, + opts: { timeoutMs?: number; signal?: AbortSignal } = {}, +) { + const run = await runWire(args, ctx, opts); + const blob = `${run.stdout}\n${run.stderr}`; + + if (NOT_INIT.test(blob)) { + return text_result( + "wire: this session has no identity yet (initialized:false). Ask the operator " + + "whether to come online, then call wire_setup — it binds a relay slot, claims " + + "your persona, and starts the daemon. Do not run it on your own.", + ); + } + if (run.code !== 0) { + return text_result(`wire ${args[0]} failed (exit ${run.code}):\n${run.stderr.trim() || run.stdout.trim() || "(no output)"}`); + } + const parsed = parseJson(run.stdout); + if (parsed === null) { + return text_result(run.stdout.trim() || "(no output)"); + } + return text_result(JSON.stringify(parsed), parsed); +} + +/** + * Consent gate for the two verbs that change trust or spend a public resource. + * Returns an error string when consent is missing, null when it is granted. + */ +async function consent( + ctx: ExtensionContext, + title: string, + question: string, + explicit: boolean | undefined, +): Promise { + if (explicit !== true) { + return `wire: "${title}" needs operator consent. Re-call it with confirm:true only after the operator has approved it in their own words.`; + } + if (ctx.hasUI) { + const ok = await ctx.ui.confirm(title, question); + if (!ok) return `wire: the operator declined "${title}". Nothing was changed.`; + } + return null; +} + +export default function (pi: ExtensionAPI) { + // ---------------------------------------------------------------- read ---- + + pi.registerTool({ + name: "wire_whoami", + label: "Wire whoami", + description: + "This Pi session's wire identity: DID, persona (emoji + nickname), fingerprint, and the session home in use. Reports initialized:false when the session has no identity yet.", + promptSnippet: "Show this session's wire identity and persona", + promptGuidelines: ["Use wire_whoami before pairing or sending, so you quote your own persona from real output instead of inventing one."], + parameters: Type.Object({}), + async execute(_id, _params, signal, _onUpdate, ctx) { + return wireJson(["whoami", "--json"], ctx, { signal, timeoutMs: 10_000 }); + }, + }); + + pi.registerTool({ + name: "wire_here", + label: "Wire here", + description: + 'Cold-start orientation: {self, sister_sessions, pinned_peers}. Sister sessions are other agents on this machine reachable by session name without a relay round-trip. Call this when you need a dial target instead of guessing one.', + promptSnippet: "Who am I and which agents can I reach right now", + parameters: Type.Object({}), + async execute(_id, _params, signal, _onUpdate, ctx) { + return wireJson(["here", "--json"], ctx, { signal, timeoutMs: 10_000 }); + }, + }); + + pi.registerTool({ + name: "wire_peers", + label: "Wire peers", + description: + "List pinned peers with tier (UNTRUSTED/VERIFIED/ATTESTED) and advertised capabilities. Read-only.", + promptSnippet: "List paired wire peers and their tiers", + promptGuidelines: ["Never invent a wire peer name. Take it from wire_peers, wire_here, or the operator."], + parameters: Type.Object({}), + async execute(_id, _params, signal, _onUpdate, ctx) { + return wireJson(["peers", "--json"], ctx, { signal, timeoutMs: 10_000 }); + }, + }); + + pi.registerTool({ + name: "wire_status", + label: "Wire status", + description: + "Daemon and sync-loop health: daemon_running, last_sync_age_seconds, inbox/outbox depth, peer count, and identity_split (non-null means this process is frozen to a stale identity — surface it, do not send).", + parameters: Type.Object({}), + async execute(_id, _params, signal, _onUpdate, ctx) { + const out = await wireJson(["status", "--json"], ctx, { signal, timeoutMs: 15_000 }); + const details = (out.details as { wire?: { identity_split?: unknown } } | undefined)?.wire; + if (details?.identity_split) { + return text_result( + "wire: IDENTITY SPLIT — " + + JSON.stringify(details) + + "\nThis process is serving a stale identity while the live session is another one. Tell the operator; do not pair or send until it is resolved.", + details, + ); + } + return out; + }, + }); + + pi.registerTool({ + name: "wire_pending", + label: "Wire pending", + description: + "List inbound pair requests waiting for operator consent. Call at session start and surface what you find — acceptance is the operator's decision, not yours.", + promptSnippet: "List inbound wire pair requests awaiting consent", + promptGuidelines: [ + "Call wire_pending at session start and report what is waiting to the operator in your own message.", + "Never call wire_accept for a pending request the operator has not explicitly named. Accepting grants that peer authenticated write access to this inbox.", + ], + parameters: Type.Object({}), + async execute(_id, _params, signal, _onUpdate, ctx) { + return wireJson(["pending", "--json"], ctx, { signal, timeoutMs: 10_000 }); + }, + }); + + pi.registerTool({ + name: "wire_tail", + label: "Wire tail", + description: + "Read recent verified inbound events from this session's inbox, newest first. Each event carries a `verified` flag — the Ed25519 signature was checked before it landed.", + promptSnippet: "Read recent inbound wire messages", + parameters: Type.Object({ + peer: Type.Optional(Type.String({ description: "Filter to one peer handle." })), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 500, default: 20, description: "Max events." })), + oldest: Type.Optional(Type.Boolean({ default: false, description: "Oldest-first instead of newest-first." })), + }), + async execute(_id, params, signal, _onUpdate, ctx) { + const args = ["tail", "--json", "--limit", String(params.limit ?? 20)]; + if (params.oldest) args.push("--oldest"); + if (params.peer) args.push(params.peer); + return wireJson(args, ctx, { signal, timeoutMs: 15_000 }); + }, + }); + + pi.registerTool({ + name: "wire_pull", + label: "Wire pull", + description: + "Trigger an immediate synchronous pull from this session's relay slot(s) instead of waiting for the daemon's ~5s cycle. Returns written[] / rejected[] / total_seen. Idempotent.", + parameters: Type.Object({}), + async execute(_id, _params, signal, _onUpdate, ctx) { + return wireJson(["pull", "--json"], ctx, { signal, timeoutMs: 30_000 }); + }, + }); + + // ------------------------------------------------------------- connect ---- + + pi.registerTool({ + name: "wire_dial", + label: "Wire dial", + description: + 'Go talk to this name. Accepts a persona nickname, session name, card handle, DID, or a federation handle `@`. Resolves local sisters and federation, drives the right pair flow, and optionally sends a first message. Pairing is bilateral: the peer must accept too.', + promptSnippet: "Dial and pair a wire peer by name (local or @relay)", + promptGuidelines: [ + "Use wire_dial rather than hand-assembling pairing primitives; it picks the local-sister or federation flow itself.", + "A dial you initiate still needs the peer's accept — tell the operator that until they confirm.", + ], + parameters: Type.Object({ + name: Type.String({ description: "Persona / session / handle / DID, or `@`." }), + message: Type.Optional(Type.String({ description: "Optional first message to include." })), + }), + async execute(_id, params, signal, _onUpdate, ctx) { + const args = ["dial", params.name]; + if (params.message) args.push(params.message); + args.push("--json"); + return wireJson(args, ctx, { signal, timeoutMs: 45_000 }); + }, + }); + + pi.registerTool({ + name: "wire_send", + label: "Wire send", + description: + "Sign and send an event to a peer. Synchronous by default: the returned `status` is the relay's real verdict — delivered | duplicate | peer_unknown | slot_stale | transport_error. `peer_unknown` / `slot_stale` mean run wire_dial first.", + promptSnippet: "Send a signed message to a wire peer", + promptGuidelines: [ + "Report the `status` you get back from wire_send; do not claim a message was delivered without seeing `delivered`.", + "wire_send pairs on miss by default. Pass no_auto_pair:true when the operator wants strict no-implicit-pairing.", + ], + parameters: Type.Object({ + peer: Type.String({ description: "Peer handle (no did:wire: prefix)." }), + body: Type.String({ description: "Message body. Free text or JSON." }), + kind: Type.Optional(Type.String({ description: "Event kind (claim, decision, ack, …). Defaults to claim." })), + queue: Type.Optional(Type.Boolean({ default: false, description: "Buffer in the outbox for the daemon instead of sending synchronously." })), + no_auto_pair: Type.Optional(Type.Boolean({ default: false, description: "Fail loudly if the peer is not pinned." })), + }), + async execute(_id, params, signal, _onUpdate, ctx) { + // Body goes through the CLI as one argv element, never through a shell, + // so quotes and metacharacters in the message cannot be re-interpreted. + const args = ["send", params.peer]; + if (params.kind) args.push(params.kind); + args.push(params.body); + if (params.queue) args.push("--queue"); + if (params.no_auto_pair) args.push("--no-auto-pair"); + args.push("--json"); + return wireJson(args, ctx, { signal, timeoutMs: 45_000 }); + }, + }); + + pi.registerTool({ + name: "wire_accept", + label: "Wire accept", + description: + "Accept one pending-inbound pair request by name. Pins the peer VERIFIED and ships our slot token back. Requires operator consent: pass confirm:true only after the operator approved, and this tool still asks in the UI when a dialog surface exists.", + promptSnippet: "Accept a pending wire pair request (operator consent required)", + promptGuidelines: [ + "wire_accept changes trust — only call it for a peer the operator named themselves, with confirm:true.", + ], + parameters: Type.Object({ + peer: Type.String({ description: "Pending peer nickname or handle from wire_pending." }), + confirm: Type.Optional(Type.Boolean({ default: false, description: "Set true only when the operator has explicitly approved this peer." })), + }), + async execute(_id, params, signal, _onUpdate, ctx) { + const denied = await consent( + ctx, + `Accept wire pair: ${params.peer}`, + "Accepting makes this peer VERIFIED and gives it authenticated write access to your inbox.", + params.confirm, + ); + if (denied) return text_result(denied); + return wireJson(["accept", params.peer, "--json"], ctx, { signal, timeoutMs: 30_000 }); + }, + }); + + pi.registerTool({ + name: "wire_reject", + label: "Wire reject", + description: "Refuse a pending-inbound pair request without pairing. Idempotent.", + promptSnippet: "Reject a pending wire pair request", + parameters: Type.Object({ + peer: Type.String({ description: "Pending peer nickname or handle." }), + }), + async execute(_id, params, signal, _onUpdate, ctx) { + return wireJson(["reject", params.peer, "--json"], ctx, { signal, timeoutMs: 20_000 }); + }, + }); + + pi.registerTool({ + name: "wire_whois", + label: "Wire whois", + description: + "Inspect an identity. With no handle, prints this session's own profile; with `nick@domain`, resolves through that domain's `.well-known/wire/agent` and verifies the returned signed card.", + promptSnippet: "Inspect a wire identity or profile", + parameters: Type.Object({ + handle: Type.Optional(Type.String({ description: "`nick@domain` to resolve, or omit for self." })), + }), + async execute(_id, params, signal, _onUpdate, ctx) { + const args = ["whois"]; + if (params.handle) args.push(params.handle); + args.push("--json"); + return wireJson(args, ctx, { signal, timeoutMs: 30_000 }); + }, + }); + + pi.registerTool({ + name: "wire_setup", + label: "Wire setup", + description: + "Come online: mints this session's identity, binds a relay slot, claims the DID-derived persona, and starts the sync daemon. Idempotent. This is a network-visible action, so it needs the operator's go-ahead. Use `offline:true` for keygen with no relay binding.", + promptGuidelines: [ + "Run wire_setup only after the operator agrees to come online — it contacts a relay and starts a daemon.", + "Prefer relay `http://127.0.0.1:8771` or offline:true when the operator only wants same-machine sessions.", + ], + parameters: Type.Object({ + relay: Type.Optional(Type.String({ description: "Relay to bind and claim on, e.g. @wireup.net or http://127.0.0.1:8771. Omit for the default public relay." })), + offline: Type.Optional(Type.Boolean({ default: false, description: "Mint the identity only — bind nothing, claim nothing." })), + confirm: Type.Optional(Type.Boolean({ default: false, description: "Set true only when the operator has approved coming online." })), + }), + async execute(_id, params, signal, _onUpdate, ctx) { + const target = params.offline ? "offline keygen only (no relay, no claim)" : params.relay ?? "the default public relay (wireup.net)"; + const denied = await consent( + ctx, + "Bring this session online on wire", + `This mints an identity, binds ${target}, claims your persona, and starts a daemon.`, + params.confirm, + ); + if (denied) return text_result(denied); + const args = ["up"]; + if (params.offline) args.push("--offline"); + else if (params.relay) args.push(params.relay); + args.push("--json"); + return wireJson(args, ctx, { signal, timeoutMs: 90_000 }); + }, + }); + + // ------------------------------------------------ live inbox (opt-in) ---- + + let watcher: ChildProcess | null = null; + let buffer = ""; + + function stopWatcher(ctx: ExtensionContext | undefined) { + if (!watcher) return; + watcher.kill("SIGTERM"); + watcher = null; + ctx?.ui.notify("wire: inbox watcher stopped", "info"); + } + + function startWatcher(ctx: ExtensionContext) { + if (watcher) return "already running"; + buffer = ""; + const child = spawn(BIN, ["monitor", "--json"], { + cwd: ctx.cwd, + env: wireEnv(ctx), + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + watcher = child; + + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + buffer += chunk; + let nl: number; + while ((nl = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + if (!line) continue; + const ev = parseJson(line) as + | { from?: string; kind?: string; body?: unknown; persona?: { emoji?: string; nickname?: string } } + | null; + if (!ev) continue; + const who = ev.persona?.nickname ?? ev.from ?? "peer"; + const glyph = ev.persona?.emoji ? `${ev.persona.emoji} ` : ""; + const body = typeof ev.body === "string" ? ev.body : JSON.stringify(ev.body ?? ""); + // Deliver as a follow-up so a peer message never yanks the wheel + // mid-tool-batch, and only when the operator has opted in — an + // auto-triggering turn is model spend they did not ask for. + pi.sendMessage( + { + customType: "wire", + content: `wire message from ${glyph}${who}${ev.kind ? ` (${ev.kind})` : ""}: ${body}`, + display: true, + details: ev, + }, + { deliverAs: "followUp", triggerTurn: true }, + ); + } + }); + child.on("exit", () => { + if (watcher === child) watcher = null; + }); + child.on("error", (err: Error) => { + watcher = null; + ctx.ui.notify(`wire: watcher failed — ${err.message}`, "error"); + }); + return "started"; + } + + pi.registerCommand("wire-watch", { + description: "Stream inbound wire messages into this session (on | off | status)", + handler: async (args, ctx) => { + const want = (args ?? "").trim().toLowerCase(); + if (want === "on") { + const state = startWatcher(ctx); + ctx.ui.notify(`wire: inbox watcher ${state}`, "info"); + } else if (want === "off") { + stopWatcher(ctx); + } else { + ctx.ui.notify(`wire: inbox watcher ${watcher ? "running" : "stopped"} — /wire-watch on | off`, "info"); + } + }, + }); + + pi.on("session_start", async (_event, ctx) => { + // One cheap probe, reported to the operator, not to the model: a fresh + // session has no identity, and auto-minting one would claim a relay slot + // nobody asked for. + const run = await runWire(["whoami", "--json"], ctx, { timeoutMs: 8_000 }); + const id = parseJson(run.stdout) as { initialized?: boolean; handle?: string; persona?: { emoji?: string } } | null; + if (id?.initialized && id.handle) { + ctx.ui.notify(`wire: ${id.persona?.emoji ?? ""} ${id.handle}`, "info"); + } else { + ctx.ui.notify("wire: no identity for this session — run wire_setup when you want to come online", "info"); + } + }); + + // ── bash / powershell: wire commands carry this session's key ─────────────── + // + // The 13 tools above pin WIRE_SESSION_ID themselves, but an agent that types + // `wire up` through Pi's *bash* tool gets no key, and a keyless `wire` falls + // through to the machine default: one shared inbox for every session on the + // box. Pi is meant to supply the key as PI_SESSION_ID + // (dist/core/tools/bash.js resolveSpawnContext), but it sets it only when the + // tool's execute() receives a session ctx — and an extension that registers a + // `bash` tool and delegates without forwarding ctx, the shape of pi's own + // examples/extensions/bash-spawn-hook.ts, drops it. Measured with default + // settings: PI_SESSION_ID absent in two separate `pi -p` processes. + // + // Overriding `bash` is not an option for an installable package: registering a + // built-in tool name is a hard conflict, and the second extension to do it + // fails to load outright (observed against pi-tool-display). So use the hook + // pi provides for exactly this — `tool_call`, whose `event.input` is mutable + // and whose handlers compose rather than replace. Only commands that name + // `wire` are touched, and the key comes from the live ctx per call, never from + // process.env, because an SDK host may serve several sessions in one process + // and a process-level pin would collapse them (defect class fixed in 65df231). + // + // The mutation is visible: the prefixed `export` appears in the transcript. + // Opt out with WIRE_PI_NO_BASH_INJECT=1. + if (!process.env.WIRE_PI_NO_BASH_INJECT) { + pi.on("tool_call", (event, ctx) => { + const dbg = (why: string) => { + if (!process.env.WIRE_PI_HOOK_DEBUG) return; + try { + appendFileSync(process.env.WIRE_PI_HOOK_DEBUG, `${event.toolName} ${JSON.stringify(event.input)} -> ${why}\n`); + } catch { + /* debug only */ + } + }; + dbg("enter"); + const input = event.input as { command?: string } | undefined; + const command = input?.command; + if (!command) return dbg("no-command"); + if (event.toolName !== "bash" && event.toolName !== "powershell") return dbg("tool-name"); + if (process.env.WIRE_SESSION_ID) return dbg("operator-pin"); // operator-override channel wins + // A command that assigns the key has chosen its own identity (an operator + // override, or a deliberate share). Merely *reading* the variable is not a + // choice, so match assignment, not mention. + if (/(^|[;&|(\n]|\s)(export\s+)?WIRE_SESSION_ID=/.test(command)) return dbg("self-keyed"); + if (!/(^|[\s;&|(])wire(\s|$)/.test(command)) return dbg("not-a-wire-cmd"); // wire only + const key = ctx?.sessionManager?.getSessionId?.(); + if (!key) return dbg("no-session-id"); + // `export`, not a `VAR=x cmd` prefix: a prefix binds only the first command + // of a chain, so `cd x && wire up` would still run keyless. + input!.command = `export WIRE_SESSION_ID='${key.replace(/'/g, "'\\''")}'; ${command}`; + dbg(`prefixed with ${key}`); + }); + } + + // The watcher is session-lifetime infrastructure, not turn scaffolding + // (wire AGENTS.md R7 — the 2026-05-12 agent-attention-layer incident root + // caused exactly by tearing a listener down between iterations). It is torn + // down only when the session ends or the operator says /wire-watch off. + pi.on("session_shutdown", async () => { + stopWatcher(undefined); + }); + +} diff --git a/pi-plugin/package.json b/pi-plugin/package.json new file mode 100644 index 0000000..4b27f84 --- /dev/null +++ b/pi-plugin/package.json @@ -0,0 +1,34 @@ +{ + "name": "wire-pi", + "version": "0.17.0", + "description": "Native wire tools for the Pi coding agent — bilateral signed agent-to-agent messaging without MCP.", + "author": { + "name": "Paul Logan", + "email": "paul@slancha.ai" + }, + "homepage": "https://wireup.net", + "repository": { + "type": "git", + "url": "https://github.com/SlanchaAi/wire", + "directory": "pi-plugin" + }, + "license": "AGPL-3.0-or-later", + "keywords": [ + "pi-package", + "agent", + "p2p", + "ed25519", + "mailbox", + "identity", + "wire" + ], + "pi": { + "extensions": ["./extensions"], + "skills": ["./skills"] + }, + "peerDependencies": { + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*", + "typebox": "*" + } +} diff --git a/pi-plugin/skills/wire-pi/SKILL.md b/pi-plugin/skills/wire-pi/SKILL.md new file mode 100644 index 0000000..b10d524 --- /dev/null +++ b/pi-plugin/skills/wire-pi/SKILL.md @@ -0,0 +1,94 @@ +--- +name: wire-pi +description: Work as a wire agent from inside the Pi coding agent. Use when the user wants to talk to another agent, pair this Pi session with a peer, read a wire inbox, or asks who you are on wire. Covers the per-session identity model, the tool surface, and the consent rules that gate pairing. +--- + +# wire in Pi + +Your Pi session is its own addressable agent on the wire bus. Pairing and +messaging run through native `wire_*` tools that call the `wire` CLI. Pi has no +MCP client and this package does not add one. + +## Identity + +Identity is keyed to the Pi session id, not the directory. + +- Pi puts `PI_SESSION_ID` in the environment of commands its bash tool runs. + wire reads it (`session_source: "pi"`) and resolves + `sessions/by-key/`. +- The extension pins `WIRE_SESSION_ID` to the same id string, so the tool path + and the bash path reach one home. Two Pi sessions in the same repo get two + personas; resuming the same session keeps yours. +- Your handle is your DID-derived persona. You do not choose it. Check it with + `wire_whoami` before you quote it anywhere. + +Verify what wire actually resolved, do not trust the naming layer: + +```bash +wire whoami --json | jq -r '.handle, .session_source, .config_dir' +wire session current # cwd registry name — may DISAGREE with the above +``` + +`wire session current` answers from the cwd registry. Identity resolution does +not consult that registry. When they disagree, `wire whoami` is the truth: the +identity is the one signing your messages. + +## Tool surface + +| Tool | Verb | Notes | +|---|---|---| +| `wire_whoami` | who am I | persona, DID, fingerprint, home | +| `wire_here` | who is around | self + same-machine sisters + pinned peers | +| `wire_peers` | who is paired | with tiers | +| `wire_status` | is the loop healthy | daemon, sync age, `identity_split` | +| `wire_pending` | what waits for consent | inbound pair requests | +| `wire_tail` | read the inbox | verified events, newest first | +| `wire_pull` | fetch now | synchronous relay GET, skips the ~5s cycle | +| `wire_dial` | talk to this name | local sister or `@` | +| `wire_send` | talk | returns the relay's real verdict | +| `wire_accept` / `wire_reject` | consent | accept needs `confirm:true` and a UI prompt | +| `wire_whois` | inspect an identity | resolves + verifies the signed card | +| `wire_setup` | come online | mints identity, binds relay, starts daemon | + +Read verbs are safe to call whenever useful. The two that change trust or spend +a public resource are gated: `wire_accept` and `wire_setup` both require +`confirm:true`, and prompt in the UI when a dialog surface exists. + +## Consent + +- Pairing is bilateral. Your `wire_dial` is only half of it; the peer must + accept. Say so instead of implying the channel is open. +- Inbound requests land in `wire_pending`. Surface them. Never accept one the + operator did not name — accepting gives that peer authenticated write access + to your inbox. +- Never invent a peer handle. Take it from `wire_peers`, `wire_here`, or the + operator. A handle you fabricated goes nowhere. +- Report the `status` field from `wire_send`. Do not say a message landed + without seeing `delivered`. +- `wire_setup` contacts a relay, claims your persona, and starts a daemon. Ask + first. `relay: "http://127.0.0.1:8771"` keeps it same-machine; + `offline: true` mints a key and touches nothing. + +## Session start + +1. `wire_whoami` — if it reports no identity, ask the operator before you call + `wire_setup`. +2. `wire_pending` — report what is waiting. +3. `wire_status` — a non-null `identity_split` means this process is frozen to a + stale persona while the live session is another one. Do not pair or send; + tell the operator. + +Peer messages do not reach you on their own. `/wire-watch on` streams them into +the session; `/wire-watch off` stops it. The watcher is session-lifetime, so +leave it running across turns and tear it down only on request or at session +end. + +## When something is wrong + +```bash +wire doctor # every silent-fail class in one command +wire whoami --json # handle + config_dir + session_source +wire status --json # daemon + queue depth + identity_split +``` + +Report errors verbatim. Do not retry mysteriously. diff --git a/src/cli/comms.rs b/src/cli/comms.rs index 3da8aa7..4ad9209 100644 --- a/src/cli/comms.rs +++ b/src/cli/comms.rs @@ -292,20 +292,22 @@ pub(super) fn cmd_send( .and_then(|s| s.get("peers").and_then(Value::as_object).cloned()) .map(|peers| peers.contains_key(&peer)) .unwrap_or(false); - if !peer_is_pinned && let Some(sister_name) = crate::session::resolve_local_sister(&peer) { + if !peer_is_pinned + && let Some(sister_home) = crate::session::resolve_local_sister_unique(&peer)? + { if no_auto_pair { bail!( - "wire send: `{peer}` resolves to local sister `{sister_name}` but is not pinned, \ - and --no-auto-pair was passed. Run `wire dial {peer}` first, \ - then re-run send." + "wire send: `{peer}` resolves to local sister session home `{sister_home}` but is \n\ + not pinned, and --no-auto-pair was passed. Run `wire dial {peer}` first, then \n\ + re-run send." ); } eprintln!( - "wire send: `{peer}` not pinned yet — auto-pairing via local-sister `{sister_name}` first. \ - Pass --no-auto-pair to refuse implicit dialing." + "wire send: `{peer}` not pinned yet — auto-pairing via local-sister session home \n\ + `{sister_home}` first. Pass --no-auto-pair to refuse implicit dialing." ); - super::cmd_add_local_sister(&sister_name, true).map_err(|e| { - anyhow!("wire send: auto-pair to local sister `{sister_name}` failed: {e:#}") + super::cmd_add_local_sister(&sister_home, true).map_err(|e| { + anyhow!("wire send: auto-pair to local sister `{sister_home}` failed: {e:#}") })?; } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index a216f9b..502481b 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1574,6 +1574,27 @@ pub enum SessionCommand { #[arg(long)] json: bool, }, + /// Move a pre-RFC-006 `sessions/` home into the 1.0 + /// `sessions/by-key/` layout. RFC-006 Part A made by-key the one + /// layout, so nothing reads the top-level location any more: a home left + /// there is invisible to every reader: `wire session list` skips it, + /// `wire session env ` errors "no session named on this + /// machine", and `wire daemon --session ` cannot pin it. Creating + /// the name again mints a second identity for the same project. Dry-run by default — `--apply` moves the directory + /// (a rename; the rollback command is printed). Refuses when the by-key + /// home already exists or the legacy home's daemon is live. + Migrate { + /// Session name whose legacy home should move. Omit with `--all`. + name: Option, + /// Migrate every legacy home under the sessions root. + #[arg(long)] + all: bool, + /// Actually move the directory. Without this, print the plan only. + #[arg(long)] + apply: bool, + #[arg(long)] + json: bool, + }, /// Tear down a session: kills its daemon (if running), deletes its /// state directory, and removes it from the registry. Requires /// `--force` because state loss is unrecoverable (keypair gone). @@ -2434,6 +2455,12 @@ fn cmd_session(cmd: SessionCommand) -> Result<()> { SessionCommand::Env { name, json } => session::cmd_session_env(name.as_deref(), json), SessionCommand::Current { json } => session::cmd_session_current(json), SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json), + SessionCommand::Migrate { + name, + all, + apply, + json, + } => session::cmd_session_migrate(name.as_deref(), all, apply, json), SessionCommand::Destroy { name, force, json } => { session::cmd_session_destroy(&name, force, json) } diff --git a/src/cli/pairing.rs b/src/cli/pairing.rs index 75ca5be..2cd8a9f 100644 --- a/src/cli/pairing.rs +++ b/src/cli/pairing.rs @@ -334,10 +334,18 @@ pub(crate) fn resolve_name_to_target(name: &str) -> Result { } } - // 2. Local sister sessions. - if let Some(session_name) = crate::session::resolve_local_sister(needle) { + // 2. Local sister sessions. Ambiguity is fatal here on purpose: dialing the + // wrong one of two same-named identities pairs us with a stranger. The + // token is the `by-key` home name, so the lookup keys on home — keying on + // name would re-enter the collision (both candidates share the name). + if let Some(sister_home) = crate::session::resolve_local_sister_unique(needle)? { let sessions = crate::session::list_sessions().unwrap_or_default(); - let s = sessions.iter().find(|s| s.name == session_name); + let s = sessions.iter().find(|s| { + s.home_dir + .file_name() + .and_then(|f| f.to_str()) + .is_some_and(|h| h == sister_home) + }); if let Some(s) = s { return Ok(DialTarget::LocalSister { session_name: s.name.clone(), @@ -730,11 +738,62 @@ fn resolve_local_session<'a>( sessions: &'a [crate::session::SessionInfo], input: &str, ) -> Result<&'a crate::session::SessionInfo, ResolveError> { - // Exact session-name match always wins, even if a nickname elsewhere - // also matches. Predictable for scripts and operator muscle memory. - if let Some(s) = sessions.iter().find(|s| s.name == input) { + // Exact session-name match wins for predictability — but only when it is + // ONE identity. list_sessions overrides name to the persona handle, and a + // persona nickname comes from a 243x242 word pair, so two different DIDs can + // share one name. Picking the first exact match (the previous behavior) sent + // signed traffic to whichever home readdir happened to yield first. + let name_matches: Vec<&crate::session::SessionInfo> = + sessions.iter().filter(|s| s.name == input).collect(); + match name_matches.len() { + 1 => return Ok(name_matches[0]), + n if n > 1 => { + let distinct_dids: std::collections::HashSet<&str> = name_matches + .iter() + .filter_map(|s| s.did.as_deref()) + .collect(); + if distinct_dids.len() > 1 { + return Err(ResolveError::Ambiguous( + name_matches + .iter() + .map(|s| { + format!( + "did={} home={}", + s.did.as_deref().unwrap_or("(no card)"), + s.home_dir + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or("?") + ) + }) + .collect(), + )); + } + // Same identity at several homes: any match is the same peer. + return Ok(name_matches[0]); + } + _ => {} + } + + // The two forms an operator or agent retypes after an ambiguity refusal, and + // the forms resolve_local_sister itself accepts. Matched before nicknames + // because they are strictly more specific than a colliding word pair. + if let Some(s) = sessions.iter().find(|s| { + s.home_dir + .file_name() + .and_then(|f| f.to_str()) + .is_some_and(|h| h.eq_ignore_ascii_case(input)) + }) { + return Ok(s); + } + if let Some(s) = sessions.iter().find(|s| { + s.did + .as_deref() + .is_some_and(|d| d.eq_ignore_ascii_case(input)) + }) { return Ok(s); } + let nick_matches: Vec<&crate::session::SessionInfo> = sessions .iter() .filter(|s| { @@ -748,7 +807,19 @@ fn resolve_local_session<'a>( 0 => Err(ResolveError::NotFound), 1 => Ok(nick_matches[0]), _ => Err(ResolveError::Ambiguous( - nick_matches.iter().map(|s| s.name.clone()).collect(), + nick_matches + .iter() + .map(|s| { + format!( + "did={} home={}", + s.did.as_deref().unwrap_or("(no card)"), + s.home_dir + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or("?") + ) + }) + .collect(), )), } } @@ -885,10 +956,10 @@ pub(crate) fn add_local_sister_core(sister_name: &str) -> Result bail!( - "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \ - Disambiguate by passing the session name (one of those listed) instead of the nickname.", + "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \n\ + Repeat with the DID, or with the home name plus `--local-sister`.", candidates.len(), - candidates.join(", ") + candidates.join(", "), ), }; @@ -1126,15 +1197,15 @@ pub(super) fn cmd_add( // its character name" ergonomic gap that forced operators into // `wire session list-local | grep | awk` dances. if local_sister { - let resolved = crate::session::resolve_local_sister(handle_arg) + let resolved = crate::session::resolve_local_sister_unique(handle_arg)? .unwrap_or_else(|| handle_arg.to_string()); return cmd_add_local_sister(&resolved, as_json); } if !handle_arg.contains('@') - && let Some(resolved) = crate::session::resolve_local_sister(handle_arg) + && let Some(resolved) = crate::session::resolve_local_sister_unique(handle_arg)? { eprintln!( - "wire add: `{handle_arg}` resolved to local sister session `{resolved}` \ + "wire add: `{handle_arg}` resolved to local sister session home `{resolved}` \ — routing via --local-sister (disk-read card, no relay lookup)." ); return cmd_add_local_sister(&resolved, as_json); @@ -1933,3 +2004,107 @@ mod resolve_tier_tests { }); } } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn session(name: &str, home: &str, did: &str, nickname: &str) -> crate::session::SessionInfo { + // Same nickname on two sessions is exactly a persona collision: one + // nickname, two DIDs. Character is normally derived from the DID, so the + // nickname is set explicitly to reproduce the collision shape. + let mut character = crate::character::Character::from_did(did); + character.nickname = nickname.to_string(); + crate::session::SessionInfo { + name: name.to_string(), + cwd: None, + home_dir: PathBuf::from("/tmp/fake").join(home), + did: Some(did.to_string()), + handle: Some(name.to_string()), + daemon_running: false, + character: Some(character), + } + } + + #[test] + fn resolve_local_session_refuses_name_shared_by_two_dids() { + let a = session( + "amber-tarn", + "aaaa111122223333", + "did:wire:amber-tarn-11111111", + "amber-tarn", + ); + let b = session( + "amber-tarn", + "bbbb111122223333", + "did:wire:amber-tarn-22222222", + "amber-tarn", + ); + let sessions = vec![a, b]; + + // The name is shared, so the old first-match behavior would silently + // pick `aaaa...` and pair with, or send signed events to, that agent. + let err = resolve_local_session(&sessions, "amber-tarn") + .expect_err("a name owned by two DIDs must not resolve"); + match err { + ResolveError::Ambiguous(c) => { + assert_eq!(c.len(), 2, "both candidates must be listed; got {c:?}"); + assert!( + c.iter().all(|x| x.contains("did=") && x.contains("home=")), + "candidates must carry the two tokens that DO resolve; got {c:?}" + ); + } + other => panic!("expected Ambiguous, got {other:?}"), + } + + // The two remedies printed in that error must actually resolve. + let by_did = resolve_local_session(&sessions, "did:wire:amber-tarn-22222222").unwrap(); + assert!( + by_did + .home_dir + .to_string_lossy() + .ends_with("bbbb111122223333") + ); + let by_home = resolve_local_session(&sessions, "aaaa111122223333").unwrap(); + assert_eq!(by_home.did.as_deref(), Some("did:wire:amber-tarn-11111111")); + } + + #[test] + fn resolve_local_session_allows_one_identity_at_two_homes() { + // An identity present at two homes is one peer, not a choice between + // peers — refusing here would be a false stop. + let a = session( + "lone-larch", + "aaaa111122223333", + "did:wire:lone-larch-33333333", + "lone-larch", + ); + let b = session( + "lone-larch", + "cccc111122223333", + "did:wire:lone-larch-33333333", + "lone-larch", + ); + let binding = [a, b]; + let got = resolve_local_session(&binding, "lone-larch") + .expect("same DID at two homes must resolve"); + assert_eq!(got.did.as_deref(), Some("did:wire:lone-larch-33333333")); + } + + #[test] + fn resolve_local_session_still_resolves_plain_names_and_reports_missing() { + let a = session( + "quiet-pond", + "aaaa111122223333", + "did:wire:quiet-pond-44444444", + "quiet-pond", + ); + let sessions = vec![a]; + assert!(resolve_local_session(&sessions, "quiet-pond").is_ok()); + match resolve_local_session(&sessions, "no-such-session") { + Err(ResolveError::NotFound) => {} + other => panic!("expected NotFound, got {other:?}"), + } + } +} diff --git a/src/cli/session.rs b/src/cli/session.rs index 8c5e36a..cf706b9 100644 --- a/src/cli/session.rs +++ b/src/cli/session.rs @@ -1292,18 +1292,73 @@ pub(super) fn cmd_session_current(as_json: bool) -> Result<()> { .map(|(_, v)| v) }) .cloned(); + // The registry answers "which session NAME was registered for this cwd". + // It is NOT the identity in use: since v0.13 identity resolves from a + // session-id key (`by-key/`) or the machine default, and never from + // the cwd registry (see `maybe_adopt_session_home`, which deliberately + // refuses cwd resolution). Reporting only the registry name made this + // command assert an identity binding that resolution no longer honours — + // `wire session current` said one handle while `wire whoami` signed as + // another. Report both, and say plainly when they disagree. + let registry_name = name.clone(); + // Report `config_dir` exactly as `wire whoami --json` does, and the raw + // WIRE_HOME env separately — a set WIRE_HOME is the operator's deliberate + // pin, and collapsing the two would hide which case a session is in. + let config_dir = crate::config::config_dir() + .ok() + .map(|d| d.display().to_string()); + let wire_home = std::env::var("WIRE_HOME").ok(); + let operative_handle = crate::session::operational_handle(); + let source = crate::session::session_source(); + // `null`, not `true`, when there is nothing to compare: an uninitialized + // home has no operative handle, and reporting agreement there would assert a + // binding the command never verified. + let agrees = match (®istry_name, &operative_handle) { + (Some(reg), Some(op)) => Some(reg == op), + _ => None, + }; if as_json { println!( "{}", serde_json::to_string(&json!({ "cwd": cwd_key, - "session": name, + // `session` keeps its pre-existing meaning (registry name) so + // scripts reading it are unaffected. + "session": registry_name, + "operative_handle": operative_handle, + "session_source": source, + "config_dir": config_dir, + "wire_home": wire_home, + "agrees": agrees, + "note": if agrees == Some(false) { Some( + "the cwd registry names a different session than the identity this process \ + resolves; `operative_handle` is the one that signs. Identity has not come \ + from the cwd registry since v0.13." + ) } else if agrees.is_none() && registry_name.is_some() { Some( + "a session is registered for this cwd but this home is uninitialized, so \n\ + agreement with the registry could not be checked." + ) } else { None } }))? ); } else if let Some(n) = name { println!("{n}"); + if agrees == Some(false) { + // stderr, per the repo's convention that agent-visible streams stay + // clean; stdout keeps exactly the historical single-token answer. + eprintln!( + "wire session current: NOTE the registry says `{n}` but this process resolves \ + handle `{}` (session_source={source}). The registry has not driven identity \ + since v0.13 — that handle is the one that signs.", + operative_handle.as_deref().unwrap_or("(uninitialized)") + ); + } } else { println!("(no session registered for this cwd)"); + eprintln!( + "wire session current: identity in use is handle `{}` (session_source={source}); \ + the cwd registry does not select identity since v0.13.", + operative_handle.as_deref().unwrap_or("(uninitialized)") + ); } Ok(()) } @@ -1370,6 +1425,164 @@ pub(super) fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Ok(()) } +/// `wire session migrate [] [--all] [--apply]` — move a pre-RFC-006 +/// `sessions/` home into the 1.0 `sessions/by-key/` layout. +/// +/// RFC-006 Part A made `by-key` the one layout and nothing reads the top-level +/// location any more, so a home left there is unreachable by name even with its +/// keypair intact: `wire --session whoami` answers "not initialized" +/// while the persona inside keeps signing. Worse, `init`/`up` for that name then +/// mints a *second* identity, which is how one project ends up owning two agents. +/// +/// Dry-run by default — `--apply` is what moves bytes. The move is a rename +/// within one filesystem, so the rollback line printed afterwards is the way +/// back; nothing here deletes a key. +pub(crate) fn cmd_session_migrate( + name: Option<&str>, + all: bool, + apply: bool, + as_json: bool, +) -> Result<()> { + let candidates: Vec = match (name.map(str::trim), all) { + (Some(n), false) => vec![n.to_string()], + (None, true) => crate::session::legacy_named_homes(), + (None, false) => { + bail!("name a session to migrate, or pass --all to move every legacy home") + } + (Some(_), true) => bail!("pass a name or --all, not both"), + }; + + #[derive(serde::Serialize)] + struct Row { + name: String, + from: Option, + to: String, + action: String, + } + let mut rows: Vec = Vec::new(); + let mut moved: Vec<(std::path::PathBuf, std::path::PathBuf)> = Vec::new(); + + for raw in candidates { + let to = crate::session::session_dir(&crate::session::sanitize_name(&raw))?; + let Some(from) = crate::session::legacy_named_home_dir(&raw) else { + let action = if to.exists() { + "already in the by-key layout; nothing to move".to_string() + } else { + "no legacy home at sessions/; a name that changes under \ + sanitizing is not migratable here" + .to_string() + }; + rows.push(Row { + name: raw, + from: None, + to: to.display().to_string(), + action, + }); + continue; + }; + + // Two homes are two identities. Merging them would pick one keypair and + // silently orphan the other's pairings, so refuse and let the operator + // decide which one keeps the name. + if to.exists() { + rows.push(Row { + name: raw, + from: Some(from.display().to_string()), + to: to.display().to_string(), + action: "refused: the by-key home already exists; wire will not merge two homes" + .to_string(), + }); + continue; + } + // Renaming out from under a live daemon would leave it writing to a path + // that no longer resolves to its name. + if let Some(pid) = crate::session::session_daemon_pid(&from) + && crate::platform::process_alive(pid) + { + rows.push(Row { + name: raw, + from: Some(from.display().to_string()), + to: to.display().to_string(), + action: format!("refused: daemon pid {pid} is live on this home; stop it first"), + }); + continue; + } + + if !apply { + rows.push(Row { + name: raw, + from: Some(from.display().to_string()), + to: to.display().to_string(), + action: "would move (pass --apply)".to_string(), + }); + continue; + } + + if let Some(parent) = to.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating {}", parent.display()))?; + } + std::fs::rename(&from, &to) + .with_context(|| format!("moving {} to {}", from.display(), to.display()))?; + // Confirm both ends before claiming the move: a rename that half-happened + // (different filesystem, permissions) must not be reported as success. + anyhow::ensure!( + crate::session::is_session_home(&to) && !from.exists(), + "move did not land cleanly: {} exists: {}, source still present: {}", + to.display(), + to.exists(), + from.exists() + ); + moved.push((from.clone(), to.clone())); + rows.push(Row { + name: raw, + from: Some(from.display().to_string()), + to: to.display().to_string(), + action: "moved".to_string(), + }); + } + + if as_json { + println!( + "{}", + serde_json::to_string(&json!({ + "dry_run": !apply, + "migrated": moved.len(), + "rows": rows, + }))? + ); + return Ok(()); + } + + println!( + "{}", + if apply { + "migrated:" + } else { + "wire session migrate (dry run — pass --apply to move):" + } + ); + if rows.is_empty() { + println!(" no legacy session homes found"); + } + for row in &rows { + match &row.from { + Some(from) => println!(" {}: {} -> {}\n {}", row.name, from, row.to, row.action), + None => println!(" {}: {}", row.name, row.action), + } + } + if !moved.is_empty() { + println!(); + println!("Rollback (move the home back):"); + for (from, to) in &moved { + println!(" mv {} {}", to.display(), from.display()); + } + println!(); + println!("Check: eval \"$(wire session env )\" && wire whoami"); + } + Ok(()) +} + #[cfg(test)] mod coerce_object_root_tests { use super::coerce_object_root; diff --git a/src/cli/setup.rs b/src/cli/setup.rs index 60b3b17..9caf086 100644 --- a/src/cli/setup.rs +++ b/src/cli/setup.rs @@ -398,6 +398,31 @@ pub(crate) fn standard_mcp_entry() -> Value { }) } +/// Hosts that need a third-party bridge before they will read the entry +/// `wire setup` writes, with the note to show for them. Pi has no MCP client of +/// its own (its README: "No MCP."), so `~/.pi/agent/mcp.json` is inert unless +/// pi-mcp-adapter is installed. Saying so is the difference between `--apply` +/// looking like it connected the host and being an honest account of what was +/// written. +const BRIDGE_ONLY_HOSTS: &[(&str, &str)] = &[( + "Pi", + "Pi has no MCP client (its README: \"No MCP.\"). ~/.pi/agent/mcp.json is read\n \ + by the third-party pi-mcp-adapter, not by Pi. For Pi prefer the native\n \ + package: `pi install /pi-plugin` — no adapter, no MCP server.\n \ + The snippet above also pins WIRE_SESSION_ID to ${CLAUDE_CODE_SESSION_ID},\n \ + which is the wrong variable under Pi.", +)]; + +/// Notes for the bridge-only hosts present in `present`'s judgement. Shared by +/// the dry-run listing and the post-apply summary so both say the same thing. +fn bridge_notes(present: impl Fn(&str) -> bool) -> Vec<(&'static str, &'static str)> { + BRIDGE_ONLY_HOSTS + .iter() + .filter(|(host, _)| present(host)) + .copied() + .collect() +} + pub(crate) fn cmd_setup(apply: bool) -> Result<()> { use crate::adapters::harness::HARNESS_ADAPTERS; use std::path::PathBuf; @@ -436,6 +461,10 @@ pub(crate) fn cmd_setup(apply: bool) -> Result<()> { println!( "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing." ); + for (host, note) in bridge_notes(|h| targets.iter().any(|(name, _)| *name == h)) { + println!(); + println!("Note on {host}: {note}"); + } return Ok(()); } @@ -471,6 +500,11 @@ pub(crate) fn cmd_setup(apply: bool) -> Result<()> { println!(" {line}"); } } + for (host, note) in bridge_notes(|h| modified.iter().any(|l| l.starts_with(&format!("✓ {h} ")))) + { + println!(); + println!("Note on {host}: {note}"); + } Ok(()) } @@ -898,3 +932,32 @@ mod relay_url_tests { ); } } + +#[cfg(test)] +mod tests { + use super::bridge_notes; + + /// The Pi target writes `~/.pi/agent/mcp.json`, which Pi proper never reads. + /// If that note ever stops being attached to the Pi target, `wire setup + /// --apply` is silently misleading again, so both selection paths are locked. + #[test] + fn pi_target_carries_the_bridge_note() { + let dry_run = bridge_notes(|h| h == "Pi"); + assert_eq!(dry_run.len(), 1, "Pi must carry exactly one note"); + assert_eq!(dry_run[0].0, "Pi"); + assert!( + dry_run[0].1.contains("pi-mcp-adapter"), + "note must name the bridge Pi needs: {}", + dry_run[0].1 + ); + assert!( + dry_run[0].1.contains("pi-plugin"), + "note must point at the native package: {}", + dry_run[0].1 + ); + + // Hosts with a real MCP client must not be flagged. + assert!(bridge_notes(|h| h == "Cursor").is_empty()); + assert!(bridge_notes(|_| false).is_empty()); + } +} diff --git a/src/session.rs b/src/session.rs index 94e842e..679c3f1 100644 --- a/src/session.rs +++ b/src/session.rs @@ -172,6 +172,56 @@ pub fn find_session_home_by_name(name: &str) -> Result> { Ok(None) } +/// True when `path` is a wire session home rather than a stray directory: a +/// self-card is written by `init` and is what every reader needs. +pub fn is_session_home(path: &std::path::Path) -> bool { + path.join("config") + .join("wire") + .join("agent-card.json") + .is_file() +} + +/// The pre-RFC-006 layout: `sessions/` used to be where a named session +/// lived. Nothing reads that path any more — `list_sessions` scans `by-key` +/// only, and `session_dir` hashes a name into `by-key` — so a home left here is +/// unreachable: `wire --session whoami` answers "not initialized", while +/// the persona inside is intact. `init`/`up` on that same name then mints a +/// second identity, which is how one name ends up owning two agents. +/// +/// Returns Some only for a plain single-component name (nothing path-shaped, +/// nothing that changes under `sanitize_name`, since the by-key home is derived +/// from the sanitized form) whose legacy home exists on disk. +pub fn legacy_named_home_dir(name: &str) -> Option { + let trimmed = name.trim(); + if trimmed.is_empty() || trimmed != sanitize_name(trimmed) { + return None; + } + let candidate = sessions_root().ok()?.join(trimmed); + is_session_home(&candidate).then_some(candidate) +} + +/// Every top-level legacy home under the sessions root, by directory name. +/// `by-key` itself is the current layout, not a candidate. +pub fn legacy_named_homes() -> Vec { + let mut out = Vec::new(); + let Ok(root) = sessions_root() else { + return out; + }; + let Ok(entries) = std::fs::read_dir(&root) else { + return out; + }; + for entry in entries.flatten() { + let Some(name) = entry.file_name().to_str().map(|s| s.to_string()) else { + continue; + }; + if name == "by-key" || !is_session_home(&entry.path()) { + continue; + } + out.push(name); + } + out.sort(); + out +} /// Registry tracks `cwd → session_name` so repeated `wire session new` /// from the same project reuses the same identity instead of creating /// a fresh one each time. Lives at `/registry.json`. @@ -450,28 +500,156 @@ fn url_is_loopback(url: &str) -> bool { /// Designed for `wire add ` ergonomics — the operator should /// be able to type whatever face wire put on the peer (statusline /// nickname, session list emoji+name) and have wire find it. -pub fn resolve_local_sister(input: &str) -> Option { +/// One local session that answers to the name a caller typed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SisterCandidate { + /// Display name — the persona handle for an initialized home, the by-key + /// hash otherwise. Two colliding identities share this value, so it is + /// never the dedupe key. + pub session: String, + /// The `by-key/` dir name. Distinct per identity even when `session` + /// is shared. This IS an actionable address: `resolve_local_session` matches + /// it, so `wire add --local-sister` resolves the exact home. + pub home: String, + pub did: Option, +} + +/// What a bare name matched among this machine's session homes. +/// +/// The `Ambiguous` arm is the whole point of this type. A persona nickname is +/// `ADJECTIVES[243] × NOUNS[242]` = 58,806 names, seeded from the 8-hex +/// fingerprint suffix (`Character::from_seed`), and v0.11 made that nickname the +/// addressable handle — the handle is even baked into the DID. Collision odds +/// reach 50% at ~285 identities, so on a box with thousands of session homes +/// (one per agent session; see `ensure_session_bootstrapped`) two DIFFERENT DIDs +/// routinely share one name. The previous implementation returned the first hit +/// in `read_dir` order, which meant `wire dial ` could silently pair with, +/// and `wire send` could silently write to, whichever of the two the filesystem +/// happened to enumerate first. Misrouting an Ed25519-signed message is not a +/// cosmetic failure, so ambiguity is now a hard error that names the candidates. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SisterMatch { + /// One identity matched. The payload is the `by-key` home dir name, which + /// is the only local token that is both unique and accepted downstream + /// (`resolve_local_session` matches it, and `resolve_name_to_target` looks + /// the session up by it). + Unique(String), + Ambiguous(Vec), +} + +impl SisterMatch { + /// The single session name, or an error listing every candidate by DID. + pub fn unique(self, input: &str) -> Result { + match self { + SisterMatch::Unique(session) => Ok(session), + SisterMatch::Ambiguous(cands) => { + let count = cands.len(); + let listing = cands + .iter() + .map(|c| { + format!( + " wire dial {} # or: wire add {} --local-sister", + c.did.as_deref().unwrap_or("(no card)"), + c.home + ) + }) + .collect::>() + .join("\n"); + Err(anyhow!( + "`{input}` is not unique: {count} local sessions answer to that name. Persona \ + nicknames collide at this population (243x242 word pair, and the nickname is \ + the addressable handle), so wire refuses to guess which agent you meant — \ + guessing would pair with, or send signed events to, the wrong \ + identity.\n\nRepeat with the DID, or with the `by-key` home name plus \ + `--local-sister`:\n{listing}" + )) + } + } + } +} + +/// Resolve a bare name against this machine's session homes. Accepts a session +/// name, card handle, persona nickname, full DID, or `by-key` home dir name — +/// the last two are the forms an ambiguous nickname must be narrowed to. +/// Use [`resolve_local_sister_unique`] at call sites about to act on the result. +pub fn resolve_local_sister(input: &str) -> Option { let needle = input.trim(); if needle.is_empty() { return None; } let sessions = list_sessions().ok()?; + let mut matches: Vec = Vec::new(); for s in &sessions { - if s.name.eq_ignore_ascii_case(needle) { - return Some(s.name.clone()); - } - if let Some(h) = &s.handle - && h.eq_ignore_ascii_case(needle) - { - return Some(s.name.clone()); - } - if let Some(ch) = &s.character - && ch.nickname.eq_ignore_ascii_case(needle) - { - return Some(s.name.clone()); + let home = s + .home_dir + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or("") + .to_string(); + let hit = s.name.eq_ignore_ascii_case(needle) + || s.handle + .as_deref() + .is_some_and(|h| h.eq_ignore_ascii_case(needle)) + || s.character + .as_ref() + .is_some_and(|ch| ch.nickname.eq_ignore_ascii_case(needle)) + // DID and by-key home are what the ambiguity error tells an operator + // to retype, so they have to resolve here too. + || s.did + .as_deref() + .is_some_and(|d| d.eq_ignore_ascii_case(needle)) + || home.eq_ignore_ascii_case(needle); + if hit { + matches.push(SisterCandidate { + session: s.name.clone(), + home, + did: s.did.clone(), + }); } } - None + // Dedupe by HOME first: for an initialized home list_sessions overrides name + // to the persona handle, so the collision this guard exists to catch — two + // DIDs, one nickname — arrives as two entries with an identical `session`. + // A home-keyed dedupe keeps them distinct. + matches.sort_by(|a, b| a.home.cmp(&b.home)); + matches.dedup_by(|a, b| a.home == b.home); + // Then collapse by DID: one identity living at two homes is one agent, not a + // choice between agents. Refusing there would be a false stop, and the error + // text would claim we might pick the wrong identity when both candidates are + // the same identity. + let distinct_dids: Vec<&Option> = { + let mut v: Vec<&Option> = matches.iter().map(|c| &c.did).collect(); + v.sort(); + v.dedup(); + v + }; + if matches.len() > 1 && distinct_dids.len() == 1 && distinct_dids[0].is_some() { + matches.truncate(1); + } + match matches.len() { + 0 => None, + 1 => Some(SisterMatch::Unique( + matches.into_iter().next().unwrap().home, + )), + _ => Some(SisterMatch::Ambiguous(matches)), + } +} + +/// `Ok(None)` — no local session answers to this name. `Ok(Some(home))` — +/// exactly one does. `Err` — several distinct identities do, and picking one +/// would misroute signed traffic. The returned `by-key` home name is accepted by +/// `resolve_local_session`, so `wire add --local-sister` works. +pub fn resolve_local_sister_unique(input: &str) -> Result> { + resolve_local_sister(input) + .map(|m| m.unique(input)) + .transpose() +} + +/// `current_operational_handle`, exposed for CLI honesty checks (`wire session +/// current` must report the identity that is actually signing, not only the name +/// the cwd registry claims). +pub fn operational_handle() -> Option { + current_operational_handle() } pub fn list_sessions() -> Result> { @@ -835,17 +1013,34 @@ pub fn detect_session_wire_home(cwd: &std::path::Path) -> Option { /// 1. `WIRE_SESSION_ID` — explicit universal override (any harness). /// 2. `CLAUDE_CODE_SESSION_ID` — Claude Code adapter (stable per /// conversation; the same id the auto-memory system keys off). -/// 3. `CODEX_SESSION_ID` — Codex compatibility adapter for hosts that +/// 3. `PI_SESSION_ID` — Pi Coding Agent (`@earendil-works/pi-coding-agent`) +/// adapter. Pi injects this into the environment of commands its +/// LLM-callable `bash` / `powershell` tools run with a session context +/// (`core/tools/bash.js` `resolveSpawnContext`, gated on +/// `exposeSessionEnvironment`, default true), so plain `wire ` from +/// inside a Pi session resolves per-session with zero operator setup. +/// Stable per Pi conversation — it is the same id that names the session +/// JSONL. Two Pi sessions in the SAME cwd therefore get two personas, +/// which the legacy cwd registry would otherwise collapse into one. +/// Two caveats, both verified against the installed Pi: the injector +/// `delete`s the variable and re-sets it only when a session context +/// exists, so context-less (e.g. sub-agent) bash tools inherit nothing; and +/// because the variable is forwarded to child commands generally, a host +/// that ships no session id of its own — Codex CLI does not forward +/// `CODEX_SESSION_ID` — started inside a Pi shell inherits the parent Pi +/// home and shares its inbox. Pi strips the variable for nested Pi, so +/// Pi-in-Pi stays separate. +/// 4. `CODEX_SESSION_ID` — Codex compatibility adapter for hosts that /// forward this older name. -/// 4. `CODEX_THREAD_ID` — current OpenAI Codex runtime adapter. Stable +/// 5. `CODEX_THREAD_ID` — current OpenAI Codex runtime adapter. Stable /// per thread and inherited by tool subprocesses. -/// 5. `AGENT_SESSION_ID` — Goose adapter, accepted only when `AGENT=goose`. -/// 6. `COPILOT_AGENT_SESSION_ID` — GitHub Copilot CLI (`gh copilot` / +/// 6. `AGENT_SESSION_ID` — Goose adapter, accepted only when `AGENT=goose`. +/// 7. `COPILOT_AGENT_SESSION_ID` — GitHub Copilot CLI (`gh copilot` / /// `copilot`) adapter. Set by the Copilot CLI host for every /// session; stable per conversation; UUID-shaped. -/// 7. `VSCODE_GIT_REPOSITORY_ROOT` — VS Code/GitHub Copilot workspace-based +/// 8. `VSCODE_GIT_REPOSITORY_ROOT` — VS Code/GitHub Copilot workspace-based /// identity (stable per workspace). -/// 8. `None` — caller falls back to legacy cwd-detect (bare CLI / +/// 9. `None` — caller falls back to legacy cwd-detect (bare CLI / /// pre-v0.13 hosts). Future host adapters slot in before this. /// /// Returns `(key, source-label)`. @@ -853,6 +1048,7 @@ pub fn resolve_session_key() -> Option<(String, &'static str)> { for (var, source) in [ ("WIRE_SESSION_ID", "override"), ("CLAUDE_CODE_SESSION_ID", "claude-code"), + ("PI_SESSION_ID", "pi"), ("CODEX_SESSION_ID", "codex-cli"), ("CODEX_THREAD_ID", "codex-cli"), ] { @@ -1566,7 +1762,7 @@ static SESSION_SOURCE: std::sync::OnceLock<&'static str> = std::sync::OnceLock:: /// The signal that won session/home resolution for this process. One of: /// `env:WIRE_HOME`, `env:WIRE_HOME_FORCE` (RFC-008 §C legacy-shape force), /// `override` (`WIRE_SESSION_ID`), `claude-code`, `claude-code-pidfile`, -/// `codex-cli`, `goose`, `copilot-cli`, `vscode-workspace`, `minted`, +/// `pi`, `codex-cli`, `goose`, `copilot-cli`, `vscode-workspace`, `minted`, /// `machine-default`, or `unknown` if adoption never ran. pub fn session_source() -> &'static str { SESSION_SOURCE.get().copied().unwrap_or("unknown") @@ -1887,6 +2083,7 @@ mod tests { let prev_agent_session = std::env::var_os("AGENT_SESSION_ID"); let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID"); let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT"); + let prev_pi = std::env::var_os("PI_SESSION_ID"); // SAFETY: ENV_LOCK is held, serializing all env access. unsafe { std::env::remove_var("WIRE_SESSION_ID"); @@ -1897,6 +2094,7 @@ mod tests { std::env::remove_var("AGENT_SESSION_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); + std::env::remove_var("PI_SESSION_ID"); } // (a) Two distinct workspace paths -> two distinct, stable session homes. @@ -1937,6 +2135,7 @@ mod tests { // Same guard for the other adapter slots. unsafe { std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); + std::env::remove_var("PI_SESSION_ID"); std::env::set_var("WIRE_SESSION_ID", "${workspaceFolder}"); } let r_guard2 = resolve_session_key(); @@ -1956,6 +2155,7 @@ mod tests { std::env::remove_var("AGENT_SESSION_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); + std::env::remove_var("PI_SESSION_ID"); if let Some(v) = prev_override { std::env::set_var("WIRE_SESSION_ID", v); } @@ -1980,6 +2180,9 @@ mod tests { if let Some(v) = prev_vscode { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v); } + if let Some(v) = prev_pi { + std::env::set_var("PI_SESSION_ID", v); + } } } @@ -2015,6 +2218,7 @@ mod tests { let prev_agent_session = std::env::var_os("AGENT_SESSION_ID"); let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID"); let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT"); + let prev_pi = std::env::var_os("PI_SESSION_ID"); // SAFETY: ENV_LOCK is held, serializing all env access. unsafe { std::env::remove_var("WIRE_SESSION_ID"); @@ -2025,6 +2229,7 @@ mod tests { std::env::remove_var("AGENT_SESSION_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); + std::env::remove_var("PI_SESSION_ID"); } // (a) COPILOT_AGENT_SESSION_ID set -> wins resolution; distinct ids @@ -2088,6 +2293,7 @@ mod tests { std::env::remove_var("AGENT_SESSION_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); + std::env::remove_var("PI_SESSION_ID"); if let Some(v) = prev_override { std::env::set_var("WIRE_SESSION_ID", v); } @@ -2112,6 +2318,9 @@ mod tests { if let Some(v) = prev_vscode { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v); } + if let Some(v) = prev_pi { + std::env::set_var("PI_SESSION_ID", v); + } } } @@ -2143,6 +2352,7 @@ mod tests { let prev_agent_session = std::env::var_os("AGENT_SESSION_ID"); let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID"); let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT"); + let prev_pi = std::env::var_os("PI_SESSION_ID"); // SAFETY: ENV_LOCK is held, serializing all env access. unsafe { std::env::remove_var("WIRE_SESSION_ID"); @@ -2153,6 +2363,7 @@ mod tests { std::env::remove_var("AGENT_SESSION_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); + std::env::remove_var("PI_SESSION_ID"); } // (a) CODEX_THREAD_ID is the runtime value current Codex processes @@ -2242,6 +2453,7 @@ mod tests { std::env::remove_var("AGENT_SESSION_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); + std::env::remove_var("PI_SESSION_ID"); if let Some(v) = prev_override { std::env::set_var("WIRE_SESSION_ID", v); } @@ -2266,6 +2478,9 @@ mod tests { if let Some(v) = prev_vscode { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v); } + if let Some(v) = prev_pi { + std::env::set_var("PI_SESSION_ID", v); + } } } @@ -2322,6 +2537,313 @@ mod tests { } } + #[test] + fn legacy_named_home_dir_refuses_names_that_are_not_plain_keys() { + // `legacy_named_home_dir` joins the typed name straight onto the sessions + // root. The by-key home is derived from the *sanitized* form, so a name + // that changes under sanitizing would move a directory to a home its own + // name does not hash to — and a path-shaped name would escape the root + // entirely. Both are refused before any filesystem access. + for bad in ["", " ", "..", "../by-key", "a/b", "./x", "Legacy Name!"] { + assert!( + legacy_named_home_dir(bad).is_none(), + "legacy lookup must refuse {bad:?}" + ); + } + } + + #[test] + fn resolve_session_key_pi_adapter_priority_and_home_parity() { + // Per-adapter test for the Pi Coding Agent path. Pi injects + // PI_SESSION_ID into the env of every command its LLM-callable bash / + // powershell tools run (`core/tools/bash.js` `resolveSpawnContext`, + // gated on `exposeSessionEnvironment`, default true), so `wire ` + // run from a Pi session resolves a per-session identity with no + // operator setup. Holds four invariants: + // + // (a) Set to a real Pi session id -> that key wins resolution and two + // distinct Pi sessions map to two distinct session homes. This is + // the point of the adapter: two Pi sessions in the SAME cwd would + // otherwise collapse onto one persona via the cwd registry. + // (b) WIRE_SESSION_ID and CLAUDE_CODE_SESSION_ID outrank it; it + // outranks CODEX_SESSION_ID / COPILOT_AGENT_SESSION_ID / + // VSCODE_GIT_REPOSITORY_ROOT (pi sits at priority 3). + // (c) HOME PARITY: the same id string arriving as PI_SESSION_ID or as + // WIRE_SESSION_ID resolves to the SAME session home. The pi + // package (pi/extensions/wire.ts) pins WIRE_SESSION_ID itself for + // hosts that do not forward PI_SESSION_ID (SDK-embedded hosts); + // parity is what makes those two paths one identity rather than + // two personas for one conversation. + // (d) Unexpanded ${...} literal is rejected by the guard, like every + // other adapter. + let _guard = crate::config::test_support::ENV_LOCK + .lock() + .unwrap_or_else(|p| p.into_inner()); + + let prev_override = std::env::var_os("WIRE_SESSION_ID"); + let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID"); + let prev_pi = std::env::var_os("PI_SESSION_ID"); + let prev_codex = std::env::var_os("CODEX_SESSION_ID"); + let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID"); + let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT"); + // SAFETY: ENV_LOCK is held, serializing all env access. + unsafe { + std::env::remove_var("WIRE_SESSION_ID"); + std::env::remove_var("CLAUDE_CODE_SESSION_ID"); + std::env::remove_var("PI_SESSION_ID"); + std::env::remove_var("CODEX_SESSION_ID"); + std::env::remove_var("COPILOT_AGENT_SESSION_ID"); + std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); + } + + // (a) PI_SESSION_ID wins resolution and is labeled `pi`; distinct Pi + // sessions -> distinct homes; same id -> same home (resume). + let session_a = "0198c1f4-2b1e-7a3d-9c40-4f0d7c1b6a55"; + let session_b = "0198c1f4-2b1e-7a3d-9c40-4f0d7c1b6a56"; + unsafe { std::env::set_var("PI_SESSION_ID", session_a) }; + let r1 = resolve_session_key(); + assert!( + matches!(&r1, Some((k, src)) if k == session_a && *src == "pi"), + "PI_SESSION_ID must win resolution and be labeled pi; got {r1:?}" + ); + let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap(); + + unsafe { std::env::set_var("PI_SESSION_ID", session_b) }; + let home_b = session_home_for_key(&resolve_session_key().unwrap().0).unwrap(); + assert_ne!( + home_a, home_b, + "two distinct Pi session ids must map to distinct session homes" + ); + + unsafe { std::env::set_var("PI_SESSION_ID", session_a) }; + let home_a2 = session_home_for_key(&resolve_session_key().unwrap().0).unwrap(); + assert_eq!( + home_a, home_a2, + "same Pi session id must yield the same home across calls" + ); + + // (b) Priority: override > claude-code > pi > codex > copilot > vscode. + unsafe { std::env::set_var("WIRE_SESSION_ID", "operator-override") }; + let r_override = resolve_session_key(); + assert!( + matches!(&r_override, Some((k, src)) if k == "operator-override" && *src == "override"), + "WIRE_SESSION_ID must beat PI_SESSION_ID; got {r_override:?}" + ); + unsafe { std::env::remove_var("WIRE_SESSION_ID") }; + + unsafe { std::env::set_var("CLAUDE_CODE_SESSION_ID", "claude-wins-over-pi") }; + let r_claude = resolve_session_key(); + assert!( + matches!(&r_claude, Some((k, src)) if k == "claude-wins-over-pi" && *src == "claude-code"), + "CLAUDE_CODE_SESSION_ID must beat PI_SESSION_ID; got {r_claude:?}" + ); + unsafe { std::env::remove_var("CLAUDE_CODE_SESSION_ID") }; + + // pi beats every later adapter, so a stray Codex/Copilot/VS Code id in + // the environment cannot steal a Pi session's identity. + unsafe { + std::env::set_var("CODEX_SESSION_ID", "codex-loses-to-pi"); + std::env::set_var("COPILOT_AGENT_SESSION_ID", "copilot-loses-to-pi"); + std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/repo/loses-to-pi"); + }; + let r_pi_wins = resolve_session_key(); + assert!( + matches!(&r_pi_wins, Some((k, src)) if k == session_a && *src == "pi"), + "PI_SESSION_ID must beat CODEX/COPILOT/VSCODE adapters; got {r_pi_wins:?}" + ); + unsafe { + std::env::remove_var("CODEX_SESSION_ID"); + std::env::remove_var("COPILOT_AGENT_SESSION_ID"); + std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); + } + + // (c) Home parity — the invariant pi/extensions/wire.ts depends on. + unsafe { std::env::remove_var("PI_SESSION_ID") }; + unsafe { std::env::set_var("WIRE_SESSION_ID", session_a) }; + let via_override = resolve_session_key().unwrap(); + assert_eq!( + via_override.1, "override", + "WIRE_SESSION_ID keeps its own source label" + ); + assert_eq!( + session_home_for_key(&via_override.0).unwrap(), + home_a, + "one Pi session id must reach one session home whether it arrives as \ + PI_SESSION_ID or as WIRE_SESSION_ID — otherwise the pi package and a \ + plain bash-tool `wire` call would run as two different personas" + ); + + // (d) Unexpanded ${...} guard. + unsafe { + std::env::remove_var("WIRE_SESSION_ID"); + std::env::set_var("PI_SESSION_ID", "${PI_SESSION_ID}"); + }; + let r_guard = resolve_session_key(); + assert!( + !matches!(&r_guard, Some((k, _)) if k.contains("${")), + "unexpanded ${{...}} in PI_SESSION_ID must be rejected by the ${{}} guard; got {r_guard:?}" + ); + + // Restore any env we displaced. + // SAFETY: ENV_LOCK still held. + unsafe { + std::env::remove_var("WIRE_SESSION_ID"); + std::env::remove_var("PI_SESSION_ID"); + if let Some(v) = prev_override { + std::env::set_var("WIRE_SESSION_ID", v); + } + if let Some(v) = prev_claude { + std::env::set_var("CLAUDE_CODE_SESSION_ID", v); + } + if let Some(v) = prev_pi { + std::env::set_var("PI_SESSION_ID", v); + } + if let Some(v) = prev_codex { + std::env::set_var("CODEX_SESSION_ID", v); + } + if let Some(v) = prev_copilot { + std::env::set_var("COPILOT_AGENT_SESSION_ID", v); + } + if let Some(v) = prev_vscode { + std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v); + } + } + } + + #[test] + fn resolve_local_sister_unique_refuses_ambiguous_persona() { + // A persona nickname is 243x242 word pairs seeded from the 8-hex + // fingerprint suffix, and v0.11 made that nickname the addressable + // handle. On a box with thousands of session homes the birthday bound + // is long past: the machine this was found on held 8,861 initialized + // homes and 566 handle groups shared by two different DIDs. Before this + // guard, `resolve_local_sister` returned the FIRST match in read_dir + // order, so `wire dial ` / `wire send ` could pair with or + // write signed events to whichever of the two the filesystem happened + // to enumerate. Locks the refusal, the actionable candidates, and the + // two paths that must keep working. + let _guard = crate::config::test_support::ENV_LOCK + .lock() + .unwrap_or_else(|p| p.into_inner()); + let tmp = std::env::temp_dir().join(format!("wire-sisters-{}", rand::random::())); + let _ = std::fs::remove_dir_all(&tmp); + let root = tmp.join("sessions"); + + let mk_session = |key: &str, handle: &str, fingerprint: &str| -> PathBuf { + let home = root.join("by-key").join(key); + let cfg = home.join("config").join("wire"); + std::fs::create_dir_all(&cfg).unwrap(); + std::fs::write( + cfg.join("agent-card.json"), + format!( + r#"{{"did":"did:wire:{handle}-{fingerprint}","handle":"{handle}","verify_keys":{{}}}}"# + ), + ) + .unwrap(); + home + }; + + // Two DISTINCT identities answering to ONE persona handle. + let h_a = mk_session("aaaa5c8806dde7b1", "amber-tarn", "11111111"); + let h_b = mk_session("bbbb53029dbcde65", "amber-tarn", "22222222"); + // One identity with a name nobody else answers to. + let _h_c = mk_session("cccc5fc3cbae0adf", "lone-larch", "33333333"); + + // sessions_root() resolves from inside a session home. + // SAFETY: ENV_LOCK is held. + unsafe { std::env::set_var("WIRE_HOME", &h_a) }; + + // (1) The collision must be refused, not resolved. + let amb = resolve_local_sister("amber-tarn"); + assert!( + matches!(&amb, Some(SisterMatch::Ambiguous(c)) if c.len() == 2), + "two DIDs sharing a persona handle must surface as Ambiguous; got {amb:?}" + ); + if let Some(SisterMatch::Ambiguous(c)) = &amb { + let homes: Vec<&str> = c.iter().map(|x| x.home.as_str()).collect(); + assert!( + homes.contains(&"aaaa5c8806dde7b1") && homes.contains(&"bbbb53029dbcde65"), + "candidates must name both homes so the caller can disambiguate; got {homes:?}" + ); + } + + // (2) Acting on it errors, and the error names both DIDs and both + // by-key homes (the disambiguator that find_session_home_by_name + // actually accepts). + let err = resolve_local_sister_unique("amber-tarn") + .expect_err("ambiguous persona must not resolve"); + let msg = err.to_string(); + for needle in [ + "not unique", + "did:wire:amber-tarn-11111111", + "did:wire:amber-tarn-22222222", + "aaaa5c8806dde7b1", + "bbbb53029dbcde65", + ] { + assert!( + msg.contains(needle), + "ambiguity error must contain {needle:?}; got: {msg}" + ); + } + + // (3) A name owned by one identity still resolves normally, and the + // token is the by-key home (the form resolve_local_session accepts). + let lone = resolve_local_sister_unique("lone-larch").unwrap(); + assert_eq!( + lone.as_deref(), + Some("cccc5fc3cbae0adf"), + "a unique persona must resolve to its by-key home" + ); + + // (4) No match is Ok(None) — the caller falls through to federation. + let missing = resolve_local_sister_unique("no-such-peer").unwrap(); + assert_eq!(missing, None, "unknown name must stay Ok(None)"); + + // (5) The remedy the ambiguity error prints must actually resolve, or + // the guard strands the operator with a name they cannot use. + assert_eq!( + resolve_local_sister_unique("did:wire:amber-tarn-11111111") + .unwrap() + .as_deref(), + Some("aaaa5c8806dde7b1"), + "the full DID printed by the error must resolve to exactly one home" + ); + assert_eq!( + resolve_local_sister_unique("bbbb53029dbcde65") + .unwrap() + .as_deref(), + Some("bbbb53029dbcde65"), + "the by-key home printed by the error must resolve to itself" + ); + + // (6) One identity at two homes is one agent, not a choice between + // agents. Refusing there is a false stop, and the refusal text would + // claim we might pick the wrong identity when both candidates are + // the same identity. + let twin = root.join("by-key").join("dddd5c8806dde7b1"); + std::fs::create_dir_all(twin.join("config").join("wire")).unwrap(); + std::fs::write( + twin.join("config").join("wire").join("agent-card.json"), + r#"{"did":"did:wire:amber-tarn-11111111","handle":"amber-tarn","verify_keys":{}}"#, + ) + .unwrap(); + let same_did_two_homes = resolve_local_sister("did:wire:amber-tarn-11111111"); + assert!( + matches!(&same_did_two_homes, Some(SisterMatch::Unique(_))), + "the same DID at two homes must not read as an ambiguity; got {same_did_two_homes:?}" + ); + let _ = std::fs::remove_dir_all(&twin); + + // (7) Dedupe must key on home, not display name: both colliding entries + // carry the same `session` (list_sessions overrides name to the + // handle), so a name-keyed dedupe would have collapsed them and let + // the ambiguity through. + assert_ne!(h_a, h_b, "fixture sanity: the two homes differ"); + + unsafe { std::env::remove_var("WIRE_HOME") }; + let _ = std::fs::remove_dir_all(&tmp); + } + #[test] fn list_sessions_sees_by_key_homes_and_root_resolves_from_inside() { // Regression (v0.13.2): v0.13 moved session homes under @@ -3024,6 +3546,7 @@ mod tests { "override", "claude-code", "claude-code-pidfile", + "pi", "codex-cli", "goose", "copilot-cli", diff --git a/tests/cli.rs b/tests/cli.rs index 1daf21c..8a9c209 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1949,3 +1949,233 @@ fn tail_multi_peer_sorts_by_timestamp() { "expected 3 newest across peers (interleaved by timestamp)" ); } + +#[test] +fn session_current_reports_operative_identity_alongside_the_registry_name() { + // `wire session current` used to print only the cwd registry's name, which + // since v0.13 is not the identity that signs: resolution comes from a + // session-id key or the machine default and never from the registry. The + // two disagreeing silently is how an operator ends up sending as a persona + // they believe they are not. The registry answer is still reported (scripts + // parse it) and the operative identity is reported beside it. + let home = fresh_home(); + let up = run(&home, &["up", "--offline"]); + assert!( + up.status.success(), + "offline up must succeed: {}", + String::from_utf8_lossy(&up.stderr) + ); + + let out = Command::new(wire_bin()) + .args(["session", "current", "--json"]) + .current_dir(&home) + .env("WIRE_HOME", &home) + .env("WIRE_HOME_FORCE", "1") + .output() + .expect("spawn wire session current --json"); + assert!(out.status.success()); + let v: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("session current --json must emit JSON"); + for key in [ + "cwd", + "session", + "operative_handle", + "session_source", + "config_dir", + "wire_home", + "agrees", + "note", + ] { + assert!( + v.get(key).is_some(), + "`session current --json` must expose `{key}`; got {v}" + ); + } + // Fresh home: nothing registered for this cwd, so there is no claim to + // compare against. `agrees` is null (not true) because agreement was never + // verified. The operative identity is still named. + assert!( + v["session"].is_null(), + "no registry entry expected; got {v}" + ); + assert!( + v["agrees"].is_null(), + "agrees must be null when there is nothing to compare; got {v}" + ); + assert!( + v["operative_handle"].is_string(), + "operative_handle must name the signing persona; got {v}" + ); + // This harness forces the legacy home shape, so the source is the + // WIRE_HOME_FORCE label; a plain WIRE_HOME pin reports `env:WIRE_HOME`. Both + // are the deliberate-fleet-share pins, and neither is a cwd-derived source. + assert!( + ["env:WIRE_HOME", "env:WIRE_HOME_FORCE"] + .contains(&v["session_source"].as_str().unwrap_or("")), + "session_source must name the explicit home pin; got {v}" + ); + + // The historical stdout contract is untouched: first line is the registry + // name, or the same sentinel. The honesty went to stderr so nothing parsing + // stdout breaks. + let text = Command::new(wire_bin()) + .args(["session", "current"]) + .current_dir(&home) + .env("WIRE_HOME", &home) + .env("WIRE_HOME_FORCE", "1") + .output() + .expect("spawn wire session current"); + assert_eq!( + String::from_utf8_lossy(&text.stdout), + "(no session registered for this cwd)\n", + "stdout must keep its historical single-line answer" + ); + let err = String::from_utf8_lossy(&text.stderr); + assert!( + err.contains("does not select identity") && err.contains("session_source="), + "stderr must state that the registry does not drive identity: {err}" + ); + + let _ = std::fs::remove_dir_all(&home); +} + +/// RFC-006 Part A made `sessions/by-key/` the only layout readers look +/// at, so a home still sitting at `sessions/` is unreachable: `session +/// list` skips it and creating the name again mints a second identity for the +/// same project. `wire session migrate` moves it back into reach. Dry-run by +/// default, and the identity must survive the move. +#[test] +fn session_migrate_moves_a_legacy_named_home_into_the_by_key_layout() { + let home = fresh_home(); + let legacy = home.join("sessions").join("legacy-api"); + std::fs::create_dir_all(&legacy).unwrap(); + let init = Command::new(wire_bin()) + .args(["init", "--offline"]) + .env("WIRE_HOME", &legacy) + .env("WIRE_HOME_FORCE", "1") + .output() + .expect("init legacy home"); + assert!( + init.status.success(), + "legacy home init failed: {}", + String::from_utf8_lossy(&init.stderr) + ); + let before: serde_json::Value = serde_json::from_slice( + &Command::new(wire_bin()) + .args(["whoami", "--json"]) + .env("WIRE_HOME", &legacy) + .env("WIRE_HOME_FORCE", "1") + .output() + .unwrap() + .stdout, + ) + .unwrap(); + let handle = before["handle"].as_str().unwrap().to_string(); + + // Invisible to the reader before the move. + let list = run(&home, &["session", "list", "--json"]); + assert!(list.status.success()); + let listed = String::from_utf8_lossy(&list.stdout).into_owned(); + assert!( + !listed.contains(&handle), + "a legacy top-level home must not be listed before migration; got {listed}" + ); + + // Dry run: names the move, touches nothing. + let dry = run(&home, &["session", "migrate", "legacy-api", "--json"]); + assert!(dry.status.success()); + let plan: serde_json::Value = serde_json::from_slice(&dry.stdout).unwrap(); + assert_eq!(plan["dry_run"], true); + assert_eq!(plan["migrated"], 0); + assert!(legacy.is_dir(), "dry run must not move the home"); + let target = PathBuf::from(plan["rows"][0]["to"].as_str().unwrap()); + assert!(target.starts_with(home.join("sessions").join("by-key"))); + assert!(!target.exists(), "dry run must not create the target"); + + // Apply: the move lands and the identity is the same one. + let apply = run( + &home, + &["session", "migrate", "legacy-api", "--apply", "--json"], + ); + assert!(apply.status.success()); + let done: serde_json::Value = serde_json::from_slice(&apply.stdout).unwrap(); + assert_eq!(done["migrated"], 1, "apply must move one home: {done}"); + assert!(!legacy.exists(), "source must be gone after the move"); + let after: serde_json::Value = serde_json::from_slice( + &Command::new(wire_bin()) + .args(["whoami", "--json"]) + .env("WIRE_HOME", &target) + .env("WIRE_HOME_FORCE", "1") + .output() + .unwrap() + .stdout, + ) + .unwrap(); + assert_eq!( + after["handle"].as_str().unwrap(), + handle, + "the move must carry the same keypair, not mint a new one" + ); + + // Reachable afterwards, and a second run changes nothing. + let list = run(&home, &["session", "list", "--json"]); + assert!( + String::from_utf8_lossy(&list.stdout).contains(&handle), + "the migrated home must appear in `session list`" + ); + let again = run(&home, &["session", "migrate", "legacy-api"]); + assert!(again.status.success()); + assert!( + String::from_utf8_lossy(&again.stdout).contains("already in the by-key layout"), + "re-running must be a no-op: {}", + String::from_utf8_lossy(&again.stdout) + ); + let _ = std::fs::remove_dir_all(&home); +} + +/// Two homes for one name are two identities. Merging them would quietly keep +/// one keypair and orphan the other's pairings, so the move is refused and both +/// directories are left exactly where they were. +#[test] +fn session_migrate_refuses_to_merge_two_homes() { + let home = fresh_home(); + let legacy = home.join("sessions").join("clash-api"); + std::fs::create_dir_all(&legacy).unwrap(); + let init = Command::new(wire_bin()) + .args(["init", "--offline"]) + .env("WIRE_HOME", &legacy) + .env("WIRE_HOME_FORCE", "1") + .output() + .expect("init legacy home"); + assert!(init.status.success()); + + let plan: serde_json::Value = + serde_json::from_slice(&run(&home, &["session", "migrate", "clash-api", "--json"]).stdout) + .unwrap(); + let target = PathBuf::from(plan["rows"][0]["to"].as_str().unwrap()); + std::fs::create_dir_all(&target).unwrap(); + let rival = Command::new(wire_bin()) + .args(["init", "--offline"]) + .env("WIRE_HOME", &target) + .env("WIRE_HOME_FORCE", "1") + .output() + .expect("init rival by-key home"); + assert!(rival.status.success()); + + let apply = run( + &home, + &["session", "migrate", "clash-api", "--apply", "--json"], + ); + assert!(apply.status.success()); + let done: serde_json::Value = serde_json::from_slice(&apply.stdout).unwrap(); + assert_eq!(done["migrated"], 0, "collision must not move: {done}"); + assert!( + done["rows"][0]["action"] + .as_str() + .unwrap() + .contains("will not merge two homes"), + "refusal must explain itself: {done}" + ); + assert!(legacy.is_dir(), "refusal must leave the source in place"); + let _ = std::fs::remove_dir_all(&home); +}