diff --git a/.github/workflows/probe-release.yml b/.github/workflows/probe-release.yml new file mode 100644 index 0000000000..b454a3bc9e --- /dev/null +++ b/.github/workflows/probe-release.yml @@ -0,0 +1,230 @@ +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 + # 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 + 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 + # 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 + 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' && github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' + 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 45788366d4..1f9f160c7b 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 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`. - `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/contract/CoreContract.swift b/crates/probe/contract/CoreContract.swift index 326720ef5a..e05cf40d3e 100644 --- a/crates/probe/contract/CoreContract.swift +++ b/crates/probe/contract/CoreContract.swift @@ -7444,6 +7444,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? @@ -7464,7 +7466,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 @@ -7487,6 +7489,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 @@ -7525,6 +7528,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" @@ -7564,6 +7568,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) @@ -7603,6 +7608,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) @@ -7710,6 +7716,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. @@ -7761,7 +7773,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 @@ -7787,6 +7799,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 @@ -7829,6 +7842,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" @@ -7872,6 +7886,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) @@ -7915,6 +7930,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 2e958703d5..6fe2e4f45f 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 @@ -513,7 +521,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"?, "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 | diff --git a/crates/probe/src/probe/relay_commands.rs b/crates/probe/src/probe/relay_commands.rs index 93c2b1586e..b56a059b4d 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/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/claude/session_hook.rs b/crates/probe/src/relay/claude/session_hook.rs index f22cfacddb..06bbbfe9f1 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,12 @@ pub fn read_status(http_status: u16, body: &[u8]) -> StatusRead { mcp: McpAccess::parse(session["mcp"].as_str()), }; } + // 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"] .as_bool() .unwrap_or(true); @@ -162,6 +171,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 +195,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 +811,44 @@ 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,"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 { .. })); + 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..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?") @@ -463,6 +464,7 @@ fn session_registered(socket: &Path) -> Option { return Some(registered); } @@ -741,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/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 6ea6ea7c87..fb218d5573 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), @@ -675,9 +686,12 @@ 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 = options.headless; + relay_send.agent_led_defaults_at_start = own_defaults(&options).agent_led; } let plugins = crate::relay::account::with_account( local, @@ -686,6 +700,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 +934,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 +1053,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 +1095,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/local.rs b/crates/probe/src/relay/local.rs index 999cba6f6c..b1920b7538 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. @@ -3508,6 +3508,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 14a2a0356f..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)], @@ -414,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/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..d92dcb06b7 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,56 @@ 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 `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()) + && 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()); } @@ -147,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 { @@ -393,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, @@ -449,6 +510,18 @@ 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() { + // 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(), + ); + } if native { if let Err(outcome) = native_choice::reserve(config, uid, registration, message_id, text) { return outcome; @@ -579,9 +652,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, }; @@ -608,6 +685,65 @@ 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 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/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/sessions.rs b/crates/probe/src/relay/sessions.rs index 5ce440c2dd..cc261b3b91 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 would put `session` on the relay, by + /// the same check `auto_activation_sessions` applies. May block. + fn auto_activates(&self, _session: &DiscoveredSession) -> 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/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/crates/probe/src/relay/setup.rs b/crates/probe/src/relay/setup.rs index 1f3955f756..e1da84305e 100644 --- a/crates/probe/src/relay/setup.rs +++ b/crates/probe/src/relay/setup.rs @@ -702,6 +702,22 @@ 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 + // activation's own check admits this session. + 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)) + .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(); @@ -731,6 +747,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/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 cef0ed3dfe..006230efdc 100644 --- a/docs/probe-move.md +++ b/docs/probe-move.md @@ -276,21 +276,195 @@ 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. 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 +`~/.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 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 +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 | `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 +--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, 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 +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: 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. + +**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 +# ~/.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. + 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 +'{"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 @@ -329,3 +503,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..6f083d788a 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,697 @@ 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; + 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, + direct_delivery: false, + claude_session_hook: false, + codex_stop_hook: false, + 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. */ + 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: options.workspaceId ?? 'ws_1', + verification: 'verified', + }; + const writeInbound = (value: string, only: string | undefined) => { + for (const file of options.claudeFiles ?? []) { + 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 }); + fs.writeFileSync(file, JSON.stringify({ ...current, crossSessionInbound: value })); + } + }; + 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 (value === true && options.refuse?.includes(key)) throw new Error(`${key} is not available.`); + if (key === 'direct_delivery_default') { + if (!userChoseInbound) { + settings.direct_delivery = true; + writeInbound('accept', undefined); + } + } else { + settings[key] = value; + if (key === 'direct_delivery') { + userChoseInbound = false; + if (value === false) writeInbound('hold', 'accept'); + } + } + } + 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([ + { 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)'); + 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: ['direct_delivery', 'claude_session_hook', 'codex_stop_hook', 'auto_activate'], + }); + + 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('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('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'); + 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 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 () => { + 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'); + 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..725592bef9 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,46 @@ 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 { + AUTO_ACTIVATE, + CORE_START_TIMEOUT_MS, + PROBE_DELIVERY, + PROBE_VERSION, + connectCore, + coreSocketPath, + deliveryKeys, + disableDelivery, + enableDelivery, + probeAssetName, + probeAssetUrl, + readCoreState, + 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'; 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 +121,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 +298,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: readProbeVersion, ...overrides, }; } @@ -362,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), { @@ -382,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 { @@ -418,8 +492,14 @@ 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; + /** 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; } @@ -483,12 +563,15 @@ 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}".`, details: ['Your key is only ever sent to this server.', ...lines], }; @@ -501,6 +584,9 @@ 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}".`, details: [ ...lines, @@ -515,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) { @@ -530,10 +618,14 @@ 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.`, details: [...lines, 'To work with agents on other machines later, run: relay init --hosted'], }; @@ -651,6 +743,608 @@ 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…'); + let pid: number; + try { + pid = deps.startCore({ binary: probe.binary, socket, logFile }); + } catch (error) { + return { reason: `the Agent Relay core could not be started (${(error as Error).message})` }; + } + 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; + } +} + +/** + * 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 + * 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 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'}), ` + + `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 record = readDeliveryRecord(deliveryRecordPath()); + const ctx = deps.toolContext(); + // 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); + // 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) => { + // 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') + ? {} + : turnedOn.includes('direct_delivery') + ? originals + : current.claudeInbound, + }; + }); + try { + save(); + } catch (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) { + 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[], + before: Record, + cause: Error +): Promise { + const failures = await disableDelivery(client, turnedOn); + if (turnedOn.includes('direct_delivery') && !failures.some((f) => f.startsWith('direct_delivery:'))) { + restoreClaudeInbound(before); + } + 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 { + 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. 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' || 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.' + ); +} + +/** 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 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 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 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 { + return fs.realpathSync(file); + } catch { + return path.resolve(file); + } +} + +/** 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; + } +} + +/** 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, 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(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; + 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. + } + } +} + +/** 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')]: null }; +} + +/** + * 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, + ...(Object.keys(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) { + 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); + const done = keys.filter((key) => !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) { + throw new Error(`Some injection settings could not be turned off: ${failures.join('; ')}`); + } + return done; + } finally { + session.client.close(); + // A core started only to undo the settings is stopped again. + if (session.origin === 'started') await stopCore(deps, true); + } +} + +/** Turn off everything relay init turned on in the core. */ +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`) + */ +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 { + // No core answers: the one init started has exited. Its pid may belong to + // another program by now, so it is never signalled; the record is dropped. + say('The Agent Relay core relay init started is no longer running.'); + } + fs.rmSync(coreStatePath(), { force: true }); +} + // --------------------------------------------------------------------------- // Command @@ -663,6 +1357,7 @@ interface InitOptions { stop?: boolean; tools?: ToolId[]; scope?: InstallScope; + delivery?: Delivery; yes?: boolean; uninstall?: boolean; } @@ -676,7 +1371,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 +1380,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 +1391,95 @@ 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 = 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(', ')})`); + } 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[]; + /** 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. */ +async function planDelivery( + deps: InitCommandDependencies, + tools: ToolId[], + delivery: Delivery, + setup: ServerSetup, + ctx: ToolContext +): Promise { + 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: [], 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: ` + + '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) => describeSupersededMcp(result, ctx)), + ...(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 +1492,29 @@ 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)); + } + if (plan.turnedOff.length > 0) { + deps.log(''); + deps.log(turnedOffLine(plan.turnedOff)); + } + 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 +1522,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 +1635,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 +1650,96 @@ 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)); + } + if (plan.turnedOff.length > 0) p.info(turnedOffLine(plan.turnedOff)); + + 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 +1753,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 +1766,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..ca1f0319a7 --- /dev/null +++ b/packages/cli/src/cli/lib/probe-core.test.ts @@ -0,0 +1,374 @@ +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, + readDeliveryRecord, + readProbeVersion, + 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[], + greeting: unknown = { bridge_version: BRIDGE_VERSION, pid: 42 } + ): 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: greeting } + : 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)); + // The core binds its socket 0600. + fs.chmodSync(socket, 0o600); + } + + 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'); + }); + + 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', () => { + 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, + pi_extension: false, + }); + const outcome = await enableDelivery(core, ['codex', 'opencode', 'pi']); + 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'], + ['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('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('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']); + 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']); + 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([]); + }); + + 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': 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: {} }); + }); +}); 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..3c0256c1f0 --- /dev/null +++ b/packages/cli/src/cli/lib/probe-core.ts @@ -0,0 +1,608 @@ +/** + * 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 { execFileSync, 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.` + ); + } +} + +/** + * 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. + * @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); + trustedSocketFile(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(); + }, + }; + let greeting: { bridge_version?: unknown; pid?: unknown } | null; + try { + greeting = 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 } + ); + } + // 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 }; + 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, + }); + // 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; + } 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: { + // A core relay init started installs the extension only through this setting. + settings: [setting('pi_extension')], + route: 'the Agent Relay extension in ~/.pi/agent/extensions', + }, + 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; + /** + * 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. */ +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; + +/** + * 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 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 + * @returns Per-tool results, and the settings turned on and off + */ +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 { + 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; + }; + /** 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[] = []; + for (const id of tools) { + const { settings, route } = PROBE_DELIVERY[id]; + let failure: string | undefined; + for (const s of settings) { + failure = await turnOn(s); + if (failure) break; + } + if (failure) { + const stuck = await rollBack(deliveryKeys(id)); + 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, + 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 }); + } + } + + const injected = results.filter((r) => r.status !== 'failed'); + if (injected.length === 0) { + // 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); + 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('; '); + if (stuck.length > 0) result.stuck = stuck; + } + } + return { results, turnedOn, turnedOff }; +} + +/** + * 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 }); +} + +/** 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[]; + /** + * 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: 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` + * @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 Record | null; + return { turnedOn: strings(parsed?.turnedOn), claudeInbound: originals(parsed?.claudeInbound) }; + } catch { + return { turnedOn: [], claudeInbound: {} }; + } +} + +/** The core settings init turned on, kept for `--uninstall`. */ +export function readTurnedOn(file: string): string[] { + return readDeliveryRecord(file).turnedOn; +} 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