From a85c838fd51bc2acf41cd6dd82406baf88f4084e Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Wed, 28 Oct 2026 17:27:05 -0700 Subject: [PATCH 01/11] feat(cli): relay init injects messages through the Agent Relay core relay init now installs the core (agent-relay-probe) into the install directory's bin from a probe-v release (SHA-256 sidecar, temp file, --version check, atomic rename), attaches to a core already running on the machine (the desktop app's or an earlier init's) or starts one headless with --cli-managed, and turns on the core's delivery per tool through settings.set: Claude Code's session inbox and SessionStart hook, Codex hooks, OpenCode plugin, Grok leader socket and Devin hooks, plus automatic activation. Injected tools get no MCP server, and an MCP entry an earlier init added is removed, so each session has one identity; tools the core cannot serve fall back to the MCP server (--delivery mcp asks for it). --stop stops only the core init started; --uninstall turns off exactly the settings init turned on. Probe: `relay serve --cli-managed` keeps the desktop app's login entry and turns nothing on by itself (no agent-led or integration defaults); the Claude SessionStart hook tells a session that automatic activation will put on the relay that it joins by itself, with the socket's whoami, agents and send commands, instead of asking the person to register it. Adds .github/workflows/probe-release.yml (dispatchable, dry run by default) building linux-x64/arm64, darwin-arm64/x64 and windows-x64 binaries with .sha256 sidecars for probe-v releases. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/probe-release.yml | 220 +++++++ CHANGELOG.md | 2 + crates/probe/docs/relay-core.md | 10 +- crates/probe/src/probe/relay_commands.rs | 7 + crates/probe/src/relay/claude/session_hook.rs | 80 +++ crates/probe/src/relay/cli.rs | 1 + crates/probe/src/relay/daemon.rs | 104 ++- crates/probe/src/relay/transport.rs | 2 + docs/probe-move.md | 187 +++++- packages/cli/src/cli/commands/init.test.ts | 334 +++++++++- packages/cli/src/cli/commands/init.ts | 602 ++++++++++++++++-- packages/cli/src/cli/lib/probe-core.test.ts | 235 +++++++ packages/cli/src/cli/lib/probe-core.ts | 449 +++++++++++++ .../src/cli/lib/relaycast-engine-install.ts | 53 +- packages/harness-driver/src/install-paths.ts | 19 + 15 files changed, 2232 insertions(+), 73 deletions(-) create mode 100644 .github/workflows/probe-release.yml create mode 100644 packages/cli/src/cli/lib/probe-core.test.ts create mode 100644 packages/cli/src/cli/lib/probe-core.ts diff --git a/.github/workflows/probe-release.yml b/.github/workflows/probe-release.yml new file mode 100644 index 0000000000..be245a2cf4 --- /dev/null +++ b/.github/workflows/probe-release.yml @@ -0,0 +1,220 @@ +name: Probe Release + +# Builds the Agent Relay core (`agent-relay-probe`, crates/probe) for every +# platform `relay init` installs it on, and publishes each binary with a +# `.sha256` sidecar to a GitHub release tagged `probe-v`, apart from +# the CLI's `v*` tags: +# +# probe-v/agent-relay-probe---[.exe] +# probe-v/agent-relay-probe---[.exe].sha256 (" ") +# +# - is linux-x64, linux-arm64 (glibc), darwin-arm64, darwin-x64 or +# windows-x64, the names packages/cli/src/cli/lib/probe-core.ts downloads. +# +# Dispatch with dry_run (the default) to build, smoke-test and upload the +# binaries as workflow artifacts only. With dry_run off it creates the tag at +# the dispatched commit and the release. A pull request that changes this file +# runs it dry. + +on: + workflow_dispatch: + inputs: + version: + description: 'Core version, e.g. 15.0.0 (released as tag probe-v)' + required: true + type: string + dry_run: + description: 'Dry run: build and upload artifacts, but create no tag or release' + required: false + type: boolean + default: true + pull_request: + paths: + - '.github/workflows/probe-release.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || inputs.version }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + # Git fetches the relayhistory git dependencies. + CARGO_NET_GIT_FETCH_WITH_CLI: 'true' + AGENT_RELAY_TELEMETRY_DISABLED: 1 + +jobs: + prepare: + name: Version + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + version: ${{ steps.version.outputs.version }} + dry_run: ${{ steps.version.outputs.dry_run }} + steps: + - name: Validate the version and the tag + id: version + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + if [ "$EVENT" = "pull_request" ]; then + VERSION="0.0.0-pr.${PR_NUMBER}" + DRY_RUN=true + else + VERSION="${INPUT_VERSION#v}" + VERSION="${VERSION#probe-v}" + DRY_RUN="${INPUT_DRY_RUN}" + fi + if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "::error::'$VERSION' is not a version like 15.0.0 or 15.0.0-rc.1" + exit 1 + fi + if [ "$DRY_RUN" != "true" ] && gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/probe-v${VERSION}" >/dev/null 2>&1; then + echo "::error::Tag probe-v${VERSION} already exists; release a new version." + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "dry_run=$DRY_RUN" >> "$GITHUB_OUTPUT" + echo "Agent Relay core ${VERSION} (tag probe-v${VERSION}), dry run: ${DRY_RUN}" >> "$GITHUB_STEP_SUMMARY" + + build: + name: Build ${{ matrix.suffix }} + needs: prepare + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + # Ubuntu 22.04 keeps the glibc floor at 2.35. + - suffix: linux-x64 + os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + ext: '' + smoke: true + - suffix: linux-arm64 + os: ubuntu-22.04-arm + target: aarch64-unknown-linux-gnu + ext: '' + smoke: true + - suffix: darwin-arm64 + os: macos-latest + target: aarch64-apple-darwin + ext: '' + smoke: true + # Cross-compiled on Apple silicon; it is not run there. + - suffix: darwin-x64 + os: macos-latest + target: x86_64-apple-darwin + ext: '' + smoke: false + - suffix: windows-x64 + os: windows-latest + target: x86_64-pc-windows-msvc + ext: .exe + smoke: true + defaults: + run: + shell: bash + env: + VERSION: ${{ needs.prepare.outputs.version }} + SUFFIX: ${{ matrix.suffix }} + EXT: ${{ matrix.ext }} + TARGET: ${{ matrix.target }} + steps: + - name: Allow long paths + if: runner.os == 'Windows' + run: | + git config --global core.autocrlf false + git config --global core.longpaths true + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2 + with: + workspaces: crates/probe + key: release-${{ matrix.target }} + cache-bin: false + - name: Build + working-directory: crates/probe + run: | + # A short cargo home on Windows: RelayHistory's fixtures hold paths + # close to its 260-character limit. + if [ "$RUNNER_OS" = "Windows" ]; then export CARGO_HOME='C:\ch'; fi + AGENT_RELAY_PROBE_VERSION="$VERSION" cargo build --locked --release --target "$TARGET" --bin agent-relay-probe + - name: Package with a .sha256 sidecar + run: | + set -euo pipefail + ASSET="agent-relay-probe-${VERSION}-${SUFFIX}${EXT}" + mkdir -p dist + cp "crates/probe/target/${TARGET}/release/agent-relay-probe${EXT}" "dist/${ASSET}" + cd dist + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$ASSET" > "${ASSET}.sha256" + else + shasum -a 256 "$ASSET" > "${ASSET}.sha256" + fi + cat "${ASSET}.sha256" + echo "ASSET=${ASSET}" >> "$GITHUB_ENV" + - name: Smoke test (--version reports the release version) + if: matrix.smoke + run: | + set -euo pipefail + reported="$("dist/${ASSET}" --version)" + echo "$reported" + [ "$reported" = "agent-relay-probe ${VERSION}" ] || { echo "::error::expected agent-relay-probe ${VERSION}"; exit 1; } + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: agent-relay-probe-${{ matrix.suffix }} + path: dist/* + if-no-files-found: error + retention-days: 14 + + publish: + name: Publish probe-v${{ needs.prepare.outputs.version }} + needs: [prepare, build] + if: needs.prepare.outputs.dry_run == 'false' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + env: + VERSION: ${{ needs.prepare.outputs.version }} + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: agent-relay-probe-* + path: dist + merge-multiple: true + - name: Verify every checksum + working-directory: dist + run: | + set -euo pipefail + ls -la + test "$(ls *.sha256 | wc -l)" -eq 5 + for sums in *.sha256; do sha256sum -c "$sums"; done + - name: Create the tag and the release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + TAG="probe-v${VERSION}" + PRERELEASE="" + case "$VERSION" in *-*) PRERELEASE="--prerelease" ;; esac + gh release create "$TAG" dist/* \ + --repo "$GITHUB_REPOSITORY" \ + --target "$GITHUB_SHA" \ + --title "Agent Relay core ${VERSION}" \ + --notes "The Agent Relay core (agent-relay-probe ${VERSION}) built from crates/probe at ${GITHUB_SHA}. \`relay init\` downloads it and checks each binary against its .sha256 sidecar." \ + --latest=false \ + $PRERELEASE diff --git a/CHANGELOG.md b/CHANGELOG.md index fcaf3a6013..69dfc16cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- `relay init` now delivers messages by injecting them into running agent sessions instead of having agents poll an MCP inbox. It installs the Agent Relay core (`agent-relay-probe`, SHA-256-verified from a `probe-v` GitHub release) into the install directory's `bin`, runs it in the background or attaches to the one the desktop app already runs, and turns on its delivery for each chosen tool: Claude Code's session inbox and SessionStart hook, Codex hooks, the OpenCode and Pi plugins, Devin hooks and Grok's leader socket. These are per-user settings for every project. Re-running removes an MCP entry an earlier `relay init` added, so each session has one identity. `--delivery mcp` keeps the MCP server, which is also the fallback where the core cannot run. +- `relay init --stop` also stops the core `relay init` started (never the desktop app's), and `relay init --uninstall` turns off exactly the injection settings it turned on. - `install.sh` and `relay init` use Relaycast engine 11.1.0, which ships as a standalone binary: `install.sh` now installs `relaycast-engine` into `~/.agentworkforce/relay/bin`, so the local server no longer needs Node.js or a native addon. - The CLI and `@agent-relay/harness-driver` now resolve the broker in this order: `BROKER_BINARY_PATH` / `AGENT_RELAY_BIN`, then `~/.agentworkforce/relay/bin`, then the npm platform package, then `PATH`. A broker in the install directory now wins over one found on `PATH`. - `install.sh` honours `AGENT_RELAY_HOME` as the install root when `AGENT_RELAY_INSTALL_DIR` is not set. A relocated install exports its root from the `agent-relay` launcher. diff --git a/crates/probe/docs/relay-core.md b/crates/probe/docs/relay-core.md index f4ac3cd86e..cbabc64252 100644 --- a/crates/probe/docs/relay-core.md +++ b/crates/probe/docs/relay-core.md @@ -10,7 +10,7 @@ in the `agent-relay-probe` binary every desktop build already bundles and signs, and it runs as one long-lived daemon per user: ```sh -agent-relay-probe relay serve [--socket PATH] [--app-data-dir PATH] [--site-url URL] [--exit-on-stdin-eof] [--headless] +agent-relay-probe relay serve [--socket PATH] [--app-data-dir PATH] [--site-url URL] [--exit-on-stdin-eof] [--headless [--cli-managed]] ``` A relative `--socket` lives beside the default socket and a relative @@ -156,6 +156,14 @@ watchdog exposes retry and diagnostics if the service has not become ready. turning the setting off first turns off the systemd unit with the person's agreement. A core an earlier run left running on another version is stopped and replaced. +- **The relay CLI.** `relay init` attaches to whichever core answers on the + socket and starts one only when none does: + `agent-relay-probe relay serve --headless --cli-managed`. `--cli-managed` + keeps the app's login entry and turns on no defaults by itself (neither the + agent-led defaults at start nor the integration defaults of each refresh + pass); the CLI turns on the delivery settings the person chose through + `settings.set` and turns them off again on `relay init --uninstall`. See + `docs/probe-move.md` at the relay repository root. - **Shutdown.** SIGTERM, SIGINT, `daemon.shutdown`, or stdin closing (with `--exit-on-stdin-eof`). The daemon publishes the `shutdown` event to every greeted client, stops push channels, closes connections, and removes its diff --git a/crates/probe/src/probe/relay_commands.rs b/crates/probe/src/probe/relay_commands.rs index d8a1ad202b..71851dcc80 100644 --- a/crates/probe/src/probe/relay_commands.rs +++ b/crates/probe/src/probe/relay_commands.rs @@ -45,6 +45,11 @@ pub enum RelayCommands { /// Stop this temporary core after Connect work becomes idle. #[arg(long, requires = "headless")] connect_ephemeral: bool, + /// Run for the relay CLI (`relay init`): leave the desktop app's + /// login entry alone and turn on no settings by default (no agent-led + /// or integration defaults). The CLI turns on only what it was asked. + #[arg(long, requires = "headless", conflicts_with = "connect_ephemeral")] + cli_managed: bool, }, /// Send one bridge request to the running core and print its result. Call { @@ -170,6 +175,7 @@ pub fn run(command: RelayCommands) -> Result<()> { site_url, headless, connect_ephemeral, + cli_managed, } => relay::daemon::serve(relay::daemon::ServeOptions { socket, app_data_dir, @@ -177,6 +183,7 @@ pub fn run(command: RelayCommands) -> Result<()> { site_url, headless, connect_ephemeral, + cli_managed, }), RelayCommands::Call { method, diff --git a/crates/probe/src/relay/claude/session_hook.rs b/crates/probe/src/relay/claude/session_hook.rs index f22cfacddb..a711820750 100644 --- a/crates/probe/src/relay/claude/session_hook.rs +++ b/crates/probe/src/relay/claude/session_hook.rs @@ -59,6 +59,9 @@ pub enum StatusRead { on_relay: bool, self_registration: bool, }, + /// Not on the relay yet, but automatic activation is on: the core puts + /// the session on the relay by itself within seconds. + Joining, /// On the relay, with the name, address and access status reported. OnRelay { name: String, @@ -91,6 +94,9 @@ pub fn read_status(http_status: u16, body: &[u8]) -> StatusRead { mcp: McpAccess::parse(session["mcp"].as_str()), }; } + if !on_relay && value["data"]["auto_activate"].as_bool() == Some(true) { + return StatusRead::Joining; + } let self_registration = value["data"]["allow_self_registration"] .as_bool() .unwrap_or(true); @@ -162,6 +168,7 @@ pub fn hook_output(status: Option, socket: &Path, interactive: bool) return json!({}); } let (person, agent) = match status { + Some(StatusRead::Joining) => (JOINING_FOR_PERSON.to_string(), joining(socket)), Some(StatusRead::Registered { on_relay: false, self_registration: true, @@ -185,6 +192,48 @@ pub fn hook_output(status: Option, socket: &Path, interactive: bool) }) } +/// What the person sees when automatic activation will put the session on +/// the relay. +pub const JOINING_FOR_PERSON: &str = + "This Claude Code session joins Agent Relay by itself in a few seconds (automatic activation is on)."; + +/// What Claude reads when automatic activation will put the session on the +/// relay: nothing to ask or register, and how to reach peers once it is on. +pub fn joining(socket: &Path) -> String { + let shown = socket.display().to_string(); + let quoted = crate::relay::outbound::shell_quote(&shown); + let body = "'{\"to\":\"\",\"text\":\"…\"}'"; + let (agents, whoami, send) = match crate::relay::outbound::probe_client(false) + .then(|| { + Some(( + crate::relay::outbound::probe_socket_command(socket, "GET", "/agents")?, + crate::relay::outbound::probe_socket_command(socket, "GET", "/whoami")?, + format!( + "printf '%s' {body} | {}", + crate::relay::outbound::probe_socket_command(socket, "POST", "/send")? + ), + )) + }) + .flatten() + { + Some(commands) => commands, + None => ( + format!("curl -s --unix-socket {quoted} http://relay/agents"), + format!("curl -s --unix-socket {quoted} http://relay/whoami"), + format!("curl -s --unix-socket {quoted} http://relay/send --data-binary {body}"), + ), + }; + format!( + "This Claude Code session joins Agent Relay by itself within a few seconds: automatic \ + activation is on. Do not register it and do not ask the person about it. Relayed \ + messages arrive in this session as messages from another session; each names the \ + command that reads it and the one that replies. Once on the relay, through the Agent \ + Relay session socket at {shown}: {whoami} shows this session's name, {agents} lists \ + the agents it can message, and {send} sends one a message (\"address\" instead of \ + \"to\" names an exact address)." + ) +} + /// What the person sees when the session may register itself. The person /// decides; Claude only asks. pub const NUDGE_FOR_PERSON: &str = "This Claude Code session is not on Agent Relay. \ @@ -759,6 +808,37 @@ mod tests { ); } + #[test] + fn automatic_activation_tells_claude_it_joins_without_asking() { + let body = br#"{"ok":true,"data":{"auto_activate":true,"allow_self_registration":true,"session":{"registered":false}}}"#; + assert_eq!(read_status(200, body), StatusRead::Joining); + // Already on the relay: the identity reminder, whatever the setting. + let on = br#"{"ok":true,"data":{"auto_activate":true,"session":{"registered":true,"name":"n","address":"n@m"}}}"#; + assert!(matches!(read_status(200, on), StatusRead::OnRelay { .. })); + let off = br#"{"ok":true,"data":{"auto_activate":false,"session":{"registered":false}}}"#; + assert!(matches!( + read_status(200, off), + StatusRead::Registered { .. } + )); + + let socket = Path::new("/run/user/1000/agent-relay/relay.sock"); + let out = hook_output(Some(StatusRead::Joining), socket, true); + let text = out["hookSpecificOutput"]["additionalContext"] + .as_str() + .unwrap(); + assert_eq!(out["systemMessage"], JOINING_FOR_PERSON); + assert!(text.contains("Do not register it"), "{text}"); + assert!(!text.contains("ask the person whether"), "{text}"); + assert!(!text.contains("POST /register"), "{text}"); + assert!(text.contains(socket.to_str().unwrap()), "{text}"); + assert!(text.contains("/agents"), "{text}"); + assert!(text.contains("/send"), "{text}"); + assert_eq!( + hook_output(Some(StatusRead::Joining), socket, false), + json!({}) + ); + } + #[test] fn nudge_has_claude_ask_the_person_before_registering() { let socket = Path::new("/run/user/1000/agent-relay/relay.sock"); diff --git a/crates/probe/src/relay/cli.rs b/crates/probe/src/relay/cli.rs index 5a575f686c..e643fd8b22 100644 --- a/crates/probe/src/relay/cli.rs +++ b/crates/probe/src/relay/cli.rs @@ -463,6 +463,7 @@ fn session_registered(socket: &Path) -> Option { return Some(registered); } diff --git a/crates/probe/src/relay/daemon.rs b/crates/probe/src/relay/daemon.rs index 0978ad83ad..b8fe93ea73 100644 --- a/crates/probe/src/relay/daemon.rs +++ b/crates/probe/src/relay/daemon.rs @@ -72,6 +72,12 @@ pub struct ServeOptions { /// and the agent-led setup defaults apply at start. pub headless: bool, pub connect_ephemeral: bool, + /// Started by the relay CLI (`relay init`, `--cli-managed`): headless, + /// but the desktop app's login entry is kept, and neither the agent-led + /// defaults at start nor the integration defaults of each refresh pass + /// are applied, because the CLI turns on exactly the settings the person + /// chose (and turns them off again on `relay init --uninstall`). + pub cli_managed: bool, } /// Everything the core reads from its surroundings, fixed at start. Its @@ -235,6 +241,10 @@ pub struct Core { /// One relayfile subscription change at a time: they share workspace /// subscription state. pub(crate) integration_lock: tokio::sync::Mutex<()>, + /// Whether the refresh pass turns on the integration defaults + /// (`apply_integration_defaults`). Off for a core the relay CLI manages + /// (`--cli-managed`): it turns on only the tools the person chose. + pub(crate) integration_defaults: std::sync::atomic::AtomicBool, /// Serializes credential-bearing Cloud reconciliations. A sign-out or /// workspace clear queued behind an upsert is therefore always last. pub(crate) desktop_identity_sync: crate::relay::desktop_identity::DesktopIdentitySync, @@ -331,6 +341,7 @@ impl Core { connect_lifetime: Default::default(), messaging: Mutex::default(), integration_lock: tokio::sync::Mutex::new(()), + integration_defaults: std::sync::atomic::AtomicBool::new(true), desktop_identity_sync: Default::default(), contractors: std::sync::OnceLock::new(), spawn_node: tokio::sync::Mutex::new(None), @@ -677,7 +688,7 @@ pub fn serve(options: ServeOptions) -> Result<()> { let socket = BoundSocket::bind(&config.paths)?; let mut local = crate::relay::local::plugins_with(&config, None); if let Some(relay_send) = &mut local.relay_send { - relay_send.agent_led_defaults_at_start = options.headless; + relay_send.agent_led_defaults_at_start = own_defaults(&options).agent_led; } let plugins = crate::relay::account::with_account( local, @@ -686,6 +697,10 @@ pub fn serve(options: ServeOptions) -> Result<()> { options.headless, ); let (core, signals) = Core::with_plugins(config, plugins); + core.integration_defaults.store( + own_defaults(&options).integration, + std::sync::atomic::Ordering::SeqCst, + ); // A user may install and open the app after a standalone probe was // started. Once the app's launchd core is registered for this HOME, // yield the socket so its KeepAlive job can take over. @@ -916,9 +931,16 @@ async fn refresh_loop(core: Arc) { ticks.tick().await; // The workspace, then the hosted MCP links that pick reply hints. core.refresh_pass().await; - // A tool installed since the last pass gets its integration default. - let defaults = core.clone(); - let _ = tokio::task::spawn_blocking(move || defaults.apply_integration_defaults()).await; + // A tool installed since the last pass gets its integration default, + // unless the relay CLI manages this core and chose the tools itself. + if core + .integration_defaults + .load(std::sync::atomic::Ordering::SeqCst) + { + let defaults = core.clone(); + let _ = + tokio::task::spawn_blocking(move || defaults.apply_integration_defaults()).await; + } } } @@ -1028,12 +1050,30 @@ fn watch_connect_idle(core: Arc) { }); } +/// The defaults a core turns on by itself. +#[derive(Debug, PartialEq, Eq)] +struct OwnDefaults { + /// The agent-led setup defaults, applied at start (headless only). + agent_led: bool, + /// The integration defaults, applied on each refresh pass. + integration: bool, +} + +/// What a core started with `options` turns on by itself: nothing when the +/// relay CLI manages it, since the CLI turns on only what the person chose. +fn own_defaults(options: &ServeOptions) -> OwnDefaults { + OwnDefaults { + agent_led: options.headless && !options.cli_managed, + integration: !options.cli_managed, + } +} + fn cleanup_gui_autostart( options: &ServeOptions, home: &Path, env: &BTreeMap, ) -> Option> { - (options.headless && !options.connect_ephemeral) + (options.headless && !options.connect_ephemeral && !options.cli_managed) .then(|| crate::relay::login_item::remove_gui_autostart(home, env)) } #[cfg(test)] @@ -1052,6 +1092,60 @@ mod connect_startup_tests { site_url: None, headless: true, connect_ephemeral: true, + cli_managed: false, + }; + assert!(cleanup_gui_autostart(&options, home.path(), &BTreeMap::new()).is_none()); + assert_eq!(fs::read_to_string(path).unwrap(), "desktop preference"); + } + + #[test] + fn a_cli_managed_core_turns_nothing_on_by_itself() { + let options = |headless, cli_managed| ServeOptions { + socket: None, + app_data_dir: None, + exit_on_stdin_eof: false, + site_url: None, + headless, + connect_ephemeral: false, + cli_managed, + }; + assert_eq!( + own_defaults(&options(true, true)), + OwnDefaults { + agent_led: false, + integration: false + } + ); + assert_eq!( + own_defaults(&options(true, false)), + OwnDefaults { + agent_led: true, + integration: true + } + ); + assert_eq!( + own_defaults(&options(false, false)), + OwnDefaults { + agent_led: false, + integration: true + } + ); + } + + #[test] + fn cli_managed_run_preserves_desktop_login_entry() { + let home = tempfile::tempdir().unwrap(); + let path = home.path().join(".config/autostart/agent-relay.desktop"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "desktop preference").unwrap(); + let options = ServeOptions { + socket: None, + app_data_dir: None, + exit_on_stdin_eof: false, + site_url: None, + headless: true, + connect_ephemeral: false, + cli_managed: true, }; assert!(cleanup_gui_autostart(&options, home.path(), &BTreeMap::new()).is_none()); assert_eq!(fs::read_to_string(path).unwrap(), "desktop preference"); diff --git a/crates/probe/src/relay/transport.rs b/crates/probe/src/relay/transport.rs index fdb6a1751e..1cb8ff9c02 100644 --- a/crates/probe/src/relay/transport.rs +++ b/crates/probe/src/relay/transport.rs @@ -255,6 +255,8 @@ mod tests { let name = pipe_name(Path::new(r"C:\Users\A\.agentworkforce\desktop\core.sock")); assert!(name.starts_with(r"\\.\pipe\agent-relay-"), "{name}"); assert_eq!(name.len(), r"\\.\pipe\agent-relay-".len() + 16); + // The relay CLI derives the same name (packages/cli/src/cli/lib/probe-core.ts). + assert_eq!(name, r"\\.\pipe\agent-relay-367ccf5106272db6"); assert_eq!( name, pipe_name(Path::new("c:/users/a/.agentworkforce/desktop/core.sock")) diff --git a/docs/probe-move.md b/docs/probe-move.md index 785ffc4df7..f41379ae6b 100644 --- a/docs/probe-move.md +++ b/docs/probe-move.md @@ -183,21 +183,169 @@ Behaviour change for desktop users: a desktop that is signed in to Cloud still go to the signed-in account). This lands in relay-desktop only when it consumes this core (phase 4). +## Phase 3: `relay init` installs and runs the core (done) + +`relay init` now injects messages into running sessions through the core +instead of adding the Relay MCP server (`packages/cli/src/cli/commands/init.ts`, +`packages/cli/src/cli/lib/probe-core.ts`). + +**Release artifacts.** `.github/workflows/probe-release.yml` builds +`agent-relay-probe` with `AGENT_RELAY_PROBE_VERSION=` for +linux-x64 and linux-arm64 (glibc, Ubuntu 22.04 runners), darwin-arm64, +darwin-x64 (cross-compiled on Apple silicon, not smoke-tested) and +windows-x64, checks that each binary reports `agent-relay-probe `, +and publishes it with a `.sha256` sidecar (` `): + +```text +probe-v/agent-relay-probe---[.exe] +probe-v/agent-relay-probe---[.exe].sha256 +``` + +`probe-v*` tags never collide with the CLI's `v*` tags, and the release is +created with `--latest=false` because `install.sh` resolves the CLI through +`releases/latest`. The workflow is `workflow_dispatch` only (inputs `version`, +`dry_run`, which defaults to true and builds without tagging), plus a dry run +on pull requests that change the workflow. No release has been cut: the CLI +pins `PROBE_VERSION = '15.0.0'`, so `probe-v15.0.0` must be released before +relay 15.0.0 ships, or `relay init` falls back to the MCP server. + +**Install.** The CLI downloads the pinned asset into the canonical bin +directory (`$AGENT_RELAY_INSTALL_DIR/bin`, else `$AGENT_RELAY_HOME/bin`, else +`~/.agentworkforce/relay/bin`; `getCanonicalProbePath` in +`@agent-relay/harness-driver/install-paths`) exactly as it installs +`relaycast-engine` (`ensureReleaseBinary`): checksum first, SHA-256 verified, +written to a temp file beside the target, run with `--version`, then renamed +into place atomically. A binary that already reports the pinned version or a +newer one is kept; `AGENT_RELAY_PROBE_PATH` names a binary to use instead. +There is no musl, arm64 Windows or 32-bit build: those machines, and any +failed download, keep the MCP server. + +**One core per machine (the attach rule).** Before installing anything, init +connects to the core's bridge socket (`AGENT_RELAY_CORE_SOCKET`, else +`~/.agentworkforce/desktop/core.sock`; on Windows the named pipe derived from +it, a vector shared with `relay::transport`'s tests). It refuses a socket in a +directory another user can write to, greets with `hello` (bridge version 2) +and reads `status.get`. A core that answers is used: the desktop app's +service, a systemd unit, or the one an earlier `relay init` started (told +apart by the pid in `/local/core.json`). Only when nothing answers +does init start `agent-relay-probe relay serve --headless --cli-managed +--socket ` detached (log `/local/core.log`) and wait up +to 30 s for it; the core's own `flock` on `core.sock.lock` settles a race, and +init then attaches to the winner. Re-running init reuses the running core. +`relay init --stop` stops only the core whose pid init recorded (through +`daemon.shutdown`, SIGTERM as a fallback), never the desktop app's. +`agent-relay-probe relay call status.get` shows which core runs. + +`--cli-managed` (new, requires `--headless`) is headless without the parts +that belong to the desktop's service: it keeps the desktop app's login entry, +and the core turns on nothing by itself, neither the agent-led defaults at +start nor the integration defaults of each refresh pass +(`apply_integration_defaults`), so every per-user file it edits is one the +person chose in `relay init`. + +**Hooks or MCP, per tool.** Each injected tool gets the core's own delivery +route and no MCP server, so a session has exactly one Relay identity: the one +the core registers. Init turns on `auto_activate` (sessions join on their +own, named `--`) and, per tool, through `settings.set`: + +| Tool | Core setting(s) | What the core writes | +| ----------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | +| Claude Code | `direct_delivery_default`, `claude_session_hook` | `crossSessionInbound: "accept"` (only when unset) and a SessionStart hook in `~/.claude/settings.json` | +| Codex | `codex_stop_hook` | PostToolUse and Stop hooks in `~/.codex/hooks.json`, trusted through Codex's app-server | +| OpenCode | `opencode_plugin` | the Agent Relay plugin under `~/.config/opencode/plugins` | +| Grok CLI | `grok_leader` | `[cli] use_leader` in `~/.grok/config.toml` | +| Devin | `devin_direct_delivery` | lifecycle hooks in `~/.config/devin/config.json` | +| Pi | none | the Pi extension, which any core installs at start when `~/.pi/agent` exists | + +A setting that is already on is left alone; the ones init turned on are +recorded in `/local/core-settings.json`, and `relay init +--uninstall` (both scopes, or `--scope global`) turns off exactly those. The +core turns Claude's direct delivery off by writing `hold`; init then removes +the key it had added. A tool whose setting fails, and every tool when the core +cannot be had, gets the Relay MCP server instead, as before; `--delivery mcp` +asks for that explicitly. An MCP entry an earlier `relay init` added is +removed for each injected tool, in both scopes. + +These are settings of the user account, so they apply in every project +whatever `--scope` says; `--scope` now only places the MCP fallback, and the +guided flow no longer asks for it unless `--delivery mcp`. The earlier fixes +stay: no `_npx` paths or home paths in project files, the workspace key is +git-excluded, the active workspace is not switched, and Codex's project-trust +offer still applies to a project-scope MCP fallback. + +The Claude SessionStart hook now knows automatic activation: a session that +is not registered yet but will be is told it joins by itself (with the +session socket's `/whoami`, `/agents` and `/send` commands) instead of being +told to ask the person whether to register, which raced auto-activation. + +**Workspace.** The core serves one workspace per machine (phase 2). After +setting up, init calls `workspace.refresh` and warns when the core serves a +different workspace than the project's (for example a desktop signed in to +another workspace, or a machine-wide active workspace this project does not +use), with the `relay workspace switch` command that aligns them. + +**Service.** The core init starts is a background process, not a service: it +stops at logout or reboot, and `relay init` starts it again. For an always-on +core, install the desktop app (its systemd unit or LaunchAgent), or run the +core as a user service yourself, for example: + +```ini +# ~/.config/systemd/user/agent-relay-core.service +[Unit] +Description=Agent Relay core +[Service] +ExecStart=%h/.agentworkforce/relay/bin/agent-relay-probe relay serve --headless --cli-managed +Restart=on-failure +UMask=0077 +[Install] +WantedBy=default.target +``` + +`relay init` attaches to it like any other running core. + +**End-to-end proof** (Linux, Claude Code 2.1.280, Codex 0.154.0; isolated +`HOME`, `XDG_*`, `AGENT_RELAY_HOME`, `AGENT_RELAY_INSTALL_DIR`, socket and +app data, with a throwaway git project): `relay init --tools claude,codex` +started the core; an interactive Claude Code session in tmux joined by +itself; `relay message dm send … --as alice` was injected into the live +session as a turn three seconds later (no inbox call), and its reply reached +alice. Claude then messaged the Codex session over the session socket; the +message was injected into the idle Codex TUI through Codex's queue, and +Codex's reply was injected back into Claude. A core started outside init (as +the desktop's would be) was attached to, not doubled, and survived `relay +init --stop`; `relay init --uninstall` turned the four settings back off. + ## Remaining phases -3. **`relay init` installs and runs the probe.** It downloads - `agent-relay-probe` into `~/.agentworkforce/relay/bin`, runs `relay serve ---headless` as a per-user service, and installs the probe's delivery hooks - through its `harness_setup` (Claude Code SessionStart and Stop hooks, the - Codex app-server, the OpenCode and Pi plugins, and so on). This replaces - today's MCP-only setup. 4. **relay-desktop consumes the core from relay releases**, one core per - machine. Whichever of the relay CLI or a desktop app installed the core, - the other attaches to the running daemon (`~/.agentworkforce/desktop/core.sock`) - instead of starting a second one. -5. **Real end-to-end proof.** A Codex DM is injected into a running Claude - Code session without polling, for local, hosted and self-hosted - workspaces. + machine (see below). The attach rule above already makes `relay init` use + the desktop's running core. +5. **Per-project workspaces.** The core still serves one workspace per + machine; a project pinned to another workspace is warned about, not + served. That needs per-registration workspaces in the core. +6. **`AGENT_RELAY_PROJECT` inference.** The daemon has no working directory, + so it uses a project's pin only when `AGENT_RELAY_PROJECT` is in its + environment; the core could infer the project from each session's `cwd`. + +Known gaps in phase 3: + +- No `probe-v*` release exists yet; until one does, `relay init` uses the MCP + server unless a core is running or `AGENT_RELAY_PROBE_PATH` is set. +- Codex's default sandbox cannot reach the session socket, so Codex asks to + run `agent-relay-probe relay read` and the reply `curl` outside it (the + desktop has the same prompts). +- Messages arrive as pointers (`show_relay_messages` off, the core's default): + the session runs `agent-relay-probe relay read ` for the text. +- `--uninstall` leaves what the core keeps after turning a hook off: an empty + `~/.codex/hooks.json`, Codex's hook-trust entries in `~/.codex/config.toml`, + and the one-time `.relay-desktop-backup` copies of edited settings files. It + cannot remove the Pi extension, which any core installs at start. +- The core's own telemetry (its `analytics_enabled` setting, on by default) + does not follow the CLI's opt-out (`AGENT_RELAY_TELEMETRY_DISABLED`); turn + it off with `agent-relay-probe relay call settings.set --params +'{"analytics_enabled":false}'`. +- relay-desktop CI lanes still not ported (above): glibc floor check, arm64 + Linux tests, x64 macOS and arm64 Windows lanes. ## What relay-desktop must change to consume the core @@ -235,3 +383,18 @@ agent_relay_probe::install;`. In `linux_app/models.rs`, delete the six 7. Replace relay-desktop's `docs/relay-core.md` and `docs/agent-relay-probe.md` with pointers to `crates/probe/docs/`. The core's contract test now checks relay's copy. + +Phase 3 adds, for one core per machine: + +8. Take over from a core `relay init` started. That core runs + `relay serve --headless --cli-managed` from `~/.agentworkforce/relay/bin` + and holds `core.sock.lock`, so the desktop's systemd unit, logon task or + LaunchAgent would exit with "already running for this socket" and restart + in a loop. When its own core cannot take the lock, the desktop should read + `status.get` and, if the running core is not its own build, retire it with + `daemon.shutdown` (no handshake needed) and start its own, as the macOS + headless core already yields to the app's LaunchAgent. `relay init` + attaches to whichever core answers, so it needs no change for this. +9. Optionally stop bundling the core and install the `probe-v` + release asset instead (same names, same `.sha256` sidecars), so the CLI + and the desktop run byte-identical cores. diff --git a/packages/cli/src/cli/commands/init.test.ts b/packages/cli/src/cli/commands/init.test.ts index 5d3b8599dc..1a2429d50d 100644 --- a/packages/cli/src/cli/commands/init.test.ts +++ b/packages/cli/src/cli/commands/init.test.ts @@ -15,6 +15,7 @@ import { readProjectWorkspaceKey } from '../lib/project-workspace-key.js'; import { registerAgentCommands } from './agent.js'; import { DownloadHttpError, engineAssetName } from '../lib/relaycast-engine-install.js'; import { LOCAL_ENGINE_VERSION, registerInitCommand, type InitCommandDependencies } from './init.js'; +import { PROBE_VERSION, type CoreClient } from '../lib/probe-core.js'; import { getProjectPaths } from '@agent-relay/config'; let home: string; @@ -37,6 +38,9 @@ beforeEach(() => { vi.stubEnv('AGENT_RELAY_PROJECT', project); vi.stubEnv('RELAY_BASE_URL', ''); vi.stubEnv('RELAY_WORKSPACE_KEY', ''); + vi.stubEnv('AGENT_RELAY_PROBE_PATH', ''); + // Never reach a real Agent Relay core on the machine running the tests. + vi.stubEnv('AGENT_RELAY_CORE_SOCKET', path.join(home, 'no-core.sock')); }); afterEach(() => { @@ -95,6 +99,16 @@ function setup(overrides: Partial = {}) { }), engineVersion: vi.fn(() => undefined), enginePlatform: () => ({ platform: 'linux', arch: 'x64', musl: false }), + // No core runs and none can be downloaded unless a test says so: the MCP route. + coreSocket: () => path.join(home, 'no-core.sock'), + connectCore: vi.fn(async () => undefined), + startCore: vi.fn(() => { + throw new Error('no core in this test'); + }), + downloadProbe: vi.fn(async () => { + throw new Error('offline'); + }), + probeVersion: vi.fn(() => undefined), ...overrides, }; const program = new Command().exitOverride(); @@ -628,7 +642,7 @@ describe('relay init guided flow', () => { true, ]); const { run, createWorkspace } = setup({ isInteractive: () => true, prompter: () => script.prompter }); - await run(); + await run('--delivery', 'mcp'); expect(script.asked).toEqual([ 'Where should your agents talk?', @@ -685,7 +699,7 @@ describe('relay init guided flow', () => { it('stops without installing when the person cancels', async () => { const script = scriptedPrompter(['local', ['claude'], 'project', CANCELLED]); const { run } = setup({ isInteractive: () => true, prompter: () => script.prompter }); - await run(); + await run('--delivery', 'mcp'); expect(script.shown.join('\n')).toContain('cancel Setup cancelled'); expect(fs.existsSync(path.join(project, '.mcp.json'))).toBe(false); }); @@ -861,3 +875,319 @@ describe('engineAssetName', () => { expect(engineAssetName('11.1.0', target)).toBeUndefined(); }); }); + +/** + * An in-memory Agent Relay core: its settings, its workspace, and whether it + * runs. `owner` is who started it: relay init (through `startCore`) or + * someone else (the desktop app). + */ +function fakeCore( + options: { running?: boolean; settings?: Record; workspaceName?: string } = {} +) { + const settings: Record = { + auto_activate: false, + direct_delivery: false, + claude_session_hook: false, + codex_stop_hook: false, + opencode_plugin: false, + grok_leader: false, + devin_direct_delivery: false, + ...options.settings, + }; + /** The person's own crossSessionInbound, which the default never overrides. */ + let userChoseInbound = 'direct_delivery' in (options.settings ?? {}); + let up = options.running ?? false; + let pid = up ? 777 : 0; + const calls: Array<[string, unknown]> = []; + const workspace = { + state: 'found', + base_url: 'http://127.0.0.1:8787', + workspace_name: options.workspaceName ?? 'local', + workspace_id: 'ws_1', + verification: 'verified', + }; + const client: CoreClient = { + async call(method: string, params: Record = {}): Promise { + calls.push([method, params]); + switch (method) { + case 'status.get': + return { daemon: { pid, version: PROBE_VERSION, socket: '/core.sock' }, workspace } as T; + case 'settings.get': + return { ...settings } as T; + case 'settings.set': + for (const [key, value] of Object.entries(params)) { + if (key === 'direct_delivery_default') { + if (!userChoseInbound) settings.direct_delivery = true; + } else { + settings[key] = value; + if (key === 'direct_delivery') userChoseInbound = false; + } + } + return { ...settings } as T; + case 'workspace.refresh': + return workspace as T; + case 'daemon.shutdown': + up = false; + return { stopping: true } as T; + default: + throw new Error(`unexpected ${method}`); + } + }, + close: () => undefined, + }; + return { + settings, + calls, + isUp: () => up, + sets: () => calls.filter(([method]) => method === 'settings.set').map(([, params]) => params), + deps: { + connectCore: vi.fn(async () => (up ? client : undefined)), + startCore: vi.fn(() => { + up = true; + pid = 5151; + return pid; + }), + // Only the core's pid is alive; the fake local server's is separate. + probeVersion: vi.fn(() => PROBE_VERSION), + } satisfies Partial, + }; +} + +/** A probe binary already in the bin slot, so no download is needed. */ +function installedProbe(): string { + const binary = path.join(home, 'bin', 'agent-relay-probe'); + fs.mkdirSync(path.dirname(binary), { recursive: true }); + fs.writeFileSync(binary, '#!/bin/sh\necho agent-relay-probe\n', { mode: 0o755 }); + return binary; +} + +describe('relay init message injection', () => { + it('starts the core, turns on delivery for the chosen tools, and reuses it on re-run', async () => { + installedProbe(); + const core = fakeCore(); + const { run, logs, deps } = setup(core.deps); + await run('--tools', 'claude,codex'); + + expect(core.deps.startCore).toHaveBeenCalledTimes(1); + expect(core.deps.startCore).toHaveBeenCalledWith( + expect.objectContaining({ + binary: path.join(home, 'bin', 'agent-relay-probe'), + socket: deps.coreSocket?.(), + }) + ); + expect(core.sets()).toEqual([ + { auto_activate: true }, + { direct_delivery_default: true }, + { claude_session_hook: true }, + { codex_stop_hook: true }, + ]); + const output = logs.join('\n'); + expect(output).toContain('Started the Agent Relay core (agent-relay-probe 15.0.0, pid 5151)'); + expect(output).toMatch(/Claude Code\s+injected through Claude Code's session inbox/); + expect(output).toMatch(/Codex\s+injected through PostToolUse and Stop hooks/); + expect(output).toContain('apply in every project on this machine'); + // One identity per session: no MCP server is added for injected tools. + expect(fs.existsSync(path.join(project, '.mcp.json'))).toBe(false); + expect(fs.existsSync(path.join(project, '.codex', 'config.toml'))).toBe(false); + expect(output).not.toContain('only in projects it trusts'); + expect(output).toContain('Each session joins Relay on its own'); + expect(JSON.parse(fs.readFileSync(path.join(home, 'local', 'core-settings.json'), 'utf8'))).toEqual({ + turnedOn: ['auto_activate', 'direct_delivery', 'claude_session_hook', 'codex_stop_hook'], + }); + + const again = setup(core.deps); + await again.run('--tools', 'claude,codex'); + expect(core.deps.startCore).toHaveBeenCalledTimes(1); + expect(again.logs.join('\n')).toContain('The Agent Relay core relay init started earlier is running ('); + expect(core.sets()).toHaveLength(4); + }); + + it('attaches to a core it did not start, such as the desktop app, and never starts a second one', async () => { + const core = fakeCore({ running: true, settings: { auto_activate: true, codex_stop_hook: true } }); + const { run, logs } = setup(core.deps); + await run('--tools', 'codex'); + expect(core.deps.startCore).not.toHaveBeenCalled(); + expect(core.sets()).toEqual([]); + expect(logs.join('\n')).toContain('Using the Agent Relay core already running on this machine'); + // Nothing was turned on, so nothing is recorded to turn off. + expect(fs.existsSync(path.join(home, 'local', 'core-settings.json'))).toBe(false); + + const stop = setup(core.deps); + await stop.run('--stop'); + expect(core.isUp()).toBe(true); + }); + + it('stops only the core it started', async () => { + installedProbe(); + const core = fakeCore(); + const { run } = setup(core.deps); + await run('--tools', 'claude'); + const stop = setup({ ...core.deps, isAlive: vi.fn(() => false) }); + await stop.run('--stop'); + expect(core.isUp()).toBe(false); + expect(core.calls.some(([method]) => method === 'daemon.shutdown')).toBe(true); + expect(stop.logs.join('\n')).toContain('Stopped the Agent Relay core relay init started'); + expect(fs.existsSync(path.join(home, 'local', 'core.json'))).toBe(false); + }); + + it('removes the MCP entry an earlier relay init added, so each session has one identity', async () => { + const mcp = setup(); + await mcp.run('--tools', 'claude', '--delivery', 'mcp'); + expect(mcpJson().mcpServers).toHaveProperty('agent-relay'); + + installedProbe(); + const { run, logs } = setup(fakeCore().deps); + await run('--tools', 'claude'); + expect(fs.existsSync(path.join(project, '.mcp.json'))).toBe(false); + expect(logs.join('\n')).toMatch(/Claude Code\s+removed the earlier Relay MCP entry from \.mcp\.json/); + }); + + it("keeps the person's own crossSessionInbound and says messages are held", async () => { + installedProbe(); + const core = fakeCore({ settings: { direct_delivery: false } }); + const { run, logs } = setup(core.deps); + await run('--tools', 'claude'); + expect(core.settings.direct_delivery).toBe(false); + expect(logs.join('\n')).toContain('messages are held for your approval unless it is "accept"'); + }); + + it('turns off on --uninstall exactly what it turned on, and only for the user scope', async () => { + installedProbe(); + const core = fakeCore({ running: true, settings: { auto_activate: true } }); + await setup(core.deps).run('--tools', 'claude,devin'); + + const projectOnly = setup(core.deps); + await projectOnly.run('--uninstall', '--scope', 'project'); + expect(core.settings.devin_direct_delivery).toBe(true); + expect(projectOnly.logs.join('\n')).toContain('Message injection is a per-user setting and was left on'); + + const { run, logs } = setup(core.deps); + await run('--uninstall'); + expect(core.settings).toMatchObject({ + auto_activate: true, + direct_delivery: false, + claude_session_hook: false, + devin_direct_delivery: false, + }); + expect(logs.join('\n')).toContain( + 'turned off what relay init turned on (direct_delivery, claude_session_hook, devin_direct_delivery)' + ); + expect(fs.existsSync(path.join(home, 'local', 'core-settings.json'))).toBe(false); + }); + + it('removes the crossSessionInbound it added rather than leaving the hold the core writes', async () => { + installedProbe(); + const claudeSettings = path.join(toolHome, '.claude', 'settings.json'); + fs.mkdirSync(path.dirname(claudeSettings), { recursive: true }); + fs.writeFileSync(claudeSettings, JSON.stringify({ theme: 'dark' })); + const core = fakeCore({ running: true, settings: { auto_activate: true, claude_session_hook: true } }); + await setup(core.deps).run('--tools', 'claude'); + // What the core does on direct_delivery false: Claude Code's default, hold. + fs.writeFileSync(claudeSettings, JSON.stringify({ theme: 'dark', crossSessionInbound: 'hold' })); + await setup(core.deps).run('--uninstall'); + expect(JSON.parse(fs.readFileSync(claudeSettings, 'utf8'))).toEqual({ theme: 'dark' }); + }); + + it('starts a core just to turn injection off when none runs, and stops it again', async () => { + installedProbe(); + const core = fakeCore(); + await setup(core.deps).run('--tools', 'opencode'); + await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--stop'); + expect(core.isUp()).toBe(false); + + await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--uninstall'); + expect(core.settings.opencode_plugin).toBe(false); + expect(core.isUp()).toBe(false); + }); + + it('warns when the core serves a different workspace than this project', async () => { + installedProbe(); + const core = fakeCore({ running: true, workspaceName: 'ghsub-live' }); + const { run, logs } = setup(core.deps); + await run('--tools', 'claude'); + expect(logs.join('\n')).toContain( + 'serves workspace "ghsub-live" (http://127.0.0.1:8787), not "local": agents here join that workspace' + ); + expect(logs.join('\n')).toContain('relay workspace switch local'); + }); + + it('uses the MCP server for a tool the core cannot deliver to', async () => { + installedProbe(); + const core = fakeCore(); + const connect = core.deps.connectCore; + const { run, logs } = setup({ + ...core.deps, + connectCore: vi.fn(async (socket: string) => { + const client = await connect(socket); + if (!client) return undefined; + return { + ...client, + call: async (method: string, params?: Record) => { + if (method === 'settings.set' && params && 'grok_leader' in params) { + throw new Error('Grok is not installed.'); + } + return client.call(method, params); + }, + }; + }), + }); + await expect(run('--tools', 'claude,grok')).resolves.toBeDefined(); + const output = logs.join('\n'); + expect(output).toMatch(/Grok CLI\s+not injected: Grok is not installed\./); + expect(fs.readFileSync(path.join(project, '.grok', 'config.toml'), 'utf8')).toContain('agent-relay'); + expect(fs.existsSync(path.join(project, '.mcp.json'))).toBe(false); + }); + + it('falls back to the MCP server when the core cannot be had', async () => { + const { run, logs } = setup(); + await run('--tools', 'claude'); + expect(logs.join('\n')).toContain('Message injection is not available (offline)'); + expect(mcpJson().mcpServers).toHaveProperty('agent-relay'); + }); + + it('downloads, verifies and installs the pinned core from its probe-v release', async () => { + const asset = `agent-relay-probe-${PROBE_VERSION}-linux-x64`; + const url = `https://github.com/AgentWorkforce/relay/releases/download/probe-v${PROBE_VERSION}/${asset}`; + const body = new TextEncoder().encode('#!/bin/sh\necho agent-relay-probe\n'); + const digest = createHash('sha256').update(body).digest('hex'); + const downloadProbe = vi.fn(async (requested: string) => { + if (requested === url) return body; + if (requested === `${url}.sha256`) return new TextEncoder().encode(`${digest} ${asset}\n`); + throw new DownloadHttpError(requested, 404); + }); + const core = fakeCore(); + const probeVersion = vi.fn((binary: string) => (binary.endsWith('.tmp') ? PROBE_VERSION : undefined)); + const { run, logs } = setup({ ...core.deps, downloadProbe, probeVersion }); + await run('--tools', 'claude'); + expect(downloadProbe).toHaveBeenCalledWith(url, expect.anything()); + expect(fs.readFileSync(path.join(home, 'bin', 'agent-relay-probe'))).toEqual(Buffer.from(body)); + expect(logs.join('\n')).toContain(`Installed agent-relay-probe v${PROBE_VERSION}`); + expect(core.deps.startCore).toHaveBeenCalledTimes(1); + }); + + it('refuses a core whose checksum does not match, and uses the MCP server', async () => { + const downloadProbe = vi.fn(async (requested: string) => + requested.endsWith('.sha256') ? new TextEncoder().encode(`${'0'.repeat(64)} x\n`) : new Uint8Array([1]) + ); + const core = fakeCore(); + const { run, logs } = setup({ ...core.deps, downloadProbe }); + await run('--tools', 'claude'); + expect(fs.existsSync(path.join(home, 'bin', 'agent-relay-probe'))).toBe(false); + expect(core.deps.startCore).not.toHaveBeenCalled(); + expect(logs.join('\n')).toMatch(/Message injection is not available \(SHA-256 mismatch/); + }); + + it('guided: shows the injection routes, asks no scope, and proceeds', async () => { + installedProbe(); + const core = fakeCore(); + const script = scriptedPrompter([['claude'], true]); + const { run } = setup({ ...core.deps, isInteractive: () => true, prompter: () => script.prompter }); + await run('--hosted'); + expect(script.asked).toEqual(['Which agents should get Relay?', 'Proceed with installation?']); + const shown = script.shown.join('\n'); + expect(shown).toContain("Claude Code Claude Code's session inbox"); + expect(shown).toContain('note Message injection'); + expect(shown).toContain('outro Relay is ready.'); + expect(fs.existsSync(path.join(project, '.mcp.json'))).toBe(false); + }); +}); diff --git a/packages/cli/src/cli/commands/init.ts b/packages/cli/src/cli/commands/init.ts index 561d01d8be..54cdeef2c7 100644 --- a/packages/cli/src/cli/commands/init.ts +++ b/packages/cli/src/cli/commands/init.ts @@ -29,6 +29,7 @@ import { resolveMcpLaunch, trustCodexProject, uninstallTool, + writeKeepingMode, type InstallScope, type McpLaunch, type RunTool, @@ -42,17 +43,42 @@ import { sessionBaseName } from '../lib/mcp-session-name.js'; import { runSdk, withSdkDefaults, type SdkCommandDeps } from '../lib/sdk-command.js'; import { currentEnginePlatform, + ensureReleaseBinary, ensureStandaloneEngine, fetchDownload, readEngineVersion, type DownloadFile, type EnginePlatform, } from '../lib/relaycast-engine-install.js'; +import { + CORE_START_TIMEOUT_MS, + PROBE_DELIVERY, + PROBE_VERSION, + connectCore, + coreSocketPath, + disableDelivery, + enableDelivery, + probeAssetName, + probeAssetUrl, + readCoreState, + readTurnedOn, + startCoreProcess, + writePrivateJson, + type CoreClient, + type CoreState, + type CoreStatus, + type DeliveryResult, +} from '../lib/probe-core.js'; import { shellQuote } from '../lib/agent-registration.js'; import { persistWorkspaceSession } from '../lib/workspace-session.js'; import { readWorkspaceStore } from '../lib/workspace-store.js'; import { getProjectPaths } from '@agent-relay/config'; -import { getCanonicalRelaycastEnginePath } from '@agent-relay/harness-driver/install-paths'; +import { + PROBE_PATH_ENV, + getCanonicalProbePath, + getCanonicalRelaycastEnginePath, + isUsableBinary, +} from '@agent-relay/harness-driver/install-paths'; /** The Relaycast server `relay init` runs locally. Pinned so every machine runs the same server. */ export const LOCAL_ENGINE_VERSION = '11.1.0'; @@ -91,6 +117,16 @@ export interface InitCommandDependencies extends SdkCommandDeps { engineVersion: (binary: string) => string | undefined; /** Platform, architecture and libc that pick the standalone server asset. */ enginePlatform: () => EnginePlatform; + /** The Agent Relay core's bridge socket. */ + coreSocket: () => string; + /** Connect to the core at a socket; undefined when none answers. */ + connectCore: (socket: string) => Promise; + /** Start the core detached; returns its pid. */ + startCore: (input: { binary: string; socket: string; logFile: string }) => number; + /** Downloads one file of the core's release (binary or `.sha256`). */ + downloadProbe: DownloadFile; + /** The version an installed `agent-relay-probe` reports for `--version`. */ + probeVersion: (binary: string) => string | undefined; } interface LocalServerState { @@ -258,6 +294,11 @@ export function withInitDefaults(overrides: Partial = { downloadEngine: fetchDownload, engineVersion: readEngineVersion, enginePlatform: currentEnginePlatform, + coreSocket: () => coreSocketPath(process.env, os.homedir()), + connectCore: (socket) => connectCore(socket), + startCore: startCoreProcess, + downloadProbe: fetchDownload, + probeVersion: readEngineVersion, ...overrides, }; } @@ -418,6 +459,10 @@ interface ServerChoice { interface ServerSetup { headline: string; details: string[]; + /** Where the workspace lives. */ + mode: ServerMode; + /** The Relaycast server the workspace is on. */ + origin: string; /** The workspace saved for this project. */ workspace: string; /** Another workspace left active machine-wide, when there was one. */ @@ -489,6 +534,8 @@ async function setupServer(deps: InitCommandDependencies, choice: ServerChoice): const { lines, ...saved } = saveWorkspace(name, key); return { ...saved, + mode: 'own', + origin, headline: `Connected to your server at ${origin} as workspace "${name}".`, details: ['Your key is only ever sent to this server.', ...lines], }; @@ -501,6 +548,8 @@ async function setupServer(deps: InitCommandDependencies, choice: ServerChoice): const { lines, ...saved } = saveWorkspace(name, relay.workspaceKey); return { ...saved, + mode: 'hosted', + origin: HOSTED_ORIGIN, headline: `Created hosted workspace "${name}".`, details: [ ...lines, @@ -534,6 +583,8 @@ async function setupServer(deps: InitCommandDependencies, choice: ServerChoice): const { lines, ...saved } = saveWorkspace(name, workspaceKey); return { ...saved, + mode: 'local', + origin, headline: `Relay is running on this machine (${origin}), workspace "${name}". No account needed.`, details: [...lines, 'To work with agents on other machines later, run: relay init --hosted'], }; @@ -651,6 +702,337 @@ function installAll( ); } +// --------------------------------------------------------------------------- +// Message injection: the Agent Relay core + +/** How messages reach an agent tool's sessions. */ +type Delivery = 'inject' | 'mcp'; + +function coreStatePath(): string { + return path.join(localDir(), 'core.json'); +} + +/** The core settings relay init turned on, so `--uninstall` turns off exactly those. */ +function deliveryRecordPath(): string { + return path.join(localDir(), 'core-settings.json'); +} + +/** How init came to be talking to the core. */ +type CoreOrigin = 'started' | 'reused' | 'attached'; + +interface CoreSession { + client: CoreClient; + status: CoreStatus; + origin: CoreOrigin; + logFile?: string; +} + +/** The core binary: `AGENT_RELAY_PROBE_PATH`, else the pinned release in the canonical bin slot. */ +async function installProbe(deps: InitCommandDependencies): Promise<{ binary: string } | { reason: string }> { + const explicit = process.env[PROBE_PATH_ENV]?.trim(); + if (explicit) { + return isUsableBinary(explicit) + ? { binary: explicit } + : { reason: `${PROBE_PATH_ENV} (${explicit}) is not an executable file` }; + } + const target = getCanonicalProbePath(); + const platform = deps.enginePlatform(); + const asset = probeAssetName(PROBE_VERSION, platform); + const result = await ensureReleaseBinary({ + version: PROBE_VERSION, + target, + asset, + url: asset ? probeAssetUrl(PROBE_VERSION, asset) : undefined, + unsupported: `no Agent Relay core is published for ${platform.platform}-${platform.arch}${ + platform.musl ? ' (musl)' : '' + }`, + description: `the Agent Relay core (agent-relay-probe v${PROBE_VERSION}, ${asset})`, + download: deps.downloadProbe, + readVersion: deps.probeVersion, + progress: (line) => deps.log(line), + }); + if (result.status === 'installed') deps.log(`Installed agent-relay-probe v${PROBE_VERSION} to ${target}.`); + if (result.status !== 'failed') return { binary: target }; + if (isUsableBinary(target)) { + deps.log(`Could not update the Agent Relay core (${result.reason}); using the one at ${target}.`); + return { binary: target }; + } + return { reason: result.reason }; +} + +async function connectTo( + deps: InitCommandDependencies, + socket: string +): Promise<{ client: CoreClient; status: CoreStatus } | undefined> { + const client = await deps.connectCore(socket); + if (!client) return undefined; + try { + return { client, status: await client.call('status.get') }; + } catch (error) { + client.close(); + throw error; + } +} + +/** + * Attach to the core running on this machine, or start one. One core runs + * per machine: the desktop app's, a service's, or the one an earlier + * `relay init` started; a second is never started beside it. + */ +async function ensureCore(deps: InitCommandDependencies): Promise { + const socket = deps.coreSocket(); + const state = readCoreState(coreStatePath()); + let running: Awaited>; + try { + running = await connectTo(deps, socket); + } catch (error) { + return { reason: (error as Error).message }; + } + if (running) { + return { ...running, origin: state?.pid === running.status.daemon.pid ? 'reused' : 'attached' }; + } + const probe = await installProbe(deps); + if ('reason' in probe) return probe; + // The download can take a while; a desktop app may have started its core meanwhile. + const meanwhile = await connectTo(deps, socket).catch(() => undefined); + if (meanwhile) return { ...meanwhile, origin: 'attached' }; + + fs.mkdirSync(localDir(), { recursive: true, mode: 0o700 }); + const logFile = path.join(localDir(), 'core.log'); + deps.log('Starting the Agent Relay core…'); + const pid = deps.startCore({ binary: probe.binary, socket, logFile }); + const deadline = Date.now() + CORE_START_TIMEOUT_MS; + while (Date.now() < deadline) { + const up = await connectTo(deps, socket).catch(() => undefined); + if (up) { + // Another core may have taken the socket first; that one is then attached to. + const started = up.status.daemon.pid === pid; + if (started) { + const record: CoreState = { pid, socket, binary: probe.binary, version: up.status.daemon.version }; + writePrivateJson(coreStatePath(), record); + } + return { ...up, origin: started ? 'started' : 'attached', logFile }; + } + if (!deps.isAlive(pid)) break; + await deps.sleep(250); + } + deps.stop(pid); + const tail = logTail(logFile); + return { + reason: `the Agent Relay core did not start${tail ? `. Last lines of ${logFile}:\n${tail}` : ''}`, + }; +} + +function describeCore(session: CoreSession): string { + const { pid, version } = session.status.daemon; + switch (session.origin) { + case 'started': + return `Started the Agent Relay core (agent-relay-probe ${version}, pid ${pid}) in the background. Log: ${session.logFile}`; + case 'reused': + return `The Agent Relay core relay init started earlier is running (agent-relay-probe ${version}, pid ${pid}).`; + case 'attached': + return ( + `Using the Agent Relay core already running on this machine (agent-relay-probe ${version}, pid ${pid}), ` + + `the desktop app's or a service's. relay init does not start a second one.` + ); + } +} + +function sameOrigin(a: string | null | undefined, b: string): boolean { + if (!a) return false; + try { + return new URL(a).origin === new URL(b).origin; + } catch { + return false; + } +} + +/** + * The core serves one workspace per machine (the machine-wide active one, + * unless its environment names another). Say so when that is not this + * project's, since its agents would then be injected from a different + * workspace than the one the CLI uses here. + */ +async function coreWorkspaceNote( + deps: InitCommandDependencies, + client: CoreClient, + setup: ServerSetup +): Promise { + let workspace: CoreStatus['workspace']; + try { + workspace = await client.call('workspace.refresh'); + // Verifying a newly found key is quick but asynchronous. + for (let i = 0; i < 20 && workspace.state === 'found' && workspace.verification === 'pending'; i += 1) { + await deps.sleep(500); + workspace = (await client.call('status.get')).workspace; + } + } catch { + return undefined; + } + if (workspace.state !== 'found') { + return `The Agent Relay core has not found workspace "${setup.workspace}" yet; it looks again every 30 seconds.`; + } + if (workspace.verification === 'rejected') { + return `The Agent Relay core's workspace key was refused by ${workspace.base_url ?? 'its server'}.`; + } + const sameName = + setup.mode === 'own' || !workspace.workspace_name || workspace.workspace_name === setup.workspace; + if (sameOrigin(workspace.base_url, setup.origin) && sameName) return undefined; + const served = workspace.workspace_name ? `workspace "${workspace.workspace_name}"` : 'another workspace'; + return ( + `The Agent Relay core on this machine serves ${served} (${workspace.base_url ?? 'unknown server'}), ` + + `not "${setup.workspace}": agents here join that workspace. The core follows the machine-wide active ` + + `workspace; to use "${setup.workspace}" everywhere, run: ${switchCommand(setup.workspace)}` + ); +} + +interface InjectionSetup { + core: string; + results: DeliveryResult[]; + workspaceNote?: string; +} + +/** + * Attach to or start the core, then turn on its delivery for `tools`. The + * settings are the person's own (under their home), so they apply to every + * project on this machine whatever the install scope. + */ +async function setupInjection( + deps: InitCommandDependencies, + tools: ToolId[], + setup: ServerSetup +): Promise { + const session = await ensureCore(deps); + if ('reason' in session) return session; + try { + const { results, turnedOn } = await enableDelivery(session.client, tools); + const recorded = new Set([...readTurnedOn(deliveryRecordPath()), ...turnedOn]); + if (recorded.size > 0) writePrivateJson(deliveryRecordPath(), { turnedOn: [...recorded] }); + const workspaceNote = await coreWorkspaceNote(deps, session.client, setup); + return { core: describeCore(session), results, workspaceNote }; + } catch (error) { + return { reason: `the Agent Relay core could not be set up (${(error as Error).message})` }; + } finally { + session.client.close(); + } +} + +const DELIVERY_MARK: Record = { on: '✓', held: '!', failed: '✗' }; + +function describeDelivery(result: DeliveryResult): string { + const label = findToolTarget(result.id)!.label.padEnd(18); + if (result.status === 'failed') { + return `${DELIVERY_MARK.failed} ${label} not injected: ${result.message}\n Using the Relay MCP server for it instead.`; + } + const line = `${DELIVERY_MARK[result.status]} ${label} injected through ${result.route}`; + return result.status === 'held' ? `${line}\n ${result.message}` : line; +} + +const INJECTION_SCOPE_NOTE = + 'These are settings of your user account, so they apply in every project on this machine. ' + + 'relay init --uninstall turns off what it turned on; relay init --stop stops a core it started.'; + +/** + * Remove the MCP entry an earlier `relay init` added for tools whose messages + * are now injected, in both scopes: the session's identity is the core's, and + * a second one through MCP would split its messages. + */ +function removeSupersededMcp(tools: ToolId[], ctx: ToolContext): ToolResult[] { + return tools.flatMap((id) => + (['project', 'global'] as const) + .map((scope) => uninstallTool(findToolTarget(id)!, { ctx, scope })) + .filter((result) => result.status === 'removed') + ); +} + +/** What is next when messages are injected. */ +function injectionNextStep(injected: ToolId[]): string[] { + const labels = injected.map((id) => findToolTarget(id)!.label); + const tools = labels.length > 1 ? `${labels.slice(0, -1).join(', ')} or ${labels.at(-1)}` : labels[0]; + return [ + `Open ${tools} in any project. Each session joins Relay on its own, and messages to it are`, + 'typed into the running session: no inbox to check. See who is on Relay with: relay agent list', + ]; +} + +/** + * The core turns Claude Code's direct delivery off by writing + * `crossSessionInbound: "hold"` (Claude Code's own default). When relay init + * had turned it on, the key was absent before (the default only fills an + * absent key), so it is removed again rather than left as `hold`. + */ +function restoreClaudeInbound(ctx: ToolContext): void { + const configured = ctx.env.CLAUDE_CONFIG_DIR; + const dir = configured && path.isAbsolute(configured) ? configured : path.join(ctx.home, '.claude'); + const file = path.join(dir, 'settings.json'); + try { + const settings = JSON.parse(fs.readFileSync(file, 'utf8')) as Record; + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return; + if (settings.crossSessionInbound !== 'hold') return; + delete settings.crossSessionInbound; + writeKeepingMode(file, `${JSON.stringify(settings, null, 2)}\n`); + } catch { + // Unreadable or not JSON: left as the core wrote it. + } +} + +/** Turn off what relay init turned on in the core, starting a core for it when none runs. */ +async function uninstallInjection(deps: InitCommandDependencies): Promise { + const keys = readTurnedOn(deliveryRecordPath()); + if (keys.length === 0) return []; + const session = await ensureCore(deps); + if ('reason' in session) { + throw new Error(`Could not reach the Agent Relay core to turn off message injection: ${session.reason}`); + } + try { + const failures = await disableDelivery(session.client, keys); + if (failures.length > 0) { + writePrivateJson(deliveryRecordPath(), { + turnedOn: keys.filter((key) => failures.some((failure) => failure.startsWith(`${key}:`))), + }); + throw new Error(`Some injection settings could not be turned off: ${failures.join('; ')}`); + } + fs.rmSync(deliveryRecordPath(), { force: true }); + if (keys.includes('direct_delivery')) restoreClaudeInbound(deps.toolContext()); + return keys; + } finally { + session.client.close(); + // A core started only to undo the settings is stopped again. + if (session.origin === 'started') await stopCore(deps, true); + } +} + +/** + * Stop the core relay init started, never one it attached to. + * @param quiet - Say nothing (used after a core started only for `--uninstall`) + */ +async function stopCore(deps: InitCommandDependencies, quiet = false): Promise { + const say = (line: string) => { + if (!quiet) deps.log(line); + }; + const state = readCoreState(coreStatePath()); + if (!state) return; + const running = await connectTo(deps, state.socket).catch(() => undefined); + if (running && running.status.daemon.pid !== state.pid) { + running.client.close(); + say( + 'The Agent Relay core running now was not started by relay init (the desktop app or a service); left running.' + ); + } else if (running) { + await running.client.call('daemon.shutdown').catch(() => undefined); + running.client.close(); + for (let i = 0; i < 40 && deps.isAlive(state.pid); i += 1) await deps.sleep(250); + if (deps.isAlive(state.pid)) deps.stop(state.pid); + say( + 'Stopped the Agent Relay core relay init started. Messages are no longer injected until it runs again.' + ); + } else if (deps.isAlive(state.pid)) { + deps.stop(state.pid); + say('Stopped the Agent Relay core relay init started.'); + } + fs.rmSync(coreStatePath(), { force: true }); +} + // --------------------------------------------------------------------------- // Command @@ -663,6 +1045,7 @@ interface InitOptions { stop?: boolean; tools?: ToolId[]; scope?: InstallScope; + delivery?: Delivery; yes?: boolean; uninstall?: boolean; } @@ -676,7 +1059,7 @@ function serverChoiceFromFlags(o: InitOptions): ServerChoice | undefined { return undefined; } -function stopLocalServer(deps: InitCommandDependencies): void { +async function stopLocalServer(deps: InitCommandDependencies): Promise { const state = readState(); if (!state || !deps.isAlive(state.pid)) { deps.log('No local Relay server is running.'); @@ -685,9 +1068,10 @@ function stopLocalServer(deps: InitCommandDependencies): void { deps.log(`Stopped the local Relay server on ${state.origin}. Its data is kept in ${localDir()}.`); } fs.rmSync(statePath(), { force: true }); + await stopCore(deps); } -function uninstall(deps: InitCommandDependencies, o: InitOptions): void { +async function uninstall(deps: InitCommandDependencies, o: InitOptions): Promise { const ctx = deps.toolContext(); const scopes: InstallScope[] = o.scope ? [o.scope] : ['project', 'global']; const tools = o.tools ?? [...TOOL_IDS]; @@ -695,15 +1079,82 @@ function uninstall(deps: InitCommandDependencies, o: InitOptions): void { tools.map((id) => uninstallTool(findToolTarget(id)!, { ctx, scope })) ); const shown = results.filter((result) => result.status !== 'absent'); - if (shown.length === 0) { + for (const result of shown) deps.log(describeResult(result, ctx)); + + // Injection settings are the user's own, not a project's: turned off with + // the global scope (or both), and only for all tools at once. + let turnedOff: string[] = []; + const injectionKept = readTurnedOn(deliveryRecordPath()).length > 0; + if (injectionKept && o.scope !== 'project' && o.tools === undefined) { + turnedOff = await uninstallInjection(deps); + deps.log(`✓ Message injection turned off what relay init turned on (${turnedOff.join(', ')})`); + } else if (injectionKept) { + deps.log( + '! Message injection is a per-user setting and was left on. Run relay init --uninstall without ' + + '--scope project or --tools to turn it off.' + ); + } + if (shown.length === 0 && turnedOff.length === 0 && !injectionKept) { deps.log('relay init had not added Relay to any agent tool here; nothing to remove.'); return; } - for (const result of shown) deps.log(describeResult(result, ctx)); if (results.some((result) => result.status === 'failed')) throw new Error('Some entries could not be removed.'); } +/** The tools that end up on the MCP server, and the injection results, for one run. */ +interface DeliveryPlan { + injection?: InjectionSetup; + /** Why injection is not used, when it was wanted. */ + unavailable?: string; + injected: ToolId[]; + mcp: ToolId[]; + removedMcp: ToolResult[]; +} + +/** Set up injection for `tools` when wanted; whatever it cannot serve goes to the MCP server. */ +async function planDelivery( + deps: InitCommandDependencies, + tools: ToolId[], + delivery: Delivery, + setup: ServerSetup, + ctx: ToolContext +): Promise { + if (delivery === 'mcp' || tools.length === 0) return { injected: [], mcp: tools, removedMcp: [] }; + const injection = await setupInjection(deps, tools, setup); + if ('reason' in injection) + return { unavailable: injection.reason, injected: [], mcp: tools, removedMcp: [] }; + const injected = injection.results.filter((r) => r.status !== 'failed').map((r) => r.id); + return { + injection, + injected, + mcp: injection.results.filter((r) => r.status === 'failed').map((r) => r.id), + removedMcp: removeSupersededMcp(injected, ctx), + }; +} + +function unavailableLine(reason: string): string { + return ( + `Message injection is not available (${reason}). Using the Relay MCP server instead: ` + + 'agents check its inbox themselves.' + ); +} + +function injectionLines(plan: DeliveryPlan, ctx: ToolContext): string[] { + if (!plan.injection) return []; + return [ + plan.injection.core, + ...plan.injection.results.map(describeDelivery), + ...plan.removedMcp.map( + (result) => + `✓ ${result.label.padEnd(18)} removed the earlier Relay MCP entry from ${displayPath(result.path, ctx)}: ` + + 'one identity per session' + ), + ...(plan.injection.workspaceNote ? [`! ${plan.injection.workspaceNote}`] : []), + INJECTION_SCOPE_NOTE, + ]; +} + /** `relay init` without prompts: flags, then sensible defaults. */ async function runPlain(deps: InitCommandDependencies, o: InitOptions): Promise { const setup = await setupServer( @@ -716,15 +1167,25 @@ async function runPlain(deps: InitCommandDependencies, o: InitOptions): Promise< const ctx = deps.toolContext(); const tools = o.tools ?? detectTools(ctx).map((tool) => tool.id); const scope = o.scope ?? 'project'; + const plan = await planDelivery(deps, tools, o.delivery ?? 'inject', setup, ctx); + if (plan.injection) { + deps.log(''); + deps.log('Injecting messages into your agent sessions:'); + for (const line of injectionLines(plan, ctx)) deps.log(` ${line}`); + } else if (plan.unavailable) { + deps.log(''); + deps.log(unavailableLine(plan.unavailable)); + } + const installed: ToolId[] = []; - if (tools.length > 0) { + if (plan.mcp.length > 0) { const launch = deps.resolveLaunch(ctx, scope); deps.warmLaunch(launch); deps.log(''); deps.log( `Adding the Relay MCP server (${[launch.command, ...launch.args].join(' ')}) to your agent tools:` ); - for (const result of installAll(deps, { tools, scope }, ctx, launch)) { + for (const result of installAll(deps, { tools: plan.mcp, scope }, ctx, launch)) { deps.log(` ${describeResult(result, ctx)}`); if (['installed', 'updated', 'unchanged'].includes(result.status)) installed.push(result.id); } @@ -732,12 +1193,13 @@ async function runPlain(deps: InitCommandDependencies, o: InitOptions): Promise< for (const note of notesFor(installed, scope, ctx)) deps.log(` ${note}`); const scopeNote = globalScopeNote(setup, scope, installed); if (scopeNote) deps.log(` ! ${scopeNote}`); - } else if (o.tools === undefined) { + } else if (tools.length === 0 && o.tools === undefined) { deps.log(''); deps.log(`No agent tools found (looked for ${TOOL_IDS.join(', ')}). Pass --tools to choose some.`); } deps.log(''); deps.log('Next:'); + if (plan.injected.length > 0) for (const line of injectionNextStep(plan.injected)) deps.log(` ${line}`); for (const line of installed.length > 0 ? nextStep(installed, scope, ctx) : []) deps.log(` ${line}`); for (const line of CLI_FALLBACK) deps.log(` ${line}`); } @@ -844,9 +1306,10 @@ async function runGuided(deps: InitCommandDependencies, o: InitOptions): Promise return; } - // d. Scope. + // d. Scope: where the MCP server goes. Injection is per user, so only the MCP route asks. + const delivery = o.delivery ?? 'inject'; let scope = o.scope; - if (!scope) { + if (!scope && delivery === 'mcp') { const picked = await p.select({ message: 'Installation scope', initialValue: 'project', @@ -858,51 +1321,95 @@ async function runGuided(deps: InitCommandDependencies, o: InitOptions): Promise if (picked === CANCELLED) return cancelled(); scope = picked; } + const mcpScope = scope ?? 'project'; // e. Install. - const launch = deps.resolveLaunch(ctx, scope); - const summary = [ - ...tools.map((id) => { - const target = findToolTarget(id)!; - return `${target.label.padEnd(18)} ${displayPath(target.configPath(scope, ctx), ctx)} (${plannedMethod( - target, - scope, - ctx - )})`; - }), - '', - `Runs: ${[launch.command, ...launch.args].join(' ')}`, - ` ${launch.reason}`, - ]; + const launch = deps.resolveLaunch(ctx, mcpScope); + const summary = + delivery === 'inject' + ? [ + ...tools.map((id) => `${findToolTarget(id)!.label.padEnd(18)} ${PROBE_DELIVERY[id].route}`), + '', + 'Messages are injected into running sessions by the Agent Relay core, which relay init', + 'starts in the background (or reuses, when the desktop app already runs one).', + INJECTION_SCOPE_NOTE, + ] + : [ + ...tools.map((id) => { + const target = findToolTarget(id)!; + return `${target.label.padEnd(18)} ${displayPath(target.configPath(mcpScope, ctx), ctx)} (${plannedMethod( + target, + mcpScope, + ctx + )})`; + }), + '', + `Runs: ${[launch.command, ...launch.args].join(' ')}`, + ` ${launch.reason}`, + ]; p.note(summary.join('\n'), 'Installation Summary'); - if (launch.warning) p.warn(launch.warning); + if (delivery === 'mcp' && launch.warning) p.warn(launch.warning); if (!o.yes) { const proceed = await p.confirm({ message: 'Proceed with installation?', initialValue: true }); if (proceed === CANCELLED || !proceed) return cancelled(); } - deps.warmLaunch(launch); - const results = installAll(deps, { tools, scope }, ctx, launch); + + const injecting = p.spinner(); + if (delivery === 'inject') injecting.start('Setting up message injection'); + const plan = await planDelivery( + { ...deps, log: (...args) => injecting.message(args.join(' ')) }, + tools, + delivery, + setup, + ctx + ); + if (plan.injection) { + injecting.stop('Message injection is set up.'); + p.note(injectionLines(plan, ctx).join('\n'), 'Message injection'); + } else if (plan.unavailable) { + injecting.stop('Message injection is not available.'); + p.warn(unavailableLine(plan.unavailable)); + } + + let results: ToolResult[] = []; + if (plan.mcp.length > 0) { + if (delivery === 'inject' && launch.warning) p.warn(launch.warning); + deps.warmLaunch(launch); + results = installAll(deps, { tools: plan.mcp, scope: mcpScope }, ctx, launch); + p.note(results.map((result) => describeResult(result, ctx)).join('\n'), 'Relay MCP server'); + } const installed = results .filter((result) => ['installed', 'updated', 'unchanged'].includes(result.status)) .map((result) => result.id); - p.note(results.map((result) => describeResult(result, ctx)).join('\n'), 'Relay MCP server'); - if (scope === 'project' && installed.includes('codex')) await offerCodexTrust(p, ctx); - for (const note of notesFor(installed, scope, ctx)) p.info(note); - const scopeNote = globalScopeNote(setup, scope, installed); + if (mcpScope === 'project' && installed.includes('codex')) await offerCodexTrust(p, ctx); + for (const note of notesFor(installed, mcpScope, ctx)) p.info(note); + const scopeNote = globalScopeNote(setup, mcpScope, installed); if (scopeNote) p.warn(scopeNote); // f. Next step. - p.note([...nextStep(installed, scope, ctx), '', ...CLI_FALLBACK].join('\n'), 'Next'); - p.outro(installed.length > 0 ? 'Relay is ready.' : 'Relay is running, but no agent tool was set up.'); + const next = [ + ...(plan.injected.length > 0 ? injectionNextStep(plan.injected) : []), + ...(installed.length > 0 ? nextStep(installed, mcpScope, ctx) : []), + ]; + p.note([...next, '', ...CLI_FALLBACK].join('\n'), 'Next'); + const ready = plan.injected.length > 0 || installed.length > 0; + p.outro(ready ? 'Relay is ready.' : 'Relay is running, but no agent tool was set up.'); if (results.some((result) => result.status === 'failed')) throw new Error('Some agent tools could not be set up.'); } +function parseDelivery(value: string): Delivery { + if (value === 'inject' || value === 'mcp') return value; + throw new InvalidArgumentError('Expected inject or mcp.'); +} + /** * Register `relay init`: set up where agents talk (a private server on this - * machine by default, the hosted service, or your own server), then install - * the Relay MCP server into your agent tools. In a terminal it asks; with - * `--yes` or without a TTY it uses flags and defaults. + * machine by default, the hosted service, or your own server), then connect + * your agent tools. By default the Agent Relay core injects messages into + * their running sessions; `--delivery mcp` (or a machine the core cannot run + * on) uses the Relay MCP server instead. In a terminal it asks; with `--yes` + * or without a TTY it uses flags and defaults. * * @param program - The commander program to add the command to. * @param overrides - Dependencies to replace, typically in tests. @@ -916,8 +1423,8 @@ export function registerInitCommand( .command('init') .description( 'Set up Relay and connect your agent tools: a private server on this machine (default), the hosted ' + - 'service (--hosted), or your own server (--url and --key), then the Relay MCP server in Claude Code, ' + - 'Codex, OpenCode, Grok, Pi and Devin' + 'service (--hosted), or your own server (--url and --key); then the Agent Relay core injects messages ' + + 'into running Claude Code, Codex, OpenCode, Grok, Pi and Devin sessions' ) .option('--hosted', 'Use the hosted service so agents on other machines can join') .option('--url ', 'Your own Relaycast server') @@ -929,10 +1436,23 @@ export function registerInitCommand( `Agent tools to connect: ${TOOL_IDS.join(',')}, all, or none (default: the ones installed here)`, parseTools ) - .option('--scope ', 'Install for this project or every project: project or global', parseScope) + .option( + '--delivery ', + 'inject (default): the Agent Relay core types messages into running sessions; mcp: the Relay MCP ' + + 'server, whose inbox agents check', + parseDelivery + ) + .option( + '--scope ', + 'Where the Relay MCP server goes: project or global (injection is always per user)', + parseScope + ) .option('-y, --yes', 'Do not ask; use flags and defaults') - .option('--uninstall', 'Remove the Relay MCP entries relay init added (both scopes unless --scope)') - .option('--stop', 'Stop the local server started by relay init') + .option( + '--uninstall', + 'Remove the Relay MCP entries relay init added (both scopes unless --scope) and turn off the injection it turned on' + ) + .option('--stop', 'Stop the local server and the Agent Relay core started by relay init') .action(async (o: InitOptions) => { await runSdk(deps, async () => { if (o.stop) return stopLocalServer(deps); diff --git a/packages/cli/src/cli/lib/probe-core.test.ts b/packages/cli/src/cli/lib/probe-core.test.ts new file mode 100644 index 0000000000..8938a697c4 --- /dev/null +++ b/packages/cli/src/cli/lib/probe-core.test.ts @@ -0,0 +1,235 @@ +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + BRIDGE_VERSION, + connectCore, + coreSocketPath, + enableDelivery, + pipeName, + probeAssetName, + probeAssetUrl, + readCoreState, + readTurnedOn, + type CoreClient, +} from './probe-core.js'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../..'); + +describe('probeAssetName', () => { + it.each([ + [{ platform: 'darwin', arch: 'arm64', musl: false }, 'darwin-arm64'], + [{ platform: 'darwin', arch: 'x64', musl: false }, 'darwin-x64'], + [{ platform: 'linux', arch: 'x64', musl: false }, 'linux-x64'], + [{ platform: 'linux', arch: 'arm64', musl: false }, 'linux-arm64'], + [{ platform: 'win32', arch: 'x64', musl: false }, 'windows-x64.exe'], + ] as const)('maps %o to %s', (target, suffix) => { + expect(probeAssetName('15.0.0', target)).toBe(`agent-relay-probe-15.0.0-${suffix}`); + }); + + it.each([ + { platform: 'linux', arch: 'x64', musl: true }, + { platform: 'win32', arch: 'arm64', musl: false }, + { platform: 'freebsd', arch: 'x64', musl: false }, + ] as const)('publishes nothing for %o', (target) => { + expect(probeAssetName('15.0.0', target)).toBeUndefined(); + }); + + it('downloads from probe-v tags, apart from the CLI v* tags', () => { + expect(probeAssetUrl('15.0.0', 'agent-relay-probe-15.0.0-linux-x64')).toBe( + 'https://github.com/AgentWorkforce/relay/releases/download/probe-v15.0.0/agent-relay-probe-15.0.0-linux-x64' + ); + }); + + it('names the assets the release workflow publishes', () => { + const workflow = fs.readFileSync(path.join(repoRoot, '.github/workflows/probe-release.yml'), 'utf8'); + for (const suffix of ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']) { + expect(workflow).toContain(`suffix: ${suffix}`); + } + expect(workflow).toContain('agent-relay-probe-${VERSION}-${SUFFIX}${EXT}'); + expect(workflow).toContain('probe-v${VERSION}'); + }); +}); + +describe('core socket', () => { + it('defaults to the desktop socket under home, as the core does', () => { + expect(coreSocketPath({}, '/home/me')).toBe('/home/me/.agentworkforce/desktop/core.sock'); + }); + + it('honours AGENT_RELAY_CORE_SOCKET, a relative one beside the default', () => { + expect(coreSocketPath({ AGENT_RELAY_CORE_SOCKET: '/run/x.sock' }, '/home/me')).toBe('/run/x.sock'); + expect(coreSocketPath({ AGENT_RELAY_CORE_SOCKET: 'core.dev.sock' }, '/home/me')).toBe( + '/home/me/.agentworkforce/desktop/core.dev.sock' + ); + expect(coreSocketPath({ AGENT_RELAY_CORE_SOCKET: ' ' }, '/home/me')).toBe( + '/home/me/.agentworkforce/desktop/core.sock' + ); + }); + + it('derives the Windows pipe exactly as the core does (relay::transport tests share the vector)', () => { + const expected = '\\\\.\\pipe\\agent-relay-367ccf5106272db6'; + expect(pipeName('C:\\Users\\A\\.agentworkforce\\desktop\\core.sock')).toBe(expected); + expect(pipeName('c:/users/a/.agentworkforce/desktop/core.sock')).toBe(expected); + expect(pipeName('\\\\?\\C:\\Users\\A\\.agentworkforce\\desktop\\core.sock')).toBe(expected); + }); + + it('speaks the bridge version the core declares', () => { + const protocol = fs.readFileSync(path.join(repoRoot, 'crates/probe/src/relay/protocol.rs'), 'utf8'); + expect(protocol).toContain(`pub const BRIDGE_VERSION: u32 = ${BRIDGE_VERSION};`); + }); +}); + +describe.skipIf(process.platform === 'win32')('connectCore', () => { + let dir: string; + let server: net.Server | undefined; + + beforeEach(() => { + // Short: a Unix socket path has a ~104-byte limit. + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rc-')); + fs.chmodSync(dir, 0o700); + }); + + afterEach(async () => { + await new Promise((resolve) => (server ? server.close(() => resolve()) : resolve())); + server = undefined; + fs.rmSync(dir, { recursive: true, force: true }); + }); + + /** A tiny NDJSON core: answers hello and status.get, sends an event in between, refuses the rest. */ + async function serve(socket: string, seen: string[]): Promise { + server = net.createServer((connection) => { + let buffer = ''; + connection.on('data', (chunk) => { + buffer += chunk.toString('utf8'); + let newline = buffer.indexOf('\n'); + while (newline >= 0) { + const request = JSON.parse(buffer.slice(0, newline)) as { + id: number; + method: string; + params: unknown; + }; + buffer = buffer.slice(newline + 1); + newline = buffer.indexOf('\n'); + seen.push(`${request.method} ${JSON.stringify(request.params)}`); + connection.write( + `${JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { revision: 3 } })}\n` + ); + const reply = + request.method === 'hello' + ? { result: { bridge_version: BRIDGE_VERSION } } + : request.method === 'status.get' + ? { result: { daemon: { pid: 42, version: '15.0.0', socket } } } + : { error: { code: -32601, message: 'Unknown method.' } }; + connection.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, ...reply })}\n`); + } + }); + }); + await new Promise((resolve) => server!.listen(socket, resolve)); + } + + it('greets the core, skips events, and returns results and errors by id', async () => { + const socket = path.join(dir, 'core.sock'); + const seen: string[] = []; + await serve(socket, seen); + const client = (await connectCore(socket, 2_000))!; + expect(client).toBeDefined(); + await expect(client.call('status.get')).resolves.toMatchObject({ daemon: { pid: 42 } }); + await expect(client.call('nope')).rejects.toThrow('Unknown method.'); + client.close(); + expect(seen[0]).toBe(`hello {"bridge_version":${BRIDGE_VERSION}}`); + }); + + it('answers undefined when no core listens', async () => { + await expect(connectCore(path.join(dir, 'missing.sock'), 500)).resolves.toBeUndefined(); + }); + + it('refuses a socket in a directory others can write to', async () => { + const socket = path.join(dir, 'core.sock'); + await serve(socket, []); + fs.chmodSync(dir, 0o777); + await expect(connectCore(socket, 500)).rejects.toThrow('only you can write to'); + }); +}); + +describe('enableDelivery', () => { + function client(initial: Record, refuse: string[] = []): CoreClient & { sets: unknown[] } { + const values = { ...initial }; + const sets: unknown[] = []; + return { + sets, + async call(method: string, params: Record = {}): Promise { + if (method === 'settings.get') return { ...values } as T; + sets.push(params); + const [key] = Object.keys(params); + if (refuse.includes(key)) throw new Error(`${key} is not available here.`); + if (key === 'direct_delivery_default') values.direct_delivery ??= true; + else values[key] = params[key]; + return { ...values } as T; + }, + close: () => undefined, + }; + } + + it('turns on only what is off, and reports what it turned on', async () => { + const core = client({ auto_activate: true, codex_stop_hook: false, opencode_plugin: true }); + const outcome = await enableDelivery(core, ['codex', 'opencode', 'pi']); + expect(core.sets).toEqual([{ codex_stop_hook: true }]); + expect(outcome.turnedOn).toEqual(['codex_stop_hook']); + expect(outcome.results.map((r) => [r.id, r.status])).toEqual([ + ['codex', 'on'], + ['opencode', 'on'], + ['pi', 'on'], + ]); + }); + + it('reports a refused setting as a failure for that tool only', async () => { + const core = client({ auto_activate: true }, ['devin_direct_delivery']); + const outcome = await enableDelivery(core, ['devin', 'grok']); + expect(outcome.results).toEqual([ + expect.objectContaining({ + id: 'devin', + status: 'failed', + message: 'devin_direct_delivery is not available here.', + }), + expect.objectContaining({ id: 'grok', status: 'on' }), + ]); + expect(outcome.turnedOn).toEqual(['grok_leader']); + }); + + it("leaves the person's crossSessionInbound alone and says messages are held", async () => { + const core = client({ auto_activate: true, claude_session_hook: true, direct_delivery: false }); + const outcome = await enableDelivery(core, ['claude']); + expect(outcome.results[0]).toMatchObject({ id: 'claude', status: 'held' }); + expect(outcome.turnedOn).toEqual([]); + }); +}); + +describe('records', () => { + let dir: string; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'probe-records-')); + }); + afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); + + it('never trusts a core record whose pid could signal a process group', () => { + const file = path.join(dir, 'core.json'); + for (const pid of [0, 1, -5, 1.5, '12']) { + fs.writeFileSync(file, JSON.stringify({ pid, socket: '/s', binary: '/b' })); + expect(readCoreState(file)).toBeUndefined(); + } + fs.writeFileSync(file, JSON.stringify({ pid: 4242, socket: '/s', binary: '/b' })); + expect(readCoreState(file)).toEqual({ pid: 4242, socket: '/s', binary: '/b' }); + }); + + it('reads only string keys from the settings record', () => { + const file = path.join(dir, 'core-settings.json'); + fs.writeFileSync(file, JSON.stringify({ turnedOn: ['auto_activate', 3, null, 'codex_stop_hook'] })); + expect(readTurnedOn(file)).toEqual(['auto_activate', 'codex_stop_hook']); + expect(readTurnedOn(path.join(dir, 'missing.json'))).toEqual([]); + }); +}); diff --git a/packages/cli/src/cli/lib/probe-core.ts b/packages/cli/src/cli/lib/probe-core.ts new file mode 100644 index 0000000000..60c589a337 --- /dev/null +++ b/packages/cli/src/cli/lib/probe-core.ts @@ -0,0 +1,449 @@ +/** + * The Agent Relay core (`agent-relay-probe`): the per-user daemon that finds + * the coding-agent sessions running on this machine and injects Relay + * messages into them through each tool's own route (Claude Code's session + * inbox, Codex hooks and queue, the OpenCode and Pi plugins, Devin hooks, + * Grok's leader socket). `relay init` installs it from a `probe-v` + * GitHub release of this repository, runs it in the background, and turns on + * the core's delivery settings for the agent tools the person picked. + * + * One core runs per machine: the desktop app's, or one a previous + * `relay init` started. A running core is always attached to, never doubled. + * Its bridge is JSON-RPC 2.0 over NDJSON on `~/.agentworkforce/desktop/core.sock` + * (`AGENT_RELAY_CORE_SOCKET` moves it; on Windows a named pipe derived from + * that path), see crates/probe/docs/relay-core.md. + */ + +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; + +import type { ToolId } from './agent-tools-mcp.js'; +import type { EnginePlatform } from './relaycast-engine-install.js'; + +/** The core `relay init` installs. Pinned so every machine runs the same core. */ +export const PROBE_VERSION = '15.0.0'; +/** Release tags of the core; distinct from the CLI's `v*` tags. */ +export const PROBE_TAG_PREFIX = 'probe-v'; +const RELEASE_BASE = 'https://github.com/AgentWorkforce/relay/releases/download'; +/** The bridge version this client speaks (`crates/probe/src/relay/protocol.rs`). */ +export const BRIDGE_VERSION = 2; +/** Moves the core's bridge socket, for the core and its clients alike. */ +export const CORE_SOCKET_ENV = 'AGENT_RELAY_CORE_SOCKET'; +/** How long a started core gets to answer on its socket. */ +export const CORE_START_TIMEOUT_MS = 30_000; +const CALL_TIMEOUT_MS = 15_000; + +/** + * The release asset of the core for a platform, or undefined when none is published. + * @param version - Core version, e.g. `15.0.0` + * @param target - Platform, architecture and libc + * @returns e.g. `agent-relay-probe-15.0.0-linux-x64` + */ +export function probeAssetName(version: string, target: EnginePlatform): string | undefined { + const arch = target.arch === 'x64' || target.arch === 'arm64' ? target.arch : undefined; + if (!arch) return undefined; + switch (target.platform) { + case 'darwin': + return `agent-relay-probe-${version}-darwin-${arch}`; + case 'linux': + // Built against glibc; there is no musl build. + return target.musl ? undefined : `agent-relay-probe-${version}-linux-${arch}`; + case 'win32': + return arch === 'x64' ? `agent-relay-probe-${version}-windows-x64.exe` : undefined; + default: + return undefined; + } +} + +/** + * Where a release asset of the core downloads from. + * @param version - Core version + * @param asset - Asset name from {@link probeAssetName} + * @returns The GitHub release download URL; the checksum is at `.sha256` + */ +export function probeAssetUrl(version: string, asset: string): string { + return `${RELEASE_BASE}/${PROBE_TAG_PREFIX}${version}/${asset}`; +} + +/** + * The core's bridge socket, resolved as the core resolves it: an absolute + * `AGENT_RELAY_CORE_SOCKET`, a relative one beside the default, else + * `/.agentworkforce/desktop/core.sock`. + * @param env - Environment to read the override from + * @param home - The user's home directory + * @returns Absolute socket path + */ +export function coreSocketPath(env: NodeJS.ProcessEnv, home: string): string { + const fallback = path.join(home, '.agentworkforce', 'desktop', 'core.sock'); + const configured = env[CORE_SOCKET_ENV]?.trim(); + if (!configured) return fallback; + return path.isAbsolute(configured) ? configured : path.join(path.dirname(fallback), configured); +} + +/** + * The Windows named pipe the core serves for a socket path + * (`relay::transport::pipe_name`). + * @param socket - The socket path + * @returns `\\.\pipe\agent-relay-<16 hex>` + */ +export function pipeName(socket: string): string { + let key = socket.replace(/\//g, '\\').toLowerCase(); + if (key.startsWith('\\\\?\\')) key = key.slice(4); + while (key.length > 1 && key.endsWith('\\')) key = key.slice(0, -1); + const hex = createHash('sha256').update(key, 'utf8').digest('hex').slice(0, 16); + return `\\\\.\\pipe\\agent-relay-${hex}`; +} + +/** A failure the core reported, with its message. */ +export class CoreCallError extends Error {} + +/** A connection to the core's bridge. */ +export interface CoreClient { + /** Calls one bridge method and returns its result. */ + call(method: string, params?: Record): Promise; + close(): void; +} + +/** + * Refuse a socket whose directory another user could have planted it in: it + * must be this user's, and writable by no one else (the core creates it 0700). + */ +function trustedSocketDirectory(socket: string): void { + if (process.platform === 'win32' || typeof process.getuid !== 'function') return; + const directory = path.dirname(socket); + const entry = fs.lstatSync(directory); + const target = fs.statSync(directory); + const uid = process.getuid(); + if (entry.uid !== uid || target.uid !== uid || !target.isDirectory() || (target.mode & 0o022) !== 0) { + throw new Error( + `${directory} must be a directory only you can write to; refusing the core socket in it.` + ); + } +} + +/** + * Connect to the core at `socket` and greet it. Resolves undefined when + * nothing is listening there. + * @param socket - The core's bridge socket path + * @param timeoutMs - Bound on connecting and on each call + * @returns A client, or undefined when no core answers + */ +export async function connectCore( + socket: string, + timeoutMs = CALL_TIMEOUT_MS +): Promise { + if (process.platform !== 'win32') { + if (!fs.existsSync(socket)) return undefined; + trustedSocketDirectory(socket); + } + const address = process.platform === 'win32' ? pipeName(socket) : socket; + const stream = await new Promise((resolve) => { + const s = net.createConnection(address); + const timer = setTimeout(() => { + s.destroy(); + resolve(undefined); + }, timeoutMs); + s.once('connect', () => { + clearTimeout(timer); + resolve(s); + }); + s.once('error', () => { + clearTimeout(timer); + resolve(undefined); + }); + }); + if (!stream) return undefined; + + let nextId = 1; + let buffer = ''; + let closed = false; + const pending = new Map void; reject: (error: Error) => void }>(); + const failAll = (error: Error) => { + closed = true; + for (const waiter of pending.values()) waiter.reject(error); + pending.clear(); + }; + stream.setEncoding('utf8'); + stream.on('data', (chunk: string) => { + buffer += chunk; + let newline = buffer.indexOf('\n'); + while (newline >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + newline = buffer.indexOf('\n'); + let message: { id?: unknown; result?: unknown; error?: { message?: string } }; + try { + message = JSON.parse(line); + } catch { + continue; + } + // Events arrive between responses; only ids answer calls. + if (typeof message.id !== 'number') continue; + const waiter = pending.get(message.id); + if (!waiter) continue; + pending.delete(message.id); + if (message.error) + waiter.reject(new CoreCallError(message.error.message ?? 'The core refused the request.')); + else waiter.resolve(message.result ?? null); + } + }); + stream.on('error', (error) => failAll(error)); + stream.on('close', () => failAll(new CoreCallError('The Agent Relay core closed the connection.'))); + + const client: CoreClient = { + call(method: string, params: Record = {}): Promise { + if (closed) return Promise.reject(new CoreCallError('The Agent Relay core closed the connection.')); + const id = nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new CoreCallError(`The Agent Relay core did not answer ${method} in time.`)); + }, timeoutMs); + pending.set(id, { + resolve: (value) => { + clearTimeout(timer); + resolve(value as T); + }, + reject: (error) => { + clearTimeout(timer); + reject(error); + }, + }); + stream.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); + }); + }, + close() { + closed = true; + stream.destroy(); + }, + }; + try { + await client.call('hello', { bridge_version: BRIDGE_VERSION }); + } catch (error) { + client.close(); + throw new Error( + `A core answers at ${socket} but refused this client (${(error as Error).message}). ` + + 'Update the Agent Relay desktop app or the relay CLI so both speak the same bridge.', + { cause: error } + ); + } + return client; +} + +/** The part of the core's `status.get` that init reads. */ +export interface CoreStatus { + daemon: { pid: number; version: string; socket: string }; + workspace: { + state: 'missing' | 'found'; + base_url?: string | null; + workspace_id?: string | null; + workspace_name?: string | null; + verification?: 'pending' | 'verified' | 'rejected' | 'unreachable'; + }; +} + +/** + * Start the core in the background, detached from this process, headless and + * managed by the CLI (it keeps the desktop app's login entry and applies no + * defaults of its own). + * @returns The core's pid + */ +export function startCoreProcess({ + binary, + socket, + logFile, +}: { + binary: string; + socket: string; + logFile: string; +}): number { + const log = fs.openSync(logFile, 'a', 0o600); + try { + const child = spawn(binary, ['relay', 'serve', '--headless', '--cli-managed', '--socket', socket], { + detached: true, + stdio: ['ignore', log, log], + env: process.env, + windowsHide: true, + }); + child.unref(); + if (child.pid === undefined) throw new Error('Could not start the Agent Relay core.'); + return child.pid; + } finally { + fs.closeSync(log); + } +} + +// --------------------------------------------------------------------------- +// What relay init turns on per agent tool + +/** A core setting: written with `write`, read back as `read` in `settings.get`. */ +interface CoreSetting { + write: string; + read: string; +} + +const setting = (key: string, read = key): CoreSetting => ({ write: key, read }); + +/** Sessions of every supported tool join Relay on their own. */ +export const AUTO_ACTIVATE = setting('auto_activate'); + +/** How the core injects into each tool, and the settings that turn it on. */ +export const PROBE_DELIVERY: Record = { + claude: { + // `direct_delivery_default` sets crossSessionInbound to accept only when + // the person has not chosen a value themselves. + settings: [setting('direct_delivery_default', 'direct_delivery'), setting('claude_session_hook')], + route: + "Claude Code's session inbox (crossSessionInbound: accept in ~/.claude/settings.json) and a SessionStart hook there", + }, + codex: { + settings: [setting('codex_stop_hook')], + route: "PostToolUse and Stop hooks in ~/.codex/hooks.json, and Codex's own queue between turns", + }, + opencode: { settings: [setting('opencode_plugin')], route: 'the Agent Relay plugin for OpenCode' }, + grok: { + settings: [setting('grok_leader')], + route: "Grok's leader socket ([cli] use_leader in ~/.grok/config.toml)", + }, + pi: { settings: [], route: 'the Agent Relay extension for Pi, which the core installs when it starts' }, + devin: { + settings: [setting('devin_direct_delivery')], + route: 'lifecycle hooks in ~/.config/devin/config.json', + }, +}; + +/** One tool's delivery after init asked the core for it. */ +export interface DeliveryResult { + id: ToolId; + status: 'on' | 'failed' | 'held'; + route: string; + message?: string; +} + +/** What one `enableDelivery` did. */ +export interface DeliveryOutcome { + results: DeliveryResult[]; + /** Settings (by read key) that were off and that init turned on. */ + turnedOn: string[]; +} + +type SettingsValues = Record; + +/** + * Turn on the core's delivery for each tool, and automatic activation. A + * setting already on is left alone; only the ones turned on here are + * reported in `turnedOn`, so `--uninstall` turns off exactly those. + * @param client - A connected core + * @param tools - The agent tools to deliver to + * @returns Per-tool results and the settings turned on + */ +export async function enableDelivery(client: CoreClient, tools: ToolId[]): Promise { + let values = await client.call('settings.get'); + const turnedOn: string[] = []; + const turnOn = async (s: CoreSetting): Promise => { + if (values[s.read] === true) return undefined; + try { + values = await client.call('settings.set', { [s.write]: true }); + } catch (error) { + return (error as Error).message; + } + if (values[s.read] === true) { + turnedOn.push(s.read); + return undefined; + } + // A default that respects the person's own choice may stay off; see the Claude result. + return s.write === s.read ? `the core did not turn on ${s.write}` : undefined; + }; + + const results: DeliveryResult[] = []; + const activation = await turnOn(AUTO_ACTIVATE); + for (const id of tools) { + const { settings, route } = PROBE_DELIVERY[id]; + const failures: string[] = []; + for (const s of settings) { + const failure = await turnOn(s); + if (failure) failures.push(failure); + } + if (activation) failures.unshift(`automatic activation: ${activation}`); + if (failures.length > 0) { + results.push({ id, status: 'failed', route, message: failures.join('; ') }); + } else if (id === 'claude' && values.direct_delivery !== true) { + results.push({ + id, + status: 'held', + route, + message: + 'Your Claude settings already choose how cross-session messages arrive (crossSessionInbound), ' + + 'so they were left as is; messages are held for your approval unless it is "accept".', + }); + } else { + results.push({ id, status: 'on', route }); + } + } + return { results, turnedOn }; +} + +/** + * Turn off the settings `relay init` turned on. + * @param client - A connected core + * @param keys - Read keys recorded by {@link enableDelivery} + * @returns The keys that could not be turned off, with why + */ +export async function disableDelivery(client: CoreClient, keys: string[]): Promise { + const failures: string[] = []; + for (const key of keys) { + try { + await client.call('settings.set', { [key]: false }); + } catch (error) { + failures.push(`${key}: ${(error as Error).message}`); + } + } + return failures; +} + +// --------------------------------------------------------------------------- +// relay init's records + +/** The core `relay init` started, so `--stop` stops only that one. */ +export interface CoreState { + pid: number; + socket: string; + binary: string; + version?: string; +} + +/** + * The record of the core init started, if sane. A pid of 0 or 1 would make + * a stop signal the CLI's own process group or every process the user owns. + */ +export function readCoreState(file: string): CoreState | undefined { + try { + const state = JSON.parse(fs.readFileSync(file, 'utf8')) as Partial; + if (typeof state.pid !== 'number' || !Number.isInteger(state.pid) || state.pid <= 1) return undefined; + if (typeof state.socket !== 'string' || typeof state.binary !== 'string') return undefined; + return state as CoreState; + } catch { + return undefined; + } +} + +/** Writes a small private JSON record. */ +export function writePrivateJson(file: string, value: unknown): void { + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); +} + +/** The core settings init turned on, kept for `--uninstall`. */ +export function readTurnedOn(file: string): string[] { + try { + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as { turnedOn?: unknown }; + return Array.isArray(parsed.turnedOn) + ? parsed.turnedOn.filter((key): key is string => typeof key === 'string') + : []; + } catch { + return []; + } +} diff --git a/packages/cli/src/cli/lib/relaycast-engine-install.ts b/packages/cli/src/cli/lib/relaycast-engine-install.ts index eeff2ab530..1fc3a43b69 100644 --- a/packages/cli/src/cli/lib/relaycast-engine-install.ts +++ b/packages/cli/src/cli/lib/relaycast-engine-install.ts @@ -168,26 +168,55 @@ export type EnsureEngineResult = * @returns What happened, with a reason on failure */ export async function ensureStandaloneEngine(input: EnsureEngineInput): Promise { - const { version, target } = input; + const asset = engineAssetName(input.version, input.platform); + return ensureReleaseBinary({ + ...input, + asset, + url: asset ? `${RELEASE_BASE}/v${input.version}/${asset}` : undefined, + unsupported: `relaycast publishes no standalone server for ${input.platform.platform}-${input.platform.arch}`, + description: `the local Relaycast server (relaycast-engine v${input.version}, ${asset})`, + }); +} + +/** One release binary to put in place: where it comes from and what it is called. */ +export interface EnsureReleaseBinaryInput { + version: string; + /** Final path in the bin slot. */ + target: string; + /** The release asset name, or undefined when none is published for this platform. */ + asset: string | undefined; + /** The asset's download URL; its checksum is at `${url}.sha256`. */ + url: string | undefined; + /** Why there is no asset, when `asset` is undefined. */ + unsupported: string; + /** What is being downloaded, for the progress line. */ + description: string; + download: DownloadFile; + readVersion: (binary: string) => string | undefined; + progress: (line: string) => void; +} + +/** + * Make sure a GitHub release binary of at least `version` is at `target`: + * download the asset and its `.sha256` sidecar, verify the digest, check the + * downloaded file runs and reports `version`, then rename it into place + * atomically. Never throws; a failure leaves `target` untouched. + * @param input - Version, asset, destination and injected effects + * @returns What happened, with a reason on failure + */ +export async function ensureReleaseBinary(input: EnsureReleaseBinaryInput): Promise { + const { version, target, asset, url } = input; if (fs.existsSync(target)) { const installed = input.readVersion(target); if (installed === version || (installed && versionGreater(installed, version))) { return { status: 'present', version: installed }; } } - - const asset = engineAssetName(version, input.platform); - if (!asset) { - return { - status: 'failed', - reason: `relaycast publishes no standalone server for ${input.platform.platform}-${input.platform.arch}`, - }; - } - const url = `${RELEASE_BASE}/v${version}/${asset}`; + if (!asset || !url) return { status: 'failed', reason: input.unsupported }; let temp: string | undefined; try { - input.progress(`Downloading the local Relaycast server (relaycast-engine v${version}, ${asset})…`); + input.progress(`Downloading ${input.description}…`); // The small checksum first: an unpublished asset fails fast, before the large download. const sums = await input.download(`${url}.sha256`, { timeoutMs: ENGINE_CHECKSUM_TIMEOUT_MS }); const expected = Buffer.from(sums).toString('utf8').trim().split(/\s+/)[0]?.toLowerCase() ?? ''; @@ -210,7 +239,7 @@ export async function ensureStandaloneEngine(input: EnsureEngineInput): Promise< fs.writeFileSync(temp, binary, { mode: 0o755, flag: 'wx' }); fs.chmodSync(temp, 0o755); // A verified file can still fail to run here (too old an OS, missing libraries); - // never let it replace a working binary or shadow the npm package. + // never let it replace a working binary. const runs = input.readVersion(temp); if (runs !== version) { return { status: 'failed', reason: `the downloaded ${asset} does not run on this machine` }; diff --git a/packages/harness-driver/src/install-paths.ts b/packages/harness-driver/src/install-paths.ts index f05d6cf5b4..da92f1665a 100644 --- a/packages/harness-driver/src/install-paths.ts +++ b/packages/harness-driver/src/install-paths.ts @@ -15,6 +15,9 @@ * agent-relay-broker Rust broker (install.sh, desktop app, or copied from * the npm optional dependency on first CLI run) * relaycast-engine local Relaycast server (install.sh, when published) + * agent-relay-probe the Agent Relay core that injects messages into + * running agent sessions (`relay init`, from a + * `probe-v` GitHub release) * * `install.sh` resolves the same directory, so the shell installer and the * runtime resolvers cannot disagree about where binaries live. @@ -27,6 +30,7 @@ import { join, resolve } from 'node:path'; export const BROKER_BINARY_NAME = 'agent-relay-broker'; export const RELAYCAST_ENGINE_BINARY_NAME = 'relaycast-engine'; +export const PROBE_BINARY_NAME = 'agent-relay-probe'; /** Environment variable that relocates the whole install root. */ export const RELAY_INSTALL_DIR_ENV = 'AGENT_RELAY_INSTALL_DIR'; @@ -34,6 +38,8 @@ export const RELAY_INSTALL_DIR_ENV = 'AGENT_RELAY_INSTALL_DIR'; export const RELAY_HOME_ENV = 'AGENT_RELAY_HOME'; /** Explicit path to a relaycast-engine binary, checked before the canonical slot. */ export const RELAYCAST_ENGINE_PATH_ENV = 'RELAYCAST_ENGINE_PATH'; +/** Explicit path to an agent-relay-probe binary, used instead of the canonical slot. */ +export const PROBE_PATH_ENV = 'AGENT_RELAY_PROBE_PATH'; type Env = Record; @@ -101,6 +107,19 @@ export function getCanonicalRelaycastEnginePath( return join(getRelayBinDir(env), executableFileName(RELAYCAST_ENGINE_BINARY_NAME, platform)); } +/** + * Canonical path of the Agent Relay core (`agent-relay-probe`) binary (may not exist). + * @param env - Environment to read overrides from + * @param platform - Target platform + * @returns `/agent-relay-probe[.exe]` + */ +export function getCanonicalProbePath( + env: Env = process.env, + platform: NodeJS.Platform = process.platform +): string { + return join(getRelayBinDir(env), executableFileName(PROBE_BINARY_NAME, platform)); +} + /** * Whether `filePath` looks runnable: a non-empty regular file that is * executable (the execute bit is not checked on Windows). Cheap enough for From 8d8bc63b475781bf1a91c84499bde0e52ee8fe20 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Wed, 28 Oct 2026 19:37:40 -0700 Subject: [PATCH 02/11] ci(probe): build release binaries with only the production feature set Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/probe-release.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/probe-release.yml b/.github/workflows/probe-release.yml index be245a2cf4..5c0c95bf83 100644 --- a/.github/workflows/probe-release.yml +++ b/.github/workflows/probe-release.yml @@ -151,7 +151,11 @@ jobs: # A short cargo home on Windows: RelayHistory's fixtures hold paths # close to its 260-character limit. if [ "$RUNNER_OS" = "Windows" ]; then export CARGO_HOME='C:\ch'; fi - AGENT_RELAY_PROBE_VERSION="$VERSION" cargo build --locked --release --target "$TARGET" --bin agent-relay-probe + # Release binaries are built with only the production feature set, + # so optional extras (such as the RelayHistory backend once it is + # feature-gated) stay out of what ships. + AGENT_RELAY_PROBE_VERSION="$VERSION" cargo build --locked --release --target "$TARGET" \ + --no-default-features --features production-binaries --bin agent-relay-probe - name: Package with a .sha256 sidecar run: | set -euo pipefail From b58a18ef4bd8ec0d4058a09a2c63cca3369ebb99 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 28 Oct 2026 21:37:51 -0700 Subject: [PATCH 03/11] fix(cli): keep each tool on one delivery route and harden relay init's core handling - enableDelivery stops at a tool's first failed setting and turns off what init turned on for it (this run or recorded), so the MCP fallback is its only route; auto_activate is turned on only once a tool is injected. - --delivery mcp turns off the recorded injection settings of those tools (and auto_activate once none is left) before adding MCP, or adds none. - --stop never signals a recorded pid no core answers for; it drops the stale record. - The core-workspace check compares the verified Relaycast workspace id with the project's (GET /v1/workspace), not just the server URL. - Init records every Claude login whose crossSessionInbound the core set and restores each on uninstall or rollback. - connectCore requires a user-owned 0600 socket and a core-shaped hello; agent-relay-probe --version must print exactly "agent-relay-probe ". - Spawn failures fall back to MCP; failed MCP-entry removals are reported; the settings record is merged at write time. - probe: the SessionStart hook says a session joins by itself only when /setup/status reports session.auto_activates (workspace present, folder not excluded); the probe socket client accepts POST /send. - probe-release: real releases only from main. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/probe-release.yml | 8 +- CHANGELOG.md | 2 +- crates/probe/src/relay/claude/session_hook.rs | 14 +- crates/probe/src/relay/cli.rs | 4 +- .../probe/src/relay/local/directory_trait.rs | 4 + crates/probe/src/relay/sessions.rs | 6 + crates/probe/src/relay/setup.rs | 13 + docs/probe-move.md | 28 +- packages/cli/src/cli/commands/init.test.ts | 225 ++++++++++- packages/cli/src/cli/commands/init.ts | 360 +++++++++++++++--- packages/cli/src/cli/lib/probe-core.test.ts | 118 +++++- packages/cli/src/cli/lib/probe-core.ts | 166 +++++++- 12 files changed, 860 insertions(+), 88 deletions(-) diff --git a/.github/workflows/probe-release.yml b/.github/workflows/probe-release.yml index 5c0c95bf83..b454a3bc9e 100644 --- a/.github/workflows/probe-release.yml +++ b/.github/workflows/probe-release.yml @@ -76,6 +76,12 @@ jobs: echo "::error::'$VERSION' is not a version like 15.0.0 or 15.0.0-rc.1" exit 1 fi + # workflow_dispatch runs whatever ref the dispatcher picks, and relay init + # downloads and runs what this publishes: only main is ever released. + if [ "$DRY_RUN" != "true" ] && [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "::error::Real releases are cut from main only (this run is on $GITHUB_REF); dispatch it from main or keep dry_run on." + exit 1 + fi if [ "$DRY_RUN" != "true" ] && gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/probe-v${VERSION}" >/dev/null 2>&1; then echo "::error::Tag probe-v${VERSION} already exists; release a new version." exit 1 @@ -187,7 +193,7 @@ jobs: publish: name: Publish probe-v${{ needs.prepare.outputs.version }} needs: [prepare, build] - if: needs.prepare.outputs.dry_run == 'false' + if: needs.prepare.outputs.dry_run == 'false' && github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 15 permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index c73e0d5cde..7b9c305112 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- `relay init` now delivers messages by injecting them into running agent sessions instead of having agents poll an MCP inbox. It installs the Agent Relay core (`agent-relay-probe`, SHA-256-verified from a `probe-v` GitHub release) into the install directory's `bin`, runs it in the background or attaches to the one the desktop app already runs, and turns on its delivery for each chosen tool: Claude Code's session inbox and SessionStart hook, Codex hooks, the OpenCode and Pi plugins, Devin hooks and Grok's leader socket. These are per-user settings for every project. Re-running removes an MCP entry an earlier `relay init` added, so each session has one identity. `--delivery mcp` keeps the MCP server, which is also the fallback where the core cannot run. +- `relay init` now delivers messages by injecting them into running agent sessions instead of having agents poll an MCP inbox. It installs the Agent Relay core (`agent-relay-probe`, SHA-256-verified from a `probe-v` GitHub release) into the install directory's `bin`, runs it in the background or attaches to the one the desktop app already runs, and turns on its delivery for each chosen tool: Claude Code's session inbox and SessionStart hook, Codex hooks, the OpenCode and Pi plugins, Devin hooks and Grok's leader socket. These are per-user settings for every project. Re-running removes an MCP entry an earlier `relay init` added, so each session has one identity. `--delivery mcp` uses the MCP server instead and turns off the injection `relay init` had turned on for those tools; the MCP server is also the fallback for a tool the core cannot deliver to, so no tool is on both routes. - `relay init --stop` also stops the core `relay init` started (never the desktop app's), and `relay init --uninstall` turns off exactly the injection settings it turned on. - `install.sh` and `relay init` use Relaycast engine 11.1.0, which ships as a standalone binary: `install.sh` now installs `relaycast-engine` into `~/.agentworkforce/relay/bin`, so the local server no longer needs Node.js or a native addon. - The CLI and `@agent-relay/harness-driver` now resolve the broker in this order: `BROKER_BINARY_PATH` / `AGENT_RELAY_BIN`, then `~/.agentworkforce/relay/bin`, then the npm platform package, then `PATH`. A broker in the install directory now wins over one found on `PATH`. diff --git a/crates/probe/src/relay/claude/session_hook.rs b/crates/probe/src/relay/claude/session_hook.rs index a711820750..06bbbfe9f1 100644 --- a/crates/probe/src/relay/claude/session_hook.rs +++ b/crates/probe/src/relay/claude/session_hook.rs @@ -94,7 +94,10 @@ pub fn read_status(http_status: u16, body: &[u8]) -> StatusRead { mcp: McpAccess::parse(session["mcp"].as_str()), }; } - if !on_relay && value["data"]["auto_activate"].as_bool() == Some(true) { + // Only when the core says this session will join: automatic + // activation can be on while there is no workspace yet or the + // session's folder is excluded from it. + if !on_relay && session["auto_activates"].as_bool() == Some(true) { return StatusRead::Joining; } let self_registration = value["data"]["allow_self_registration"] @@ -810,8 +813,15 @@ mod tests { #[test] fn automatic_activation_tells_claude_it_joins_without_asking() { - let body = br#"{"ok":true,"data":{"auto_activate":true,"allow_self_registration":true,"session":{"registered":false}}}"#; + let body = br#"{"ok":true,"data":{"auto_activate":true,"allow_self_registration":true,"session":{"registered":false,"auto_activates":true}}}"#; assert_eq!(read_status(200, body), StatusRead::Joining); + // Activation on, but this session will not join (no workspace, or an + // excluded folder): the usual guidance, not a promise to join. + let skipped = br#"{"ok":true,"data":{"auto_activate":true,"allow_self_registration":true,"session":{"registered":false,"auto_activates":false}}}"#; + assert!(matches!( + read_status(200, skipped), + StatusRead::Registered { .. } + )); // Already on the relay: the identity reminder, whatever the setting. let on = br#"{"ok":true,"data":{"auto_activate":true,"session":{"registered":true,"name":"n","address":"n@m"}}}"#; assert!(matches!(read_status(200, on), StatusRead::OnRelay { .. })); diff --git a/crates/probe/src/relay/cli.rs b/crates/probe/src/relay/cli.rs index e643fd8b22..fc63de8a5d 100644 --- a/crates/probe/src/relay/cli.rs +++ b/crates/probe/src/relay/cli.rs @@ -88,7 +88,8 @@ fn socket_request_allowed(method: &str, path: &str) -> bool { } match method { "POST" => { - path.starts_with("/send?") + path == "/send" + || path.starts_with("/send?") || path.starts_with("/attachments/fetch?file_id=") || path == "/connect/send" || path.starts_with("/connect/send?") @@ -742,6 +743,7 @@ mod tests { #[test] fn socket_client_allows_only_named_session_routes() { for (method, path) in [ + ("POST", "/send"), ("POST", "/send?address=alice"), ("POST", "/attachments/fetch?file_id=file_1"), ("POST", "/connect/send"), diff --git a/crates/probe/src/relay/local/directory_trait.rs b/crates/probe/src/relay/local/directory_trait.rs index 14a2a0356f..4cc7622afb 100644 --- a/crates/probe/src/relay/local/directory_trait.rs +++ b/crates/probe/src/relay/local/directory_trait.rs @@ -364,6 +364,10 @@ impl SessionDirectory for LocalSessions { claude::registry(&self.config.claude, &*self.table).unreadable } + fn auto_activation_excluded(&self, directory: &Path) -> bool { + is_under(directory, &self.config.auto_activate_excluded_dirs) + } + fn fleet_owned(&self, target: &Target) -> Result { self.config .fleet diff --git a/crates/probe/src/relay/sessions.rs b/crates/probe/src/relay/sessions.rs index 5ce440c2dd..0412725af1 100644 --- a/crates/probe/src/relay/sessions.rs +++ b/crates/probe/src/relay/sessions.rs @@ -232,6 +232,12 @@ pub trait SessionDirectory: Send + Sync + 'static { Vec::new() } + /// Whether automatic activation skips sessions in `directory` + /// (`AGENT_RELAY_AUTO_ACTIVATE_EXCLUDED_DIRS`). + fn auto_activation_excluded(&self, _directory: &std::path::Path) -> bool { + false + } + /// Whether a fleet worker owns `target` now, read fresh from every /// broker; an error (safe to show) when any broker cannot be read, so a /// second identity is never created on a guess. May block. diff --git a/crates/probe/src/relay/setup.rs b/crates/probe/src/relay/setup.rs index 0446a00e93..bc13a2fab5 100644 --- a/crates/probe/src/relay/setup.rs +++ b/crates/probe/src/relay/setup.rs @@ -699,6 +699,18 @@ impl Setup { let workspace = account["workspace_id"] .as_str() .map(|id| json!({"id": id, "name": account["workspace_name"]})); + // Whether the core will put this session on the relay by itself: + // automatic activation is on, there is a workspace to join, and the + // session's folder is not one activation skips. + let auto_activates = self.core.store.read(|s| s.auto_activates()) + && self.core.workspace().is_some() + && session.directory.as_deref().is_some_and(|directory| { + !self + .core + .plugins + .sessions + .auto_activation_excluded(std::path::Path::new(directory)) + }); // The signed-in person, as `account.status` names them. let person = &account["person"]; let named = !person["name"].is_null() || !person["email"].is_null(); @@ -728,6 +740,7 @@ impl Setup { "id": session.target.id, "kind": session.target.kind, "registered": registration.is_some(), + "auto_activates": auto_activates, "name": registration.as_ref().map(|r| r.name.clone()), "address": registration.as_ref().map(|r| r.direct_address(&self.core.machine_prefix)), "direct_delivery": self.session_delivers_directly(session.target.kind), diff --git a/docs/probe-move.md b/docs/probe-move.md index f41379ae6b..245a58c64b 100644 --- a/docs/probe-move.md +++ b/docs/probe-move.md @@ -232,8 +232,14 @@ does init start `agent-relay-probe relay serve --headless --cli-managed --socket ` detached (log `/local/core.log`) and wait up to 30 s for it; the core's own `flock` on `core.sock.lock` settles a race, and init then attaches to the winner. Re-running init reuses the running core. -`relay init --stop` stops only the core whose pid init recorded (through -`daemon.shutdown`, SIGTERM as a fallback), never the desktop app's. +`relay init --stop` stops only the core whose pid init recorded and that +still answers on the socket with that pid (through `daemon.shutdown`, SIGTERM +as a fallback), never the desktop app's; a recorded core that no longer +answers is never signalled, since its pid may have been reused, and its +record is dropped. Init speaks only to a socket owned by the user with mode +0600 in a directory only the user can write to, whose `hello` names a bridge +version and pid (Node cannot read peer credentials; the core itself admits +only this user's peers). `agent-relay-probe relay call status.get` shows which core runs. `--cli-managed` (new, requires `--headless`) is headless without the parts @@ -260,10 +266,16 @@ own, named `--`) and, per tool, through `settings.set`: A setting that is already on is left alone; the ones init turned on are recorded in `/local/core-settings.json`, and `relay init --uninstall` (both scopes, or `--scope global`) turns off exactly those. The -core turns Claude's direct delivery off by writing `hold`; init then removes -the key it had added. A tool whose setting fails, and every tool when the core -cannot be had, gets the Relay MCP server instead, as before; `--delivery mcp` -asks for that explicitly. An MCP entry an earlier `relay init` added is +core turns Claude's direct delivery off by writing `hold` on every Claude login +it follows; init records each login file whose `crossSessionInbound` it saw go +from unset to `accept`, and removes the key again from each. Each tool is on +exactly one route. When one of a tool's settings fails, init stops there, turns +off what it had turned on for that tool, and gives it the Relay MCP server; +`auto_activate` is turned on only once a tool is injected, and every tool +gets the MCP server when the core cannot be had. `--delivery mcp` asks for +the MCP server explicitly, and first turns off the recorded settings of those +tools (and `auto_activate` once no injected tool is left); when that fails, +no MCP server is added. An MCP entry an earlier `relay init` added is removed for each injected tool, in both scopes. These are settings of the user account, so they apply in every project @@ -280,7 +292,9 @@ told to ask the person whether to register, which raced auto-activation. **Workspace.** The core serves one workspace per machine (phase 2). After setting up, init calls `workspace.refresh` and warns when the core serves a -different workspace than the project's (for example a desktop signed in to +different workspace than the project's: the verified Relaycast workspace id +decides when both sides know it (one server can host many workspaces), and a +self-hosted setup whose id cannot be compared says so (for example a desktop signed in to another workspace, or a machine-wide active workspace this project does not use), with the `relay workspace switch` command that aligns them. diff --git a/packages/cli/src/cli/commands/init.test.ts b/packages/cli/src/cli/commands/init.test.ts index 1a2429d50d..006b904511 100644 --- a/packages/cli/src/cli/commands/init.test.ts +++ b/packages/cli/src/cli/commands/init.test.ts @@ -882,7 +882,16 @@ describe('engineAssetName', () => { * someone else (the desktop app). */ function fakeCore( - options: { running?: boolean; settings?: Record; workspaceName?: string } = {} + options: { + running?: boolean; + settings?: Record; + workspaceName?: string; + workspaceId?: string; + /** Settings the core refuses to turn on. */ + refuse?: string[]; + /** Claude settings files the core's direct delivery writes, as `set_direct_delivery` does. */ + claudeFiles?: string[]; + } = {} ) { const settings: Record = { auto_activate: false, @@ -903,9 +912,18 @@ function fakeCore( state: 'found', base_url: 'http://127.0.0.1:8787', workspace_name: options.workspaceName ?? 'local', - workspace_id: 'ws_1', + workspace_id: options.workspaceId ?? 'ws_1', verification: 'verified', }; + const writeInbound = (value: string, only: string | undefined) => { + for (const file of options.claudeFiles ?? []) { + const current = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {}; + if (only !== undefined && current.crossSessionInbound !== only) continue; + if (value === 'accept' && current.crossSessionInbound !== undefined) continue; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ ...current, crossSessionInbound: value })); + } + }; const client: CoreClient = { async call(method: string, params: Record = {}): Promise { calls.push([method, params]); @@ -916,11 +934,18 @@ function fakeCore( return { ...settings } as T; case 'settings.set': for (const [key, value] of Object.entries(params)) { + if (value === true && options.refuse?.includes(key)) throw new Error(`${key} is not available.`); if (key === 'direct_delivery_default') { - if (!userChoseInbound) settings.direct_delivery = true; + if (!userChoseInbound) { + settings.direct_delivery = true; + writeInbound('accept', undefined); + } } else { settings[key] = value; - if (key === 'direct_delivery') userChoseInbound = false; + if (key === 'direct_delivery') { + userChoseInbound = false; + if (value === false) writeInbound('hold', 'accept'); + } } } return { ...settings } as T; @@ -976,10 +1001,10 @@ describe('relay init message injection', () => { }) ); expect(core.sets()).toEqual([ - { auto_activate: true }, { direct_delivery_default: true }, { claude_session_hook: true }, { codex_stop_hook: true }, + { auto_activate: true }, ]); const output = logs.join('\n'); expect(output).toContain('Started the Agent Relay core (agent-relay-probe 15.0.0, pid 5151)'); @@ -992,7 +1017,7 @@ describe('relay init message injection', () => { expect(output).not.toContain('only in projects it trusts'); expect(output).toContain('Each session joins Relay on its own'); expect(JSON.parse(fs.readFileSync(path.join(home, 'local', 'core-settings.json'), 'utf8'))).toEqual({ - turnedOn: ['auto_activate', 'direct_delivery', 'claude_session_hook', 'codex_stop_hook'], + turnedOn: ['direct_delivery', 'claude_session_hook', 'codex_stop_hook', 'auto_activate'], }); const again = setup(core.deps); @@ -1138,6 +1163,194 @@ describe('relay init message injection', () => { expect(fs.existsSync(path.join(project, '.mcp.json'))).toBe(false); }); + it('rolls back the settings of a tool whose delivery fails, so it has only the MCP route', async () => { + installedProbe(); + const claudeSettings = path.join(toolHome, '.claude', 'settings.json'); + const core = fakeCore({ refuse: ['claude_session_hook'], claudeFiles: [claudeSettings] }); + fs.mkdirSync(path.dirname(claudeSettings), { recursive: true }); + fs.writeFileSync(claudeSettings, JSON.stringify({ theme: 'dark' })); + const { run, logs } = setup(core.deps); + await run('--tools', 'claude'); + expect(core.settings).toMatchObject({ + direct_delivery: false, + claude_session_hook: false, + auto_activate: false, + }); + expect(JSON.parse(fs.readFileSync(claudeSettings, 'utf8'))).toEqual({ theme: 'dark' }); + expect(mcpJson().mcpServers).toHaveProperty('agent-relay'); + expect(logs.join('\n')).toMatch(/Claude Code\s+not injected: claude_session_hook is not available\./); + expect(fs.existsSync(path.join(home, 'local', 'core-settings.json'))).toBe(false); + }); + + it('turns off what it injected before adding the MCP server for --delivery mcp', async () => { + installedProbe(); + const core = fakeCore({ running: true }); + await setup(core.deps).run('--tools', 'claude,codex'); + expect(core.settings).toMatchObject({ + auto_activate: true, + claude_session_hook: true, + codex_stop_hook: true, + }); + + const toMcp = setup(core.deps); + await toMcp.run('--tools', 'claude', '--delivery', 'mcp'); + // Claude leaves injection; Codex keeps it, and so automatic activation stays. + expect(core.settings).toMatchObject({ + auto_activate: true, + direct_delivery: false, + claude_session_hook: false, + codex_stop_hook: true, + }); + expect(mcpJson().mcpServers).toHaveProperty('agent-relay'); + expect(toMcp.logs.join('\n')).toContain( + 'turned off what relay init had turned on (direct_delivery, claude_session_hook)' + ); + expect(JSON.parse(fs.readFileSync(path.join(home, 'local', 'core-settings.json'), 'utf8'))).toEqual({ + turnedOn: ['codex_stop_hook', 'auto_activate'], + }); + + await setup(core.deps).run('--tools', 'codex', '--delivery', 'mcp'); + expect(core.settings).toMatchObject({ auto_activate: false, codex_stop_hook: false }); + expect(fs.existsSync(path.join(home, 'local', 'core-settings.json'))).toBe(false); + }); + + it('adds no MCP server for --delivery mcp when injection it turned on cannot be turned off', async () => { + installedProbe(); + const core = fakeCore({ running: true }); + await setup(core.deps).run('--tools', 'claude'); + // The core has gone and one started for the change never answers. + const { run, errors } = setup({ + ...core.deps, + connectCore: vi.fn(async () => undefined), + isAlive: vi.fn(() => false), + }); + await expect(run('--tools', 'claude', '--delivery', 'mcp')).rejects.toEqual(new ExitSignal(1)); + expect(errors.join('\n')).toContain('Could not reach the Agent Relay core to turn off message injection'); + expect(fs.existsSync(path.join(project, '.mcp.json'))).toBe(false); + expect(core.settings.claude_session_hook).toBe(true); + }); + + it('never signals the pid of a recorded core that no longer answers', async () => { + installedProbe(); + const core = fakeCore(); + await setup(core.deps).run('--tools', 'opencode'); + // The core exits and its pid is reused by an unrelated process. + await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--stop'); + expect(fs.existsSync(path.join(home, 'local', 'core.json'))).toBe(false); + fs.writeFileSync( + path.join(home, 'local', 'core.json'), + JSON.stringify({ pid: 5151, socket: path.join(home, 'no-core.sock'), binary: '/b' }) + ); + const stop = vi.fn(); + const later = setup({ ...core.deps, isAlive: vi.fn(() => true), stop }); + await later.run('--stop'); + expect(stop).not.toHaveBeenCalledWith(5151); + expect(later.logs.join('\n')).toContain('The Agent Relay core relay init started is no longer running.'); + expect(fs.existsSync(path.join(home, 'local', 'core.json'))).toBe(false); + }); + + it('warns when the core serves another workspace on the same self-hosted server', async () => { + installedProbe(); + const core = fakeCore({ running: true, workspaceId: 'rw_core' }); + core.settings.auto_activate = true; + const workspaceFetch = (id: string) => + vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)); + if (url.pathname === '/v1/workspace') return jsonResponse(200, { ok: true, data: { id, name: 'x' } }); + return jsonResponse(404); + }) as never; + // The core's workspace is on http://127.0.0.1:8787, as this server is. + const other = setup({ ...core.deps, fetch: workspaceFetch('rw_project') }); + await other.run( + '--url', + 'http://127.0.0.1:8787', + '--key', + 'rk_live_b', + '--name', + 'b', + '--tools', + 'codex' + ); + expect(other.logs.join('\n')).toContain('serves workspace "local" (http://127.0.0.1:8787), not "b"'); + + const same = setup({ ...core.deps, fetch: workspaceFetch('rw_core') }); + await same.run('--url', 'http://127.0.0.1:8787', '--key', 'rk_live_a', '--name', 'a', '--tools', 'codex'); + expect(same.logs.join('\n')).not.toContain('agents here join that workspace'); + expect(same.logs.join('\n')).not.toContain('Could not confirm'); + expect(same.logs.join('\n')).not.toContain('rk_live_'); + + const unknown = setup({ ...core.deps, fetch: workspaceFetch('') }); + await unknown.run( + '--url', + 'http://127.0.0.1:8787', + '--key', + 'rk_live_c', + '--name', + 'c', + '--tools', + 'codex' + ); + expect(unknown.logs.join('\n')).toContain('Could not confirm that the Agent Relay core serves "c"'); + }); + + it('restores crossSessionInbound on every Claude login the core changed', async () => { + installedProbe(); + const own = path.join(toolHome, '.claude', 'settings.json'); + const work = path.join(toolHome, '.claude-work', 'settings.json'); + const chosen = path.join(toolHome, '.claude-mine', 'settings.json'); + for (const file of [own, work, chosen]) fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(path.join(toolHome, '.claude-work', '.claude.json'), '{}'); + fs.writeFileSync(path.join(toolHome, '.claude-mine', '.claude.json'), '{}'); + fs.writeFileSync(own, JSON.stringify({ theme: 'dark' })); + fs.writeFileSync(work, JSON.stringify({})); + fs.writeFileSync(chosen, JSON.stringify({ crossSessionInbound: 'accept' })); + const core = fakeCore({ + running: true, + settings: { auto_activate: true, claude_session_hook: true }, + claudeFiles: [own, work, chosen], + }); + await setup(core.deps).run('--tools', 'claude'); + expect(JSON.parse(fs.readFileSync(work, 'utf8'))).toEqual({ crossSessionInbound: 'accept' }); + + await setup(core.deps).run('--uninstall'); + expect(JSON.parse(fs.readFileSync(own, 'utf8'))).toEqual({ theme: 'dark' }); + expect(JSON.parse(fs.readFileSync(work, 'utf8'))).toEqual({}); + // The person's own accept was theirs: the core turns it to hold, and init does not remove it. + expect(JSON.parse(fs.readFileSync(chosen, 'utf8'))).toEqual({ crossSessionInbound: 'hold' }); + }); + + it('falls back to the MCP server when the core cannot be started', async () => { + installedProbe(); + const core = fakeCore(); + const { run, logs } = setup({ + ...core.deps, + startCore: vi.fn(() => { + throw new Error('spawn EACCES'); + }), + }); + await run('--tools', 'claude'); + expect(logs.join('\n')).toContain('the Agent Relay core could not be started (spawn EACCES)'); + expect(mcpJson().mcpServers).toHaveProperty('agent-relay'); + }); + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'says so when an earlier MCP entry could not be removed', + async () => { + await setup().run('--tools', 'claude', '--delivery', 'mcp'); + installedProbe(); + fs.chmodSync(project, 0o555); + try { + const { run, logs } = setup(fakeCore().deps); + await run('--tools', 'claude'); + expect(logs.join('\n')).toMatch( + /Claude Code\s+could not remove the earlier Relay MCP entry from \.mcp\.json[^]*second identity/ + ); + } finally { + fs.chmodSync(project, 0o755); + } + } + ); + it('falls back to the MCP server when the core cannot be had', async () => { const { run, logs } = setup(); await run('--tools', 'claude'); diff --git a/packages/cli/src/cli/commands/init.ts b/packages/cli/src/cli/commands/init.ts index 54cdeef2c7..73b90d4412 100644 --- a/packages/cli/src/cli/commands/init.ts +++ b/packages/cli/src/cli/commands/init.ts @@ -51,22 +51,26 @@ import { type EnginePlatform, } from '../lib/relaycast-engine-install.js'; import { + AUTO_ACTIVATE, CORE_START_TIMEOUT_MS, PROBE_DELIVERY, PROBE_VERSION, connectCore, coreSocketPath, + deliveryKeys, disableDelivery, enableDelivery, probeAssetName, probeAssetUrl, readCoreState, - readTurnedOn, + readDeliveryRecord, + readProbeVersion, startCoreProcess, writePrivateJson, type CoreClient, type CoreState, type CoreStatus, + type DeliveryRecord, type DeliveryResult, } from '../lib/probe-core.js'; import { shellQuote } from '../lib/agent-registration.js'; @@ -298,7 +302,7 @@ export function withInitDefaults(overrides: Partial = { connectCore: (socket) => connectCore(socket), startCore: startCoreProcess, downloadProbe: fetchDownload, - probeVersion: readEngineVersion, + probeVersion: readProbeVersion, ...overrides, }; } @@ -403,7 +407,15 @@ async function ensureLocalServer(deps: InitCommandDependencies, port: number): P /** The server answered and refused the key, as opposed to being unreachable or failing. */ class WorkspaceKeyRejectedError extends Error {} -async function verifyWorkspace(deps: InitCommandDependencies, origin: string, key: string): Promise { +/** + * Check a workspace key against its server. + * @returns The workspace's Relaycast id, when the server names it + */ +async function verifyWorkspace( + deps: InitCommandDependencies, + origin: string, + key: string +): Promise { let response: Response; try { response = await deps.fetch(new URL('/v1/workspace', origin), { @@ -423,6 +435,27 @@ async function verifyWorkspace(deps: InitCommandDependencies, origin: string, ke throw new WorkspaceKeyRejectedError(`${origin} rejected the workspace key.`); } if (!response.ok) throw new Error(`${origin} answered ${response.status} when checking the workspace.`); + return workspaceIdIn(response); +} + +/** The `id` of a `GET /v1/workspace` answer (`{ok, data: {id}}`), read leniently. */ +async function workspaceIdIn(response: Response): Promise { + try { + const body = (await response.json()) as { data?: { id?: unknown }; id?: unknown } | null; + const id = body?.data?.id ?? body?.id; + return typeof id === 'string' && id.trim() ? id.trim() : undefined; + } catch { + return undefined; + } +} + +/** The workspace id a key belongs to, when its server can say; never throws. */ +async function workspaceIdOf( + deps: InitCommandDependencies, + origin: string, + key: string +): Promise { + return verifyWorkspace(deps, origin, key).catch(() => undefined); } function parsePort(value: string): number { @@ -465,6 +498,8 @@ interface ServerSetup { origin: string; /** The workspace saved for this project. */ workspace: string; + /** Its Relaycast workspace id (`GET /v1/workspace`), when the server named it. */ + workspaceId?: string; /** Another workspace left active machine-wide, when there was one. */ keptActive?: string; } @@ -528,12 +563,13 @@ async function setupServer(deps: InitCommandDependencies, choice: ServerChoice): if (!url || !key) throw new Error('Pass --url and --key together for your own server.'); const origin = configuredOrigin(url, '--url'); if (!origin) throw new Error('--url is empty.'); - await verifyWorkspace(deps, origin, key); + const workspaceId = await verifyWorkspace(deps, origin, key); guardCredentialOrigin(key, url); const name = choice.name ?? new URL(origin).hostname; const { lines, ...saved } = saveWorkspace(name, key); return { ...saved, + workspaceId, mode: 'own', origin, headline: `Connected to your server at ${origin} as workspace "${name}".`, @@ -548,6 +584,7 @@ async function setupServer(deps: InitCommandDependencies, choice: ServerChoice): const { lines, ...saved } = saveWorkspace(name, relay.workspaceKey); return { ...saved, + workspaceId: await workspaceIdOf(deps, HOSTED_ORIGIN, relay.workspaceKey), mode: 'hosted', origin: HOSTED_ORIGIN, headline: `Created hosted workspace "${name}".`, @@ -564,13 +601,15 @@ async function setupServer(deps: InitCommandDependencies, choice: ServerChoice): // Re-running init keeps the same local workspace and its agents. const stored = readWorkspaceStore().workspaces[name]?.key; let workspaceKey = stored && boundOrigin(stored) === origin ? stored : undefined; + let workspaceId: string | undefined; if (workspaceKey) { // Only a key the server refuses is replaced. A timeout or server // error is reported instead, so a hiccup never swaps the workspace // (and its agents and messages) for a new one. - await verifyWorkspace(deps, origin, workspaceKey).catch((error: unknown) => { + workspaceId = await verifyWorkspace(deps, origin, workspaceKey).catch((error: unknown) => { if (!(error instanceof WorkspaceKeyRejectedError)) throw error; workspaceKey = undefined; + return undefined; }); } if (!workspaceKey) { @@ -579,10 +618,12 @@ async function setupServer(deps: InitCommandDependencies, choice: ServerChoice): if (!relay.workspaceKey) throw new Error('The local server did not return a workspace key.'); workspaceKey = relay.workspaceKey; guardCredentialOrigin(workspaceKey, origin); + workspaceId = await workspaceIdOf(deps, origin, workspaceKey); } const { lines, ...saved } = saveWorkspace(name, workspaceKey); return { ...saved, + workspaceId, mode: 'local', origin, headline: `Relay is running on this machine (${origin}), workspace "${name}". No account needed.`, @@ -800,7 +841,12 @@ async function ensureCore(deps: InitCommandDependencies): Promise undefined); @@ -847,6 +893,20 @@ function sameOrigin(a: string | null | undefined, b: string): boolean { } } +/** + * Whether the core serves this project's workspace; undefined when it cannot + * be told. One server can host many workspaces, so the verified workspace + * ids decide whenever both are known; names stand in only for workspaces + * init named itself (a self-hosted one is saved under the person's name). + */ +function sameWorkspace(workspace: CoreStatus['workspace'], setup: ServerSetup): boolean | undefined { + if (!sameOrigin(workspace.base_url, setup.origin)) return false; + const coreId = workspace.verification === 'verified' ? workspace.workspace_id : undefined; + if (coreId && setup.workspaceId) return coreId === setup.workspaceId; + if (setup.mode === 'own') return undefined; + return !workspace.workspace_name || workspace.workspace_name === setup.workspace; +} + /** * The core serves one workspace per machine (the machine-wide active one, * unless its environment names another). Say so when that is not this @@ -875,9 +935,14 @@ async function coreWorkspaceNote( if (workspace.verification === 'rejected') { return `The Agent Relay core's workspace key was refused by ${workspace.base_url ?? 'its server'}.`; } - const sameName = - setup.mode === 'own' || !workspace.workspace_name || workspace.workspace_name === setup.workspace; - if (sameOrigin(workspace.base_url, setup.origin) && sameName) return undefined; + const same = sameWorkspace(workspace, setup); + if (same === true) return undefined; + if (same === undefined) { + return ( + `Could not confirm that the Agent Relay core serves "${setup.workspace}" (${setup.origin} names no ` + + `workspace id it can compare). See which one it serves with: agent-relay-probe relay call status.get` + ); + } const served = workspace.workspace_name ? `workspace "${workspace.workspace_name}"` : 'another workspace'; return ( `The Agent Relay core on this machine serves ${served} (${workspace.base_url ?? 'unknown server'}), ` + @@ -905,9 +970,29 @@ async function setupInjection( const session = await ensureCore(deps); if ('reason' in session) return session; try { - const { results, turnedOn } = await enableDelivery(session.client, tools); - const recorded = new Set([...readTurnedOn(deliveryRecordPath()), ...turnedOn]); - if (recorded.size > 0) writePrivateJson(deliveryRecordPath(), { turnedOn: [...recorded] }); + const record = readDeliveryRecord(deliveryRecordPath()); + const ctx = deps.toolContext(); + // The Claude logins whose crossSessionInbound is unset: the ones the + // direct-delivery default may set, and uninstall must unset again. + const unset = tools.includes('claude') + ? claudeLoginSettings(ctx).filter((file) => claudeInbound(file) === null) + : []; + const { results, turnedOn, turnedOff } = await enableDelivery(session.client, tools, record.turnedOn); + const accepted = unset.filter((file) => claudeInbound(file) === 'accept'); + // Applied to the record as it is now, not as it was read: another + // relay init may have recorded its own changes meanwhile. + updateDeliveryRecord((current) => { + if (turnedOff.includes('direct_delivery')) { + // Rolled back: the core wrote hold where init had set accept, in this run or an earlier one. + restoreClaudeInbound([...new Set([...current.claudeInbound, ...unset])]); + } + return { + turnedOn: [...new Set([...current.turnedOn, ...turnedOn])].filter((key) => !turnedOff.includes(key)), + claudeInbound: turnedOff.includes('direct_delivery') + ? [] + : [...new Set([...current.claudeInbound, ...accepted])], + }; + }); const workspaceNote = await coreWorkspaceNote(deps, session.client, setup); return { core: describeCore(session), results, workspaceNote }; } catch (error) { @@ -935,13 +1020,26 @@ const INJECTION_SCOPE_NOTE = /** * Remove the MCP entry an earlier `relay init` added for tools whose messages * are now injected, in both scopes: the session's identity is the core's, and - * a second one through MCP would split its messages. + * a second one through MCP would split its messages. A removal that failed is + * returned too, so the person can finish it. */ function removeSupersededMcp(tools: ToolId[], ctx: ToolContext): ToolResult[] { return tools.flatMap((id) => (['project', 'global'] as const) .map((scope) => uninstallTool(findToolTarget(id)!, { ctx, scope })) - .filter((result) => result.status === 'removed') + .filter((result) => result.status === 'removed' || result.status === 'failed') + ); +} + +function describeSupersededMcp(result: ToolResult, ctx: ToolContext): string { + const where = displayPath(result.path, ctx); + if (result.status === 'removed') { + return `✓ ${result.label.padEnd(18)} removed the earlier Relay MCP entry from ${where}: one identity per session`; + } + return ( + `✗ ${result.label.padEnd(18)} could not remove the earlier Relay MCP entry from ${where}` + + `${result.message ? ` (${result.message})` : ''}.\n Remove its agent-relay entry by hand: ` + + 'until then its sessions also join through MCP, as a second identity.' ); } @@ -955,30 +1053,168 @@ function injectionNextStep(injected: ToolId[]): string[] { ]; } +/** The Claude Code config folder the CLI's environment uses. */ +function claudeConfigDir(ctx: ToolContext): string { + const configured = ctx.env.CLAUDE_CONFIG_DIR; + return configured && path.isAbsolute(configured) ? configured : path.join(ctx.home, '.claude'); +} + /** - * The core turns Claude Code's direct delivery off by writing - * `crossSessionInbound: "hold"` (Claude Code's own default). When relay init - * had turned it on, the key was absent before (the default only fills an - * absent key), so it is removed again rather than left as `hold`. + * The settings files of every Claude login the core's direct delivery + * follows (`ClaudeSettings::login_settings` in crates/probe): this login, + * then the `.claude` and `.claude-` folders beside it, the folders in + * `AGENT_RELAY_CLAUDE_CONFIG_DIRS`, and those the core recorded in + * `claude-config-dirs.json`, each counted only when it holds `.claude.json`. */ -function restoreClaudeInbound(ctx: ToolContext): void { - const configured = ctx.env.CLAUDE_CONFIG_DIR; - const dir = configured && path.isAbsolute(configured) ? configured : path.join(ctx.home, '.claude'); - const file = path.join(dir, 'settings.json'); +function claudeLoginSettings(ctx: ToolContext): string[] { + const own = claudeConfigDir(ctx); + const others = [...claudeSiblings(own), ...claudeEnvironmentDirs(ctx), ...claudeRecordedDirs()]; + const seen = new Set(); + const files: string[] = []; + for (const folder of [own, ...others]) { + if (folder !== own && !fs.existsSync(path.join(folder, '.claude.json'))) continue; + const file = path.join(folder, 'settings.json'); + const real = realOrResolved(file); + if (seen.has(real)) continue; + seen.add(real); + files.push(file); + } + return files; +} + +function realOrResolved(file: string): string { try { - const settings = JSON.parse(fs.readFileSync(file, 'utf8')) as Record; - if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return; - if (settings.crossSessionInbound !== 'hold') return; - delete settings.crossSessionInbound; - writeKeepingMode(file, `${JSON.stringify(settings, null, 2)}\n`); + return fs.realpathSync(file); } catch { - // Unreadable or not JSON: left as the core wrote it. + return path.resolve(file); } } -/** Turn off what relay init turned on in the core, starting a core for it when none runs. */ -async function uninstallInjection(deps: InitCommandDependencies): Promise { - const keys = readTurnedOn(deliveryRecordPath()); +/** The `.claude` and `.claude-` folders beside `own`, as account switchers keep. */ +function claudeSiblings(own: string): string[] { + const parent = path.dirname(own); + try { + return fs + .readdirSync(parent) + .sort() + .filter((name) => name === '.claude' || name.startsWith('.claude-')) + .map((name) => path.join(parent, name)); + } catch { + return []; + } +} + +/** `AGENT_RELAY_CLAUDE_CONFIG_DIRS`, as the core reads it. */ +function claudeEnvironmentDirs(ctx: ToolContext): string[] { + return (ctx.env.AGENT_RELAY_CLAUDE_CONFIG_DIRS ?? '') + .split(path.delimiter) + .map((entry) => entry.trim().replace(/^~(?=$|[\\/])/, ctx.home)) + .filter((folder) => folder && path.isAbsolute(folder)); +} + +/** The folders added in the desktop's Settings or seen running, from `claude-config-dirs.json`. */ +function claudeRecordedDirs(): string[] { + try { + const recorded = JSON.parse( + fs.readFileSync(path.join(relayHome(), 'claude-config-dirs.json'), 'utf8') + ) as { added?: unknown; running?: unknown } | null; + return [recorded?.added, recorded?.running] + .flatMap((list) => (Array.isArray(list) ? list : [])) + .filter((folder): folder is string => typeof folder === 'string' && path.isAbsolute(folder)); + } catch { + return []; + } +} + +/** A Claude settings file's `crossSessionInbound`, `null` when unset, undefined when unreadable. */ +function claudeInbound(file: string): string | null | undefined { + let text: string; + try { + text = fs.readFileSync(file, 'utf8'); + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOENT' ? null : undefined; + } + try { + const settings = JSON.parse(text) as unknown; + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return undefined; + const value = (settings as Record).crossSessionInbound; + return value === undefined ? null : typeof value === 'string' ? value : undefined; + } catch { + return undefined; + } +} + +/** + * The core turns Claude Code's direct delivery off by writing + * `crossSessionInbound: "hold"` (Claude Code's own default) on every login + * where it was on. Where relay init had turned it on, the key was absent + * before (the default only fills an absent key), so it is removed again + * rather than left as `hold`. + * @param files - The settings files init recorded as changed + */ +function restoreClaudeInbound(files: string[]): void { + for (const file of files) { + try { + const settings = JSON.parse(fs.readFileSync(file, 'utf8')) as Record; + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) continue; + if (settings.crossSessionInbound !== 'hold') continue; + delete settings.crossSessionInbound; + writeKeepingMode(file, `${JSON.stringify(settings, null, 2)}\n`); + } catch { + // Unreadable or not JSON: left as the core wrote it. + } + } +} + +/** The Claude files to restore: the recorded ones, else (an older record) this login's. */ +function recordedClaudeFiles(record: DeliveryRecord, ctx: ToolContext): string[] { + return record.claudeInbound.length > 0 + ? record.claudeInbound + : [path.join(claudeConfigDir(ctx), 'settings.json')]; +} + +/** + * Change the record of what init turned on, reading it just before the + * write so concurrent runs do not drop each other's changes. + */ +function updateDeliveryRecord(change: (current: DeliveryRecord) => DeliveryRecord): void { + writeDeliveryRecord(change(readDeliveryRecord(deliveryRecordPath()))); +} + +/** Save what init turned on; no record at all once nothing is. */ +function writeDeliveryRecord(record: DeliveryRecord): void { + if (record.turnedOn.length === 0) { + fs.rmSync(deliveryRecordPath(), { force: true }); + return; + } + writePrivateJson(deliveryRecordPath(), { + turnedOn: record.turnedOn, + ...(record.claudeInbound.length > 0 ? { claudeInbound: record.claudeInbound } : {}), + }); +} + +/** + * The recorded settings to turn off when `tools` leave injection: theirs, + * and automatic activation once no tool init injects into is left. + */ +function recordedKeysFor(tools: ToolId[]): string[] { + const recorded = readDeliveryRecord(deliveryRecordPath()).turnedOn; + const leaving = new Set(tools.flatMap(deliveryKeys)); + const keys = recorded.filter((key) => leaving.has(key)); + const remaining = recorded.filter((key) => !leaving.has(key) && key !== AUTO_ACTIVATE.read); + if (keys.length > 0 && remaining.length === 0 && recorded.includes(AUTO_ACTIVATE.read)) { + keys.push(AUTO_ACTIVATE.read); + } + return keys; +} + +/** + * Turn off recorded core settings, starting a core for it when none runs + * (and stopping that one again). Throws, keeping the record of what stayed + * on, when any could not be turned off. + * @returns The keys turned off + */ +async function turnOffInjection(deps: InitCommandDependencies, keys: string[]): Promise { if (keys.length === 0) return []; const session = await ensureCore(deps); if ('reason' in session) { @@ -986,15 +1222,19 @@ async function uninstallInjection(deps: InitCommandDependencies): Promise !failures.some((failure) => failure.startsWith(`${key}:`))); + const ctx = deps.toolContext(); + updateDeliveryRecord((current) => { + if (done.includes('direct_delivery')) restoreClaudeInbound(recordedClaudeFiles(current, ctx)); + return { + turnedOn: current.turnedOn.filter((key) => !done.includes(key)), + claudeInbound: done.includes('direct_delivery') ? [] : current.claudeInbound, + }; + }); if (failures.length > 0) { - writePrivateJson(deliveryRecordPath(), { - turnedOn: keys.filter((key) => failures.some((failure) => failure.startsWith(`${key}:`))), - }); throw new Error(`Some injection settings could not be turned off: ${failures.join('; ')}`); } - fs.rmSync(deliveryRecordPath(), { force: true }); - if (keys.includes('direct_delivery')) restoreClaudeInbound(deps.toolContext()); - return keys; + return done; } finally { session.client.close(); // A core started only to undo the settings is stopped again. @@ -1002,6 +1242,11 @@ async function uninstallInjection(deps: InitCommandDependencies): Promise { + return turnOffInjection(deps, readDeliveryRecord(deliveryRecordPath()).turnedOn); +} + /** * Stop the core relay init started, never one it attached to. * @param quiet - Say nothing (used after a core started only for `--uninstall`) @@ -1026,9 +1271,10 @@ async function stopCore(deps: InitCommandDependencies, quiet = false): Promise 0; + const injectionKept = readDeliveryRecord(deliveryRecordPath()).turnedOn.length > 0; if (injectionKept && o.scope !== 'project' && o.tools === undefined) { turnedOff = await uninstallInjection(deps); deps.log(`✓ Message injection turned off what relay init turned on (${turnedOff.join(', ')})`); @@ -1110,6 +1356,8 @@ interface DeliveryPlan { injected: ToolId[]; mcp: ToolId[]; removedMcp: ToolResult[]; + /** Core settings init had turned on for tools now on the MCP server, turned off again. */ + turnedOff: string[]; } /** Set up injection for `tools` when wanted; whatever it cannot serve goes to the MCP server. */ @@ -1120,19 +1368,34 @@ async function planDelivery( setup: ServerSetup, ctx: ToolContext ): Promise { - if (delivery === 'mcp' || tools.length === 0) return { injected: [], mcp: tools, removedMcp: [] }; + if (tools.length === 0) return { injected: [], mcp: tools, removedMcp: [], turnedOff: [] }; + if (delivery === 'mcp') { + // One route per tool: what init injected into these tools is turned off + // before their MCP server is added, or the MCP server is not added. + const turnedOff = await turnOffInjection(deps, recordedKeysFor(tools)); + return { injected: [], mcp: tools, removedMcp: [], turnedOff }; + } const injection = await setupInjection(deps, tools, setup); if ('reason' in injection) - return { unavailable: injection.reason, injected: [], mcp: tools, removedMcp: [] }; + return { unavailable: injection.reason, injected: [], mcp: tools, removedMcp: [], turnedOff: [] }; const injected = injection.results.filter((r) => r.status !== 'failed').map((r) => r.id); return { injection, injected, mcp: injection.results.filter((r) => r.status === 'failed').map((r) => r.id), removedMcp: removeSupersededMcp(injected, ctx), + turnedOff: [], }; } +/** The line saying injection was turned off for a switch to the MCP server. */ +function turnedOffLine(keys: string[]): string { + return ( + `✓ Message injection turned off what relay init had turned on (${keys.join(', ')}): ` + + 'these tools now use only the Relay MCP server, one identity per session' + ); +} + function unavailableLine(reason: string): string { return ( `Message injection is not available (${reason}). Using the Relay MCP server instead: ` + @@ -1145,11 +1408,7 @@ function injectionLines(plan: DeliveryPlan, ctx: ToolContext): string[] { return [ plan.injection.core, ...plan.injection.results.map(describeDelivery), - ...plan.removedMcp.map( - (result) => - `✓ ${result.label.padEnd(18)} removed the earlier Relay MCP entry from ${displayPath(result.path, ctx)}: ` + - 'one identity per session' - ), + ...plan.removedMcp.map((result) => describeSupersededMcp(result, ctx)), ...(plan.injection.workspaceNote ? [`! ${plan.injection.workspaceNote}`] : []), INJECTION_SCOPE_NOTE, ]; @@ -1176,6 +1435,10 @@ async function runPlain(deps: InitCommandDependencies, o: InitOptions): Promise< deps.log(''); deps.log(unavailableLine(plan.unavailable)); } + if (plan.turnedOff.length > 0) { + deps.log(''); + deps.log(turnedOffLine(plan.turnedOff)); + } const installed: ToolId[] = []; if (plan.mcp.length > 0) { @@ -1370,6 +1633,7 @@ async function runGuided(deps: InitCommandDependencies, o: InitOptions): Promise injecting.stop('Message injection is not available.'); p.warn(unavailableLine(plan.unavailable)); } + if (plan.turnedOff.length > 0) p.info(turnedOffLine(plan.turnedOff)); let results: ToolResult[] = []; if (plan.mcp.length > 0) { diff --git a/packages/cli/src/cli/lib/probe-core.test.ts b/packages/cli/src/cli/lib/probe-core.test.ts index 8938a697c4..1bd07ea7df 100644 --- a/packages/cli/src/cli/lib/probe-core.test.ts +++ b/packages/cli/src/cli/lib/probe-core.test.ts @@ -15,6 +15,8 @@ import { probeAssetName, probeAssetUrl, readCoreState, + readDeliveryRecord, + readProbeVersion, readTurnedOn, type CoreClient, } from './probe-core.js'; @@ -101,7 +103,11 @@ describe.skipIf(process.platform === 'win32')('connectCore', () => { }); /** A tiny NDJSON core: answers hello and status.get, sends an event in between, refuses the rest. */ - async function serve(socket: string, seen: string[]): Promise { + async function serve( + socket: string, + seen: string[], + greeting: unknown = { bridge_version: BRIDGE_VERSION, pid: 42 } + ): Promise { server = net.createServer((connection) => { let buffer = ''; connection.on('data', (chunk) => { @@ -121,7 +127,7 @@ describe.skipIf(process.platform === 'win32')('connectCore', () => { ); const reply = request.method === 'hello' - ? { result: { bridge_version: BRIDGE_VERSION } } + ? { result: greeting } : request.method === 'status.get' ? { result: { daemon: { pid: 42, version: '15.0.0', socket } } } : { error: { code: -32601, message: 'Unknown method.' } }; @@ -130,6 +136,8 @@ describe.skipIf(process.platform === 'win32')('connectCore', () => { }); }); await new Promise((resolve) => server!.listen(socket, resolve)); + // The core binds its socket 0600. + fs.chmodSync(socket, 0o600); } it('greets the core, skips events, and returns results and errors by id', async () => { @@ -154,6 +162,53 @@ describe.skipIf(process.platform === 'win32')('connectCore', () => { fs.chmodSync(dir, 0o777); await expect(connectCore(socket, 500)).rejects.toThrow('only you can write to'); }); + + it('refuses a socket others can use, which the core never binds', async () => { + const socket = path.join(dir, 'core.sock'); + await serve(socket, []); + fs.chmodSync(socket, 0o666); + await expect(connectCore(socket, 500)).rejects.toThrow('only you can use'); + }); + + it('refuses a plain file in place of the socket', async () => { + const socket = path.join(dir, 'core.sock'); + fs.writeFileSync(socket, '', { mode: 0o600 }); + await expect(connectCore(socket, 500)).rejects.toThrow('only you can use'); + }); + + it("refuses a peer whose greeting is not the core's", async () => { + const socket = path.join(dir, 'core.sock'); + await serve(socket, [], { ok: true }); + await expect(connectCore(socket, 2_000)).rejects.toThrow('is not an Agent Relay core'); + }); +}); + +describe.skipIf(process.platform === 'win32')('readProbeVersion', () => { + let dir: string; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'probe-version-')); + }); + afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); + + function script(output: string): string { + const file = path.join(dir, `bin-${Math.random().toString(36).slice(2)}`); + fs.writeFileSync(file, `#!/bin/sh\nprintf '%s\\n' '${output}'\n`, { mode: 0o755 }); + return file; + } + + it("reads the core's own --version line, release or development", () => { + expect(readProbeVersion(script('agent-relay-probe 15.0.0'))).toBe('15.0.0'); + expect(readProbeVersion(script('agent-relay-probe 15.0.1-dev+0123456789ab'))).toBe( + '15.0.1-dev+0123456789ab' + ); + }); + + it('refuses another program that prints the version somewhere', () => { + expect(readProbeVersion(script('relaycast-engine 15.0.0'))).toBeUndefined(); + expect(readProbeVersion(script('not agent-relay-probe 15.0.0'))).toBeUndefined(); + expect(readProbeVersion(script('agent-relay-probe 15.0.0 (and more)'))).toBeUndefined(); + expect(readProbeVersion(path.join(dir, 'missing'))).toBeUndefined(); + }); }); describe('enableDelivery', () => { @@ -201,6 +256,57 @@ describe('enableDelivery', () => { expect(outcome.turnedOn).toEqual(['grok_leader']); }); + it('rolls back the settings of a tool whose delivery fails, so MCP is its only route', async () => { + const core = client({ auto_activate: false }, ['claude_session_hook']); + const outcome = await enableDelivery(core, ['claude']); + expect(core.sets).toEqual([ + { direct_delivery_default: true }, + { claude_session_hook: true }, + { direct_delivery: false }, + ]); + expect(outcome.results[0]).toMatchObject({ id: 'claude', status: 'failed' }); + // Nothing injected: automatic activation is never turned on. + expect(outcome.turnedOn).toEqual([]); + expect(outcome.turnedOff).toEqual(['direct_delivery']); + }); + + it('turns off what an earlier run turned on for a tool that now fails', async () => { + const core = client({ auto_activate: true, direct_delivery: true, claude_session_hook: false }, [ + 'claude_session_hook', + ]); + const outcome = await enableDelivery(core, ['claude'], ['auto_activate', 'direct_delivery']); + expect(core.sets).toEqual([ + { claude_session_hook: true }, + { direct_delivery: false }, + { auto_activate: false }, + ]); + expect(outcome.turnedOff).toEqual(['direct_delivery', 'auto_activate']); + }); + + it('never turns off a setting init did not turn on', async () => { + const core = client({ auto_activate: true, direct_delivery: true }, ['claude_session_hook']); + const outcome = await enableDelivery(core, ['claude']); + expect(core.sets).toEqual([{ claude_session_hook: true }]); + expect(outcome.turnedOff).toEqual([]); + }); + + it('sends every tool to MCP and rolls back when automatic activation fails', async () => { + const core = client({ auto_activate: false }, ['auto_activate']); + const outcome = await enableDelivery(core, ['codex', 'grok']); + expect(core.sets).toEqual([ + { codex_stop_hook: true }, + { grok_leader: true }, + { auto_activate: true }, + { codex_stop_hook: false }, + { grok_leader: false }, + ]); + expect(outcome.results.map((r) => r.status)).toEqual(['failed', 'failed']); + expect(outcome.results[0].message).toContain( + 'automatic activation: auto_activate is not available here.' + ); + expect(outcome.turnedOn).toEqual([]); + }); + it("leaves the person's crossSessionInbound alone and says messages are held", async () => { const core = client({ auto_activate: true, claude_session_hook: true, direct_delivery: false }); const outcome = await enableDelivery(core, ['claude']); @@ -232,4 +338,12 @@ describe('records', () => { expect(readTurnedOn(file)).toEqual(['auto_activate', 'codex_stop_hook']); expect(readTurnedOn(path.join(dir, 'missing.json'))).toEqual([]); }); + + it('reads the recorded Claude settings files, strings only', () => { + const file = path.join(dir, 'core-settings.json'); + fs.writeFileSync(file, JSON.stringify({ turnedOn: ['direct_delivery'], claudeInbound: ['/a', 7] })); + expect(readDeliveryRecord(file)).toEqual({ turnedOn: ['direct_delivery'], claudeInbound: ['/a'] }); + fs.writeFileSync(file, 'null'); + expect(readDeliveryRecord(file)).toEqual({ turnedOn: [], claudeInbound: [] }); + }); }); diff --git a/packages/cli/src/cli/lib/probe-core.ts b/packages/cli/src/cli/lib/probe-core.ts index 60c589a337..ff42503f32 100644 --- a/packages/cli/src/cli/lib/probe-core.ts +++ b/packages/cli/src/cli/lib/probe-core.ts @@ -14,7 +14,7 @@ * that path), see crates/probe/docs/relay-core.md. */ -import { spawn } from 'node:child_process'; +import { execFileSync, spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; import net from 'node:net'; @@ -124,6 +124,23 @@ function trustedSocketDirectory(socket: string): void { } } +/** + * Refuse a socket the core did not make for this user: the core binds it + * 0600 and owned by the user it runs as. Node cannot read a Unix socket's + * peer credentials, so the socket file's owner and mode stand in for them + * (only its owner, or root, can make or chmod it in a directory only this + * user can write to). + */ +function trustedSocketFile(socket: string): void { + if (process.platform === 'win32' || typeof process.getuid !== 'function') return; + const entry = fs.lstatSync(socket); + if (!entry.isSocket() || entry.uid !== process.getuid() || (entry.mode & 0o077) !== 0) { + throw new Error( + `${socket} must be a socket only you can use (owned by you, mode 0600); refusing to speak to it.` + ); + } +} + /** * Connect to the core at `socket` and greet it. Resolves undefined when * nothing is listening there. @@ -138,6 +155,7 @@ export async function connectCore( if (process.platform !== 'win32') { if (!fs.existsSync(socket)) return undefined; trustedSocketDirectory(socket); + trustedSocketFile(socket); } const address = process.platform === 'win32' ? pipeName(socket) : socket; const stream = await new Promise((resolve) => { @@ -220,8 +238,9 @@ export async function connectCore( stream.destroy(); }, }; + let greeting: { bridge_version?: unknown; pid?: unknown } | null; try { - await client.call('hello', { bridge_version: BRIDGE_VERSION }); + greeting = await client.call('hello', { bridge_version: BRIDGE_VERSION }); } catch (error) { client.close(); throw new Error( @@ -230,9 +249,37 @@ export async function connectCore( { cause: error } ); } + // The core's greeting names its bridge and pid; anything else is not the core. + if (typeof greeting?.bridge_version !== 'number' || typeof greeting.pid !== 'number') { + client.close(); + throw new Error(`What answers at ${socket} is not an Agent Relay core; refusing to speak to it.`); + } return client; } +/** `agent-relay-probe --version` prints exactly this: clap's ` `. */ +const PROBE_VERSION_LINE = /^agent-relay-probe (\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/; + +/** + * The version an `agent-relay-probe` binary reports for `--version`. Only + * the core's own one-line answer counts, so another program that happens to + * print a version is not taken for the core. + * @param binary - Path to the binary + * @returns e.g. `15.0.0`, or undefined when it is not the core or does not run + */ +export function readProbeVersion(binary: string): string | undefined { + try { + const out = execFileSync(binary, ['--version'], { + encoding: 'utf8', + timeout: 10_000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + return PROBE_VERSION_LINE.exec(out.trim())?.[1]; + } catch { + return undefined; + } +} + /** The part of the core's `status.get` that init reads. */ export interface CoreStatus { daemon: { pid: number; version: string; socket: string }; @@ -268,6 +315,9 @@ export function startCoreProcess({ env: process.env, windowsHide: true, }); + // A failed spawn (EACCES, ENOENT, a missing interpreter) is also reported + // as an 'error' event; without a listener it would crash the CLI. + child.on('error', () => undefined); child.unref(); if (child.pid === undefined) throw new Error('Could not start the Agent Relay core.'); return child.pid; @@ -328,21 +378,44 @@ export interface DeliveryOutcome { results: DeliveryResult[]; /** Settings (by read key) that were off and that init turned on. */ turnedOn: string[]; + /** Settings init had turned on (in this run or an earlier one) that this run turned off again. */ + turnedOff: string[]; } type SettingsValues = Record; /** - * Turn on the core's delivery for each tool, and automatic activation. A + * The read keys of a tool's core settings. + * @param id - The agent tool + * @returns e.g. `['direct_delivery', 'claude_session_hook']` for Claude Code + */ +export function deliveryKeys(id: ToolId): string[] { + return PROBE_DELIVERY[id].settings.map((s) => s.read); +} + +/** + * Turn on the core's delivery for each tool, then automatic activation. A * setting already on is left alone; only the ones turned on here are * reported in `turnedOn`, so `--uninstall` turns off exactly those. + * + * Each tool ends on one route. When one of a tool's settings fails, the rest + * are not tried, and the ones init turned on for it (in this run, or in an + * earlier one: `owned`) are turned off again, so the MCP fallback is its only + * route. Automatic activation is turned on only once a tool is injected; when + * none is, init's own activation is turned off again. * @param client - A connected core * @param tools - The agent tools to deliver to - * @returns Per-tool results and the settings turned on + * @param owned - Read keys an earlier `relay init` recorded as turned on + * @returns Per-tool results, and the settings turned on and off */ -export async function enableDelivery(client: CoreClient, tools: ToolId[]): Promise { +export async function enableDelivery( + client: CoreClient, + tools: ToolId[], + owned: string[] = [] +): Promise { let values = await client.call('settings.get'); const turnedOn: string[] = []; + const turnedOff: string[] = []; const turnOn = async (s: CoreSetting): Promise => { if (values[s.read] === true) return undefined; try { @@ -357,19 +430,36 @@ export async function enableDelivery(client: CoreClient, tools: ToolId[]): Promi // A default that respects the person's own choice may stay off; see the Claude result. return s.write === s.read ? `the core did not turn on ${s.write}` : undefined; }; + /** Turn off what init owns among `keys`; says which could not be. */ + const rollBack = async (keys: string[]): Promise => { + const stuck: string[] = []; + for (const key of keys) { + const ours = turnedOn.includes(key) || owned.includes(key); + if (!ours || values[key] !== true) continue; + try { + values = await client.call('settings.set', { [key]: false }); + } catch (error) { + stuck.push(`${key} stays on (${(error as Error).message})`); + continue; + } + const index = turnedOn.indexOf(key); + if (index >= 0) turnedOn.splice(index, 1); + turnedOff.push(key); + } + return stuck; + }; const results: DeliveryResult[] = []; - const activation = await turnOn(AUTO_ACTIVATE); for (const id of tools) { const { settings, route } = PROBE_DELIVERY[id]; - const failures: string[] = []; + let failure: string | undefined; for (const s of settings) { - const failure = await turnOn(s); - if (failure) failures.push(failure); + failure = await turnOn(s); + if (failure) break; } - if (activation) failures.unshift(`automatic activation: ${activation}`); - if (failures.length > 0) { - results.push({ id, status: 'failed', route, message: failures.join('; ') }); + if (failure) { + const stuck = await rollBack(deliveryKeys(id)); + results.push({ id, status: 'failed', route, message: [failure, ...stuck].join('; ') }); } else if (id === 'claude' && values.direct_delivery !== true) { results.push({ id, @@ -383,7 +473,22 @@ export async function enableDelivery(client: CoreClient, tools: ToolId[]): Promi results.push({ id, status: 'on', route }); } } - return { results, turnedOn }; + + const injected = results.filter((r) => r.status !== 'failed'); + if (injected.length === 0) { + await rollBack([AUTO_ACTIVATE.read]); + return { results, turnedOn, turnedOff }; + } + const activation = await turnOn(AUTO_ACTIVATE); + if (activation) { + // Without activation no session joins through the core: every tool goes to MCP. + for (const result of injected) { + const stuck = await rollBack(deliveryKeys(result.id)); + result.status = 'failed'; + result.message = [`automatic activation: ${activation}`, ...stuck].join('; '); + } + } + return { results, turnedOn, turnedOff }; } /** @@ -436,14 +541,35 @@ export function writePrivateJson(file: string, value: unknown): void { fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); } -/** The core settings init turned on, kept for `--uninstall`. */ -export function readTurnedOn(file: string): string[] { +/** What `relay init` turned on in the core, kept for `--uninstall` and `--delivery mcp`. */ +export interface DeliveryRecord { + /** Core settings (by read key) init turned on. */ + turnedOn: string[]; + /** + * Claude settings files whose `crossSessionInbound` was unset until the + * core's direct-delivery default set it, on every Claude login it follows. + */ + claudeInbound: string[]; +} + +const strings = (value: unknown): string[] => + Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; + +/** + * The record of what init turned on. + * @param file - `/local/core-settings.json` + * @returns The record, empty when there is none or it is unreadable + */ +export function readDeliveryRecord(file: string): DeliveryRecord { try { - const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as { turnedOn?: unknown }; - return Array.isArray(parsed.turnedOn) - ? parsed.turnedOn.filter((key): key is string => typeof key === 'string') - : []; + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as Record | null; + return { turnedOn: strings(parsed?.turnedOn), claudeInbound: strings(parsed?.claudeInbound) }; } catch { - return []; + return { turnedOn: [], claudeInbound: [] }; } } + +/** The core settings init turned on, kept for `--uninstall`. */ +export function readTurnedOn(file: string): string[] { + return readDeliveryRecord(file).turnedOn; +} From b4ebc11b145bb29a98036c2cd06a8f051ba795dd Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 28 Oct 2026 21:47:12 -0700 Subject: [PATCH 04/11] fix(probe): install the Pi extension under --cli-managed only when Pi is chosen - A --cli-managed core no longer installs the Pi extension at start; the desktop core still does, unless it was turned off. - New core setting pi_extension: true installs Agent Relay's extension in ~/.pi/agent/extensions, false removes only its own file and records the choice in the core's data (pi-extension.off), so a desktop start keeps it off. Refused where Pi has not run or Pi sessions are off; person-only to turn on, like the other routes. Added to the bridge contract and Swift. - relay init: Pi's delivery row is pi_extension, recorded like the others, so --uninstall, --delivery mcp and a rollback turn it off. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- crates/probe/contract/CoreContract.swift | 20 +- crates/probe/docs/relay-core.md | 2 +- .../src/relay/contract/agents/settings.rs | 15 ++ crates/probe/src/relay/daemon.rs | 5 +- crates/probe/src/relay/local.rs | 2 +- crates/probe/src/relay/local/plugins.rs | 60 +++++- crates/probe/src/relay/pi.rs | 41 +++- crates/probe/src/relay/pi/setup.rs | 185 +++++++++++++++++- crates/probe/src/relay/settings_registry.rs | 4 +- docs/probe-move.md | 8 +- packages/cli/src/cli/commands/init.test.ts | 27 +++ packages/cli/src/cli/lib/probe-core.test.ts | 11 +- packages/cli/src/cli/lib/probe-core.ts | 6 +- 14 files changed, 360 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9c305112..1f9f160c7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- `relay init` now delivers messages by injecting them into running agent sessions instead of having agents poll an MCP inbox. It installs the Agent Relay core (`agent-relay-probe`, SHA-256-verified from a `probe-v` GitHub release) into the install directory's `bin`, runs it in the background or attaches to the one the desktop app already runs, and turns on its delivery for each chosen tool: Claude Code's session inbox and SessionStart hook, Codex hooks, the OpenCode and Pi plugins, Devin hooks and Grok's leader socket. These are per-user settings for every project. Re-running removes an MCP entry an earlier `relay init` added, so each session has one identity. `--delivery mcp` uses the MCP server instead and turns off the injection `relay init` had turned on for those tools; the MCP server is also the fallback for a tool the core cannot deliver to, so no tool is on both routes. +- `relay init` now delivers messages by injecting them into running agent sessions instead of having agents poll an MCP inbox. It installs the Agent Relay core (`agent-relay-probe`, SHA-256-verified from a `probe-v` GitHub release) into the install directory's `bin`, runs it in the background or attaches to the one the desktop app already runs, and turns on its delivery for each chosen tool: Claude Code's session inbox and SessionStart hook, Codex hooks, the OpenCode plugin, the Pi extension (installed only when Pi is chosen), Devin hooks and Grok's leader socket. These are per-user settings for every project. Re-running removes an MCP entry an earlier `relay init` added, so each session has one identity. `--delivery mcp` uses the MCP server instead and turns off the injection `relay init` had turned on for those tools; the MCP server is also the fallback for a tool the core cannot deliver to, so no tool is on both routes. - `relay init --stop` also stops the core `relay init` started (never the desktop app's), and `relay init --uninstall` turns off exactly the injection settings it turned on. - `install.sh` and `relay init` use Relaycast engine 11.1.0, which ships as a standalone binary: `install.sh` now installs `relaycast-engine` into `~/.agentworkforce/relay/bin`, so the local server no longer needs Node.js or a native addon. - The CLI and `@agent-relay/harness-driver` now resolve the broker in this order: `BROKER_BINARY_PATH` / `AGENT_RELAY_BIN`, then `~/.agentworkforce/relay/bin`, then the npm platform package, then `PATH`. A broker in the install directory now wins over one found on `PATH`. diff --git a/crates/probe/contract/CoreContract.swift b/crates/probe/contract/CoreContract.swift index c16749db64..8d9557fbb0 100644 --- a/crates/probe/contract/CoreContract.swift +++ b/crates/probe/contract/CoreContract.swift @@ -7439,6 +7439,8 @@ public struct SettingsChange: Codable, Equatable, Hashable, Sendable { /// Turns the OpenClaw Gateway route on or off. public let openclawGateway: Bool? public let opencodePlugin: Bool? + /// Installs (`true`) or removes (`false`) Agent Relay's Pi extension. + public let piExtension: Bool? /// Shows each relay message whole in agent chats (`true`), or hides it /// (`false`, the default): the agent still reads it from hidden history. public let showRelayMessages: Bool? @@ -7459,7 +7461,7 @@ public struct SettingsChange: Codable, Equatable, Hashable, Sendable { /// Replaces the relay senders trusted like the person. public let trustedRelaySenders: [String]? - public init(acceptSpawns: Bool? = nil, allowSelfRegistration: Bool? = nil, analyticsEnabled: Bool? = nil, autoActivate: Bool? = nil, autoJoinGeneral: Bool? = nil, claudeCloudSessions: Bool? = nil, claudeConfigDir: ClaudeConfigDirChange? = nil, claudeConfigDirs: [String]? = nil, claudeSessionHook: Bool? = nil, codexStopHook: Bool? = nil, contractorAccess: Bool? = nil, cursorDirectDelivery: Bool? = nil, devinDirectDelivery: Bool? = nil, directDelivery: Bool? = nil, directDeliveryDefault: Bool? = nil, grokLeader: Bool? = nil, hermesAgent: Bool? = nil, historyRetentionDays: Int? = nil, mobileClaudeHandoff: Bool? = nil, mobileNativeContinuation: Bool? = nil, openclawGateway: Bool? = nil, opencodePlugin: Bool? = nil, showRelayMessages: Bool? = nil, spawnAllowTeammates: Bool? = nil, spawnBillingClaude: String? = nil, spawnBillingCodex: String? = nil, spawnFullAccessClaude: Bool? = nil, spawnFullAccessCodex: Bool? = nil, spawnHarnesses: [String: SpawnHarnessSettings]? = nil, spawnMaxAgents: Int? = nil, spawnNativeInput: Bool? = nil, spawnRepositories: [String]? = nil, teammateNativeInput: Bool? = nil, trustWorkspaceMembers: Bool? = nil, trustedRelaySenders: [String]? = nil) { + public init(acceptSpawns: Bool? = nil, allowSelfRegistration: Bool? = nil, analyticsEnabled: Bool? = nil, autoActivate: Bool? = nil, autoJoinGeneral: Bool? = nil, claudeCloudSessions: Bool? = nil, claudeConfigDir: ClaudeConfigDirChange? = nil, claudeConfigDirs: [String]? = nil, claudeSessionHook: Bool? = nil, codexStopHook: Bool? = nil, contractorAccess: Bool? = nil, cursorDirectDelivery: Bool? = nil, devinDirectDelivery: Bool? = nil, directDelivery: Bool? = nil, directDeliveryDefault: Bool? = nil, grokLeader: Bool? = nil, hermesAgent: Bool? = nil, historyRetentionDays: Int? = nil, mobileClaudeHandoff: Bool? = nil, mobileNativeContinuation: Bool? = nil, openclawGateway: Bool? = nil, opencodePlugin: Bool? = nil, piExtension: Bool? = nil, showRelayMessages: Bool? = nil, spawnAllowTeammates: Bool? = nil, spawnBillingClaude: String? = nil, spawnBillingCodex: String? = nil, spawnFullAccessClaude: Bool? = nil, spawnFullAccessCodex: Bool? = nil, spawnHarnesses: [String: SpawnHarnessSettings]? = nil, spawnMaxAgents: Int? = nil, spawnNativeInput: Bool? = nil, spawnRepositories: [String]? = nil, teammateNativeInput: Bool? = nil, trustWorkspaceMembers: Bool? = nil, trustedRelaySenders: [String]? = nil) { self.acceptSpawns = acceptSpawns self.allowSelfRegistration = allowSelfRegistration self.analyticsEnabled = analyticsEnabled @@ -7482,6 +7484,7 @@ public struct SettingsChange: Codable, Equatable, Hashable, Sendable { self.mobileNativeContinuation = mobileNativeContinuation self.openclawGateway = openclawGateway self.opencodePlugin = opencodePlugin + self.piExtension = piExtension self.showRelayMessages = showRelayMessages self.spawnAllowTeammates = spawnAllowTeammates self.spawnBillingClaude = spawnBillingClaude @@ -7520,6 +7523,7 @@ public struct SettingsChange: Codable, Equatable, Hashable, Sendable { case mobileNativeContinuation = "mobile_native_continuation" case openclawGateway = "openclaw_gateway" case opencodePlugin = "opencode_plugin" + case piExtension = "pi_extension" case showRelayMessages = "show_relay_messages" case spawnAllowTeammates = "spawn_allow_teammates" case spawnBillingClaude = "spawn_billing_claude" @@ -7559,6 +7563,7 @@ public struct SettingsChange: Codable, Equatable, Hashable, Sendable { self.mobileNativeContinuation = try c.decodeIfPresent(Bool.self, forKey: .mobileNativeContinuation) self.openclawGateway = try c.decodeIfPresent(Bool.self, forKey: .openclawGateway) self.opencodePlugin = try c.decodeIfPresent(Bool.self, forKey: .opencodePlugin) + self.piExtension = try c.decodeIfPresent(Bool.self, forKey: .piExtension) self.showRelayMessages = try c.decodeIfPresent(Bool.self, forKey: .showRelayMessages) self.spawnAllowTeammates = try c.decodeIfPresent(Bool.self, forKey: .spawnAllowTeammates) self.spawnBillingClaude = try c.decodeIfPresent(String.self, forKey: .spawnBillingClaude) @@ -7598,6 +7603,7 @@ public struct SettingsChange: Codable, Equatable, Hashable, Sendable { try c.encodeIfPresent(self.mobileNativeContinuation, forKey: .mobileNativeContinuation) try c.encodeIfPresent(self.openclawGateway, forKey: .openclawGateway) try c.encodeIfPresent(self.opencodePlugin, forKey: .opencodePlugin) + try c.encodeIfPresent(self.piExtension, forKey: .piExtension) try c.encodeIfPresent(self.showRelayMessages, forKey: .showRelayMessages) try c.encodeIfPresent(self.spawnAllowTeammates, forKey: .spawnAllowTeammates) try c.encodeIfPresent(self.spawnBillingClaude, forKey: .spawnBillingClaude) @@ -7705,6 +7711,12 @@ public struct SettingsValues: Codable, Equatable, Hashable, Sendable { /// global `plugins/`, the route into a plain OpenCode session. Absent /// where OpenCode has never run. public let opencodePlugin: Bool? + /// Whether Agent Relay's Pi extension is installed in Pi's global + /// `extensions/`, the route into a Pi session. The desktop's core + /// installs it at start unless it was turned off; a core `relay init` + /// manages installs it only when this is turned on. Absent where Pi has + /// never run. + public let piExtension: Bool? /// Whether relay messages show in agent chats. Off unless turned on: the /// agent reads each message from hidden history and the chat shows none /// of it. @@ -7756,7 +7768,7 @@ public struct SettingsValues: Codable, Equatable, Hashable, Sendable { /// off, signed out, or Cloud's list has not been read lately. public let workspaceMembers: [TrustedWorkspaceMember]? - public init(acceptSpawns: Bool? = nil, agentChanges: [AgentSettingsChange]? = nil, allowSelfRegistration: Bool, analyticsEnabled: Bool? = nil, autoActivate: Bool, autoJoinGeneral: Bool? = nil, claudeCloudSessions: Bool? = nil, claudeConfigDirs: [ClaudeConfigDir]? = nil, claudeSessionHook: Bool? = nil, codexStopHook: Bool? = nil, contractorAccess: Bool? = nil, cursorDirectDelivery: Bool? = nil, devinDirectDelivery: Bool? = nil, directDelivery: Bool? = nil, directDeliveryManaged: String? = nil, grokLeader: Bool? = nil, hermesAgent: Bool? = nil, historyRetentionDays: Int? = nil, mobileClaudeHandoff: Bool? = nil, mobileClaudeHandoffSupported: Bool? = nil, mobileNativeContinuation: Bool? = nil, mobileNativeContinuationSupported: Bool? = nil, openclawGateway: Bool? = nil, openclawUnlimitedVersion: String? = nil, opencodePlugin: Bool? = nil, showRelayMessages: Bool? = nil, spawnAllowTeammates: Bool? = nil, spawnBillingClaude: String? = nil, spawnBillingCodex: String? = nil, spawnFullAccessClaude: Bool? = nil, spawnFullAccessCodex: Bool? = nil, spawnHarnesses: [String: SpawnHarnessSettings]? = nil, spawnMaxAgents: Int? = nil, spawnNativeInput: Bool? = nil, spawnRepositories: [String]? = nil, teammateNativeInput: Bool? = nil, trustWorkspaceMembers: Bool? = nil, trustedRelaySenders: [String]? = nil, workspaceMembers: [TrustedWorkspaceMember]? = nil) { + public init(acceptSpawns: Bool? = nil, agentChanges: [AgentSettingsChange]? = nil, allowSelfRegistration: Bool, analyticsEnabled: Bool? = nil, autoActivate: Bool, autoJoinGeneral: Bool? = nil, claudeCloudSessions: Bool? = nil, claudeConfigDirs: [ClaudeConfigDir]? = nil, claudeSessionHook: Bool? = nil, codexStopHook: Bool? = nil, contractorAccess: Bool? = nil, cursorDirectDelivery: Bool? = nil, devinDirectDelivery: Bool? = nil, directDelivery: Bool? = nil, directDeliveryManaged: String? = nil, grokLeader: Bool? = nil, hermesAgent: Bool? = nil, historyRetentionDays: Int? = nil, mobileClaudeHandoff: Bool? = nil, mobileClaudeHandoffSupported: Bool? = nil, mobileNativeContinuation: Bool? = nil, mobileNativeContinuationSupported: Bool? = nil, openclawGateway: Bool? = nil, openclawUnlimitedVersion: String? = nil, opencodePlugin: Bool? = nil, piExtension: Bool? = nil, showRelayMessages: Bool? = nil, spawnAllowTeammates: Bool? = nil, spawnBillingClaude: String? = nil, spawnBillingCodex: String? = nil, spawnFullAccessClaude: Bool? = nil, spawnFullAccessCodex: Bool? = nil, spawnHarnesses: [String: SpawnHarnessSettings]? = nil, spawnMaxAgents: Int? = nil, spawnNativeInput: Bool? = nil, spawnRepositories: [String]? = nil, teammateNativeInput: Bool? = nil, trustWorkspaceMembers: Bool? = nil, trustedRelaySenders: [String]? = nil, workspaceMembers: [TrustedWorkspaceMember]? = nil) { self.acceptSpawns = acceptSpawns self.agentChanges = agentChanges self.allowSelfRegistration = allowSelfRegistration @@ -7782,6 +7794,7 @@ public struct SettingsValues: Codable, Equatable, Hashable, Sendable { self.openclawGateway = openclawGateway self.openclawUnlimitedVersion = openclawUnlimitedVersion self.opencodePlugin = opencodePlugin + self.piExtension = piExtension self.showRelayMessages = showRelayMessages self.spawnAllowTeammates = spawnAllowTeammates self.spawnBillingClaude = spawnBillingClaude @@ -7824,6 +7837,7 @@ public struct SettingsValues: Codable, Equatable, Hashable, Sendable { case openclawGateway = "openclaw_gateway" case openclawUnlimitedVersion = "openclaw_unlimited_version" case opencodePlugin = "opencode_plugin" + case piExtension = "pi_extension" case showRelayMessages = "show_relay_messages" case spawnAllowTeammates = "spawn_allow_teammates" case spawnBillingClaude = "spawn_billing_claude" @@ -7867,6 +7881,7 @@ public struct SettingsValues: Codable, Equatable, Hashable, Sendable { self.openclawGateway = try c.decodeIfPresent(Bool.self, forKey: .openclawGateway) self.openclawUnlimitedVersion = try c.decodeIfPresent(String.self, forKey: .openclawUnlimitedVersion) self.opencodePlugin = try c.decodeIfPresent(Bool.self, forKey: .opencodePlugin) + self.piExtension = try c.decodeIfPresent(Bool.self, forKey: .piExtension) self.showRelayMessages = try c.decodeIfPresent(Bool.self, forKey: .showRelayMessages) self.spawnAllowTeammates = try c.decodeIfPresent(Bool.self, forKey: .spawnAllowTeammates) self.spawnBillingClaude = try c.decodeIfPresent(String.self, forKey: .spawnBillingClaude) @@ -7910,6 +7925,7 @@ public struct SettingsValues: Codable, Equatable, Hashable, Sendable { try c.encodeIfPresent(self.openclawGateway, forKey: .openclawGateway) try c.encodeIfPresent(self.openclawUnlimitedVersion, forKey: .openclawUnlimitedVersion) try c.encodeIfPresent(self.opencodePlugin, forKey: .opencodePlugin) + try c.encodeIfPresent(self.piExtension, forKey: .piExtension) try c.encodeIfPresent(self.showRelayMessages, forKey: .showRelayMessages) try c.encodeIfPresent(self.spawnAllowTeammates, forKey: .spawnAllowTeammates) try c.encodeIfPresent(self.spawnBillingClaude, forKey: .spawnBillingClaude) diff --git a/crates/probe/docs/relay-core.md b/crates/probe/docs/relay-core.md index cbabc64252..a2b5e51ac1 100644 --- a/crates/probe/docs/relay-core.md +++ b/crates/probe/docs/relay-core.md @@ -505,7 +505,7 @@ uploads already; an install a newer probe runs is left alone. | `agents.webhook.revoke` | `{"name"}` | `{"already_deleted"}` | 2a | | `agents.integrations.subscribe` | `{"name","provider","resource"}` | `{"subscribed":true,"provider","resource","path_glob"}` | 2a | | `agents.integrations.unsubscribe` / `.clean_up` | `{"name","provider","resource"}` | `{"subscribed":false,"provider","resource","already_unsubscribed"?}`; also finishes an unfinished subscription | 2a | -| `settings.get` / `settings.set` | `{"allow_self_registration"?, "auto_activate"?, "claude_session_hook"?, "direct_delivery"?, "direct_delivery_default"?, "devin_direct_delivery"?, "analytics_enabled"?, "contractor_access"?}` | All settings, plus the read-only `direct_delivery_managed` (the organization's `crossSessionInbound`, absent when it sets none); `direct_delivery_default` is write-only: `true` sets `accept` unless the user's own Claude settings already name `crossSessionInbound`, `false` changes nothing; `claude_session_hook` is off unless turned on and absent when no Claude login directory exists (the SessionStart hook never registers a session); `devin_direct_delivery` adds or removes Agent Relay's lifecycle hooks in `~/.config/devin/config.json`, on by default where Devin has run, and absent where it has not; a setting the core runs without is `invalid_params`, and so are an unknown or read-only key and a non-boolean, which change nothing | 2a (store settings), 2b (`direct_delivery`, `claude_session_hook`, `devin_direct_delivery`), 2c (`analytics_enabled`, `contractor_access`) | +| `settings.get` / `settings.set` | `{"allow_self_registration"?, "auto_activate"?, "claude_session_hook"?, "direct_delivery"?, "direct_delivery_default"?, "devin_direct_delivery"?, "analytics_enabled"?, "contractor_access"?}` | All settings, plus the read-only `direct_delivery_managed` (the organization's `crossSessionInbound`, absent when it sets none); `direct_delivery_default` is write-only: `true` sets `accept` unless the user's own Claude settings already name `crossSessionInbound`, `false` changes nothing; `claude_session_hook` is off unless turned on and absent when no Claude login directory exists (the SessionStart hook never registers a session); `devin_direct_delivery` adds or removes Agent Relay's lifecycle hooks in `~/.config/devin/config.json`, on by default where Devin has run, and absent where it has not; `pi_extension` installs or removes Agent Relay's extension in `~/.pi/agent/extensions` (only its own file), installed at start by a desktop core unless turned off and only on request by a `--cli-managed` one, and absent where Pi has not run; a setting the core runs without is `invalid_params`, and so are an unknown or read-only key and a non-boolean, which change nothing | 2a (store settings), 2b (`direct_delivery`, `claude_session_hook`, `devin_direct_delivery`), 2c (`analytics_enabled`, `contractor_access`) | | `activity.list` | `{"limit"?}` | `{"activity":[{"at_ms","agent","sender","preview","outcome":"injected"\|"skipped"\|"failed"\|"waiting"\|"in_doubt"\|"answered","detail","settlement":"handed_off"\|"waiting_for_turn"\|"visible"\|null}]}`, newest first; `settlement` is set on entries that place the message in the session (a queue handoff, a handoff behind the session's running turn, or the message recorded in the session) and includes Relay Connect deliveries under the participant's name | 2a | | `claude_delivery.status` | — | `{"restricted","sessions":[{"id","agent","title","direct"}],"allowed_senders":[{"address","name"}],"held":[{"id","agent","session_id","sender","sender_name","text","held_at_ms"}]}` | 2b | | `claude_delivery.restrict` / `.session` / `.sender` | `{"restricted"}` / `{"session_id","direct"}` / `{"sender","name","allowed"}` | `{}`; controls allowed-member enforcement, per-session Ask first, and the exact sender allowlist | 2b | diff --git a/crates/probe/src/relay/contract/agents/settings.rs b/crates/probe/src/relay/contract/agents/settings.rs index 425a3d257c..05449537fd 100644 --- a/crates/probe/src/relay/contract/agents/settings.rs +++ b/crates/probe/src/relay/contract/agents/settings.rs @@ -116,6 +116,13 @@ pub struct SettingsValues { /// where OpenCode has never run. #[serde(default, skip_serializing_if = "Option::is_none")] pub opencode_plugin: Option, + /// Whether Agent Relay's Pi extension is installed in Pi's global + /// `extensions/`, the route into a Pi session. The desktop's core + /// installs it at start unless it was turned off; a core `relay init` + /// manages installs it only when this is turned on. Absent where Pi has + /// never run. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pi_extension: Option, /// Whether Agent Relay lists the agents of the local OpenClaw Gateway /// and delivers to them, each relay sender in a session of its own. /// Absent where OpenClaw is not set up. @@ -419,6 +426,14 @@ pub struct SettingsChange { )] #[schemars(with = "bool")] pub opencode_plugin: Option, + /// Installs (`true`) or removes (`false`) Agent Relay's Pi extension. + #[serde( + default, + deserialize_with = "given", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "bool")] + pub pi_extension: Option, /// Turns the OpenClaw Gateway route on or off. #[serde( default, diff --git a/crates/probe/src/relay/daemon.rs b/crates/probe/src/relay/daemon.rs index b8fe93ea73..038ec140ad 100644 --- a/crates/probe/src/relay/daemon.rs +++ b/crates/probe/src/relay/daemon.rs @@ -686,7 +686,10 @@ pub fn serve(options: ServeOptions) -> Result<()> { let stop_signals = StopSignals::register(); let headless_cleanup = cleanup_gui_autostart(&options, &config.home, &config.env); let socket = BoundSocket::bind(&config.paths)?; - let mut local = crate::relay::local::plugins_with(&config, None); + // A CLI-managed core writes into Pi's folder only once relay init + // turns `pi_extension` on for a person who chose Pi. + let mut local = + crate::relay::local::plugins_with_options(&config, None, !options.cli_managed); if let Some(relay_send) = &mut local.relay_send { relay_send.agent_led_defaults_at_start = own_defaults(&options).agent_led; } diff --git a/crates/probe/src/relay/local.rs b/crates/probe/src/relay/local.rs index be7e6c3e67..8a8a81cbe9 100644 --- a/crates/probe/src/relay/local.rs +++ b/crates/probe/src/relay/local.rs @@ -1972,7 +1972,7 @@ mod lock_held; mod nearest; mod opencode_reply; mod plugins; -pub use plugins::{plugins, plugins_with}; +pub use plugins::{plugins, plugins_with, plugins_with_options}; /// The Codex side of the desktop mailbox's delivery passes: the same routes /// injection uses, over this computer's Codex. diff --git a/crates/probe/src/relay/local/plugins.rs b/crates/probe/src/relay/local/plugins.rs index c7780abf9d..d27ef68eb5 100644 --- a/crates/probe/src/relay/local/plugins.rs +++ b/crates/probe/src/relay/local/plugins.rs @@ -14,10 +14,23 @@ pub fn plugins(config: &Config) -> Plugins { pub fn plugins_with( config: &Config, agent_environment: Option, +) -> Plugins { + plugins_with_options(config, agent_environment, true) +} + +/// `plugins_with`; `install_pi_at_start` false (a core `relay init` +/// manages) leaves Pi's folder alone until `pi_extension` is turned on. +pub fn plugins_with_options( + config: &Config, + agent_environment: Option, + install_pi_at_start: bool, ) -> Plugins { let env = |key: &str| config.env.get(key).cloned(); let mut local = LocalConfig::from_env(&env, &config.home); - let pi_setup = Arc::new(crate::relay::pi::setup::PiSetup::new(&local.pi)); + let pi_setup = Arc::new(crate::relay::pi::setup::PiSetup::new( + &local.pi, + install_pi_at_start, + )); local.spawned_grok = Some(config.app_data_dir.join("spawned-agents/grok")); local.existing_grok = enablement::Paths::from_env(&env, &config.app_data_dir); crate::relay::message_pointer::configure(&config.app_data_dir); @@ -238,3 +251,48 @@ pub fn plugins_with( ..Plugins::default() } } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use crate::relay::paths::CorePaths; + use std::time::Duration; + + fn config(home: &Path) -> Config { + Config { + paths: CorePaths::for_socket(home.join("core.sock")).unwrap(), + home: home.to_path_buf(), + app_data_dir: home.join("app-data"), + env: [ + ("AGENT_RELAY_ENABLE_OPENCLAW", "0"), + ("AGENT_RELAY_ENABLE_HERMES", "0"), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + version: "0.0.0-test".into(), + host_name: "Test Desk.local".into(), + rediscover_every: Duration::from_secs(3600), + push_timing: Default::default(), + delivery_timing: Default::default(), + } + } + + #[test] + fn a_cli_managed_start_leaves_pis_folder_alone_and_the_desktop_installs() { + let home = tempfile::tempdir().unwrap(); + let agent_dir = home.path().join(".pi/agent"); + std::fs::create_dir_all(&agent_dir).unwrap(); + + let managed = plugins_with_options(&config(home.path()), None, false); + assert!(!agent_dir.join("extensions").exists()); + let pi_extension = managed + .settings + .iter() + .find_map(|s| s.get().get("pi_extension").cloned()); + assert_eq!(pi_extension, Some(serde_json::Value::Bool(false))); + + plugins_with(&config(home.path()), None); + assert!(agent_dir.join("extensions/agent-relay.js").is_file()); + } +} diff --git a/crates/probe/src/relay/pi.rs b/crates/probe/src/relay/pi.rs index b128a898c0..6034259bb0 100644 --- a/crates/probe/src/relay/pi.rs +++ b/crates/probe/src/relay/pi.rs @@ -20,6 +20,8 @@ use inbox::{atomic_write_guarded, delivery_lock}; const MAX_RECORD: u64 = 1024 * 1024; const SOURCE: &str = include_str!("pi/agent-relay.mjs"); +/// The first line of our extension, telling it from a person's file. +const EXTENSION_MARK: &str = "// AGENT_RELAY_PI_EXTENSION="; #[derive(Clone, Debug)] pub struct PiConfig { @@ -47,17 +49,50 @@ impl PiConfig { SOURCE.replace("__RUNTIME__", &runtime.to_string()) } + /// Whether Pi has run here (its agent folder exists) and Pi sessions + /// are on: where the extension can go. + pub fn installed(&self) -> bool { + self.enabled && self.agent_dir.is_dir() + } + + /// Where the extension goes in Pi's global extensions. + pub fn extension_file(&self) -> PathBuf { + self.agent_dir.join("extensions/agent-relay.js") + } + + /// The marker turning the extension off leaves, in the core's own data + /// beside the Pi runtime rather than in Pi's folder. + pub fn off_marker(&self) -> PathBuf { + self.runtime.with_file_name("pi-extension.off") + } + + /// Whether our extension (not a person's file of that name) is in place. + pub fn extension_installed(&self) -> bool { + fs::symlink_metadata(self.extension_file()).is_ok_and(|m| m.is_file()) + && fs::read_to_string(self.extension_file()) + .is_ok_and(|text| text.starts_with(EXTENSION_MARK)) + } + + /// Removes our extension; a person's file at that name, or none, is left. + pub fn remove(&self) -> Result<(), String> { + if !self.extension_installed() { + return Ok(()); + } + fs::remove_file(self.extension_file()) + .map_err(|e| format!("Could not remove the Pi extension: {e}")) + } + /// Preserve a person's extension at this name. Refresh only our own copy. pub fn install(&self) -> Result<(), String> { - if !self.enabled || !self.agent_dir.is_dir() { + if !self.installed() { return Ok(()); } - let path = self.agent_dir.join("extensions/agent-relay.js"); + let path = self.extension_file(); if let Ok(metadata) = fs::symlink_metadata(&path) { if !metadata.is_file() || !fs::read_to_string(&path) .unwrap_or_default() - .starts_with("// AGENT_RELAY_PI_EXTENSION=") + .starts_with(EXTENSION_MARK) { return Err("A different Pi extension already uses agent-relay.js.".into()); } diff --git a/crates/probe/src/relay/pi/setup.rs b/crates/probe/src/relay/pi/setup.rs index d226634fdf..dd152ecf0b 100644 --- a/crates/probe/src/relay/pi/setup.rs +++ b/crates/probe/src/relay/pi/setup.rs @@ -1,30 +1,195 @@ -//! Pi extension installation and the failure the desktop must keep visible. +//! Pi extension installation, the `pi_extension` setting, and the failure +//! the desktop must keep visible. use super::PiConfig; use crate::relay::agents::SettingsExtension; use serde_json::{Map, Value}; +use std::sync::Mutex; + +/// The setting that installs (`true`) or removes (`false`) the extension. +pub(crate) const SETTING: &str = "pi_extension"; pub(crate) struct PiSetup { - error: Option, + config: PiConfig, + error: Mutex>, +} + +fn install_failure(error: String) -> String { + format!( + "Pi extension could not be installed: {error} Resolve the extension conflict or file permissions. Restart Agent Relay, then run /reload in Pi." + ) } impl PiSetup { - pub(crate) fn new(config: &PiConfig) -> Self { + /// `install_at_start`: the desktop's core installs the extension when it + /// starts, unless it was turned off; a core `relay init` manages + /// (`--cli-managed`) writes nothing into Pi's folder until `pi_extension` + /// is turned on. + pub(crate) fn new(config: &PiConfig, install_at_start: bool) -> Self { + let error = (install_at_start && !config.off_marker().exists()) + .then(|| config.install().err()) + .flatten() + .map(install_failure); Self { - error: config.install().err().map(|error| format!( - "Pi extension could not be installed: {error} Resolve the extension conflict or file permissions. Restart Agent Relay, then run /reload in Pi." - )), + config: config.clone(), + error: Mutex::new(error), + } + } + + fn set_extension(&self, enabled: bool) -> Result<(), String> { + if !self.config.enabled { + return Err(if cfg!(windows) { + "Pi is not available on Windows.".to_string() + } else { + "Pi sessions are turned off (AGENT_RELAY_ENABLE_PI=0).".to_string() + }); + } + if !self.config.agent_dir.is_dir() { + return Err(format!( + "Pi has not run on this computer ({} does not exist).", + self.config.agent_dir.display() + )); } + let marker = self.config.off_marker(); + if enabled { + self.config.install().map_err(install_failure)?; + *self.error.lock().unwrap_or_else(|e| e.into_inner()) = None; + return match std::fs::remove_file(&marker) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => { + Err(format!("Could not clear the Pi opt-out marker: {e}")) + } + _ => Ok(()), + }; + } + self.config.remove()?; + // Off is kept in the core's own data, not Pi's folder, so the + // desktop's start does not install it again. + if let Some(parent) = marker.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Could not record that the Pi extension is off: {e}"))?; + } + std::fs::write( + &marker, + "Agent Relay's Pi extension was turned off (pi_extension). Delete this file, or turn \ + Pi sessions on in Agent Relay, to allow it again.\n", + ) + .map_err(|e| format!("Could not record that the Pi extension is off: {e}")) } } impl SettingsExtension for PiSetup { + /// Present only where Pi has run (its agent folder exists) and Pi + /// sessions are on. fn get(&self) -> Map { - Map::new() + let mut settings = Map::new(); + if self.config.installed() { + settings.insert( + SETTING.into(), + Value::Bool(self.config.extension_installed()), + ); + } + settings } - fn set(&self, _key: &str, _value: &Value) -> Option> { - None + + fn set(&self, key: &str, value: &Value) -> Option> { + if key != SETTING { + return None; + } + let Some(enabled) = value.as_bool() else { + return Some(Err(format!("{SETTING} must be true or false."))); + }; + Some(self.set_extension(enabled)) } + fn startup_error(&self) -> Option { - self.error.clone() + self.error.lock().unwrap_or_else(|e| e.into_inner()).clone() + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::fs; + + fn pi(home: &std::path::Path) -> PiConfig { + let config = PiConfig::from_env(&|_| None, home); + fs::create_dir_all(&config.agent_dir).unwrap(); + config + } + + #[test] + fn a_cli_managed_core_writes_nothing_into_pis_folder_at_start() { + let home = tempfile::tempdir().unwrap(); + let config = pi(home.path()); + let setup = PiSetup::new(&config, false); + assert!(!config.extension_file().exists()); + assert!(!config.agent_dir.join("extensions").exists()); + assert!(setup.startup_error().is_none()); + assert_eq!(setup.get().get(SETTING), Some(&Value::Bool(false))); + } + + #[test] + fn the_desktop_core_installs_at_start_unless_turned_off() { + let home = tempfile::tempdir().unwrap(); + let config = pi(home.path()); + PiSetup::new(&config, true); + assert!(config.extension_installed()); + + let setup = PiSetup::new(&config, true); + assert_eq!(setup.set(SETTING, &Value::Bool(false)), Some(Ok(()))); + assert!(!config.extension_file().exists()); + assert!(config.off_marker().exists()); + // Off sticks across starts, and nothing of it is left in Pi's folder. + PiSetup::new(&config, true); + assert!(!config.extension_file().exists()); + assert!(!config.off_marker().starts_with(&config.agent_dir)); + } + + #[test] + fn the_setting_installs_then_removes_only_our_extension() { + let home = tempfile::tempdir().unwrap(); + let config = pi(home.path()); + let setup = PiSetup::new(&config, false); + assert_eq!(setup.set(SETTING, &Value::Bool(true)), Some(Ok(()))); + assert!(config.extension_installed()); + assert_eq!(setup.get().get(SETTING), Some(&Value::Bool(true))); + assert!(!config.off_marker().exists()); + + assert_eq!(setup.set(SETTING, &Value::Bool(false)), Some(Ok(()))); + assert!(!config.extension_file().exists()); + assert_eq!(setup.get().get(SETTING), Some(&Value::Bool(false))); + + // A person's own file at that name is never replaced or removed. + fs::write(config.extension_file(), "// personal extension").unwrap(); + assert!(matches!( + setup.set(SETTING, &Value::Bool(true)), + Some(Err(_)) + )); + assert_eq!(setup.set(SETTING, &Value::Bool(false)), Some(Ok(()))); + assert_eq!( + fs::read_to_string(config.extension_file()).unwrap(), + "// personal extension" + ); + assert!(setup.set("other", &Value::Bool(true)).is_none()); + assert!(matches!(setup.set(SETTING, &Value::from(1)), Some(Err(_)))); + } + + #[test] + fn the_setting_is_refused_and_absent_where_pi_has_not_run() { + let home = tempfile::tempdir().unwrap(); + let config = PiConfig::from_env(&|_| None, home.path()); + let setup = PiSetup::new(&config, true); + assert!(setup.get().is_empty()); + let refused = setup.set(SETTING, &Value::Bool(true)).unwrap().unwrap_err(); + assert!(refused.contains("Pi has not run"), "{refused}"); + assert!(!config.agent_dir.exists()); + let off = PiSetup::new( + &PiConfig { + enabled: false, + ..pi(home.path()) + }, + true, + ); + assert!(off.get().is_empty()); + assert!(matches!(off.set(SETTING, &Value::Bool(true)), Some(Err(_)))); } } diff --git a/crates/probe/src/relay/settings_registry.rs b/crates/probe/src/relay/settings_registry.rs index d0fd9c5fdf..0bdba666e0 100644 --- a/crates/probe/src/relay/settings_registry.rs +++ b/crates/probe/src/relay/settings_registry.rs @@ -36,7 +36,7 @@ pub const RECORDS: [&str; 1] = ["agent_changes"]; /// Claude Code's Keychain login; mobile handoff changes where sessions /// run; mobile continuation permits native owner input). An agent may turn /// them off. -pub const PERSON_TURNS_ON: [&str; 22] = [ +pub const PERSON_TURNS_ON: [&str; 23] = [ "accept_spawns", "allow_self_registration", "auto_activate", @@ -53,6 +53,7 @@ pub const PERSON_TURNS_ON: [&str; 22] = [ "mobile_native_continuation", "openclaw_gateway", "opencode_plugin", + "pi_extension", "spawn_allow_teammates", "spawn_full_access_claude", "spawn_full_access_codex", @@ -157,6 +158,7 @@ fn unavailable(key: &str) -> String { let harness = match key { "grok_leader" => Some("Grok"), "opencode_plugin" => Some("OpenCode"), + "pi_extension" => Some("Pi"), "openclaw_gateway" | "openclaw_unlimited_version" => Some("OpenClaw"), "hermes_agent" => Some("Hermes"), "devin_direct_delivery" => Some("Devin"), diff --git a/docs/probe-move.md b/docs/probe-move.md index 245a58c64b..cc1864d825 100644 --- a/docs/probe-move.md +++ b/docs/probe-move.md @@ -261,7 +261,7 @@ own, named `--`) and, per tool, through `settings.set`: | OpenCode | `opencode_plugin` | the Agent Relay plugin under `~/.config/opencode/plugins` | | Grok CLI | `grok_leader` | `[cli] use_leader` in `~/.grok/config.toml` | | Devin | `devin_direct_delivery` | lifecycle hooks in `~/.config/devin/config.json` | -| Pi | none | the Pi extension, which any core installs at start when `~/.pi/agent` exists | +| Pi | `pi_extension` | the Agent Relay extension in `~/.pi/agent/extensions` (a desktop core installs it at start) | A setting that is already on is left alone; the ones init turned on are recorded in `/local/core-settings.json`, and `relay init @@ -352,8 +352,10 @@ Known gaps in phase 3: the session runs `agent-relay-probe relay read ` for the text. - `--uninstall` leaves what the core keeps after turning a hook off: an empty `~/.codex/hooks.json`, Codex's hook-trust entries in `~/.codex/config.toml`, - and the one-time `.relay-desktop-backup` copies of edited settings files. It - cannot remove the Pi extension, which any core installs at start. + and the one-time `.relay-desktop-backup` copies of edited settings files. + A `--cli-managed` core writes into `~/.pi` only when `pi_extension` is + turned on; turning it off removes only Agent Relay's own extension file and + records the choice in the core's data (`~/.agentworkforce/desktop/pi-extension.off`). - The core's own telemetry (its `analytics_enabled` setting, on by default) does not follow the CLI's opt-out (`AGENT_RELAY_TELEMETRY_DISABLED`); turn it off with `agent-relay-probe relay call settings.set --params diff --git a/packages/cli/src/cli/commands/init.test.ts b/packages/cli/src/cli/commands/init.test.ts index 006b904511..6c1c056c45 100644 --- a/packages/cli/src/cli/commands/init.test.ts +++ b/packages/cli/src/cli/commands/init.test.ts @@ -901,6 +901,7 @@ function fakeCore( opencode_plugin: false, grok_leader: false, devin_direct_delivery: false, + pi_extension: false, ...options.settings, }; /** The person's own crossSessionInbound, which the default never overrides. */ @@ -1163,6 +1164,32 @@ describe('relay init message injection', () => { expect(fs.existsSync(path.join(project, '.mcp.json'))).toBe(false); }); + it('installs the Pi extension only for Pi, records it, and removes it on --uninstall', async () => { + installedProbe(); + const core = fakeCore(); + await setup(core.deps).run('--tools', 'codex'); + expect(core.settings.pi_extension).toBe(false); + + await setup(core.deps).run('--tools', 'pi'); + expect(core.settings.pi_extension).toBe(true); + expect(JSON.parse(fs.readFileSync(path.join(home, 'local', 'core-settings.json'), 'utf8'))).toEqual({ + turnedOn: ['codex_stop_hook', 'auto_activate', 'pi_extension'], + }); + + const toMcp = setup(core.deps); + await toMcp.run('--tools', 'pi', '--delivery', 'mcp'); + expect(core.settings.pi_extension).toBe(false); + expect(core.settings.codex_stop_hook).toBe(true); + + await setup(core.deps).run('--tools', 'pi'); + await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--uninstall'); + expect(core.settings).toMatchObject({ + pi_extension: false, + codex_stop_hook: false, + auto_activate: false, + }); + }); + it('rolls back the settings of a tool whose delivery fails, so it has only the MCP route', async () => { installedProbe(); const claudeSettings = path.join(toolHome, '.claude', 'settings.json'); diff --git a/packages/cli/src/cli/lib/probe-core.test.ts b/packages/cli/src/cli/lib/probe-core.test.ts index 1bd07ea7df..5f0ebae2e8 100644 --- a/packages/cli/src/cli/lib/probe-core.test.ts +++ b/packages/cli/src/cli/lib/probe-core.test.ts @@ -231,10 +231,15 @@ describe('enableDelivery', () => { } it('turns on only what is off, and reports what it turned on', async () => { - const core = client({ auto_activate: true, codex_stop_hook: false, opencode_plugin: true }); + const core = client({ + auto_activate: true, + codex_stop_hook: false, + opencode_plugin: true, + pi_extension: false, + }); const outcome = await enableDelivery(core, ['codex', 'opencode', 'pi']); - expect(core.sets).toEqual([{ codex_stop_hook: true }]); - expect(outcome.turnedOn).toEqual(['codex_stop_hook']); + expect(core.sets).toEqual([{ codex_stop_hook: true }, { pi_extension: true }]); + expect(outcome.turnedOn).toEqual(['codex_stop_hook', 'pi_extension']); expect(outcome.results.map((r) => [r.id, r.status])).toEqual([ ['codex', 'on'], ['opencode', 'on'], diff --git a/packages/cli/src/cli/lib/probe-core.ts b/packages/cli/src/cli/lib/probe-core.ts index ff42503f32..6e4722489f 100644 --- a/packages/cli/src/cli/lib/probe-core.ts +++ b/packages/cli/src/cli/lib/probe-core.ts @@ -358,7 +358,11 @@ export const PROBE_DELIVERY: Record Date: Wed, 28 Oct 2026 21:47:59 -0700 Subject: [PATCH 05/11] fix(cli): keep automatic activation while an earlier injected tool still needs it Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/cli/lib/probe-core.test.ts | 8 ++++++++ packages/cli/src/cli/lib/probe-core.ts | 9 +++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/cli/lib/probe-core.test.ts b/packages/cli/src/cli/lib/probe-core.test.ts index 5f0ebae2e8..2ee6c11f66 100644 --- a/packages/cli/src/cli/lib/probe-core.test.ts +++ b/packages/cli/src/cli/lib/probe-core.test.ts @@ -288,6 +288,14 @@ describe('enableDelivery', () => { expect(outcome.turnedOff).toEqual(['direct_delivery', 'auto_activate']); }); + it('keeps automatic activation for a tool an earlier run injected into', async () => { + const core = client({ auto_activate: true, codex_stop_hook: true }, ['grok_leader']); + const outcome = await enableDelivery(core, ['grok'], ['codex_stop_hook', 'auto_activate']); + expect(core.sets).toEqual([{ grok_leader: true }]); + expect(outcome.results[0]).toMatchObject({ id: 'grok', status: 'failed' }); + expect(outcome.turnedOff).toEqual([]); + }); + it('never turns off a setting init did not turn on', async () => { const core = client({ auto_activate: true, direct_delivery: true }, ['claude_session_hook']); const outcome = await enableDelivery(core, ['claude']); diff --git a/packages/cli/src/cli/lib/probe-core.ts b/packages/cli/src/cli/lib/probe-core.ts index 6e4722489f..2aeec92935 100644 --- a/packages/cli/src/cli/lib/probe-core.ts +++ b/packages/cli/src/cli/lib/probe-core.ts @@ -406,7 +406,8 @@ export function deliveryKeys(id: ToolId): string[] { * are not tried, and the ones init turned on for it (in this run, or in an * earlier one: `owned`) are turned off again, so the MCP fallback is its only * route. Automatic activation is turned on only once a tool is injected; when - * none is, init's own activation is turned off again. + * none is and no tool an earlier run injected into is left, init's own + * activation is turned off again. * @param client - A connected core * @param tools - The agent tools to deliver to * @param owned - Read keys an earlier `relay init` recorded as turned on @@ -480,7 +481,11 @@ export async function enableDelivery( const injected = results.filter((r) => r.status !== 'failed'); if (injected.length === 0) { - await rollBack([AUTO_ACTIVATE.read]); + // A tool an earlier run injected into still needs activation. + const stillInjected = owned.some( + (key) => key !== AUTO_ACTIVATE.read && values[key] === true && !turnedOff.includes(key) + ); + if (!stillInjected) await rollBack([AUTO_ACTIVATE.read]); return { results, turnedOn, turnedOff }; } const activation = await turnOn(AUTO_ACTIVATE); From b211854c0a27127637448ba8cb9ed888507e9655 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 28 Oct 2026 21:56:35 -0700 Subject: [PATCH 06/11] fix(probe): stop Pi discovery and delivery while pi_extension is off A Pi session that loaded the extension before it was turned off is no longer listed or delivered to: bindings() returns no Pi targets while the off marker is set, and a new delivery is refused before anything is written. Recorded receipts still settle. Co-Authored-By: Claude Opus 5.5 --- crates/probe/docs/relay-core.md | 2 +- crates/probe/src/relay/pi.rs | 55 +++++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/crates/probe/docs/relay-core.md b/crates/probe/docs/relay-core.md index a2b5e51ac1..82cffdda8a 100644 --- a/crates/probe/docs/relay-core.md +++ b/crates/probe/docs/relay-core.md @@ -505,7 +505,7 @@ uploads already; an install a newer probe runs is left alone. | `agents.webhook.revoke` | `{"name"}` | `{"already_deleted"}` | 2a | | `agents.integrations.subscribe` | `{"name","provider","resource"}` | `{"subscribed":true,"provider","resource","path_glob"}` | 2a | | `agents.integrations.unsubscribe` / `.clean_up` | `{"name","provider","resource"}` | `{"subscribed":false,"provider","resource","already_unsubscribed"?}`; also finishes an unfinished subscription | 2a | -| `settings.get` / `settings.set` | `{"allow_self_registration"?, "auto_activate"?, "claude_session_hook"?, "direct_delivery"?, "direct_delivery_default"?, "devin_direct_delivery"?, "analytics_enabled"?, "contractor_access"?}` | All settings, plus the read-only `direct_delivery_managed` (the organization's `crossSessionInbound`, absent when it sets none); `direct_delivery_default` is write-only: `true` sets `accept` unless the user's own Claude settings already name `crossSessionInbound`, `false` changes nothing; `claude_session_hook` is off unless turned on and absent when no Claude login directory exists (the SessionStart hook never registers a session); `devin_direct_delivery` adds or removes Agent Relay's lifecycle hooks in `~/.config/devin/config.json`, on by default where Devin has run, and absent where it has not; `pi_extension` installs or removes Agent Relay's extension in `~/.pi/agent/extensions` (only its own file), installed at start by a desktop core unless turned off and only on request by a `--cli-managed` one, and absent where Pi has not run; a setting the core runs without is `invalid_params`, and so are an unknown or read-only key and a non-boolean, which change nothing | 2a (store settings), 2b (`direct_delivery`, `claude_session_hook`, `devin_direct_delivery`), 2c (`analytics_enabled`, `contractor_access`) | +| `settings.get` / `settings.set` | `{"allow_self_registration"?, "auto_activate"?, "claude_session_hook"?, "direct_delivery"?, "direct_delivery_default"?, "devin_direct_delivery"?, "analytics_enabled"?, "contractor_access"?}` | All settings, plus the read-only `direct_delivery_managed` (the organization's `crossSessionInbound`, absent when it sets none); `direct_delivery_default` is write-only: `true` sets `accept` unless the user's own Claude settings already name `crossSessionInbound`, `false` changes nothing; `claude_session_hook` is off unless turned on and absent when no Claude login directory exists (the SessionStart hook never registers a session); `devin_direct_delivery` adds or removes Agent Relay's lifecycle hooks in `~/.config/devin/config.json`, on by default where Devin has run, and absent where it has not; `pi_extension` installs or removes Agent Relay's extension in `~/.pi/agent/extensions` (only its own file), installed at start by a desktop core unless turned off and only on request by a `--cli-managed` one; while it is off no Pi session is listed or delivered to, even one that loaded the extension earlier; absent where Pi has not run; a setting the core runs without is `invalid_params`, and so are an unknown or read-only key and a non-boolean, which change nothing | 2a (store settings), 2b (`direct_delivery`, `claude_session_hook`, `devin_direct_delivery`), 2c (`analytics_enabled`, `contractor_access`) | | `activity.list` | `{"limit"?}` | `{"activity":[{"at_ms","agent","sender","preview","outcome":"injected"\|"skipped"\|"failed"\|"waiting"\|"in_doubt"\|"answered","detail","settlement":"handed_off"\|"waiting_for_turn"\|"visible"\|null}]}`, newest first; `settlement` is set on entries that place the message in the session (a queue handoff, a handoff behind the session's running turn, or the message recorded in the session) and includes Relay Connect deliveries under the participant's name | 2a | | `claude_delivery.status` | — | `{"restricted","sessions":[{"id","agent","title","direct"}],"allowed_senders":[{"address","name"}],"held":[{"id","agent","session_id","sender","sender_name","text","held_at_ms"}]}` | 2b | | `claude_delivery.restrict` / `.session` / `.sender` | `{"restricted"}` / `{"session_id","direct"}` / `{"sender","name","allowed"}` | `{}`; controls allowed-member enforcement, per-session Ask first, and the exact sender allowlist | 2b | diff --git a/crates/probe/src/relay/pi.rs b/crates/probe/src/relay/pi.rs index 6034259bb0..08a50fb128 100644 --- a/crates/probe/src/relay/pi.rs +++ b/crates/probe/src/relay/pi.rs @@ -66,6 +66,12 @@ impl PiConfig { self.runtime.with_file_name("pi-extension.off") } + /// Whether `pi_extension` was turned off: Pi sessions are then neither + /// listed nor delivered to, even one that loaded the extension earlier. + pub fn turned_off(&self) -> bool { + self.off_marker().exists() + } + /// Whether our extension (not a person's file of that name) is in place. pub fn extension_installed(&self) -> bool { fs::symlink_metadata(self.extension_file()).is_ok_and(|m| m.is_file()) @@ -182,7 +188,7 @@ fn atomic_write(path: &Path, bytes: &[u8]) -> std::io::Result<()> { } pub fn bindings(config: &PiConfig, table: &dyn ProcessTable, uid: u32) -> Vec { - if !config.enabled || !private(&config.runtime, uid, true) { + if !config.enabled || config.turned_off() || !private(&config.runtime, uid, true) { return vec![]; } let Ok(entries) = fs::read_dir(&config.runtime) else { @@ -484,6 +490,13 @@ fn send_with( if let Some(outcome) = recorded_delivery(config, uid, registration, message_id, text, native) { return outcome; } + // Turned off: nothing new reaches Pi, even a session still running the + // extension it loaded before. A recorded receipt above still settles. + if config.turned_off() { + return InjectOutcome::Refused( + "Pi sessions are off: Agent Relay's Pi extension was turned off (pi_extension).".into(), + ); + } if native { if let Err(outcome) = native_choice::reserve(config, uid, registration, message_id, text) { return outcome; @@ -614,9 +627,13 @@ mod tests { crate::relay::store::Registration, ) { let temp = tempfile::tempdir().unwrap(); - fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)).unwrap(); + // As the desktop lays it out, so the off marker beside the runtime + // stays inside this test's directory. + let runtime = temp.path().join("desktop/pi"); + fs::create_dir_all(&runtime).unwrap(); + fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700)).unwrap(); let config = PiConfig { - runtime: temp.path().to_path_buf(), + runtime, agent_dir: temp.path().join("agent"), enabled: true, }; @@ -643,6 +660,38 @@ mod tests { (temp, config, held, registration) } + #[test] + fn a_bound_session_gets_nothing_once_the_extension_is_turned_off() { + let (temp, config, _held, registration) = fixture(); + let table = SystemProcesses::default(); + assert_eq!(discover(&config, &table, current_uid()).len(), 1); + fs::write(config.off_marker(), "off").unwrap(); + assert!(config.off_marker().starts_with(temp.path())); + // Still running the extension it loaded, but no longer a target. + assert!(bindings(&config, &table, current_uid()).is_empty()); + assert!(discover(&config, &table, current_uid()).is_empty()); + let outcome = send( + &config, + &table, + current_uid(), + ®istration, + "m-off", + "hello", + ); + assert!(matches!(outcome, InjectOutcome::Refused(_)), "{outcome:?}"); + let instance = config.runtime.join("123456789012345678901234567890ab"); + let written: Vec<_> = fs::read_dir(instance) + .unwrap() + .flatten() + .map(|entry| entry.file_name()) + .filter(|name| name != "session.json") + .collect(); + assert!(written.is_empty(), "{written:?}"); + // Turned on again, the same session is a target again. + fs::remove_file(config.off_marker()).unwrap(); + assert_eq!(discover(&config, &table, current_uid()).len(), 1); + } + #[test] fn discovery_requires_the_live_holder_and_private_registry() { let (_temp, config, held, _) = fixture(); From 2a6c8d2d0917efb7f1ae2f09445f565700e5e8eb Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 28 Oct 2026 22:06:28 -0700 Subject: [PATCH 07/11] fix(probe): keep queued Pi messages accepted while off, and share the activation check - With pi_extension off, a message already queued in a Pi session's inbox (either record version) stays Accepted for the loaded extension to deliver; only new messages are refused. - /setup/status's session.auto_activates now uses activation's own per-session check (LocalSessions::activation_check, shared with auto_activation_sessions), so sdk- Claude sessions and the opt-in kinds are never told they join by themselves. - relay-core.md: "pi_extension"? in the documented settings shape. Co-Authored-By: Claude Opus 5.5 --- crates/probe/docs/relay-core.md | 2 +- crates/probe/src/relay/local.rs | 24 +++ .../probe/src/relay/local/directory_trait.rs | 185 ++++++++++-------- crates/probe/src/relay/pi.rs | 52 +++++ crates/probe/src/relay/sessions.rs | 6 +- crates/probe/src/relay/setup.rs | 21 +- 6 files changed, 189 insertions(+), 101 deletions(-) diff --git a/crates/probe/docs/relay-core.md b/crates/probe/docs/relay-core.md index 82cffdda8a..042370e9aa 100644 --- a/crates/probe/docs/relay-core.md +++ b/crates/probe/docs/relay-core.md @@ -505,7 +505,7 @@ uploads already; an install a newer probe runs is left alone. | `agents.webhook.revoke` | `{"name"}` | `{"already_deleted"}` | 2a | | `agents.integrations.subscribe` | `{"name","provider","resource"}` | `{"subscribed":true,"provider","resource","path_glob"}` | 2a | | `agents.integrations.unsubscribe` / `.clean_up` | `{"name","provider","resource"}` | `{"subscribed":false,"provider","resource","already_unsubscribed"?}`; also finishes an unfinished subscription | 2a | -| `settings.get` / `settings.set` | `{"allow_self_registration"?, "auto_activate"?, "claude_session_hook"?, "direct_delivery"?, "direct_delivery_default"?, "devin_direct_delivery"?, "analytics_enabled"?, "contractor_access"?}` | All settings, plus the read-only `direct_delivery_managed` (the organization's `crossSessionInbound`, absent when it sets none); `direct_delivery_default` is write-only: `true` sets `accept` unless the user's own Claude settings already name `crossSessionInbound`, `false` changes nothing; `claude_session_hook` is off unless turned on and absent when no Claude login directory exists (the SessionStart hook never registers a session); `devin_direct_delivery` adds or removes Agent Relay's lifecycle hooks in `~/.config/devin/config.json`, on by default where Devin has run, and absent where it has not; `pi_extension` installs or removes Agent Relay's extension in `~/.pi/agent/extensions` (only its own file), installed at start by a desktop core unless turned off and only on request by a `--cli-managed` one; while it is off no Pi session is listed or delivered to, even one that loaded the extension earlier; absent where Pi has not run; a setting the core runs without is `invalid_params`, and so are an unknown or read-only key and a non-boolean, which change nothing | 2a (store settings), 2b (`direct_delivery`, `claude_session_hook`, `devin_direct_delivery`), 2c (`analytics_enabled`, `contractor_access`) | +| `settings.get` / `settings.set` | `{"allow_self_registration"?, "auto_activate"?, "claude_session_hook"?, "direct_delivery"?, "direct_delivery_default"?, "devin_direct_delivery"?, "pi_extension"?, "analytics_enabled"?, "contractor_access"?}` | All settings, plus the read-only `direct_delivery_managed` (the organization's `crossSessionInbound`, absent when it sets none); `direct_delivery_default` is write-only: `true` sets `accept` unless the user's own Claude settings already name `crossSessionInbound`, `false` changes nothing; `claude_session_hook` is off unless turned on and absent when no Claude login directory exists (the SessionStart hook never registers a session); `devin_direct_delivery` adds or removes Agent Relay's lifecycle hooks in `~/.config/devin/config.json`, on by default where Devin has run, and absent where it has not; `pi_extension` installs or removes Agent Relay's extension in `~/.pi/agent/extensions` (only its own file), installed at start by a desktop core unless turned off and only on request by a `--cli-managed` one; while it is off no Pi session is listed or delivered to, even one that loaded the extension earlier; absent where Pi has not run; a setting the core runs without is `invalid_params`, and so are an unknown or read-only key and a non-boolean, which change nothing | 2a (store settings), 2b (`direct_delivery`, `claude_session_hook`, `devin_direct_delivery`), 2c (`analytics_enabled`, `contractor_access`) | | `activity.list` | `{"limit"?}` | `{"activity":[{"at_ms","agent","sender","preview","outcome":"injected"\|"skipped"\|"failed"\|"waiting"\|"in_doubt"\|"answered","detail","settlement":"handed_off"\|"waiting_for_turn"\|"visible"\|null}]}`, newest first; `settlement` is set on entries that place the message in the session (a queue handoff, a handoff behind the session's running turn, or the message recorded in the session) and includes Relay Connect deliveries under the participant's name | 2a | | `claude_delivery.status` | — | `{"restricted","sessions":[{"id","agent","title","direct"}],"allowed_senders":[{"address","name"}],"held":[{"id","agent","session_id","sender","sender_name","text","held_at_ms"}]}` | 2b | | `claude_delivery.restrict` / `.session` / `.sender` | `{"restricted"}` / `{"session_id","direct"}` / `{"sender","name","allowed"}` | `{}`; controls allowed-member enforcement, per-session Ask first, and the exact sender allowlist | 2b | diff --git a/crates/probe/src/relay/local.rs b/crates/probe/src/relay/local.rs index 8a8a81cbe9..2a936992fc 100644 --- a/crates/probe/src/relay/local.rs +++ b/crates/probe/src/relay/local.rs @@ -3473,6 +3473,30 @@ mod tests { assert_eq!(world.sessions.fleet_owned(&found[0].target), Ok(false)); } + /// `/setup/status`'s `auto_activates` is activation's own check: an + /// `sdk-` Claude session, which activation skips, is never told it joins. + #[test] + fn auto_activates_matches_activation_for_an_sdk_session() { + let world = world(); + { + let mut processes = world.table.processes.lock().unwrap(); + let claude = processes.get_mut(&300).unwrap(); + claude.arguments = vec!["claude".into()]; + claude.has_terminal = true; + } + let session = world.sessions.sessions().remove(0); + assert!(world.sessions.auto_activates(&session)); + assert_eq!(world.sessions.auto_activation_sessions().len(), 1); + + let registry = world._temp.path().join(".claude/sessions/300.json"); + let mut record: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(®istry).unwrap()).unwrap(); + record["name"] = "sdk-pipeline".into(); + std::fs::write(®istry, record.to_string()).unwrap(); + assert!(world.sessions.auto_activation_sessions().is_empty()); + assert!(!world.sessions.auto_activates(&session)); + } + #[test] fn auto_activation_only_lists_interactive_non_temporary_sessions() { let mut world = world(); diff --git a/crates/probe/src/relay/local/directory_trait.rs b/crates/probe/src/relay/local/directory_trait.rs index 4cc7622afb..2eff0dcdff 100644 --- a/crates/probe/src/relay/local/directory_trait.rs +++ b/crates/probe/src/relay/local/directory_trait.rs @@ -114,93 +114,17 @@ impl SessionDirectory for LocalSessions { } fn auto_activation_sessions(&self) -> Vec { - let claude_sessions = claude::registry(&self.config.claude, &*self.table).sessions; - let grok_reachable = |session_id: &str| self.grok_open_reachable(session_id); - let automated = std::cell::OnceCell::new(); - let started_by_automation = |session_id: &str| { - automated - .get_or_init(|| { - self.config - .probe_root - .as_deref() - .map(crate::relay::devin_cloud::started_by_automation) - .unwrap_or_default() - }) - .contains(session_id) - }; + let eligible = self.activation_check(); self.fresh_sessions() .into_iter() - .filter(|session| { - if !session.live { - return false; - } - // A Devin Cloud row's `live` already proved this machine's - // sign-in can reach it — the caller proof a local lock - // proves for a local row — so it activates the same way, - // and a remote directory has nothing local to exclude. One - // an automation started (a Devin review) has nobody in it - // to talk to, so it never joins. - if session.target.kind == SessionKind::Devin - && session.host.as_deref() == Some(crate::relay::devin_cloud::CLOUD_HOST) - { - return !started_by_automation(&session.target.id); - } - if let Some(admitted) = self.gateway_admits(session.target.kind) { - return admitted; - } - let Some(directory) = session.directory.as_deref().map(Path::new) else { - return false; - }; - if is_under(directory, &self.config.auto_activate_excluded_dirs) { - return false; - } - match session.target.kind { - SessionKind::ClaudeTerminal => claude::terminal_session( - &self.config.claude, - &*self.table, - &session.target.id, - ) - .is_some_and(|terminal| { - claude::is_interactive_terminal(&*self.table, &terminal) - || claude::is_conductor_chat( - &*self.table, - self.config.uid, - &terminal, - &claude_sessions, - ) - || claude::is_app_hosted_chat(&*self.table, &terminal, &claude_sessions) - }), - SessionKind::Codex => true, - SessionKind::ClaudeCloud => false, - // A Grok TUI session is live only while a leader of this - // user is running, the one route into it; registering it - // binds and proves that leader first, and a binding that - // does not prove is refused, so nothing is sent. - SessionKind::GrokOpen => grok_reachable(&session.target.id), - // Launched by the probe, which registers it itself. - SessionKind::Grok | SessionKind::OpenCode => false, - // An open Muse session registers like a Codex one: the - // row already admits only live message-capable sessions, - // and delivery re-proves each one in Muse's own registry - // on every send, pinning the courier reference. - SessionKind::MuseOpen => true, - // Opt-in, like every provider route the plugin opens. - SessionKind::OpenCodeOpen | SessionKind::Pi => false, - // A Devin row exists only while a `devin` process holds - // the session's lock open, which is also its caller - // proof. Devin Cloud rows return early above; any other - // hosted row is never activated. - SessionKind::Devin => session.host.is_none(), - // A Cursor CLI row exists only while a `cursor-agent` - // holds its conversation open, which is also its caller - // proof; like Codex, it joins only with the opt-in. - SessionKind::CursorCli => true, - SessionKind::OpenClaw | SessionKind::Hermes => false, - } - }) + .filter(|session| eligible(session)) .collect() } + fn auto_activates(&self, session: &DiscoveredSession) -> bool { + (self.activation_check())(session) + } + fn activity( &self, sessions: &[(Target, Option)], @@ -364,10 +288,6 @@ impl SessionDirectory for LocalSessions { claude::registry(&self.config.claude, &*self.table).unreadable } - fn auto_activation_excluded(&self, directory: &Path) -> bool { - is_under(directory, &self.config.auto_activate_excluded_dirs) - } - fn fleet_owned(&self, target: &Target) -> Result { self.config .fleet @@ -418,3 +338,96 @@ impl SessionDirectory for LocalSessions { } } } + +impl LocalSessions { + /// Whether automatic activation puts `session` on the relay: one + /// predicate for activation itself and for `/setup/status`'s + /// `auto_activates`, so the SessionStart hook never promises a join + /// activation would not make. + fn activation_check(&self) -> impl Fn(&DiscoveredSession) -> bool + '_ { + let claude_sessions = claude::registry(&self.config.claude, &*self.table).sessions; + let grok_reachable = move |session_id: &str| self.grok_open_reachable(session_id); + let automated = std::cell::OnceCell::new(); + let started_by_automation = move |session_id: &str| { + automated + .get_or_init(|| { + self.config + .probe_root + .as_deref() + .map(crate::relay::devin_cloud::started_by_automation) + .unwrap_or_default() + }) + .contains(session_id) + }; + move |session: &DiscoveredSession| { + if !session.live { + return false; + } + // A Devin Cloud row's `live` already proved this machine's + // sign-in can reach it — the caller proof a local lock + // proves for a local row — so it activates the same way, + // and a remote directory has nothing local to exclude. One + // an automation started (a Devin review) has nobody in it + // to talk to, so it never joins. + if session.target.kind == SessionKind::Devin + && session.host.as_deref() == Some(crate::relay::devin_cloud::CLOUD_HOST) + { + return !started_by_automation(&session.target.id); + } + if let Some(admitted) = self.gateway_admits(session.target.kind) { + return admitted; + } + let Some(directory) = session.directory.as_deref().map(Path::new) else { + return false; + }; + if is_under(directory, &self.config.auto_activate_excluded_dirs) { + return false; + } + match session.target.kind { + SessionKind::ClaudeTerminal => { + claude::terminal_session(&self.config.claude, &*self.table, &session.target.id) + .is_some_and(|terminal| { + claude::is_interactive_terminal(&*self.table, &terminal) + || claude::is_conductor_chat( + &*self.table, + self.config.uid, + &terminal, + &claude_sessions, + ) + || claude::is_app_hosted_chat( + &*self.table, + &terminal, + &claude_sessions, + ) + }) + } + SessionKind::Codex => true, + SessionKind::ClaudeCloud => false, + // A Grok TUI session is live only while a leader of this + // user is running, the one route into it; registering it + // binds and proves that leader first, and a binding that + // does not prove is refused, so nothing is sent. + SessionKind::GrokOpen => grok_reachable(&session.target.id), + // Launched by the probe, which registers it itself. + SessionKind::Grok | SessionKind::OpenCode => false, + // An open Muse session registers like a Codex one: the + // row already admits only live message-capable sessions, + // and delivery re-proves each one in Muse's own registry + // on every send, pinning the courier reference. + SessionKind::MuseOpen => true, + // Opt-in, like every provider route the plugin opens. + SessionKind::OpenCodeOpen | SessionKind::Pi => false, + // A Devin row exists only while a `devin` process holds + // the session's lock open, which is also its caller + // proof. Devin Cloud rows return early above; any other + // hosted row is never activated. + SessionKind::Devin => session.host.is_none(), + // A Cursor CLI row exists only while a `cursor-agent` + // holds its conversation open, which is also its caller + // proof; like Codex, it joins only with the opt-in. + SessionKind::CursorCli => true, + SessionKind::OpenClaw | SessionKind::Hermes => false, + } + } + } +} diff --git a/crates/probe/src/relay/pi.rs b/crates/probe/src/relay/pi.rs index 08a50fb128..d92dcb06b7 100644 --- a/crates/probe/src/relay/pi.rs +++ b/crates/probe/src/relay/pi.rs @@ -434,6 +434,26 @@ fn recorded_delivery( } } +/// Whether this exact message is already in the pinned session's inbox, +/// waiting for its extension (`pending` or `claimed`), in either version. +fn queued_delivery( + config: &PiConfig, + uid: u32, + registration: &crate::relay::store::Registration, + message_id: &str, + text: &str, +) -> bool { + let Some(directory) = pinned_directory(config, uid, registration) else { + return false; + }; + read::(&directory.join(format!("{}.json", key(message_id))), uid).is_some_and(|d| { + d.session_id == registration.target.id + && d.message_id == message_id + && d.text == text + && matches!(d.state.as_str(), "pending" | "claimed") + }) +} + pub fn send( config: &PiConfig, table: &dyn ProcessTable, @@ -493,6 +513,11 @@ fn send_with( // Turned off: nothing new reaches Pi, even a session still running the // extension it loaded before. A recorded receipt above still settles. if config.turned_off() { + // A message already queued stays the loaded extension's to deliver; + // only a new one is refused. + if queued_delivery(config, uid, registration, message_id, text) { + return InjectOutcome::Accepted("Waiting for Pi to record this message.".into()); + } return InjectOutcome::Refused( "Pi sessions are off: Agent Relay's Pi extension was turned off (pi_extension).".into(), ); @@ -692,6 +717,33 @@ mod tests { assert_eq!(discover(&config, &table, current_uid()).len(), 1); } + #[test] + fn a_message_queued_before_the_extension_is_turned_off_stays_accepted() { + let (_temp, config, _held, registration) = fixture(); + let table = SystemProcesses::default(); + let uid = current_uid(); + let queued = send(&config, &table, uid, ®istration, "m-1", "hello"); + assert!(matches!(queued, InjectOutcome::Accepted(_)), "{queued:?}"); + fs::write(config.off_marker(), "off").unwrap(); + let instance = config.runtime.join("123456789012345678901234567890ab"); + let files = || { + let mut names: Vec<_> = fs::read_dir(&instance) + .unwrap() + .flatten() + .map(|entry| entry.file_name()) + .collect(); + names.sort(); + names + }; + let before = files(); + // The loaded extension can still deliver the queued version-1 record. + let retry = send(&config, &table, uid, ®istration, "m-1", "hello"); + assert!(matches!(retry, InjectOutcome::Accepted(_)), "{retry:?}"); + let new = send(&config, &table, uid, ®istration, "m-2", "hello"); + assert!(matches!(new, InjectOutcome::Refused(_)), "{new:?}"); + assert_eq!(files(), before); + } + #[test] fn discovery_requires_the_live_holder_and_private_registry() { let (_temp, config, held, _) = fixture(); diff --git a/crates/probe/src/relay/sessions.rs b/crates/probe/src/relay/sessions.rs index 0412725af1..cc261b3b91 100644 --- a/crates/probe/src/relay/sessions.rs +++ b/crates/probe/src/relay/sessions.rs @@ -232,9 +232,9 @@ pub trait SessionDirectory: Send + Sync + 'static { Vec::new() } - /// Whether automatic activation skips sessions in `directory` - /// (`AGENT_RELAY_AUTO_ACTIVATE_EXCLUDED_DIRS`). - fn auto_activation_excluded(&self, _directory: &std::path::Path) -> bool { + /// Whether automatic activation would put `session` on the relay, by + /// the same check `auto_activation_sessions` applies. May block. + fn auto_activates(&self, _session: &DiscoveredSession) -> bool { false } diff --git a/crates/probe/src/relay/setup.rs b/crates/probe/src/relay/setup.rs index bc13a2fab5..557a4df20c 100644 --- a/crates/probe/src/relay/setup.rs +++ b/crates/probe/src/relay/setup.rs @@ -700,17 +700,16 @@ impl Setup { .as_str() .map(|id| json!({"id": id, "name": account["workspace_name"]})); // Whether the core will put this session on the relay by itself: - // automatic activation is on, there is a workspace to join, and the - // session's folder is not one activation skips. - let auto_activates = self.core.store.read(|s| s.auto_activates()) - && self.core.workspace().is_some() - && session.directory.as_deref().is_some_and(|directory| { - !self - .core - .plugins - .sessions - .auto_activation_excluded(std::path::Path::new(directory)) - }); + // automatic activation is on, there is a workspace to join, and + // activation's own check admits this session. + let auto_activates = + self.core.store.read(|s| s.auto_activates()) && self.core.workspace().is_some() && { + let directory = self.core.plugins.sessions.clone(); + let session = session.clone(); + tokio::task::spawn_blocking(move || directory.auto_activates(&session)) + .await + .unwrap_or(false) + }; // The signed-in person, as `account.status` names them. let person = &account["person"]; let named = !person["name"].is_null() || !person["email"].is_null(); From 6ec1f5f2e5c0ec03179e85483b16be845b038342 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 28 Oct 2026 22:30:52 -0700 Subject: [PATCH 08/11] test(cli): read the fake Claude settings without a check-then-read race Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/cli/commands/init.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cli/commands/init.test.ts b/packages/cli/src/cli/commands/init.test.ts index 6c1c056c45..dd849df37e 100644 --- a/packages/cli/src/cli/commands/init.test.ts +++ b/packages/cli/src/cli/commands/init.test.ts @@ -918,7 +918,12 @@ function fakeCore( }; const writeInbound = (value: string, only: string | undefined) => { for (const file of options.claudeFiles ?? []) { - const current = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {}; + let current: Record = {}; + try { + current = JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + } if (only !== undefined && current.crossSessionInbound !== only) continue; if (value === 'accept' && current.crossSessionInbound !== undefined) continue; fs.mkdirSync(path.dirname(file), { recursive: true }); From a662c235b03d163b1a19bf5820fb3005cc8af2e7 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 28 Oct 2026 22:36:23 -0700 Subject: [PATCH 09/11] docs(probe): say a queued Pi message can still arrive after pi_extension is turned off Co-Authored-By: Claude Opus 5.5 --- crates/probe/docs/relay-core.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/probe/docs/relay-core.md b/crates/probe/docs/relay-core.md index 042370e9aa..4a170368ab 100644 --- a/crates/probe/docs/relay-core.md +++ b/crates/probe/docs/relay-core.md @@ -505,7 +505,7 @@ uploads already; an install a newer probe runs is left alone. | `agents.webhook.revoke` | `{"name"}` | `{"already_deleted"}` | 2a | | `agents.integrations.subscribe` | `{"name","provider","resource"}` | `{"subscribed":true,"provider","resource","path_glob"}` | 2a | | `agents.integrations.unsubscribe` / `.clean_up` | `{"name","provider","resource"}` | `{"subscribed":false,"provider","resource","already_unsubscribed"?}`; also finishes an unfinished subscription | 2a | -| `settings.get` / `settings.set` | `{"allow_self_registration"?, "auto_activate"?, "claude_session_hook"?, "direct_delivery"?, "direct_delivery_default"?, "devin_direct_delivery"?, "pi_extension"?, "analytics_enabled"?, "contractor_access"?}` | All settings, plus the read-only `direct_delivery_managed` (the organization's `crossSessionInbound`, absent when it sets none); `direct_delivery_default` is write-only: `true` sets `accept` unless the user's own Claude settings already name `crossSessionInbound`, `false` changes nothing; `claude_session_hook` is off unless turned on and absent when no Claude login directory exists (the SessionStart hook never registers a session); `devin_direct_delivery` adds or removes Agent Relay's lifecycle hooks in `~/.config/devin/config.json`, on by default where Devin has run, and absent where it has not; `pi_extension` installs or removes Agent Relay's extension in `~/.pi/agent/extensions` (only its own file), installed at start by a desktop core unless turned off and only on request by a `--cli-managed` one; while it is off no Pi session is listed or delivered to, even one that loaded the extension earlier; absent where Pi has not run; a setting the core runs without is `invalid_params`, and so are an unknown or read-only key and a non-boolean, which change nothing | 2a (store settings), 2b (`direct_delivery`, `claude_session_hook`, `devin_direct_delivery`), 2c (`analytics_enabled`, `contractor_access`) | +| `settings.get` / `settings.set` | `{"allow_self_registration"?, "auto_activate"?, "claude_session_hook"?, "direct_delivery"?, "direct_delivery_default"?, "devin_direct_delivery"?, "pi_extension"?, "analytics_enabled"?, "contractor_access"?}` | All settings, plus the read-only `direct_delivery_managed` (the organization's `crossSessionInbound`, absent when it sets none); `direct_delivery_default` is write-only: `true` sets `accept` unless the user's own Claude settings already name `crossSessionInbound`, `false` changes nothing; `claude_session_hook` is off unless turned on and absent when no Claude login directory exists (the SessionStart hook never registers a session); `devin_direct_delivery` adds or removes Agent Relay's lifecycle hooks in `~/.config/devin/config.json`, on by default where Devin has run, and absent where it has not; `pi_extension` installs or removes Agent Relay's extension in `~/.pi/agent/extensions` (only its own file), installed at start by a desktop core unless turned off and only on request by a `--cli-managed` one; while it is off no Pi session is listed and new messages to Pi are refused, even for a session that loaded the extension earlier, though a message already queued before it was turned off stays accepted and that loaded extension can still deliver it; absent where Pi has not run; a setting the core runs without is `invalid_params`, and so are an unknown or read-only key and a non-boolean, which change nothing | 2a (store settings), 2b (`direct_delivery`, `claude_session_hook`, `devin_direct_delivery`), 2c (`analytics_enabled`, `contractor_access`) | | `activity.list` | `{"limit"?}` | `{"activity":[{"at_ms","agent","sender","preview","outcome":"injected"\|"skipped"\|"failed"\|"waiting"\|"in_doubt"\|"answered","detail","settlement":"handed_off"\|"waiting_for_turn"\|"visible"\|null}]}`, newest first; `settlement` is set on entries that place the message in the session (a queue handoff, a handoff behind the session's running turn, or the message recorded in the session) and includes Relay Connect deliveries under the participant's name | 2a | | `claude_delivery.status` | — | `{"restricted","sessions":[{"id","agent","title","direct"}],"allowed_senders":[{"address","name"}],"held":[{"id","agent","session_id","sender","sender_name","text","held_at_ms"}]}` | 2b | | `claude_delivery.restrict` / `.session` / `.sender` | `{"restricted"}` / `{"session_id","direct"}` / `{"sender","name","allowed"}` | `{}`; controls allowed-member enforcement, per-session Ask first, and the exact sender allowlist | 2b | From d4208dbbc2595c19cc2441d680fd7e849deb23dc Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 28 Oct 2026 23:07:39 -0700 Subject: [PATCH 10/11] fix(cli): undo injection relay init cannot record before falling back to MCP If saving core-settings.json fails after delivery was turned on, init turns off the settings this run enabled (and restores the Claude files it set) before the MCP fallback. If any stays on, setup stops with the settings to turn off instead of adding a second route. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/cli/commands/init.test.ts | 40 ++++++++++++++ packages/cli/src/cli/commands/init.ts | 63 +++++++++++++++++----- 2 files changed, 91 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/cli/commands/init.test.ts b/packages/cli/src/cli/commands/init.test.ts index dd849df37e..562111b4ab 100644 --- a/packages/cli/src/cli/commands/init.test.ts +++ b/packages/cli/src/cli/commands/init.test.ts @@ -1195,6 +1195,46 @@ describe('relay init message injection', () => { }); }); + it('turns off what it enabled when its record cannot be saved, then uses the MCP server', async () => { + installedProbe(); + const core = fakeCore({ running: true }); + // A directory where the record goes: writing it fails. + fs.mkdirSync(path.join(home, 'local', 'core-settings.json'), { recursive: true }); + const { run, logs } = setup(core.deps); + await run('--tools', 'codex'); + expect(core.settings).toMatchObject({ codex_stop_hook: false, auto_activate: false }); + expect(logs.join('\n')).toContain( + 'Message injection is not available (the Agent Relay core could not be set up' + ); + expect(fs.readFileSync(path.join(project, '.codex', 'config.toml'), 'utf8')).toContain('agent-relay'); + }); + + it('stops without adding MCP when an unrecorded setting cannot be turned off again', async () => { + installedProbe(); + const core = fakeCore({ running: true }); + fs.mkdirSync(path.join(home, 'local', 'core-settings.json'), { recursive: true }); + const connect = core.deps.connectCore; + const { run, errors } = setup({ + ...core.deps, + connectCore: vi.fn(async (socket: string) => { + const client = await connect(socket); + if (!client) return undefined; + return { + ...client, + call: async (method: string, params?: Record) => { + if (method === 'settings.set' && params && Object.values(params).includes(false)) { + throw new Error('the core is busy'); + } + return client.call(method, params); + }, + }; + }), + }); + await expect(run('--tools', 'codex')).rejects.toEqual(new ExitSignal(1)); + expect(errors.join('\n')).toContain('could not be turned off again: codex_stop_hook: the core is busy'); + expect(fs.existsSync(path.join(project, '.codex', 'config.toml'))).toBe(false); + }); + it('rolls back the settings of a tool whose delivery fails, so it has only the MCP route', async () => { installedProbe(); const claudeSettings = path.join(toolHome, '.claude', 'settings.json'); diff --git a/packages/cli/src/cli/commands/init.ts b/packages/cli/src/cli/commands/init.ts index 73b90d4412..bed70ce911 100644 --- a/packages/cli/src/cli/commands/init.ts +++ b/packages/cli/src/cli/commands/init.ts @@ -981,27 +981,66 @@ async function setupInjection( const accepted = unset.filter((file) => claudeInbound(file) === 'accept'); // Applied to the record as it is now, not as it was read: another // relay init may have recorded its own changes meanwhile. - updateDeliveryRecord((current) => { - if (turnedOff.includes('direct_delivery')) { - // Rolled back: the core wrote hold where init had set accept, in this run or an earlier one. - restoreClaudeInbound([...new Set([...current.claudeInbound, ...unset])]); - } - return { - turnedOn: [...new Set([...current.turnedOn, ...turnedOn])].filter((key) => !turnedOff.includes(key)), - claudeInbound: turnedOff.includes('direct_delivery') - ? [] - : [...new Set([...current.claudeInbound, ...accepted])], - }; - }); + const save = () => + updateDeliveryRecord((current) => { + if (turnedOff.includes('direct_delivery')) { + // Rolled back: the core wrote hold where init had set accept, in this run or an earlier one. + restoreClaudeInbound([...new Set([...current.claudeInbound, ...unset])]); + } + return { + turnedOn: [...new Set([...current.turnedOn, ...turnedOn])].filter( + (key) => !turnedOff.includes(key) + ), + claudeInbound: turnedOff.includes('direct_delivery') + ? [] + : [...new Set([...current.claudeInbound, ...accepted])], + }; + }); + try { + save(); + } catch (error) { + await undoUnrecorded(session.client, turnedOn, accepted, error as Error); + throw error; + } const workspaceNote = await coreWorkspaceNote(deps, session.client, setup); return { core: describeCore(session), results, workspaceNote }; } catch (error) { + if (error instanceof InjectionStuckError) throw error; return { reason: `the Agent Relay core could not be set up (${(error as Error).message})` }; } finally { session.client.close(); } } +/** Settings init turned on that are neither recorded nor turned off: setup stops. */ +class InjectionStuckError extends Error {} + +/** + * The record of what this run turned on could not be saved: turn those + * settings off again, so the MCP fallback is the only route and nothing is + * left that `--uninstall` would not know about. + * @throws InjectionStuckError when any stays on; no MCP server is added then. + */ +async function undoUnrecorded( + client: CoreClient, + turnedOn: string[], + accepted: string[], + cause: Error +): Promise { + const failures = await disableDelivery(client, turnedOn); + if (turnedOn.includes('direct_delivery') && !failures.some((f) => f.startsWith('direct_delivery:'))) { + restoreClaudeInbound(accepted); + } + if (failures.length > 0) { + throw new InjectionStuckError( + `Could not save ${deliveryRecordPath()} (${cause.message}), and these settings relay init turned on ` + + `could not be turned off again: ${failures.join('; ')}. Turn them off with ` + + 'agent-relay-probe relay call settings.set --params \'{"":false}\', then run relay init again.', + { cause } + ); + } +} + const DELIVERY_MARK: Record = { on: '✓', held: '!', failed: '✗' }; function describeDelivery(result: DeliveryResult): string { From 2296756a81516cd343eabf7f5a414b3d4204a012 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 28 Oct 2026 23:16:15 -0700 Subject: [PATCH 11/11] fix: restore each Claude login's own inbound choice, stop on a stuck rollback, share the activation policy - relay init records every Claude login's crossSessionInbound (or its absence) before turning direct delivery on, and every turn-off (rollback, failed record save, --delivery mcp, --uninstall) puts those values back after the core writes hold, so a person's own "accept" survives. - A failed tool whose settings cannot be turned off again stops setup with a clear error and gets no MCP server; the setting stays recorded. - probe: auto_activation::activation_applies is the one per-kind policy (unset: Devin, Muse, OpenClaw, Hermes) for activate_new_sessions and for /setup/status's auto_activates. - docs/probe-move.md: when MCP is the fallback, the remaining two-route case (a failed MCP-entry removal), and the detached core without a service. Co-Authored-By: Claude Opus 5.5 --- crates/probe/src/relay/agents.rs | 18 ++-- .../probe/src/relay/agents/auto_activation.rs | 30 ++++++ crates/probe/src/relay/setup.rs | 9 +- docs/probe-move.md | 40 +++++--- packages/cli/src/cli/commands/init.test.ts | 97 ++++++++++++++++++- packages/cli/src/cli/commands/init.ts | 83 ++++++++++------ packages/cli/src/cli/lib/probe-core.test.ts | 16 ++- packages/cli/src/cli/lib/probe-core.ts | 36 +++++-- 8 files changed, 264 insertions(+), 65 deletions(-) diff --git a/crates/probe/src/relay/agents.rs b/crates/probe/src/relay/agents.rs index 57579ca67d..0b822e5362 100644 --- a/crates/probe/src/relay/agents.rs +++ b/crates/probe/src/relay/agents.rs @@ -1647,15 +1647,13 @@ impl Core { // a Hermes API that answers its own key are already caller proof, // re-proved on every send — though an explicit Auto-activate off is // still honored. Every other provider needs the setting on. - match self.store.read(|s| s.auto_activate) { - Some(false) => return failures, - Some(true) => {} - None => { - found.retain(|target, _| auto_activation::auto_activates_by_default(target.kind)); - if found.is_empty() { - return failures; - } - } + let setting = self.store.read(|s| s.auto_activate); + if setting == Some(false) { + return failures; + } + found.retain(|target, _| auto_activation::activation_applies(setting, target.kind)); + if setting.is_none() && found.is_empty() { + return failures; } let (allowance, wait_reason) = match self @@ -1721,7 +1719,7 @@ impl Core { // `auto_activation` shares its line with `aliases` so this file stays under its line ratchet. mod aliases; -mod auto_activation; +pub(crate) mod auto_activation; use aliases::already_registered; pub(crate) use aliases::{agent_push, is_listed, update_registered_title}; diff --git a/crates/probe/src/relay/agents/auto_activation.rs b/crates/probe/src/relay/agents/auto_activation.rs index c71549458c..a76fd8a321 100644 --- a/crates/probe/src/relay/agents/auto_activation.rs +++ b/crates/probe/src/relay/agents/auto_activation.rs @@ -13,6 +13,17 @@ pub(crate) fn auto_activates_by_default(kind: SessionKind) -> bool { ) } +/// Whether automatic activation applies to sessions of `kind` under the +/// `auto_activate` setting: on, every kind; off, none; unset, only the kinds +/// that join by default. The one policy for activation itself and for +/// `/setup/status`'s `auto_activates`. +pub(crate) fn activation_applies(setting: Option, kind: SessionKind) -> bool { + match setting { + Some(enabled) => enabled, + None => auto_activates_by_default(kind), + } +} + #[cfg(test)] mod tests { use super::*; @@ -28,4 +39,23 @@ mod tests { assert!(!auto_activates_by_default(SessionKind::ClaudeTerminal)); assert!(!auto_activates_by_default(SessionKind::OpenCodeOpen)); } + + #[test] + fn the_setting_decides_and_unset_keeps_the_default_kinds() { + for kind in [ + SessionKind::Devin, + SessionKind::MuseOpen, + SessionKind::Codex, + ] { + assert!(activation_applies(Some(true), kind)); + assert!(!activation_applies(Some(false), kind)); + assert_eq!( + activation_applies(None, kind), + auto_activates_by_default(kind) + ); + } + assert!(activation_applies(None, SessionKind::Devin)); + assert!(activation_applies(None, SessionKind::MuseOpen)); + assert!(!activation_applies(None, SessionKind::ClaudeTerminal)); + } } diff --git a/crates/probe/src/relay/setup.rs b/crates/probe/src/relay/setup.rs index c2401a0456..e1da84305e 100644 --- a/crates/probe/src/relay/setup.rs +++ b/crates/probe/src/relay/setup.rs @@ -705,8 +705,13 @@ impl Setup { // Whether the core will put this session on the relay by itself: // automatic activation is on, there is a workspace to join, and // activation's own check admits this session. - let auto_activates = - self.core.store.read(|s| s.auto_activates()) && self.core.workspace().is_some() && { + let setting = self.core.store.read(|s| s.auto_activate); + let auto_activates = self.core.workspace().is_some() + && crate::relay::agents::auto_activation::activation_applies( + setting, + session.target.kind, + ) + && { let directory = self.core.plugins.sessions.clone(); let session = session.clone(); tokio::task::spawn_blocking(move || directory.auto_activates(&session)) diff --git a/docs/probe-move.md b/docs/probe-move.md index e3368dc6f9..006230efdc 100644 --- a/docs/probe-move.md +++ b/docs/probe-move.md @@ -310,8 +310,10 @@ directory (`$AGENT_RELAY_INSTALL_DIR/bin`, else `$AGENT_RELAY_HOME/bin`, else written to a temp file beside the target, run with `--version`, then renamed into place atomically. A binary that already reports the pinned version or a newer one is kept; `AGENT_RELAY_PROBE_PATH` names a binary to use instead. -There is no musl, arm64 Windows or 32-bit build: those machines, and any -failed download, keep the MCP server. +There is no musl, arm64 Windows or 32-bit build. When a download or update +fails, init uses the binary already in the bin slot if there is one; the MCP +server is the fallback only when no core answers and no usable core binary +is there to start (those machines included). **One core per machine (the attach rule).** Before installing anything, init connects to the core's bridge socket (`AGENT_RELAY_CORE_SOCKET`, else @@ -360,16 +362,24 @@ A setting that is already on is left alone; the ones init turned on are recorded in `/local/core-settings.json`, and `relay init --uninstall` (both scopes, or `--scope global`) turns off exactly those. The core turns Claude's direct delivery off by writing `hold` on every Claude login -it follows; init records each login file whose `crossSessionInbound` it saw go -from unset to `accept`, and removes the key again from each. Each tool is on -exactly one route. When one of a tool's settings fails, init stops there, turns -off what it had turned on for that tool, and gives it the Relay MCP server; -`auto_activate` is turned on only once a tool is injected, and every tool -gets the MCP server when the core cannot be had. `--delivery mcp` asks for -the MCP server explicitly, and first turns off the recorded settings of those -tools (and `auto_activate` once no injected tool is left); when that fails, -no MCP server is added. An MCP entry an earlier `relay init` added is -removed for each injected tool, in both scopes. +it follows, the person's own `accept` included. Before turning it on, init +records each login's `crossSessionInbound` (or its absence), and after every +turn-off (a rollback, a record it could not save, `--delivery mcp`, +`--uninstall`) puts those values back where the core left `hold`. + +Each tool is on exactly one route. When one of a tool's settings fails, init +stops there, turns off what it had turned on for that tool, and gives it the +Relay MCP server; `auto_activate` is turned on only once a tool is injected, +and every tool gets the MCP server when the core cannot be had. When a +setting init turned on cannot be turned off again (after a failed tool, or +when the record cannot be saved), setup stops with that error and adds no +MCP server for it. `--delivery mcp` asks for the MCP server explicitly, and +first turns off the recorded settings of those tools (and `auto_activate` +once no injected tool is left); when that fails, no MCP server is added. An +MCP entry an earlier `relay init` added is removed for each injected tool, in +both scopes. The one case that can leave a tool on both routes is a removal +that fails (a read-only config, for example): init reports it with the file, +and the entry must be removed by hand. These are settings of the user account, so they apply in every project whatever `--scope` says; `--scope` now only places the MCP fallback, and the @@ -391,9 +401,9 @@ self-hosted setup whose id cannot be compared says so (for example a desktop sig another workspace, or a machine-wide active workspace this project does not use), with the `relay workspace switch` command that aligns them. -**Service.** The core init starts is a background process, not a service: it -stops at logout or reboot, and `relay init` starts it again. For an always-on -core, install the desktop app (its systemd unit or LaunchAgent), or run the +**Service.** `relay init` starts the core as a detached background process +and installs no service; when it is not running, `relay init` starts it +again. For an always-on core, install the desktop app (its systemd unit or LaunchAgent), or run the core as a user service yourself, for example: ```ini diff --git a/packages/cli/src/cli/commands/init.test.ts b/packages/cli/src/cli/commands/init.test.ts index 562111b4ab..6f083d788a 100644 --- a/packages/cli/src/cli/commands/init.test.ts +++ b/packages/cli/src/cli/commands/init.test.ts @@ -1387,8 +1387,101 @@ describe('relay init message injection', () => { await setup(core.deps).run('--uninstall'); expect(JSON.parse(fs.readFileSync(own, 'utf8'))).toEqual({ theme: 'dark' }); expect(JSON.parse(fs.readFileSync(work, 'utf8'))).toEqual({}); - // The person's own accept was theirs: the core turns it to hold, and init does not remove it. - expect(JSON.parse(fs.readFileSync(chosen, 'utf8'))).toEqual({ crossSessionInbound: 'hold' }); + // The person's own accept was theirs: the core turns it to hold, and init puts it back. + expect(JSON.parse(fs.readFileSync(chosen, 'utf8'))).toEqual({ crossSessionInbound: 'accept' }); + }); + + /** This login unset, another with the person's own accept, both followed by the core. */ + function twoClaudeLogins() { + const own = path.join(toolHome, '.claude', 'settings.json'); + const work = path.join(toolHome, '.claude-work', 'settings.json'); + for (const file of [own, work]) fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(path.join(toolHome, '.claude-work', '.claude.json'), '{}'); + fs.writeFileSync(own, JSON.stringify({ theme: 'dark' })); + fs.writeFileSync(work, JSON.stringify({ crossSessionInbound: 'accept' })); + const read = (file: string) => JSON.parse(fs.readFileSync(file, 'utf8')); + return { own, work, read, files: [own, work] }; + } + + it("puts back each login's own crossSessionInbound when its record cannot be saved", async () => { + installedProbe(); + const logins = twoClaudeLogins(); + const core = fakeCore({ + running: true, + settings: { auto_activate: true, claude_session_hook: true }, + claudeFiles: logins.files, + }); + fs.mkdirSync(path.join(home, 'local', 'core-settings.json'), { recursive: true }); + await setup(core.deps).run('--tools', 'claude'); + expect(core.settings.direct_delivery).toBe(false); + expect(logins.read(logins.own)).toEqual({ theme: 'dark' }); + expect(logins.read(logins.work)).toEqual({ crossSessionInbound: 'accept' }); + }); + + it("puts back each login's own crossSessionInbound on a switch to MCP", async () => { + installedProbe(); + const logins = twoClaudeLogins(); + const core = fakeCore({ + running: true, + settings: { auto_activate: true, claude_session_hook: true }, + claudeFiles: logins.files, + }); + await setup(core.deps).run('--tools', 'claude'); + expect(logins.read(logins.own)).toEqual({ theme: 'dark', crossSessionInbound: 'accept' }); + await setup(core.deps).run('--tools', 'claude', '--delivery', 'mcp'); + expect(core.settings.direct_delivery).toBe(false); + expect(logins.read(logins.own)).toEqual({ theme: 'dark' }); + expect(logins.read(logins.work)).toEqual({ crossSessionInbound: 'accept' }); + }); + + it("puts back each login's own crossSessionInbound when Claude's delivery is rolled back", async () => { + installedProbe(); + const logins = twoClaudeLogins(); + const core = fakeCore({ + running: true, + settings: { auto_activate: true }, + refuse: ['claude_session_hook'], + claudeFiles: logins.files, + }); + await setup(core.deps).run('--tools', 'claude'); + expect(core.settings.direct_delivery).toBe(false); + expect(logins.read(logins.own)).toEqual({ theme: 'dark' }); + expect(logins.read(logins.work)).toEqual({ crossSessionInbound: 'accept' }); + }); + + it('stops without adding MCP when a failed tool cannot be rolled back', async () => { + installedProbe(); + const core = fakeCore({ + running: true, + settings: { auto_activate: true }, + refuse: ['claude_session_hook'], + }); + const connect = core.deps.connectCore; + const { run, errors } = setup({ + ...core.deps, + connectCore: vi.fn(async (socket: string) => { + const client = await connect(socket); + if (!client) return undefined; + return { + ...client, + call: async (method: string, params?: Record) => { + if (method === 'settings.set' && params && Object.values(params).includes(false)) { + throw new Error('the core is busy'); + } + return client.call(method, params); + }, + }; + }), + }); + await expect(run('--tools', 'claude')).rejects.toEqual(new ExitSignal(1)); + const shown = errors.join('\n'); + expect(shown).toContain('direct_delivery stays on (the core is busy)'); + expect(shown).toContain('no Relay MCP server was added'); + expect(fs.existsSync(path.join(project, '.mcp.json'))).toBe(false); + // Recorded, so --uninstall can still turn it off. + expect( + JSON.parse(fs.readFileSync(path.join(home, 'local', 'core-settings.json'), 'utf8')).turnedOn + ).toContain('direct_delivery'); }); it('falls back to the MCP server when the core cannot be started', async () => { diff --git a/packages/cli/src/cli/commands/init.ts b/packages/cli/src/cli/commands/init.ts index bed70ce911..725592bef9 100644 --- a/packages/cli/src/cli/commands/init.ts +++ b/packages/cli/src/cli/commands/init.ts @@ -972,36 +972,47 @@ async function setupInjection( try { const record = readDeliveryRecord(deliveryRecordPath()); const ctx = deps.toolContext(); - // The Claude logins whose crossSessionInbound is unset: the ones the - // direct-delivery default may set, and uninstall must unset again. - const unset = tools.includes('claude') - ? claudeLoginSettings(ctx).filter((file) => claudeInbound(file) === null) - : []; + // Every Claude login's crossSessionInbound before anything changes: what + // turning direct delivery off again puts back, the person's own choices + // included, instead of the hold the core writes on each login. + const before = tools.includes('claude') ? claudeInboundValues(ctx) : {}; const { results, turnedOn, turnedOff } = await enableDelivery(session.client, tools, record.turnedOn); - const accepted = unset.filter((file) => claudeInbound(file) === 'accept'); // Applied to the record as it is now, not as it was read: another // relay init may have recorded its own changes meanwhile. const save = () => updateDeliveryRecord((current) => { - if (turnedOff.includes('direct_delivery')) { - // Rolled back: the core wrote hold where init had set accept, in this run or an earlier one. - restoreClaudeInbound([...new Set([...current.claudeInbound, ...unset])]); - } + // Older originals win: they were recorded before init first changed anything. + const originals = { ...before, ...current.claudeInbound }; + if (turnedOff.includes('direct_delivery')) restoreClaudeInbound(originals); return { turnedOn: [...new Set([...current.turnedOn, ...turnedOn])].filter( (key) => !turnedOff.includes(key) ), claudeInbound: turnedOff.includes('direct_delivery') - ? [] - : [...new Set([...current.claudeInbound, ...accepted])], + ? {} + : turnedOn.includes('direct_delivery') + ? originals + : current.claudeInbound, }; }); try { save(); } catch (error) { - await undoUnrecorded(session.client, turnedOn, accepted, error as Error); + await undoUnrecorded(session.client, turnedOn, before, error as Error); throw error; } + // A failed tool whose settings could not be turned off is still on the + // core's route: it gets no MCP server, and setup stops (it is recorded, + // so --uninstall can still turn it off). + const stuck = results.filter((result) => result.stuck && result.stuck.length > 0); + if (stuck.length > 0) { + throw new InjectionStuckError( + stuck.map((result) => `${findToolTarget(result.id)!.label}: ${result.message}`).join('\n') + + '\nThese settings relay init turned on could not be turned off after the tool failed, so no ' + + 'Relay MCP server was added for it (two routes would split its messages). Run relay init ' + + 'again once the core answers, or relay init --uninstall.' + ); + } const workspaceNote = await coreWorkspaceNote(deps, session.client, setup); return { core: describeCore(session), results, workspaceNote }; } catch (error) { @@ -1024,12 +1035,12 @@ class InjectionStuckError extends Error {} async function undoUnrecorded( client: CoreClient, turnedOn: string[], - accepted: string[], + before: Record, cause: Error ): Promise { const failures = await disableDelivery(client, turnedOn); if (turnedOn.includes('direct_delivery') && !failures.some((f) => f.startsWith('direct_delivery:'))) { - restoreClaudeInbound(accepted); + restoreClaudeInbound(before); } if (failures.length > 0) { throw new InjectionStuckError( @@ -1183,21 +1194,37 @@ function claudeInbound(file: string): string | null | undefined { } } +/** Each readable Claude login's `crossSessionInbound` (null: absent), by settings file. */ +function claudeInboundValues(ctx: ToolContext): Record { + const values: Record = {}; + for (const file of claudeLoginSettings(ctx)) { + // A login folder Claude Code has never made holds nothing to put back. + if (!fs.existsSync(path.dirname(file))) continue; + const value = claudeInbound(file); + if (value !== undefined) values[file] = value; + } + return values; +} + /** * The core turns Claude Code's direct delivery off by writing * `crossSessionInbound: "hold"` (Claude Code's own default) on every login - * where it was on. Where relay init had turned it on, the key was absent - * before (the default only fills an absent key), so it is removed again - * rather than left as `hold`. - * @param files - The settings files init recorded as changed + * where it was on, the person's own `accept` included. Run after that core + * call (the core writes it then, and not again until direct delivery is + * turned on), this puts back each login's value from before init: removed + * where it was absent, the person's own value where they had one. A login + * the person changed since (anything but `hold`) is left as it is. + * @param originals - Settings file to its value before init (null: absent) */ -function restoreClaudeInbound(files: string[]): void { - for (const file of files) { +function restoreClaudeInbound(originals: Record): void { + for (const [file, original] of Object.entries(originals)) { + if (original === 'hold') continue; try { const settings = JSON.parse(fs.readFileSync(file, 'utf8')) as Record; if (!settings || typeof settings !== 'object' || Array.isArray(settings)) continue; if (settings.crossSessionInbound !== 'hold') continue; - delete settings.crossSessionInbound; + if (original === null) delete settings.crossSessionInbound; + else settings.crossSessionInbound = original; writeKeepingMode(file, `${JSON.stringify(settings, null, 2)}\n`); } catch { // Unreadable or not JSON: left as the core wrote it. @@ -1205,11 +1232,11 @@ function restoreClaudeInbound(files: string[]): void { } } -/** The Claude files to restore: the recorded ones, else (an older record) this login's. */ -function recordedClaudeFiles(record: DeliveryRecord, ctx: ToolContext): string[] { - return record.claudeInbound.length > 0 +/** The Claude values to restore: the recorded ones, else (an older record) this login's unset. */ +function recordedClaudeFiles(record: DeliveryRecord, ctx: ToolContext): Record { + return Object.keys(record.claudeInbound).length > 0 ? record.claudeInbound - : [path.join(claudeConfigDir(ctx), 'settings.json')]; + : { [path.join(claudeConfigDir(ctx), 'settings.json')]: null }; } /** @@ -1228,7 +1255,7 @@ function writeDeliveryRecord(record: DeliveryRecord): void { } writePrivateJson(deliveryRecordPath(), { turnedOn: record.turnedOn, - ...(record.claudeInbound.length > 0 ? { claudeInbound: record.claudeInbound } : {}), + ...(Object.keys(record.claudeInbound).length > 0 ? { claudeInbound: record.claudeInbound } : {}), }); } @@ -1267,7 +1294,7 @@ async function turnOffInjection(deps: InitCommandDependencies, keys: string[]): if (done.includes('direct_delivery')) restoreClaudeInbound(recordedClaudeFiles(current, ctx)); return { turnedOn: current.turnedOn.filter((key) => !done.includes(key)), - claudeInbound: done.includes('direct_delivery') ? [] : current.claudeInbound, + claudeInbound: done.includes('direct_delivery') ? {} : current.claudeInbound, }; }); if (failures.length > 0) { diff --git a/packages/cli/src/cli/lib/probe-core.test.ts b/packages/cli/src/cli/lib/probe-core.test.ts index 2ee6c11f66..ca1f0319a7 100644 --- a/packages/cli/src/cli/lib/probe-core.test.ts +++ b/packages/cli/src/cli/lib/probe-core.test.ts @@ -354,9 +354,21 @@ describe('records', () => { it('reads the recorded Claude settings files, strings only', () => { const file = path.join(dir, 'core-settings.json'); + // An older record listed the files that were unset. fs.writeFileSync(file, JSON.stringify({ turnedOn: ['direct_delivery'], claudeInbound: ['/a', 7] })); - expect(readDeliveryRecord(file)).toEqual({ turnedOn: ['direct_delivery'], claudeInbound: ['/a'] }); + expect(readDeliveryRecord(file)).toEqual({ + turnedOn: ['direct_delivery'], + claudeInbound: { '/a': null }, + }); + fs.writeFileSync( + file, + JSON.stringify({ + turnedOn: ['direct_delivery'], + claudeInbound: { '/a': null, '/b': 'accept', '/c': 3 }, + }) + ); + expect(readDeliveryRecord(file).claudeInbound).toEqual({ '/a': null, '/b': 'accept' }); fs.writeFileSync(file, 'null'); - expect(readDeliveryRecord(file)).toEqual({ turnedOn: [], claudeInbound: [] }); + expect(readDeliveryRecord(file)).toEqual({ turnedOn: [], claudeInbound: {} }); }); }); diff --git a/packages/cli/src/cli/lib/probe-core.ts b/packages/cli/src/cli/lib/probe-core.ts index 2aeec92935..3c0256c1f0 100644 --- a/packages/cli/src/cli/lib/probe-core.ts +++ b/packages/cli/src/cli/lib/probe-core.ts @@ -375,6 +375,11 @@ export interface DeliveryResult { status: 'on' | 'failed' | 'held'; route: string; message?: string; + /** + * Settings init turned on for a failed tool that could not be turned off + * again: the tool is still on the core's route, so it must not get MCP. + */ + stuck?: string[]; } /** What one `enableDelivery` did. */ @@ -464,7 +469,13 @@ export async function enableDelivery( } if (failure) { const stuck = await rollBack(deliveryKeys(id)); - results.push({ id, status: 'failed', route, message: [failure, ...stuck].join('; ') }); + results.push({ + id, + status: 'failed', + route, + message: [failure, ...stuck].join('; '), + ...(stuck.length > 0 ? { stuck } : {}), + }); } else if (id === 'claude' && values.direct_delivery !== true) { results.push({ id, @@ -495,6 +506,7 @@ export async function enableDelivery( const stuck = await rollBack(deliveryKeys(result.id)); result.status = 'failed'; result.message = [`automatic activation: ${activation}`, ...stuck].join('; '); + if (stuck.length > 0) result.stuck = stuck; } } return { results, turnedOn, turnedOff }; @@ -555,15 +567,27 @@ export interface DeliveryRecord { /** Core settings (by read key) init turned on. */ turnedOn: string[]; /** - * Claude settings files whose `crossSessionInbound` was unset until the - * core's direct-delivery default set it, on every Claude login it follows. + * Each Claude login's `crossSessionInbound` (null: absent) from before init + * turned direct delivery on, by settings file: what turning it off again + * puts back in place of the `hold` the core writes. */ - claudeInbound: string[]; + claudeInbound: Record; } const strings = (value: unknown): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; +/** Recorded originals; an older record's list named files that were unset. */ +function originals(value: unknown): Record { + if (Array.isArray(value)) return Object.fromEntries(strings(value).map((file) => [file, null])); + if (!value || typeof value !== 'object') return {}; + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, string | null] => entry[1] === null || typeof entry[1] === 'string' + ) + ); +} + /** * The record of what init turned on. * @param file - `/local/core-settings.json` @@ -572,9 +596,9 @@ const strings = (value: unknown): string[] => export function readDeliveryRecord(file: string): DeliveryRecord { try { const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as Record | null; - return { turnedOn: strings(parsed?.turnedOn), claudeInbound: strings(parsed?.claudeInbound) }; + return { turnedOn: strings(parsed?.turnedOn), claudeInbound: originals(parsed?.claudeInbound) }; } catch { - return { turnedOn: [], claudeInbound: [] }; + return { turnedOn: [], claudeInbound: {} }; } }