Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ the PR description linked in each section.

## [Unreleased]

### Fixed

- **The "two names" identity split — self-healed at its source + made self-reporting** (#351): a Claude session's statusline showed one wire identity while the session *operated* on wire as another (confirmed live: a session whose `CLAUDE_CODE_SESSION_ID` derived `daydream-gorge` was sending/pairing as `merry-spindle`, a key matching no live session; box-wide, 115 of 434 running `wire` procs served an identity no live session used). Root cause: the long-lived `wire mcp` server resolves + **freezes** its identity at launch, but the statusline re-resolves live each render — and when the MCP boots before Claude writes its session PID-file, `resolve_session_key()` misses and the server **mints** a throwaway `mcp-proc-*` key, pins it into `WIRE_SESSION_ID`, and serves it for life. Fixes: (1) before minting, **retry the PID-file** (walk the ancestor chain once, cached — no per-hop `ps` re-fork; the #353 Windows trap avoided) so the live session is recovered instead of frozen; (2) the mint **no longer pins `WIRE_SESSION_ID`** (that slot is the operator-override/live channel — a minted value there beat the live session on re-resolve); (3) `detect_identity_split` rewritten to compare the **served** identity vs the **live** PID-file identity, so the frozen MCP itself detects the drift the old env-vs-pidfile check structurally couldn't — surfaced as an `identity_split` field on `wire_status`/`wire_whoami` so an agent self-detects at the session-start health check and recommends `/mcp reconnect`; the RFC-008 deliberate fleet-share (`WIRE_HOME` pin) is exempt. Also fixed a latent bug the redesign exposed: `handle_for_key` hashed the *sanitized* (32-char-truncated) key while homes are created from the *raw* key hash, so a 36-char session-id uuid resolved to the wrong home. (4) `wire setup` now bakes `env:{WIRE_SESSION_ID:"${CLAUDE_CODE_SESSION_ID}"}` into the MCP entry it writes, so the forward survives `--apply` (inert on non-Claude hosts). Existing frozen MCPs heal on the next `/mcp reconnect`; the self-report makes that one-command recovery visible.

### Added

- **Nostr transport is now bidirectional through the daemon** (#227 D3, RFC-007): `run_sync_pull` now also pulls Nostr-delivered events — it pulls events `#p`-tagged to our npub (`kind:1`) from the relays we're reachable on, transport-verifies them (NIP-01 id + schnorr), and feeds them through the **same `process_events` path** as HTTP-pulled events (inner Ed25519 signature + trust pin + inbox dedup all reused). The pull set is `self.nostr_relays[]` (relays we paired/fetched over — where peers publish *our* inbound, so it's correct even when pairing is asymmetric) unioned with the peer-transport relays; `wire nostr pair/accept/fetch --relay X` records X. Together with the send-path fallback (#322), a `transport: nostr` peer now fully round-trips through the daemon. Strictly additive — a no-op when this session isn't `wire enroll nostr`'d or has no nostr relay recorded (the HTTP-slot pull is byte-identical). The last RFC-007 D3 piece; live public-relay e2e (AC-3) remains a manual dogfood via `wire nostr`.
Expand Down
10 changes: 5 additions & 5 deletions src/cli/dash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,13 @@ fn split_banner(color: bool) -> String {
let Some(s) = crate::session::detect_identity_split() else {
return String::new();
};
let headline = match (s.env_handle.as_deref(), s.live_handle.as_deref()) {
(Some(env), Some(live)) => {
format!("this shell operates as {env} but your live Claude session is {live}")
let headline = match (s.operational_handle.as_deref(), s.live_handle.as_deref()) {
(Some(op), Some(live)) => {
format!("this process operates as {op} but your live Claude session is {live}")
}
_ => format!(
"the wire identity this shell resolves (via {}) doesn't match your live Claude session",
s.env_source
"the wire identity this process serves (via {}) doesn't match your live Claude session",
s.source
),
};
let body = format!(
Expand Down
64 changes: 54 additions & 10 deletions src/cli/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,20 +377,32 @@ pub(crate) fn assert_relay_url_clean_for_publish(url: &str) -> Result<()> {

// ---------- setup — one-shot MCP host registration ----------

/// The canonical MCP-server entry `wire setup` upserts into every host config.
///
/// The `env` forward is load-bearing: the earlier "Claude Code propagates
/// CLAUDE_CODE_SESSION_ID by default so this is redundant" assumption was FALSE
/// in the failure case — the MCP server can boot before Claude writes its
/// session PID-file, so the pidfile fallback misses and the server MINTS a
/// throwaway identity, giving the "two names" split (operational identity ≠
/// statusline). An explicit `${CLAUDE_CODE_SESSION_ID}` in the MCP env closes
/// that race for every session, not just those launched from a project dir that
/// happened to carry the mapping. Inert on non-Claude hosts: an unexpanded
/// `${...}` is rejected by `session::valid_session_key` and falls through
/// safely. Living in one function (not a hand-edit) is what makes it survive the
/// next `wire setup --apply`, which upserts this exact shape.
pub(crate) fn standard_mcp_entry() -> Value {
json!({
"command": "wire",
"args": ["mcp"],
"env": { "WIRE_SESSION_ID": "${CLAUDE_CODE_SESSION_ID}" }
})
}

pub(crate) fn cmd_setup(apply: bool) -> Result<()> {
use crate::adapters::harness::HARNESS_ADAPTERS;
use std::path::PathBuf;

// v0.14.x: no `env` mapping. Per-session identity for Claude Code is
// resolved by `crate::session::resolve_session_key`, which reads
// `WIRE_SESSION_ID` then falls back to `CLAUDE_CODE_SESSION_ID`. Current
// Claude Code (verified 2026-05) propagates `CLAUDE_CODE_SESSION_ID`
// into every MCP subprocess by default, so the historical mapping was
// redundant and triggered a misleading MCP Config Diagnostics warning.
let entry = json!({
"command": "wire",
"args": ["mcp"]
});
let entry = standard_mcp_entry();
let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;

// v0.14.2 (#92 category 1): per-host detection + upsert logic lives
Expand Down Expand Up @@ -684,6 +696,38 @@ fn resolve_git_bash() -> Option<String> {
mod statusline_tests {
use super::*;

#[test]
fn setup_entry_forwards_session_id_env() {
// Regression: the MCP entry `wire setup` writes MUST carry the session-id
// env-forward. Without it the MCP server races the session PID-file and
// mints a throwaway identity — the "two names" split. This is the canonical
// shape upserted into every host config, so the guard belongs on the entry.
let e = standard_mcp_entry();
assert_eq!(e["command"], "wire");
assert_eq!(e["args"], json!(["mcp"]));
assert_eq!(
e["env"]["WIRE_SESSION_ID"], "${CLAUDE_CODE_SESSION_ID}",
"setup entry must forward CLAUDE_CODE_SESSION_ID into the MCP env"
);
}

#[test]
fn setup_entry_upserts_idempotently_with_env() {
// The env-carrying entry round-trips through upsert_standard: first apply
// writes it, second is a no-op (byte-match) — so re-running `wire setup
// --apply` on every machine is the propagation path, not a clobber.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("claude.json");
let entry = standard_mcp_entry();
assert!(crate::adapters::harness::upsert_standard(&path, "wire", &entry).unwrap());
assert!(!crate::adapters::harness::upsert_standard(&path, "wire", &entry).unwrap());
let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
v["mcpServers"]["wire"]["env"]["WIRE_SESSION_ID"],
"${CLAUDE_CODE_SESSION_ID}"
);
}

#[test]
fn statusline_merge_preserves_keys_and_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
Expand Down
30 changes: 27 additions & 3 deletions src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,7 @@ fn handle_initialize(id: &Value) -> Value {
"name": SERVER_NAME,
"version": SERVER_VERSION,
},
"instructions": "wire — agent-to-agent comms. Canonical verbs (v0.9+): wire_dial (initiate pair), wire_send (talk; auto-pairs on miss), wire_pending (see inbound requests), wire_accept (consent to a pending pair), wire_reject (refuse), wire_whois (inspect identity), wire_status (daemon + sync health). For cross-machine pairing pass `<handle>@<relay>` to wire_dial. Bilateral: the peer must also accept (wire_accept or wire_dial back) before capability flows in both directions. INBOUND pair requests from strangers land in pending-inbound: call wire_pending to enumerate, surface to operator, then wire_accept or wire_reject. NEVER auto-accept inbound pair requests without operator consent — accepting grants the peer authenticated write access to this agent's inbox. Resources: 'wire://inbox/<peer>' exposes each pinned peer's verified inbox (JSONL). ON SESSION START, DO THIS IMMEDIATELY (every wire session): (1) call wire_status to confirm the sync loop is healthy — `daemon_running:true` + `last_sync_age_seconds < 60`. The daemon is auto-started for you when this MCP server launches, so this is normally already true; if daemon_running is false, run `wire up` (or `wire service install` for a reboot-durable daemon). (2) arm a PERSISTENT stream-watcher on `wire monitor --json` — in Claude Code, the Monitor tool with persistent:true — so peer messages surface live mid-session. The monitor does NOT sync the relay; it only tails the inbox the daemon writes. Both are required. WHEN A PEER MESSAGE ARRIVES, reply to it in your own live context WITHOUT waiting for the operator to prompt you; this is how paired agents collaborate immediately. (Default filter strips pair_drop/pair_drop_ack/heartbeat noise.) v0.14.2: wire_send POSTs synchronously by default — response `status` is the actual relay verdict: `delivered` (event landed on peer's slot), `duplicate` (same event_id already on slot; peer can still pull), `peer_unknown` (peer not pinned — run wire_dial first), `slot_stale` (peer's slot rotated — run wire_dial to re-pair), or `transport_error` (TLS/DNS/relay-5xx; check `reason` field). Pass `queue:true` to opt back into the legacy outbox→daemon-push path for offline-buffer / pre-pair queueing. wire_pull is the symmetric receive primitive — call it to trigger an immediate relay GET instead of waiting for the daemon's 5s pull cycle; returns written[]/rejected[]/total_seen the same way `wire pull --json` does. See docs/AGENT_INTEGRATION.md for the full monitor recipe and THREAT_MODEL.md (T10/T14)."
"instructions": "wire — agent-to-agent comms. Canonical verbs (v0.9+): wire_dial (initiate pair), wire_send (talk; auto-pairs on miss), wire_pending (see inbound requests), wire_accept (consent to a pending pair), wire_reject (refuse), wire_whois (inspect identity), wire_status (daemon + sync health). For cross-machine pairing pass `<handle>@<relay>` to wire_dial. Bilateral: the peer must also accept (wire_accept or wire_dial back) before capability flows in both directions. INBOUND pair requests from strangers land in pending-inbound: call wire_pending to enumerate, surface to operator, then wire_accept or wire_reject. NEVER auto-accept inbound pair requests without operator consent — accepting grants the peer authenticated write access to this agent's inbox. Resources: 'wire://inbox/<peer>' exposes each pinned peer's verified inbox (JSONL). ON SESSION START, DO THIS IMMEDIATELY (every wire session): (1) call wire_status to confirm the sync loop is healthy — `daemon_running:true` + `last_sync_age_seconds < 60` + `identity_split:null`. The daemon is auto-started for you when this MCP server launches, so this is normally already true; if daemon_running is false, run `wire up` (or `wire service install` for a reboot-durable daemon). If `identity_split` is NON-null, this MCP process is frozen to a stale wire identity (`identity_split.operational`) while your live session is a different one (`identity_split.live`) — the \"two names\" bug: you will send/pair as the wrong identity. Surface it to the operator and recommend `/mcp reconnect` (or restarting this session); do not pair or send until resolved. (2) arm a PERSISTENT stream-watcher on `wire monitor --json` — in Claude Code, the Monitor tool with persistent:true — so peer messages surface live mid-session. The monitor does NOT sync the relay; it only tails the inbox the daemon writes. Both are required. WHEN A PEER MESSAGE ARRIVES, reply to it in your own live context WITHOUT waiting for the operator to prompt you; this is how paired agents collaborate immediately. (Default filter strips pair_drop/pair_drop_ack/heartbeat noise.) v0.14.2: wire_send POSTs synchronously by default — response `status` is the actual relay verdict: `delivered` (event landed on peer's slot), `duplicate` (same event_id already on slot; peer can still pull), `peer_unknown` (peer not pinned — run wire_dial first), `slot_stale` (peer's slot rotated — run wire_dial to re-pair), or `transport_error` (TLS/DNS/relay-5xx; check `reason` field). Pass `queue:true` to opt back into the legacy outbox→daemon-push path for offline-buffer / pre-pair queueing. wire_pull is the symmetric receive primitive — call it to trigger an immediate relay GET instead of waiting for the daemon's 5s pull cycle; returns written[]/rejected[]/total_seen the same way `wire pull --json` does. See docs/AGENT_INTEGRATION.md for the full monitor recipe and THREAT_MODEL.md (T10/T14)."
}
})
}
Expand All @@ -546,7 +546,7 @@ fn tool_defs() -> Vec<Value> {
vec![
json!({
"name": "wire_whoami",
"description": "Return this agent's DID, fingerprint, key_id, public key, and capabilities. Read-only.",
"description": "Return this agent's DID, fingerprint, key_id, public key, and capabilities. Also `identity_split`: null when healthy, or {operational, live, hint} when THIS MCP process is frozen to a stale identity while the live Claude session is a different one (the \"two names\" bug) — surface it and /mcp reconnect. Read-only.",
"inputSchema": {"type": "object", "properties": {}, "required": []}
}),
json!({
Expand All @@ -561,7 +561,7 @@ fn tool_defs() -> Vec<Value> {
}),
json!({
"name": "wire_status",
"description": "v0.14.2 — daemon + sync-loop health check. Returns: daemon_running (pidfile pid alive), all_running_pids (pgrep for `wire daemon`), last_sync_age_seconds (age of the most recent successful daemon cycle; null if no cycle ever recorded), outbox_count, inbox_count, peer count. The daemon is auto-started for you on MCP launch; a healthy session shows daemon_running:true + last_sync_age_seconds < 60. Default `wire_send` is synchronous (its own status is the delivery verdict); only `queue:true` sends depend on the daemon to drain — a nonzero outbox_count with a stale last_sync means those are stuck. Read-only.",
"description": "v0.14.2 — daemon + sync-loop health check. Returns: daemon_running (pidfile pid alive), all_running_pids (pgrep for `wire daemon`), last_sync_age_seconds (age of the most recent successful daemon cycle; null if no cycle ever recorded), outbox_count, inbox_count, peer count. The daemon is auto-started for you on MCP launch; a healthy session shows daemon_running:true + last_sync_age_seconds < 60 + identity_split:null. `identity_split` is non-null ({operational, live, hint}) when this MCP process is frozen to a stale wire identity while the live Claude session is a different one (the \"two names\" bug — you'd send/pair as the wrong identity); surface it and /mcp reconnect. Default `wire_send` is synchronous (its own status is the delivery verdict); only `queue:true` sends depend on the daemon to drain — a nonzero outbox_count with a stale last_sync means those are stuck. Read-only.",
"inputSchema": {"type": "object", "properties": {}, "required": []}
}),
json!({
Expand Down Expand Up @@ -958,6 +958,10 @@ fn tool_whoami() -> Result<Value, String> {
"session_source".into(),
json!(crate::session::session_source()),
);
// Self-report the "two names" split: non-null when THIS long-lived MCP is
// frozen to a different identity than the live Claude session. Reaches the
// agent at the moment it inspects its own identity — no `wire dash` needed.
payload.insert("identity_split".into(), identity_split_json());
for (k, v) in crate::cli::op_claims_from_card(&card) {
payload.insert(k, v);
}
Expand Down Expand Up @@ -1255,9 +1259,29 @@ fn tool_status() -> Result<Value, String> {
"pending_push_count": pending_push_count,
"pending_push_breakdown": pending_push_breakdown,
"stream_state": stream_state,
// Non-null when this MCP process operates as a different identity than
// the live Claude session (the "two names" split). See identity_split_json.
"identity_split": identity_split_json(),
}))
}

/// Additive `identity_split` field for `wire_status`/`wire_whoami`: non-null
/// when THIS long-lived MCP process is frozen to a stale identity while the live
/// Claude session resolves to a different one (the "two names" bug). Null when
/// healthy (or unresolvable). Read INSIDE the frozen process, so it sees the
/// stale served identity a fresh `wire dash` CLI cannot. Lets an agent
/// self-detect the split at its session-start health check.
fn identity_split_json() -> Value {
match crate::session::detect_identity_split() {
Some(s) => json!({
"operational": s.operational_handle,
"live": s.live_handle,
"hint": "this MCP process is frozen to a stale wire identity; the live Claude session is different. Fix: reconnect it (/mcp) or restart this session.",
}),
None => Value::Null,
}
}

fn tool_send(args: &Value) -> Result<Value, String> {
use crate::config;
use crate::signing::{b64decode, sign_message_v31};
Expand Down
Loading