fix(probe,cli): Claude reads and replies without prompts; --uninstall leaves ~/.claude and OpenCode as found

Merged
KJ
9d ago
+1772-92

What the e2e of agent-relay@15.0.0-next.5 (core 15.0.1) found for Claude Code after relay init

  1. In Claude's default ("manual") permission mode, Claude asked to approve the pointer's agent-relay-probe relay read --socket '<…Application Support…/relay.sock>' <ref>, then again for its curl --unix-socket … /send … --data-binary 'pong' reply.
  2. --uninstall left the isolated ~/.claude/settings.json (which init created) as {}, and its .relay-desktop-backup.
  3. --uninstall left ~/.config/opencode/plugins/agent-relay.js.off.

Root causes

  1. Nothing allowed those commands in Claude, and Claude's reply hints were curl (only Codex/OpenCode/Grok on macOS got the probe client), so even a probe rule could not have covered the reply.
  2. The core's hook removal always wrote the file back ({} included) and never restored its backup. Uninstall turns direct delivery off first (the core writes hold), and init removes the key afterwards, so only init knows the file was its own.
  3. .off is deliberate: the core's record that the plugin was turned off, so the desktop's on-by-default does not reinstall it. Nobody removed it when the install was init's own.

Changes

Core (crates/probe)

  • claude/session_hook/permissions.rs: with the SessionStart hook, the core adds permissions.allow rules to each login's settings.json it puts the hook in. They cover exactly the commands a relayed message hands the session (<req> = <probe> relay socket-request --socket <socket>):

    • Bash(<probe> relay read --socket <socket> *)
    • Bash(<req> --method POST --path '/send?*) (DM and channel-thread replies)
    • Bash(<req> --method POST --path '/connect/send?*)
    • Bash(<req> --method POST --path '/attachments/fetch?file_id=*)
    • Bash(<req> --method GET --path /agents)

    The probe, socket and path are quoted exactly as the hints quote them (shell_quote). Every other session route still prompts: /register, PATCH /settings, /integrations/subscribe and the rest. relay socket-request refuses a second --socket, --method or --path, so text after a * cannot change them. The rules Agent Relay added are recorded in <app data>/claude-permission-rules.json, so turning the hook off removes only those. A rule the person already had, even an identical one, is theirs and stays. A moved probe or socket replaces only Agent Relay's rules. No rule is written for a path containing * or a parenthesis.

  • session_hook.rs: once the hook and rules are out, the person's .relay-desktop-backup is restored byte for byte if nothing else changed. A file the hook created is removed once it is {}, but never through a symlink. The backup is no longer taken of a file that already holds the hook (Codex precedent from #2019).

  • outbound.rs, outbound/channels.rs, connect.rs: Claude sessions (claude-terminal, claude-cloud) get the probe's one-command relay socket-request … --body '…' on every platform for reply, [agents: …], attachment fetch, channel and Connect commands. They fall back to curl only when the probe's path is unknown. Other kinds are unchanged.

  • local/plugins.rs: the hook gets this probe and this core's relay-send socket (the same path pointers and hints name).

CLI (packages/cli)

  • lib/init-leftovers.ts (new): before the core edits anything, init records each Claude login's settings.json (whether the file, its backup and its folder existed) and the OpenCode plugin's place (whether agent-relay.js, its .off marker and plugins/ existed). The record lives in core-settings.json as claudeFiles / openCode.
  • commands/init.ts: tidies those files once nothing init turned on edits them any more. This runs after --uninstall, after a rollback, and after an unsaved record.
    • Claude: a file init created goes once it is {}, together with the core's copy of it and an empty folder init created. A file that existed comes back byte for byte when its content is unchanged. A file the person changed since, a linked file, and a backup that predates init are all left alone.
    • OpenCode: the core-written .off marker (and an empty plugins/ init created) goes only when there was neither a plugin nor a marker before init. Desktop behaviour is unchanged: a desktop core turning the plugin off still leaves the marker.
  • lib/probe-core.ts: PROBE_VERSION 15.0.1 → 15.0.2, so relay init installs the core with these fixes. probe-v15.0.2 is released from main right after this merges, and main is not published until then. Tests use the constant, so no test literal changes. This is release-only, so it is not in the changelog.

Docs: docs/probe-move.md ("Claude's permission prompts", "--uninstall and the tools' own folders", the table and known gaps) and crates/probe/docs/relay-core.md. The changelog entries are under [Unreleased - Major].

Safety text is unchanged. The "This came from another Claude session…" preamble is Claude Code's own, and the [via Agent Relay …] header is untouched.

Claude Code's rule matching, verified against ~/.local/bin/claude 2.1.314

The docs say Claude matches "everything before the first * as written", after splitting compound commands. To check that empirically, I set up an isolated HOME with the rules (a fake probe and a socket path containing Application Support). The first table was run with a single broad socket-request rule; the second with the final pinned rules. Then claude -p --permission-mode default was told to run each command verbatim, and I read permission_denials plus a log written by the fake probe:

CommandResult
<probe> relay read --socket '<sock with space>' 0123456789abran, no prompt
<probe> relay socket-request --socket '<sock>' --method POST --path '/send?address=alice@direct&reply_to=abc' --body 'pong'ran, no prompt
body 'hi; there && $(touch …) it'\''s fine' (single-quoted)ran; nothing touched
multi-line single-quoted bodyran
socket double-quoted "…Application Support…"asked (denied)
socket backslash-escaped Application\ Supportasked
another socket /tmp/arc6m/other.sockasked
<allowed read> && touch /tmp/arc6m/pwnedasked; not run
body "$(touch …)" (double-quoted substitution)asked; not run

With the final pinned rules:

Command (<req> --method … --path …)Result
POST '/send?address=alice@direct&reply_to=abc' --body 'pong'ran, no prompt
POST '/send?channel=c&thread_id=1' --body 'ok'ran, no prompt
POST '/connect/send?to=host' --body 'hi'ran, no prompt
POST '/attachments/fetch?file_id=f1'ran, no prompt
GET /agentsran, no prompt
PATCH /settings --body '{"x":1}'asked
POST /register --body '{"name":"x"}'asked
POST /integrations/subscribe --body '{}'asked

So the quoted path matches reliably as long as the command uses the hints' own single-quoted spelling. A model that respells it gets a prompt, which is the safe fallback. I did not add a symlink or space-free socket alias: it is not needed, and it would be one more path for the rules to name and keep alive.

Tests

  • Rust, crates/probe: new session_hook/permissions.rs unit tests and session_hook/permission_tests.rs:
    • fresh install then off leaves ~/.claude byte-identical, with no record left behind;
    • the person's own settings, formatting, deny: [] and an identical rule come back byte for byte;
    • a file changed since keeps the person's change and their copy;
    • a moved probe or socket replaces our rules;
    • a bad permissions shape is refused unchanged;
    • the rules equal the pointer reader's and reply hint's prefixes.
  • Also new: outbound::claude_is_handed_the_probe_reply_its_permission_rules_allow. The existing curl-form tests now use a curl kind. Existing hook tests are updated for created-file removal and byte-for-byte restore.
  • cargo test --locked and cargo test --locked --no-default-features --features production-binaries: see CI (both run locally).
  • CLI: init-leftovers.test.ts (new), probe-core.test.ts (record parsing), and in init.test.ts a fresh install then --uninstall leaves ~/.claude and OpenCode's folder as found; pre-existing settings and markers are kept. Root npm run typecheck, and eslint and prettier on the changed files, are clean.

Review fixes in b132065f6: method- and path-pinned rules; a login's backup is removed only once every login is written, so a failed multi-login removal keeps it for the retry (a_failed_removal_keeps_each_logins_copy_for_the_retry); a tool rolled back in the same init run is tidied too; the CodeQL test-helper race is fixed.

E2E (Claude only), locally built probe + CLI (run on the first commit, re-run with the pinned rules)

Isolated HOME/AGENT_RELAY_HOME/AGENT_RELAY_INSTALL_DIR/XDG under /tmp/arc6, AGENT_RELAY_CORE_SOCKET=$HOME/.agentworkforce/desktop/core.sock, AGENT_RELAY_PROBE_PATH set to the local build, project ~/arc6-project, Claude Code 2.1.314 in tmux on a private server, --permission-mode manual.

  • relay init --yes --tools claude wrote the hook and these rules:
    Bash(/tmp/arc6/relay/bin/agent-relay-probe relay read --socket '/tmp/arc6/home/Library/Application Support/com.agentrelay.desktop/run/relay.sock' *) (and the socket-request one).
  • relay message dm send claude-arc6-project-arc6-project-2 "ping from alice: please reply with the single word pong" --as alice arrived as a pointer, and Claude ran both commands with no approval prompt ("Ran 2 shell commands"):
    • /tmp/arc6/relay/bin/agent-relay-probe relay read --socket '/tmp/arc6/home/Library/Application Support/com.agentrelay.desktop/run/relay.sock' bebc2b532424
    • /tmp/arc6/relay/bin/agent-relay-probe relay socket-request --socket '…/relay.sock' --method POST --path '/send?address=alice@direct&reply_to=241807179388686336' --body 'pong', which returned {"ok":true,…}
  • relay message inbox check --as alice: an unread DM from claude-arc6-project-arc6-project-2, "text": "pong".
  • relay init --uninstall: ~/.claude/settings.json and its .relay-desktop-backup are gone. Only Claude Code's own runtime files remain (history, projects, sessions…), the claude-permission-rules.json record is gone, and so is core-settings.json.
  • Every process, tmux server and directory created for the run was cleaned up.

🤖 Generated with Claude Code


Agent Relay sessions

  • claude session 8ab900cc · contributor · ran gh pr commands · last active 2026-10-30