fix(probe,cli): Claude reads and replies without prompts; --uninstall leaves ~/.claude and OpenCode as found
What the e2e of agent-relay@15.0.0-next.5 (core 15.0.1) found for Claude Code after relay init
- 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 itscurl --unix-socket … /send … --data-binary 'pong'reply. --uninstallleft the isolated~/.claude/settings.json(which init created) as{}, and its.relay-desktop-backup.--uninstallleft~/.config/opencode/plugins/agent-relay.js.off.
Root causes
- 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.
- The core's hook removal always wrote the file back (
{}included) and never restored its backup. Uninstall turns direct delivery off first (the core writeshold), and init removes the key afterwards, so only init knows the file was its own. .offis 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 addspermissions.allowrules to each login'ssettings.jsonit 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/subscribeand the rest.relay socket-requestrefuses a second--socket,--methodor--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-backupis 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-commandrelay 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'ssettings.json(whether the file, its backup and its folder existed) and the OpenCode plugin's place (whetheragent-relay.js, its.offmarker andplugins/existed). The record lives incore-settings.jsonasclaudeFiles/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
.offmarker (and an emptyplugins/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.
- Claude: a file init created goes once it is
lib/probe-core.ts:PROBE_VERSION15.0.1 → 15.0.2, sorelay initinstalls 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:
| Command | Result |
|---|---|
<probe> relay read --socket '<sock with space>' 0123456789ab | ran, 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 body | ran |
socket double-quoted "…Application Support…" | asked (denied) |
socket backslash-escaped Application\ Support | asked |
another socket /tmp/arc6m/other.sock | asked |
<allowed read> && touch /tmp/arc6m/pwned | asked; 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 /agents | ran, 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: newsession_hook/permissions.rsunit tests andsession_hook/permission_tests.rs:- fresh install then off leaves
~/.claudebyte-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
permissionsshape is refused unchanged; - the rules equal the pointer reader's and reply hint's prefixes.
- fresh install then off leaves
- 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 --lockedandcargo 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 ininit.test.tsa fresh install then--uninstallleaves~/.claudeand OpenCode's folder as found; pre-existing settings and markers are kept. Rootnpm 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 claudewrote 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 thesocket-requestone).relay message dm send claude-arc6-project-arc6-project-2 "ping from alice: please reply with the single word pong" --as alicearrived 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 fromclaude-arc6-project-arc6-project-2,"text": "pong".relay init --uninstall:~/.claude/settings.jsonand its.relay-desktop-backupare gone. Only Claude Code's own runtime files remain (history, projects, sessions…), theclaude-permission-rules.jsonrecord is gone, and so iscore-settings.json.- Every process, tmux server and directory created for the run was cleaned up.
🤖 Generated with Claude Code
Agent Relay sessions
claudesession8ab900cc· contributor · rangh prcommands · last active 2026-10-30