From 349a8278c8af3791013666763725a5eb68e43dda Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 2 Oct 2026 18:51:37 +0200 Subject: [PATCH 1/8] feat(connect): install standalone signed probe on macOS Use release tarballs for clean Macs, verify checksums and the pinned Developer ID before starting a headless probe, and retain the installed app update path. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 6 +- packages/connect/README.md | 21 +++-- packages/connect/src/install.js | 101 ++++++++++++++++++++----- packages/connect/tests/connect.test.ts | 82 +++++++++++++++++++- vitest.connect.config.mjs | 1 + 5 files changed, 179 insertions(+), 32 deletions(-) create mode 100644 vitest.connect.config.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index bf946055d..c1c4d2377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased - Patch] + +### Changed + +- `@agent-relay/connect` installs a signed standalone macOS probe for a clean Mac, without installing the GUI app. ## [13.0.1] - 2026-10-02 diff --git a/packages/connect/README.md b/packages/connect/README.md index 202254850..24f1257e5 100644 --- a/packages/connect/README.md +++ b/packages/connect/README.md @@ -7,23 +7,27 @@ or a preinstalled skill: Run this for me: `npx -y @agent-relay/connect join ` ``` -The command reuses a live Agent Relay Desktop probe at version 2026.10.4 or +The command reuses a live Agent Relay probe at version 2026.10.4 or newer when its pointer and socket are owned by the current user and the pointer is not group- or world-writable. Unsafe pointer or socket metadata fails closed. If an existing socket or Relay process may be restarting, the command retries liveness for up to 15 seconds. When no eligible probe is available, it downloads the current release from `AgentWorkforce/relay-desktop-releases`, verifies the adjacent SHA-256 file, and starts the probe without signing in. -A responsive old Linux probe instead fails with an update-required error so the -CLI never starts a second headless probe beside it. +A responsive old standalone probe instead fails with an update-required error +so the CLI never starts a second headless probe beside it. An installed macOS +app keeps its existing app update path. - Linux x64 and arm64 use the relocatable tarball under `~/.local/lib/agent-relay/current`, a symlink at `~/.local/bin/agent-relay-probe`, and a detached headless process. No `sudo` is used. -- macOS x64 and arm64 use the signed DMG. The app is staged, verified with - `codesign --verify --deep --strict`, and installed in `/Applications` when - writable. `~/Applications` is an explicitly untested fallback. +- macOS x64 and arm64 use a signed, notarized standalone probe tarball. The + installer verifies its SHA-256 and the extracted binary's pinned Developer + ID before swapping it into `~/.local/lib/agent-relay/current` and starting + it headless. A clean Mac does not install the GUI app. If an app is already + installed but its probe needs updating, the existing signed DMG path updates + that app. - For Claude Code, starting the probe sets `"crossSessionInbound": "accept"` in `~/.claude/settings.json` so replies can be injected into the live session. The command never signs the user in. @@ -52,9 +56,10 @@ curl -fsS --unix-socket "$S" -X POST http://relay/setup/direct-delivery \ -H 'content-type: application/json' -d '{"enabled":false}' ``` -On Linux, stop the `agent-relay-probe` process, then remove only +For a standalone install on Linux or macOS, stop the `agent-relay-probe` +process, then remove only `~/.local/bin/agent-relay-probe` and `~/.local/lib/agent-relay/current`. Remove the pointer under `~/.agentworkforce/desktop/relay-socket` only after the probe -has stopped. On macOS, quit Agent Relay and move the installed app from +has stopped. For an installed macOS app, quit Agent Relay and move the app from `/Applications` (or `~/Applications`) to Trash. The CLI never removes Relay account data, keys, or other Relay installations. diff --git a/packages/connect/src/install.js b/packages/connect/src/install.js index cd9903a77..61accc338 100644 --- a/packages/connect/src/install.js +++ b/packages/connect/src/install.js @@ -53,13 +53,19 @@ export function getPlatformAsset(platform = process.platform, arch = process.arc throw new InstallError(`Unsupported Linux architecture: ${arch}`, 2); } if (platform === 'darwin') { - if (arch === 'x64') return 'AgentRelay-macOS-x64.dmg'; - if (arch === 'arm64') return 'AgentRelay-macOS-arm64.dmg'; + if (arch === 'x64') return 'AgentRelay-macOS-x64-probe.tar.gz'; + if (arch === 'arm64') return 'AgentRelay-macOS-arm64-probe.tar.gz'; throw new InstallError(`Unsupported macOS architecture: ${arch}`, 2); } throw new InstallError(`Unsupported platform: ${platform}`, 2); } +export function getMacAppAsset(arch = process.arch) { + if (arch === 'x64') return 'AgentRelay-macOS-x64.dmg'; + if (arch === 'arm64') return 'AgentRelay-macOS-arm64.dmg'; + throw new InstallError(`Unsupported macOS architecture: ${arch}`, 2); +} + export function downloadCommands(platform, tmpDir, asset) { const destination = join(tmpDir, asset); const checksum = `${destination}.sha256`; @@ -91,6 +97,14 @@ export async function verifyMacSignature(staged, run = runCommand) { } } +export async function verifyMacProbeSignature(staged, run = runCommand) { + try { + await run('codesign', ['--verify', '--strict', `-R=${MAC_SIGNING_REQUIREMENT}`, staged]); + } catch { + throw new InstallError('Agent Relay probe is not signed by Agent Workforce; refusing installation.'); + } +} + export async function runCommand(file, args, { cwd, capture = false, allowFailure = false } = {}) { return new Promise((resolve, reject) => { const child = spawn(file, args, { @@ -347,15 +361,17 @@ export async function finishSwap(destination, swap, ready, options = {}) { } } -export async function installLinux({ +async function installHeadless({ home, + platform, arch, run, start = startDetachedProbe, wait = waitForLiveSocket, + require = requireCommands, }) { - await requireCommands(['curl', 'sha256sum', 'tar']); - const asset = getPlatformAsset('linux', arch); + await require(platform === 'darwin' ? ['codesign', 'curl', 'shasum', 'tar'] : ['curl', 'sha256sum', 'tar']); + const asset = getPlatformAsset(platform, arch); const tmpDir = await mkdtemp(join(os.tmpdir(), 'agent-relay-connect-')); let child; let ready = false; @@ -368,14 +384,20 @@ export async function installLinux({ const installDir = join(installRoot, 'current'); const stagedDir = join(installRoot, 'current.new'); try { - await downloadAndVerify('linux', tmpDir, asset, run); + await downloadAndVerify(platform, tmpDir, asset, run); await mkdir(installRoot, { recursive: true }); await rm(stagedDir, { recursive: true, force: true }); await mkdir(stagedDir, { recursive: true }); - await run('tar', ['-xzf', join(tmpDir, asset), '-C', stagedDir]); + const archive = join(tmpDir, asset); + // The Mac archive needs only its signed helper. Extracting a named member + // prevents any additional archive entries from writing into this HOME. + await run('tar', platform === 'darwin' + ? ['-xzf', archive, '-C', stagedDir, 'agent_relay/helpers/agent-relay-probe'] + : ['-xzf', archive, '-C', stagedDir]); const stagedProbe = await findProbe(stagedDir); if (!stagedProbe) throw new InstallError(`agent-relay-probe not found or not executable in ${asset}.`); + if (platform === 'darwin') await verifyMacProbeSignature(stagedProbe, run); const probeRelativePath = relative(stagedDir, stagedProbe); swap = await swapWithBackup(installDir, stagedDir); const probe = join(installDir, probeRelativePath); @@ -389,7 +411,10 @@ export async function installLinux({ try { previousLinkTarget = await readlink(link); } catch (error) { - if (error?.code !== 'ENOENT' && error?.code !== 'EINVAL') throw error; + if (error?.code === 'EINVAL') { + throw new InstallError(`Refusing to replace a non-symlink at ${link}.`); + } + if (error?.code !== 'ENOENT') throw error; } await rm(link, { force: true }); await symlink(probe, link); @@ -449,6 +474,14 @@ export async function installLinux({ } } +export async function installLinux(options) { + return installHeadless({ ...options, platform: 'linux' }); +} + +export async function installMacProbe(options) { + return installHeadless({ ...options, platform: 'darwin' }); +} + async function processRunning(run) { const result = await run('pgrep', ['-x', 'RelayDesktop'], { allowFailure: true }); return result.code === 0; @@ -614,11 +647,12 @@ export async function installMac({ arch, run, warn, + appPath = null, wait = waitForLiveSocket, require = requireCommands, }) { await require(['codesign', 'curl', 'ditto', 'hdiutil', 'open', 'osascript', 'pgrep', 'shasum']); - const asset = getPlatformAsset('darwin', arch); + const asset = getMacAppAsset(arch); const tmpDir = await mkdtemp(join(os.tmpdir(), 'agent-relay-connect-')); let volume = ''; let staged = ''; @@ -639,14 +673,16 @@ export async function installMac({ await quitRelayDesktop(run); - app = '/Applications/Agent Relay.app'; - try { - await access('/Applications', constants.W_OK); - } catch { - const applications = join(home, 'Applications'); - await mkdir(applications, { recursive: true }); - app = join(applications, 'Agent Relay.app'); - warn(`Using the untested per-user Applications fallback: ${app}`); + app = appPath || '/Applications/Agent Relay.app'; + if (!appPath) { + try { + await access('/Applications', constants.W_OK); + } catch { + const applications = join(home, 'Applications'); + await mkdir(applications, { recursive: true }); + app = join(applications, 'Agent Relay.app'); + warn(`Using the untested per-user Applications fallback: ${app}`); + } } staged = `${app}.new`; @@ -690,6 +726,19 @@ export async function installMac({ } } +export async function findInstalledMacApp(home = os.homedir()) { + for (const app of ['/Applications/Agent Relay.app', join(home, 'Applications/Agent Relay.app')]) { + try { + if ((await stat(app)).isDirectory()) return app; + } catch (error) { + if (error?.code !== 'ENOENT') { + throw new InstallError(`Could not inspect the Agent Relay app: ${error.message}`); + } + } + } + return null; +} + export async function ensureProbe({ home = os.homedir(), platform = process.platform, @@ -703,7 +752,9 @@ export async function ensureProbe({ start = startDetachedProbe, wait = waitForLiveSocket, installLinuxFn = installLinux, + installMacProbeFn = installMacProbe, installMacFn = installMac, + installedMacApp = findInstalledMacApp, acquire = acquireInstallLock, } = {}) { const existing = await findRecoveringProbe({ home, find, run, active, now, sleep }); @@ -711,6 +762,10 @@ export async function ensureProbe({ if (existing) return { ...existing, installed: false }; } else if (platform === 'linux') { requireSupportedProbe(existing); + } else if (platform === 'darwin' && !(await installedMacApp(home))) { + // An old standalone core still owns the per-home socket. Never start a + // second core against that pointer; the user must stop or update it. + requireSupportedProbe(existing); } if (platform !== 'linux' && platform !== 'darwin') { @@ -724,12 +779,16 @@ export async function ensureProbe({ if (afterLock) return { ...afterLock, installed: false }; } else if (platform === 'linux') { requireSupportedProbe(afterLock); + } else if (platform === 'darwin' && !(await installedMacApp(home))) { + requireSupportedProbe(afterLock); } - const result = - platform === 'linux' - ? await installLinuxFn({ home, arch, run, start, wait }) - : await installMacFn({ home, arch, run, warn, wait }); + const appPath = platform === 'darwin' ? await installedMacApp(home) : null; + const result = platform === 'linux' + ? await installLinuxFn({ home, arch, run, start, wait }) + : appPath + ? await installMacFn({ home, arch, run, warn, wait, appPath }) + : await installMacProbeFn({ home, arch, run, start, wait }); return { ...result, installed: true }; } finally { await installLock.release(); diff --git a/packages/connect/tests/connect.test.ts b/packages/connect/tests/connect.test.ts index f51ddbddd..6581576d8 100644 --- a/packages/connect/tests/connect.test.ts +++ b/packages/connect/tests/connect.test.ts @@ -24,8 +24,10 @@ import { finishSwap, findLiveSocket, findRecoveringProbe, + getMacAppAsset, getPlatformAsset, installMac, + installMacProbe, installLinux, macStageCommands, probeVersionSupported, @@ -35,6 +37,7 @@ import { swapMacApp, verifyChecksum, verifyMacSignature, + verifyMacProbeSignature, waitForLiveSocket, } from '../src/install.js'; import { requestJson } from '../src/http.js'; @@ -278,8 +281,9 @@ describe('probe installer', () => { it('selects release assets for every supported platform and architecture', () => { expect(getPlatformAsset('linux', 'x64')).toBe('AgentRelay-Linux-x64.tar.gz'); expect(getPlatformAsset('linux', 'arm64')).toBe('AgentRelay-Linux-arm64.tar.gz'); - expect(getPlatformAsset('darwin', 'x64')).toBe('AgentRelay-macOS-x64.dmg'); - expect(getPlatformAsset('darwin', 'arm64')).toBe('AgentRelay-macOS-arm64.dmg'); + expect(getPlatformAsset('darwin', 'x64')).toBe('AgentRelay-macOS-x64-probe.tar.gz'); + expect(getPlatformAsset('darwin', 'arm64')).toBe('AgentRelay-macOS-arm64-probe.tar.gz'); + expect(getMacAppAsset('arm64')).toBe('AgentRelay-macOS-arm64.dmg'); expect(() => getPlatformAsset('linux', 'riscv64')).toThrow('Unsupported Linux architecture'); expect(() => getPlatformAsset('win32', 'x64')).toThrow('Unsupported platform'); }); @@ -335,6 +339,7 @@ describe('probe installer', () => { const result = await ensureProbe({ home: '/tmp/old-mac-probe', platform: 'darwin', + installedMacApp: async () => '/Applications/Agent Relay.app', find: async () => ({ socketPath: '/tmp/old.sock', status: { ok: true, data: { version: '2026.10.3' } }, @@ -354,6 +359,40 @@ describe('probe installer', () => { expect(result).toMatchObject({ socketPath: '/tmp/new.sock', installed: true }); }); + it('chooses a standalone probe for a clean Mac and the app updater for an installed app', async () => { + for (const app of [null, '/Applications/Agent Relay.app']) { + const calls: string[] = []; + const result = await ensureProbe({ + home: '/tmp/clean-mac', + platform: 'darwin', + find: async () => null, + active: async () => false, + installedMacApp: async () => app, + acquire: async () => ({ release: async () => {} }), + installMacProbeFn: async () => { + calls.push('probe'); + return { socketPath: '/tmp/probe.sock', status: { ok: true } }; + }, + installMacFn: async () => { + calls.push('app'); + return { socketPath: '/tmp/app.sock', status: { ok: true } }; + }, + }); + expect(calls).toEqual([app ? 'app' : 'probe']); + expect(result.installed).toBe(true); + } + }); + + it('refuses to start a second standalone core beside an old live Mac probe', async () => { + await expect(ensureProbe({ + home: '/tmp/old-standalone-mac', + platform: 'darwin', + find: async () => ({ version: '2026.10.3', supported: false }), + installedMacApp: async () => null, + installMacProbeFn: async () => { throw new Error('must not install'); }, + })).rejects.toMatchObject({ code: 'probe_too_old' }); + }); + it('gives status, send, and leave the same distinct old-probe error', async () => { const { home } = await listen((request, response) => { if (request.url === '/setup/status') { @@ -431,6 +470,44 @@ describe('probe installer', () => { ).rejects.toThrow('Agent Relay download is not signed by Agent Workforce; refusing installation.'); }); + it('verifies the extracted Mac helper against the pinned Developer ID before it can start', async () => { + const root = await mkdtemp(join(os.tmpdir(), 'connect-mac-probe-test-')); + cleanups.push(async () => rm(root, { recursive: true, force: true })); + const home = join(root, 'home'); + const commands: Array<[string, string[]]> = []; + const run = async (file: string, args: string[]) => { + commands.push([file, args]); + if (file === 'tar') { + const destination = args[args.indexOf('-C') + 1]; + const probe = join(destination, 'agent_relay/helpers/agent-relay-probe'); + await mkdir(join(probe, '..'), { recursive: true }); + await writeFile(probe, '#!/bin/sh\n'); + await chmod(probe, 0o755); + } + return { code: 0, stdout: '', stderr: '' }; + }; + const result = await installMacProbe({ + home, arch: 'arm64', run, require: async () => {}, + start: async () => ({ pid: undefined, unref() {} }), + wait: async () => ({ socketPath: '/tmp/mac-probe.sock', status: { ok: true } }), + }); + expect(result.socketPath).toBe('/tmp/mac-probe.sock'); + expect(commands.map(([file]) => file)).toEqual(['curl', 'curl', 'shasum', 'tar', 'codesign']); + expect(commands[0][1].at(-1)).toContain('AgentRelay-macOS-arm64-probe.tar.gz'); + expect(commands[3][1].at(-1)).toBe('agent_relay/helpers/agent-relay-probe'); + expect(commands[4][1].slice(0, 3)).toEqual([ + '--verify', '--strict', + '-R=anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "QUJ7SA6X8X"', + ]); + await expect(readFile(join(home, '.local/lib/agent-relay/current/agent_relay/helpers/agent-relay-probe'), 'utf8')).resolves.toBe('#!/bin/sh\n'); + }); + + it('rejects an unsigned extracted Mac helper before swapping or starting it', async () => { + await expect(verifyMacProbeSignature('/tmp/agent-relay-probe', async () => { + throw new Error('signature mismatch'); + })).rejects.toThrow('Agent Relay probe is not signed by Agent Workforce'); + }); + it('restores the previous macOS app when the verified replacement cannot be installed', async () => { const app = '/Applications/Agent Relay.app'; const staged = `${app}.new`; @@ -515,6 +592,7 @@ describe('probe installer', () => { arch: 'arm64', run, warn: () => {}, + appPath: app, require: async () => {}, wait: async () => { throw new Error('replacement never became ready'); diff --git a/vitest.connect.config.mjs b/vitest.connect.config.mjs new file mode 100644 index 000000000..c4c50d085 --- /dev/null +++ b/vitest.connect.config.mjs @@ -0,0 +1 @@ +export default { test: { include: ["packages/connect/tests/**/*.test.ts"] } }; From 907616b81d5035f87bd168126aade6777f4118c3 Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 2 Oct 2026 19:03:53 +0200 Subject: [PATCH 2/8] fix(connect): guard macOS app and standalone update paths Keep a single app lookup under the install lock, reject old standalone cores even beside an app, and explain when the installed app requires an administrator update. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 +- packages/connect/README.md | 3 +- packages/connect/src/install.js | 54 ++++++++++----- packages/connect/tests/connect.test.ts | 93 ++++++++++++++++++++++---- vitest.connect.config.mjs | 2 +- 5 files changed, 123 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1c4d2377..0399337a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased - Patch] +## [Unreleased - Minor] ### Changed diff --git a/packages/connect/README.md b/packages/connect/README.md index 24f1257e5..e007d836c 100644 --- a/packages/connect/README.md +++ b/packages/connect/README.md @@ -27,7 +27,8 @@ app keeps its existing app update path. ID before swapping it into `~/.local/lib/agent-relay/current` and starting it headless. A clean Mac does not install the GUI app. If an app is already installed but its probe needs updating, the existing signed DMG path updates - that app. + that app. An app in `/Applications` that this user cannot replace requires + an administrator update or moving the app to `~/Applications`. - For Claude Code, starting the probe sets `"crossSessionInbound": "accept"` in `~/.claude/settings.json` so replies can be injected into the live session. The command never signs the user in. diff --git a/packages/connect/src/install.js b/packages/connect/src/install.js index 61accc338..e575b848e 100644 --- a/packages/connect/src/install.js +++ b/packages/connect/src/install.js @@ -16,7 +16,7 @@ import { writeFile, } from 'node:fs/promises'; import os from 'node:os'; -import { basename, join, relative } from 'node:path'; +import { basename, dirname, join, relative } from 'node:path'; import { requestJson } from './http.js'; const RELEASE = 'https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download'; @@ -391,9 +391,12 @@ async function installHeadless({ const archive = join(tmpDir, asset); // The Mac archive needs only its signed helper. Extracting a named member // prevents any additional archive entries from writing into this HOME. - await run('tar', platform === 'darwin' - ? ['-xzf', archive, '-C', stagedDir, 'agent_relay/helpers/agent-relay-probe'] - : ['-xzf', archive, '-C', stagedDir]); + await run( + 'tar', + platform === 'darwin' + ? ['-xzf', archive, '-C', stagedDir, 'agent_relay/helpers/agent-relay-probe'] + : ['-xzf', archive, '-C', stagedDir] + ); const stagedProbe = await findProbe(stagedDir); if (!stagedProbe) throw new InstallError(`agent-relay-probe not found or not executable in ${asset}.`); @@ -650,7 +653,17 @@ export async function installMac({ appPath = null, wait = waitForLiveSocket, require = requireCommands, + accessPath = access, }) { + if (appPath) { + try { + await accessPath(dirname(appPath), constants.W_OK); + } catch { + throw new InstallError( + `Cannot replace Agent Relay at ${appPath}; ask an administrator to update it or move the app to ~/Applications.` + ); + } + } await require(['codesign', 'curl', 'ditto', 'hdiutil', 'open', 'osascript', 'pgrep', 'shasum']); const asset = getMacAppAsset(arch); const tmpDir = await mkdtemp(join(os.tmpdir(), 'agent-relay-connect-')); @@ -739,6 +752,17 @@ export async function findInstalledMacApp(home = os.homedir()) { return null; } +export async function hasStandaloneMacProbe(home = os.homedir()) { + const link = join(home, '.local/bin/agent-relay-probe'); + try { + const target = await readlink(link); + return target.startsWith(`${join(home, '.local/lib/agent-relay')}/`); + } catch (error) { + if (error?.code === 'ENOENT' || error?.code === 'EINVAL') return false; + throw new InstallError(`Could not inspect the standalone Agent Relay probe: ${error.message}`); + } +} + export async function ensureProbe({ home = os.homedir(), platform = process.platform, @@ -755,6 +779,7 @@ export async function ensureProbe({ installMacProbeFn = installMacProbe, installMacFn = installMac, installedMacApp = findInstalledMacApp, + standaloneMacProbe = hasStandaloneMacProbe, acquire = acquireInstallLock, } = {}) { const existing = await findRecoveringProbe({ home, find, run, active, now, sleep }); @@ -762,10 +787,6 @@ export async function ensureProbe({ if (existing) return { ...existing, installed: false }; } else if (platform === 'linux') { requireSupportedProbe(existing); - } else if (platform === 'darwin' && !(await installedMacApp(home))) { - // An old standalone core still owns the per-home socket. Never start a - // second core against that pointer; the user must stop or update it. - requireSupportedProbe(existing); } if (platform !== 'linux' && platform !== 'darwin') { @@ -774,21 +795,24 @@ export async function ensureProbe({ const installLock = await acquire({ home, platform, now, sleep }); try { + const appPath = platform === 'darwin' ? await installedMacApp(home) : null; const afterLock = await find(home, 1_000); if (afterLock?.supported !== false) { if (afterLock) return { ...afterLock, installed: false }; } else if (platform === 'linux') { requireSupportedProbe(afterLock); - } else if (platform === 'darwin' && !(await installedMacApp(home))) { + } else if (platform === 'darwin' && (!appPath || (await standaloneMacProbe(home)))) { + // An old standalone core may still own the socket even if the app is + // installed. Updating the app would leave that core running beside it. requireSupportedProbe(afterLock); } - const appPath = platform === 'darwin' ? await installedMacApp(home) : null; - const result = platform === 'linux' - ? await installLinuxFn({ home, arch, run, start, wait }) - : appPath - ? await installMacFn({ home, arch, run, warn, wait, appPath }) - : await installMacProbeFn({ home, arch, run, start, wait }); + const result = + platform === 'linux' + ? await installLinuxFn({ home, arch, run, start, wait }) + : appPath + ? await installMacFn({ home, arch, run, warn, wait, appPath }) + : await installMacProbeFn({ home, arch, run, start, wait }); return { ...result, installed: true }; } finally { await installLock.release(); diff --git a/packages/connect/tests/connect.test.ts b/packages/connect/tests/connect.test.ts index 6581576d8..1bdfe8746 100644 --- a/packages/connect/tests/connect.test.ts +++ b/packages/connect/tests/connect.test.ts @@ -26,6 +26,7 @@ import { findRecoveringProbe, getMacAppAsset, getPlatformAsset, + hasStandaloneMacProbe, installMac, installMacProbe, installLinux, @@ -384,13 +385,70 @@ describe('probe installer', () => { }); it('refuses to start a second standalone core beside an old live Mac probe', async () => { - await expect(ensureProbe({ - home: '/tmp/old-standalone-mac', - platform: 'darwin', - find: async () => ({ version: '2026.10.3', supported: false }), - installedMacApp: async () => null, - installMacProbeFn: async () => { throw new Error('must not install'); }, - })).rejects.toMatchObject({ code: 'probe_too_old' }); + await expect( + ensureProbe({ + home: '/tmp/old-standalone-mac', + platform: 'darwin', + find: async () => ({ version: '2026.10.3', supported: false }), + installedMacApp: async () => null, + installMacProbeFn: async () => { + throw new Error('must not install'); + }, + }) + ).rejects.toMatchObject({ code: 'probe_too_old' }); + }); + + it('refuses an old standalone core even when the GUI app is installed', async () => { + let appLookups = 0; + await expect( + ensureProbe({ + home: '/tmp/old-standalone-with-app', + platform: 'darwin', + find: async () => ({ version: '2026.10.3', supported: false }), + installedMacApp: async () => { + appLookups += 1; + return '/Applications/Agent Relay.app'; + }, + standaloneMacProbe: async () => true, + acquire: async () => ({ release: async () => {} }), + installMacFn: async () => { + throw new Error('must not install'); + }, + }) + ).rejects.toMatchObject({ code: 'probe_too_old' }); + expect(appLookups).toBe(1); + }); + + it('recognizes the standalone symlink made by this installer', async () => { + const home = await mkdtemp(join(os.tmpdir(), 'connect-standalone-link-test-')); + cleanups.push(async () => rm(home, { recursive: true, force: true })); + expect(await hasStandaloneMacProbe(home)).toBe(false); + await mkdir(join(home, '.local/bin'), { recursive: true }); + await symlink( + join(home, '.local/lib/agent-relay/current/agent_relay/helpers/agent-relay-probe'), + join(home, '.local/bin/agent-relay-probe') + ); + expect(await hasStandaloneMacProbe(home)).toBe(true); + }); + + it('explains when the installed system app cannot be replaced', async () => { + await expect( + installMac({ + home: '/tmp/non-admin-mac', + arch: 'arm64', + appPath: '/Applications/Agent Relay.app', + accessPath: async () => { + throw new Error('permission denied'); + }, + require: async () => { + throw new Error('must not download'); + }, + run: async () => { + throw new Error('must not run'); + }, + warn: () => {}, + }) + ).rejects.toThrow('ask an administrator to update it or move the app to ~/Applications'); }); it('gives status, send, and leave the same distinct old-probe error', async () => { @@ -487,7 +545,10 @@ describe('probe installer', () => { return { code: 0, stdout: '', stderr: '' }; }; const result = await installMacProbe({ - home, arch: 'arm64', run, require: async () => {}, + home, + arch: 'arm64', + run, + require: async () => {}, start: async () => ({ pid: undefined, unref() {} }), wait: async () => ({ socketPath: '/tmp/mac-probe.sock', status: { ok: true } }), }); @@ -496,16 +557,21 @@ describe('probe installer', () => { expect(commands[0][1].at(-1)).toContain('AgentRelay-macOS-arm64-probe.tar.gz'); expect(commands[3][1].at(-1)).toBe('agent_relay/helpers/agent-relay-probe'); expect(commands[4][1].slice(0, 3)).toEqual([ - '--verify', '--strict', + '--verify', + '--strict', '-R=anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "QUJ7SA6X8X"', ]); - await expect(readFile(join(home, '.local/lib/agent-relay/current/agent_relay/helpers/agent-relay-probe'), 'utf8')).resolves.toBe('#!/bin/sh\n'); + await expect( + readFile(join(home, '.local/lib/agent-relay/current/agent_relay/helpers/agent-relay-probe'), 'utf8') + ).resolves.toBe('#!/bin/sh\n'); }); it('rejects an unsigned extracted Mac helper before swapping or starting it', async () => { - await expect(verifyMacProbeSignature('/tmp/agent-relay-probe', async () => { - throw new Error('signature mismatch'); - })).rejects.toThrow('Agent Relay probe is not signed by Agent Workforce'); + await expect( + verifyMacProbeSignature('/tmp/agent-relay-probe', async () => { + throw new Error('signature mismatch'); + }) + ).rejects.toThrow('Agent Relay probe is not signed by Agent Workforce'); }); it('restores the previous macOS app when the verified replacement cannot be installed', async () => { @@ -593,6 +659,7 @@ describe('probe installer', () => { run, warn: () => {}, appPath: app, + accessPath: async () => {}, require: async () => {}, wait: async () => { throw new Error('replacement never became ready'); diff --git a/vitest.connect.config.mjs b/vitest.connect.config.mjs index c4c50d085..41f48e2a1 100644 --- a/vitest.connect.config.mjs +++ b/vitest.connect.config.mjs @@ -1 +1 @@ -export default { test: { include: ["packages/connect/tests/**/*.test.ts"] } }; +export default { test: { include: ['packages/connect/tests/**/*.test.ts'] } }; From 04466ce817889a50b5869092d4cfbeaf62d11c14 Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 2 Oct 2026 19:07:05 +0200 Subject: [PATCH 3/8] test(connect): prove unsigned Mac probe never starts Exercise the installer failure path, clarify the explicit install command and publisher notarization in the docs. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 +- packages/connect/README.md | 6 ++--- packages/connect/tests/connect.test.ts | 34 +++++++++++++++++++++++--- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0399337a6..742e138b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- `@agent-relay/connect` installs a signed standalone macOS probe for a clean Mac, without installing the GUI app. +- `npx -y @agent-relay/connect install` installs a signed standalone probe on a clean Mac without installing the GUI app. ## [13.0.1] - 2026-10-02 diff --git a/packages/connect/README.md b/packages/connect/README.md index e007d836c..affcb5a07 100644 --- a/packages/connect/README.md +++ b/packages/connect/README.md @@ -22,9 +22,9 @@ app keeps its existing app update path. `~/.local/lib/agent-relay/current`, a symlink at `~/.local/bin/agent-relay-probe`, and a detached headless process. No `sudo` is used. -- macOS x64 and arm64 use a signed, notarized standalone probe tarball. The - installer verifies its SHA-256 and the extracted binary's pinned Developer - ID before swapping it into `~/.local/lib/agent-relay/current` and starting +- macOS x64 and arm64 use a standalone probe tarball notarized by the + publisher. The installer verifies its SHA-256 and the extracted binary's + pinned Developer ID before swapping it into `~/.local/lib/agent-relay/current` and starting it headless. A clean Mac does not install the GUI app. If an app is already installed but its probe needs updating, the existing signed DMG path updates that app. An app in `/Applications` that this user cannot replace requires diff --git a/packages/connect/tests/connect.test.ts b/packages/connect/tests/connect.test.ts index 1bdfe8746..ae3a8d348 100644 --- a/packages/connect/tests/connect.test.ts +++ b/packages/connect/tests/connect.test.ts @@ -38,7 +38,6 @@ import { swapMacApp, verifyChecksum, verifyMacSignature, - verifyMacProbeSignature, waitForLiveSocket, } from '../src/install.js'; import { requestJson } from '../src/http.js'; @@ -567,11 +566,40 @@ describe('probe installer', () => { }); it('rejects an unsigned extracted Mac helper before swapping or starting it', async () => { + const root = await mkdtemp(join(os.tmpdir(), 'connect-unsigned-mac-probe-test-')); + cleanups.push(async () => rm(root, { recursive: true, force: true })); + const home = join(root, 'home'); + let started = false; + const run = async (file: string, args: string[]) => { + if (file === 'tar') { + const destination = args[args.indexOf('-C') + 1]; + const probe = join(destination, 'agent_relay/helpers/agent-relay-probe'); + await mkdir(join(probe, '..'), { recursive: true }); + await writeFile(probe, '#!/bin/sh\n'); + await chmod(probe, 0o755); + } + if (file === 'codesign') throw new Error('signature mismatch'); + return { code: 0, stdout: '', stderr: '' }; + }; await expect( - verifyMacProbeSignature('/tmp/agent-relay-probe', async () => { - throw new Error('signature mismatch'); + installMacProbe({ + home, + arch: 'arm64', + run, + require: async () => {}, + start: async () => { + started = true; + throw new Error('must not start'); + }, + wait: async () => { + throw new Error('must not wait'); + }, }) ).rejects.toThrow('Agent Relay probe is not signed by Agent Workforce'); + expect(started).toBe(false); + await expect( + readFile(join(home, '.local/lib/agent-relay/current/agent_relay/helpers/agent-relay-probe')) + ).rejects.toMatchObject({ code: 'ENOENT' }); }); it('restores the previous macOS app when the verified replacement cannot be installed', async () => { From be37fe46d215f1d40f641cc6d1d81349af0f7217 Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 2 Oct 2026 19:24:44 +0200 Subject: [PATCH 4/8] fix(connect): retain unsupported probe observation under lock Fail closed when a short post-lock status lookup misses an older core, and reject a non-symlink standalone probe path. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/connect/src/install.js | 15 ++++++++++----- packages/connect/tests/connect.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/connect/src/install.js b/packages/connect/src/install.js index e575b848e..6f735b347 100644 --- a/packages/connect/src/install.js +++ b/packages/connect/src/install.js @@ -758,7 +758,7 @@ export async function hasStandaloneMacProbe(home = os.homedir()) { const target = await readlink(link); return target.startsWith(`${join(home, '.local/lib/agent-relay')}/`); } catch (error) { - if (error?.code === 'ENOENT' || error?.code === 'EINVAL') return false; + if (error?.code === 'ENOENT') return false; throw new InstallError(`Could not inspect the standalone Agent Relay probe: ${error.message}`); } } @@ -799,12 +799,17 @@ export async function ensureProbe({ const afterLock = await find(home, 1_000); if (afterLock?.supported !== false) { if (afterLock) return { ...afterLock, installed: false }; - } else if (platform === 'linux') { - requireSupportedProbe(afterLock); - } else if (platform === 'darwin' && (!appPath || (await standaloneMacProbe(home)))) { + } + // A short lookup can miss a core observed immediately before the lock. + // Keep that unsupported observation until the guarded install decision. + const unsupported = + afterLock?.supported === false ? afterLock : existing?.supported === false ? existing : null; + if (platform === 'linux') { + requireSupportedProbe(unsupported); + } else if (platform === 'darwin' && unsupported && (!appPath || (await standaloneMacProbe(home)))) { // An old standalone core may still own the socket even if the app is // installed. Updating the app would leave that core running beside it. - requireSupportedProbe(afterLock); + requireSupportedProbe(unsupported); } const result = diff --git a/packages/connect/tests/connect.test.ts b/packages/connect/tests/connect.test.ts index ae3a8d348..70ee73b70 100644 --- a/packages/connect/tests/connect.test.ts +++ b/packages/connect/tests/connect.test.ts @@ -418,6 +418,23 @@ describe('probe installer', () => { expect(appLookups).toBe(1); }); + it('retains an unsupported probe observed before a short post-lock miss', async () => { + let looks = 0; + await expect( + ensureProbe({ + home: '/tmp/old-standalone-short-miss', + platform: 'darwin', + find: async () => (++looks === 1 ? { version: '2026.10.3', supported: false } : null), + installedMacApp: async () => null, + acquire: async () => ({ release: async () => {} }), + installMacProbeFn: async () => { + throw new Error('must not install'); + }, + }) + ).rejects.toMatchObject({ code: 'probe_too_old' }); + expect(looks).toBe(2); + }); + it('recognizes the standalone symlink made by this installer', async () => { const home = await mkdtemp(join(os.tmpdir(), 'connect-standalone-link-test-')); cleanups.push(async () => rm(home, { recursive: true, force: true })); @@ -428,6 +445,11 @@ describe('probe installer', () => { join(home, '.local/bin/agent-relay-probe') ); expect(await hasStandaloneMacProbe(home)).toBe(true); + await rm(join(home, '.local/bin/agent-relay-probe')); + await writeFile(join(home, '.local/bin/agent-relay-probe'), 'copied binary'); + await expect(hasStandaloneMacProbe(home)).rejects.toThrow( + 'Could not inspect the standalone Agent Relay probe' + ); }); it('explains when the installed system app cannot be replaced', async () => { From 1cd360cbe0719674d5354fc69fae475bc7d261c3 Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 2 Oct 2026 21:11:16 +0200 Subject: [PATCH 5/8] fix(connect): require 2026.10.5 on macOS Update an existing macOS app when its live core predates shared Codex daemon support. Keep Linux 2026.10.4 usable and explain session identification failures with a clear update instruction. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/connect/README.md | 10 ++- packages/connect/src/cli.js | 23 +++++- packages/connect/src/install.js | 56 ++++++++++---- packages/connect/tests/connect.test.ts | 103 ++++++++++++++++++++----- 4 files changed, 151 insertions(+), 41 deletions(-) diff --git a/packages/connect/README.md b/packages/connect/README.md index 202254850..de0b93829 100644 --- a/packages/connect/README.md +++ b/packages/connect/README.md @@ -7,15 +7,19 @@ or a preinstalled skill: Run this for me: `npx -y @agent-relay/connect join ` ``` -The command reuses a live Agent Relay Desktop probe at version 2026.10.4 or -newer when its pointer and socket are owned by the current user and the pointer -is not group- or world-writable. Unsafe pointer or socket metadata fails closed. +The command reuses a live Agent Relay Desktop probe at version 2026.10.5 or +newer on macOS, or 2026.10.4 or newer on Linux, when its pointer and socket are +owned by the current user and the pointer is not group- or world-writable. +Unsafe pointer or socket metadata fails closed. If an existing socket or Relay process may be restarting, the command retries liveness for up to 15 seconds. When no eligible probe is available, it downloads the current release from `AgentWorkforce/relay-desktop-releases`, verifies the adjacent SHA-256 file, and starts the probe without signing in. A responsive old Linux probe instead fails with an update-required error so the CLI never starts a second headless probe beside it. +If a Linux 2026.10.4 probe cannot identify the calling session during join or +status, the CLI tells the user to update Agent Relay rather than retrying the +same request. - Linux x64 and arm64 use the relocatable tarball under `~/.local/lib/agent-relay/current`, a symlink at diff --git a/packages/connect/src/cli.js b/packages/connect/src/cli.js index 3f5c78d6c..dc9417a5b 100644 --- a/packages/connect/src/cli.js +++ b/packages/connect/src/cli.js @@ -2,7 +2,12 @@ import { readFile } from 'node:fs/promises'; import os from 'node:os'; -import { ensureProbe, InstallError, requireExistingProbe as findExistingProbe } from './install.js'; +import { + ensureProbe, + InstallError, + linuxProbeSessionUpdateHint, + requireExistingProbe as findExistingProbe, +} from './install.js'; import { requestJson, requireOk, SocketResponseError } from './http.js'; const USAGE = `Usage: @@ -126,6 +131,12 @@ async function requireExistingProbe() { return existing; } +function requireSessionOk(response, probe) { + const hint = linuxProbeSessionUpdateHint(probe, response); + if (hint) throw new InstallError(hint, 6); + return requireOk(response); +} + function joinSummary(response) { const data = response.data || {}; const hostAgent = safeText(data.host?.agent_name); @@ -195,13 +206,14 @@ async function run(options) { if (!claim) throw new UsageError('--host-claim-stdin requires a claim on stdin.'); body.host_claim = claim; } - const response = requireOk( + const response = requireSessionOk( await requestJson(probe.socketPath, { method: 'POST', path: '/connect/join', body: JSON.stringify(body), headers: { 'content-type': 'application/json' }, - }) + }), + probe ); delete body.host_claim; @@ -248,7 +260,10 @@ async function run(options) { } if (options.command === 'status') { - const response = requireOk(await requestJson(probe.socketPath, { path: '/connect/status' })); + const response = requireSessionOk( + await requestJson(probe.socketPath, { path: '/connect/status' }), + probe + ); if (options.json) printJson(response); else process.stdout.write(`${statusSummary(response)}\n`); return; diff --git a/packages/connect/src/install.js b/packages/connect/src/install.js index cd9903a77..755ae64fa 100644 --- a/packages/connect/src/install.js +++ b/packages/connect/src/install.js @@ -21,6 +21,7 @@ import { requestJson } from './http.js'; const RELEASE = 'https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download'; const MINIMUM_PROBE_VERSION = '2026.10.4'; +const MINIMUM_MAC_PROBE_VERSION = '2026.10.5'; const RECOVERY_TIMEOUT_MS = 15_000; const INSTALL_LOCK_TIMEOUT_MS = 90_000; const INSTALL_LOCK_STALE_MS = 15 * 60_000; @@ -36,9 +37,9 @@ export class InstallError extends Error { } export class OutdatedProbeError extends InstallError { - constructor(version) { + constructor(version, minimum = MINIMUM_PROBE_VERSION) { super( - `Agent Relay ${version || 'unknown'} is too old for Relay Connect (needs ${MINIMUM_PROBE_VERSION} or newer); update it and retry.`, + `Agent Relay ${version || 'unknown'} is too old for Relay Connect (needs ${minimum} or newer); update it and retry.`, 9 ); this.name = 'OutdatedProbeError'; @@ -202,6 +203,28 @@ export function probeVersionSupported(version, minimum = MINIMUM_PROBE_VERSION) return true; } +function minimumProbeVersion(platform) { + return platform === 'darwin' ? MINIMUM_MAC_PROBE_VERSION : MINIMUM_PROBE_VERSION; +} + +function probeSupportedOnPlatform(existing, platform) { + if (!existing) return false; + return existing.version + ? probeVersionSupported(existing.version, minimumProbeVersion(platform)) + : existing.supported !== false; +} + +export function linuxProbeSessionUpdateHint(probe, response, platform = process.platform) { + if ( + platform !== 'linux' || + response?.error?.code !== 'not_a_relay_session' || + !probeVersionSupported(probe?.version) || + probeVersionSupported(probe?.version, MINIMUM_MAC_PROBE_VERSION) + ) + return null; + return `Agent Relay ${probe.version} could not identify this session; update Agent Relay to ${MINIMUM_MAC_PROBE_VERSION} or newer and retry.`; +} + async function liveStatus(socketPath, timeoutMs = 5_000) { if (!socketPath) return null; try { @@ -241,13 +264,14 @@ export async function waitForLiveSocket({ sleep = delay, pointer = readPointer, check = liveStatus, + minimumVersion = MINIMUM_PROBE_VERSION, } = {}) { const deadline = now() + timeoutMs; while (now() < deadline) { const socketPath = await pointer(home); const remaining = Math.max(1, deadline - now()); const status = await check(socketPath, Math.min(perRequestTimeoutMs, remaining)); - if (status && probeVersionSupported(status?.data?.version)) return { socketPath, status }; + if (status && probeVersionSupported(status?.data?.version, minimumVersion)) return { socketPath, status }; const sleepFor = Math.min(1_000, Math.max(0, deadline - now())); if (sleepFor > 0) await sleep(sleepFor); } @@ -600,8 +624,10 @@ export async function acquireInstallLock({ } } -function requireSupportedProbe(existing) { - if (existing && existing.supported === false) throw new OutdatedProbeError(existing.version); +function requireSupportedProbe(existing, platform = process.platform) { + if (existing && !probeSupportedOnPlatform(existing, platform)) { + throw new OutdatedProbeError(existing.version, minimumProbeVersion(platform)); + } return existing; } @@ -660,7 +686,7 @@ export async function installMac({ // Do not delete the existing pointer on macOS: RelayDesktop may reuse it. await run(stage[2][0], stage[2][1]); - const result = await wait({ home }); + const result = await wait({ home, minimumVersion: MINIMUM_MAC_PROBE_VERSION }); ready = true; await finishSwap(app, appSwap, true); return result; @@ -707,10 +733,10 @@ export async function ensureProbe({ acquire = acquireInstallLock, } = {}) { const existing = await findRecoveringProbe({ home, find, run, active, now, sleep }); - if (existing?.supported !== false) { - if (existing) return { ...existing, installed: false }; - } else if (platform === 'linux') { - requireSupportedProbe(existing); + if (probeSupportedOnPlatform(existing, platform)) { + return { ...existing, installed: false }; + } else if (existing && platform === 'linux') { + requireSupportedProbe(existing, platform); } if (platform !== 'linux' && platform !== 'darwin') { @@ -720,10 +746,10 @@ export async function ensureProbe({ const installLock = await acquire({ home, platform, now, sleep }); try { const afterLock = await find(home, 1_000); - if (afterLock?.supported !== false) { - if (afterLock) return { ...afterLock, installed: false }; - } else if (platform === 'linux') { - requireSupportedProbe(afterLock); + if (probeSupportedOnPlatform(afterLock, platform)) { + return { ...afterLock, installed: false }; + } else if (afterLock && platform === 'linux') { + requireSupportedProbe(afterLock, platform); } const result = @@ -737,5 +763,5 @@ export async function ensureProbe({ } export async function requireExistingProbe(options = {}) { - return requireSupportedProbe(await findRecoveringProbe(options)); + return requireSupportedProbe(await findRecoveringProbe(options), options.platform || process.platform); } diff --git a/packages/connect/tests/connect.test.ts b/packages/connect/tests/connect.test.ts index f51ddbddd..97555f6bf 100644 --- a/packages/connect/tests/connect.test.ts +++ b/packages/connect/tests/connect.test.ts @@ -27,6 +27,7 @@ import { getPlatformAsset, installMac, installLinux, + linuxProbeSessionUpdateHint, macStageCommands, probeVersionSupported, quitRelayDesktop, @@ -107,7 +108,7 @@ describe('@agent-relay/connect CLI', () => { const hellos: string[] = []; const { home } = await listen((request, response, body) => { if (request.url === '/setup/status') { - json(response, { ok: true, data: { version: '2026.10.4' } }); + json(response, { ok: true, data: { version: '2026.10.5' } }); } else if (request.url === '/connect/join') { expect(request.headers['content-type']).toBe('application/json'); joins.push(JSON.parse(body)); @@ -160,7 +161,7 @@ describe('@agent-relay/connect CLI', () => { const sent: Array<{ url: string; body: string }> = []; const { home } = await listen((request, response, body) => { if (request.url === '/setup/status') { - json(response, { ok: true, data: { version: '2026.10.4' } }); + json(response, { ok: true, data: { version: '2026.10.5' } }); } else if (request.url?.startsWith('/connect/send')) { sent.push({ url: request.url, body }); json(response, { ok: true, data: { sent: [{ to: 'host agent', message_id: 'm1' }] } }); @@ -195,7 +196,7 @@ describe('@agent-relay/connect CLI', () => { let sendRequests = 0; const { home } = await listen((request, response) => { if (request.url === '/setup/status') { - json(response, { ok: true, data: { version: '2026.10.4' } }); + json(response, { ok: true, data: { version: '2026.10.5' } }); } else if (request.url === '/connect/join') { json(response, { ok: true, @@ -223,7 +224,7 @@ describe('@agent-relay/connect CLI', () => { it('keeps a completed join successful when the host hello fails', async () => { const { home } = await listen((request, response) => { if (request.url === '/setup/status') { - json(response, { ok: true, data: { version: '2026.10.4' } }); + json(response, { ok: true, data: { version: '2026.10.5' } }); } else if (request.url === '/connect/join') { json(response, { ok: true, @@ -255,7 +256,7 @@ describe('@agent-relay/connect CLI', () => { it('maps socket errors and preserves their code in JSON mode', async () => { const { home } = await listen((request, response) => { if (request.url === '/setup/status') { - json(response, { ok: true, data: { version: '2026.10.4' } }); + json(response, { ok: true, data: { version: '2026.10.5' } }); } else { json(response, { ok: false, error: { code: 'connect_expired', message: 'expired upstream' } }, 410); } @@ -337,15 +338,15 @@ describe('probe installer', () => { platform: 'darwin', find: async () => ({ socketPath: '/tmp/old.sock', - status: { ok: true, data: { version: '2026.10.3' } }, - version: '2026.10.3', - supported: false, + status: { ok: true, data: { version: '2026.10.4' } }, + version: '2026.10.4', + supported: true, }), installMacFn: async () => { installs += 1; return { socketPath: '/tmp/new.sock', - status: { ok: true, data: { version: '2026.10.4' } }, + status: { ok: true, data: { version: '2026.10.5' } }, }; }, acquire: async () => ({ release: async () => {} }), @@ -354,6 +355,56 @@ describe('probe installer', () => { expect(result).toMatchObject({ socketPath: '/tmp/new.sock', installed: true }); }); + it('accepts a live Linux 2026.10.4 probe without starting another one', async () => { + let installs = 0; + const result = await ensureProbe({ + home: '/tmp/current-linux-probe', + platform: 'linux', + find: async () => ({ + socketPath: '/tmp/current.sock', + status: { ok: true, data: { version: '2026.10.4' } }, + version: '2026.10.4', + supported: true, + }), + installLinuxFn: async () => { + installs += 1; + throw new Error('must not install'); + }, + }); + expect(result).toMatchObject({ socketPath: '/tmp/current.sock', installed: false }); + expect(installs).toBe(0); + }); + + it('advises an update when a Linux 2026.10.4 probe cannot identify the session', () => { + const probe = { version: '2026.10.4' }; + const rejection = { error: { code: 'not_a_relay_session' } }; + expect(linuxProbeSessionUpdateHint(probe, rejection, 'linux')).toBe( + 'Agent Relay 2026.10.4 could not identify this session; update Agent Relay to 2026.10.5 or newer and retry.' + ); + expect(linuxProbeSessionUpdateHint(probe, rejection, 'darwin')).toBeNull(); + expect(linuxProbeSessionUpdateHint({ version: '2026.10.5' }, rejection, 'linux')).toBeNull(); + expect(linuxProbeSessionUpdateHint(probe, { error: { code: 'connect_not_joined' } }, 'linux')).toBeNull(); + }); + + it('prints the Linux update advice for join and status rejections', async () => { + if (process.platform !== 'linux') return; + const { home } = await listen((request, response) => { + if (request.url === '/setup/status') { + json(response, { ok: true, data: { version: '2026.10.4' } }); + } else { + json(response, { ok: false, error: { code: 'not_a_relay_session' } }, 403); + } + }); + for (const args of [['join', 'connect-test'], ['status']]) { + expect(await runCli(home, args)).toEqual({ + code: 6, + stdout: '', + stderr: + 'Agent Relay 2026.10.4 could not identify this session; update Agent Relay to 2026.10.5 or newer and retry.\n', + }); + } + }); + it('gives status, send, and leave the same distinct old-probe error', async () => { const { home } = await listen((request, response) => { if (request.url === '/setup/status') { @@ -369,8 +420,7 @@ describe('probe installer', () => { expect(result).toEqual({ code: 9, stdout: '', - stderr: - 'Agent Relay 2026.10.3 is too old for Relay Connect (needs 2026.10.4 or newer); update it and retry.\n', + stderr: `Agent Relay 2026.10.3 is too old for Relay Connect (needs 2026.10.${process.platform === 'darwin' ? '5' : '4'} or newer); update it and retry.\n`, }); } }); @@ -566,7 +616,7 @@ describe('probe installer', () => { return attempts === 3 ? { socketPath: '/tmp/relay.sock', - status: { ok: true, data: { version: '2026.10.4' } }, + status: { ok: true, data: { version: '2026.10.5' } }, } : null; }, @@ -644,8 +694,8 @@ describe('probe installer', () => { return findCalls >= 2 ? { socketPath: '/tmp/ready-after-lock.sock', - status: { ok: true, data: { version: '2026.10.4' } }, - version: '2026.10.4', + status: { ok: true, data: { version: '2026.10.5' } }, + version: '2026.10.5', supported: true, } : null; @@ -728,8 +778,8 @@ describe('probe installer', () => { return attempts === 5 ? { socketPath: '/tmp/recovered.sock', - status: { ok: true, data: { version: '2026.10.4' } }, - version: '2026.10.4', + status: { ok: true, data: { version: '2026.10.5' } }, + version: '2026.10.5', supported: true, } : null; @@ -774,7 +824,7 @@ describe('probe installer', () => { }, wait: async () => ({ socketPath: '/tmp/relay.sock', - status: { ok: true, data: { version: '2026.10.4' } }, + status: { ok: true, data: { version: '2026.10.5' } }, }), }); @@ -918,7 +968,7 @@ describe('probe installer', () => { it('rejects an unsafe socket pointer before sending a request', async () => { const { home } = await listen((request, response) => { if (request.url === '/setup/status') { - json(response, { ok: true, data: { version: '2026.10.4' } }); + json(response, { ok: true, data: { version: '2026.10.5' } }); } }); await chmod(join(home, '.agentworkforce/desktop/relay-socket'), 0o666); @@ -928,7 +978,7 @@ describe('probe installer', () => { it('rejects a non-regular socket pointer before reading it', async () => { const { home } = await listen((request, response) => { if (request.url === '/setup/status') { - json(response, { ok: true, data: { version: '2026.10.4' } }); + json(response, { ok: true, data: { version: '2026.10.5' } }); } }); const pointer = join(home, '.agentworkforce/desktop/relay-socket'); @@ -961,6 +1011,21 @@ describe('probe installer', () => { expect(clock).toBe(3_000); }); + it('waits for the macOS 2026.10.5 core after an app update', async () => { + let checks = 0; + const result = await waitForLiveSocket({ + home: '/tmp/mac-update-home', + minimumVersion: '2026.10.5', + timeoutMs: 3_000, + now: () => checks * 1_000, + sleep: async () => {}, + pointer: async () => '/tmp/relay.sock', + check: async () => ({ ok: true, data: { version: ++checks === 1 ? '2026.10.4' : '2026.10.5' } }), + }); + expect(result.status.data.version).toBe('2026.10.5'); + expect(checks).toBe(2); + }); + it('enforces a timeout on each Unix-socket request', async () => { const { socketPath } = await listen(() => { // Deliberately leave the response open until the client destroys it. From 5dafdfe37eb787b809e1bf604ab4614c9718b5e2 Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 2 Oct 2026 21:27:35 +0200 Subject: [PATCH 6/8] fix(connect): preserve JSON errors with update guidance Keep the original socket error code in structured mode and explain both the live-session and running-service cases in the Linux 2026.10.4 diagnostic. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/connect/README.md | 3 ++- packages/connect/src/cli.js | 7 ++++++- packages/connect/src/install.js | 2 +- packages/connect/tests/connect.test.ts | 15 +++++++++++++-- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/connect/README.md b/packages/connect/README.md index de0b93829..bb19d8ff5 100644 --- a/packages/connect/README.md +++ b/packages/connect/README.md @@ -19,7 +19,8 @@ A responsive old Linux probe instead fails with an update-required error so the CLI never starts a second headless probe beside it. If a Linux 2026.10.4 probe cannot identify the calling session during join or status, the CLI tells the user to update Agent Relay rather than retrying the -same request. +same request. A live Linux headless probe cannot be replaced by this CLI while +it is running; update the running service separately. - Linux x64 and arm64 use the relocatable tarball under `~/.local/lib/agent-relay/current`, a symlink at diff --git a/packages/connect/src/cli.js b/packages/connect/src/cli.js index dc9417a5b..e66ddbf45 100644 --- a/packages/connect/src/cli.js +++ b/packages/connect/src/cli.js @@ -133,7 +133,12 @@ async function requireExistingProbe() { function requireSessionOk(response, probe) { const hint = linuxProbeSessionUpdateHint(probe, response); - if (hint) throw new InstallError(hint, 6); + if (hint) { + const error = new InstallError(hint, 6); + error.code = 'probe_update_required'; + error.response = { ...response, error: { ...response.error, message: hint } }; + throw error; + } return requireOk(response); } diff --git a/packages/connect/src/install.js b/packages/connect/src/install.js index 755ae64fa..cc99da26e 100644 --- a/packages/connect/src/install.js +++ b/packages/connect/src/install.js @@ -222,7 +222,7 @@ export function linuxProbeSessionUpdateHint(probe, response, platform = process. probeVersionSupported(probe?.version, MINIMUM_MAC_PROBE_VERSION) ) return null; - return `Agent Relay ${probe.version} could not identify this session; update Agent Relay to ${MINIMUM_MAC_PROBE_VERSION} or newer and retry.`; + return `Agent Relay ${probe.version} could not identify this session. If this is a live Claude Code or Codex session, update the running Agent Relay probe to ${MINIMUM_MAC_PROBE_VERSION} or newer; otherwise run this from a live session.`; } async function liveStatus(socketPath, timeoutMs = 5_000) { diff --git a/packages/connect/tests/connect.test.ts b/packages/connect/tests/connect.test.ts index 97555f6bf..a427277cb 100644 --- a/packages/connect/tests/connect.test.ts +++ b/packages/connect/tests/connect.test.ts @@ -379,7 +379,7 @@ describe('probe installer', () => { const probe = { version: '2026.10.4' }; const rejection = { error: { code: 'not_a_relay_session' } }; expect(linuxProbeSessionUpdateHint(probe, rejection, 'linux')).toBe( - 'Agent Relay 2026.10.4 could not identify this session; update Agent Relay to 2026.10.5 or newer and retry.' + 'Agent Relay 2026.10.4 could not identify this session. If this is a live Claude Code or Codex session, update the running Agent Relay probe to 2026.10.5 or newer; otherwise run this from a live session.' ); expect(linuxProbeSessionUpdateHint(probe, rejection, 'darwin')).toBeNull(); expect(linuxProbeSessionUpdateHint({ version: '2026.10.5' }, rejection, 'linux')).toBeNull(); @@ -400,7 +400,18 @@ describe('probe installer', () => { code: 6, stdout: '', stderr: - 'Agent Relay 2026.10.4 could not identify this session; update Agent Relay to 2026.10.5 or newer and retry.\n', + 'Agent Relay 2026.10.4 could not identify this session. If this is a live Claude Code or Codex session, update the running Agent Relay probe to 2026.10.5 or newer; otherwise run this from a live session.\n', + }); + const structured = await runCli(home, [...args, '--json']); + expect(structured.code).toBe(6); + expect(structured.stderr).toBe(''); + expect(JSON.parse(structured.stdout)).toMatchObject({ + ok: false, + error: { + code: 'not_a_relay_session', + message: + 'Agent Relay 2026.10.4 could not identify this session. If this is a live Claude Code or Codex session, update the running Agent Relay probe to 2026.10.5 or newer; otherwise run this from a live session.', + }, }); } }); From 813c4c967b372dd7b589667e3c51b2668063d968 Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 2 Oct 2026 21:49:17 +0200 Subject: [PATCH 7/8] fix(connect): guide every command on older probes Name the macOS install command and preserve the Linux session update guidance for send and leave as well as join and status. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/connect/README.md | 4 ++-- packages/connect/src/cli.js | 10 +++++--- packages/connect/src/install.js | 8 ++++--- packages/connect/tests/connect.test.ts | 33 ++++++++++++++++++++++---- 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/packages/connect/README.md b/packages/connect/README.md index bb19d8ff5..b101c909f 100644 --- a/packages/connect/README.md +++ b/packages/connect/README.md @@ -17,8 +17,8 @@ downloads the current release from `AgentWorkforce/relay-desktop-releases`, verifies the adjacent SHA-256 file, and starts the probe without signing in. A responsive old Linux probe instead fails with an update-required error so the CLI never starts a second headless probe beside it. -If a Linux 2026.10.4 probe cannot identify the calling session during join or -status, the CLI tells the user to update Agent Relay rather than retrying the +If a Linux 2026.10.4 probe cannot identify the calling session during join, +send, status, or leave, the CLI tells the user to update Agent Relay rather than retrying the same request. A live Linux headless probe cannot be replaced by this CLI while it is running; update the running service separately. diff --git a/packages/connect/src/cli.js b/packages/connect/src/cli.js index e66ddbf45..49cd3b462 100644 --- a/packages/connect/src/cli.js +++ b/packages/connect/src/cli.js @@ -249,12 +249,13 @@ async function run(options) { const message = await stdinText(); if (!message) throw new UsageError('send requires message text on stdin.'); const suffix = options.to ? `?to=${encodeURIComponent(options.to)}` : ''; - const response = requireOk( + const response = requireSessionOk( await requestJson(probe.socketPath, { method: 'POST', path: `/connect/send${suffix}`, body: message, - }) + }), + probe ); if (options.json) printJson(response); else { @@ -274,7 +275,10 @@ async function run(options) { return; } - const response = requireOk(await requestJson(probe.socketPath, { method: 'POST', path: '/connect/leave' })); + const response = requireSessionOk( + await requestJson(probe.socketPath, { method: 'POST', path: '/connect/leave' }), + probe + ); if (options.json) printJson(response); else process.stdout.write(`Left Relay Connect ${safeText(response.data?.connect_id)}.\n`); } diff --git a/packages/connect/src/install.js b/packages/connect/src/install.js index cc99da26e..c5c226340 100644 --- a/packages/connect/src/install.js +++ b/packages/connect/src/install.js @@ -37,9 +37,9 @@ export class InstallError extends Error { } export class OutdatedProbeError extends InstallError { - constructor(version, minimum = MINIMUM_PROBE_VERSION) { + constructor(version, minimum = MINIMUM_PROBE_VERSION, nextStep = 'update it and retry.') { super( - `Agent Relay ${version || 'unknown'} is too old for Relay Connect (needs ${minimum} or newer); update it and retry.`, + `Agent Relay ${version || 'unknown'} is too old for Relay Connect (needs ${minimum} or newer); ${nextStep}`, 9 ); this.name = 'OutdatedProbeError'; @@ -626,7 +626,9 @@ export async function acquireInstallLock({ function requireSupportedProbe(existing, platform = process.platform) { if (existing && !probeSupportedOnPlatform(existing, platform)) { - throw new OutdatedProbeError(existing.version, minimumProbeVersion(platform)); + const nextStep = + platform === 'darwin' ? 'run `npx -y @agent-relay/connect install` and retry.' : 'update it and retry.'; + throw new OutdatedProbeError(existing.version, minimumProbeVersion(platform), nextStep); } return existing; } diff --git a/packages/connect/tests/connect.test.ts b/packages/connect/tests/connect.test.ts index a427277cb..97ac21376 100644 --- a/packages/connect/tests/connect.test.ts +++ b/packages/connect/tests/connect.test.ts @@ -375,6 +375,21 @@ describe('probe installer', () => { expect(installs).toBe(0); }); + it('names the install command for existing macOS 2026.10.4 status calls', async () => { + await expect( + requireExistingProbe({ + home: '/tmp/old-mac-status', + platform: 'darwin', + find: async () => ({ + socketPath: '/tmp/old.sock', + status: { ok: true, data: { version: '2026.10.4' } }, + version: '2026.10.4', + supported: true, + }), + }) + ).rejects.toThrow('run `npx -y @agent-relay/connect install` and retry.'); + }); + it('advises an update when a Linux 2026.10.4 probe cannot identify the session', () => { const probe = { version: '2026.10.4' }; const rejection = { error: { code: 'not_a_relay_session' } }; @@ -386,7 +401,7 @@ describe('probe installer', () => { expect(linuxProbeSessionUpdateHint(probe, { error: { code: 'connect_not_joined' } }, 'linux')).toBeNull(); }); - it('prints the Linux update advice for join and status rejections', async () => { + it('prints the Linux update advice for session route rejections', async () => { if (process.platform !== 'linux') return; const { home } = await listen((request, response) => { if (request.url === '/setup/status') { @@ -395,14 +410,19 @@ describe('probe installer', () => { json(response, { ok: false, error: { code: 'not_a_relay_session' } }, 403); } }); - for (const args of [['join', 'connect-test'], ['status']]) { - expect(await runCli(home, args)).toEqual({ + for (const [args, input] of [ + [['join', 'connect-test'], ''], + [['send'], 'hello'], + [['status'], ''], + [['leave'], ''], + ] as Array<[string[], string]>) { + expect(await runCli(home, args, input)).toEqual({ code: 6, stdout: '', stderr: 'Agent Relay 2026.10.4 could not identify this session. If this is a live Claude Code or Codex session, update the running Agent Relay probe to 2026.10.5 or newer; otherwise run this from a live session.\n', }); - const structured = await runCli(home, [...args, '--json']); + const structured = await runCli(home, [...args, '--json'], input); expect(structured.code).toBe(6); expect(structured.stderr).toBe(''); expect(JSON.parse(structured.stdout)).toMatchObject({ @@ -431,7 +451,10 @@ describe('probe installer', () => { expect(result).toEqual({ code: 9, stdout: '', - stderr: `Agent Relay 2026.10.3 is too old for Relay Connect (needs 2026.10.${process.platform === 'darwin' ? '5' : '4'} or newer); update it and retry.\n`, + stderr: + process.platform === 'darwin' + ? 'Agent Relay 2026.10.3 is too old for Relay Connect (needs 2026.10.5 or newer); run `npx -y @agent-relay/connect install` and retry.\n' + : 'Agent Relay 2026.10.3 is too old for Relay Connect (needs 2026.10.4 or newer); update it and retry.\n', }); } }); From 76fe8194ca23a7daacfd6b4081d90afcb2d96992 Mon Sep 17 00:00:00 2001 From: Miya Date: Sat, 3 Oct 2026 08:44:15 +0200 Subject: [PATCH 8/8] Wait for macOS minimum after standalone probe install Use the platform minimum during headless readiness so a 2026.10.4 Mac responder cannot complete an install that requires 2026.10.5. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/connect/src/install.js | 2 +- packages/connect/tests/connect.test.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/connect/src/install.js b/packages/connect/src/install.js index d8d6e43cf..1ebb3d50a 100644 --- a/packages/connect/src/install.js +++ b/packages/connect/src/install.js @@ -457,7 +457,7 @@ async function installHeadless({ await logHandle.close(); } - const result = await wait({ home }); + const result = await wait({ home, minimumVersion: minimumProbeVersion(platform) }); ready = true; await finishSwap(installDir, swap, true); return result; diff --git a/packages/connect/tests/connect.test.ts b/packages/connect/tests/connect.test.ts index 3b840bfae..3b9dffc70 100644 --- a/packages/connect/tests/connect.test.ts +++ b/packages/connect/tests/connect.test.ts @@ -603,6 +603,7 @@ describe('probe installer', () => { cleanups.push(async () => rm(root, { recursive: true, force: true })); const home = join(root, 'home'); const commands: Array<[string, string[]]> = []; + let minimumVersion: string | undefined; const run = async (file: string, args: string[]) => { commands.push([file, args]); if (file === 'tar') { @@ -620,9 +621,13 @@ describe('probe installer', () => { run, require: async () => {}, start: async () => ({ pid: undefined, unref() {} }), - wait: async () => ({ socketPath: '/tmp/mac-probe.sock', status: { ok: true } }), + wait: async (options: { minimumVersion?: string }) => { + minimumVersion = options.minimumVersion; + return { socketPath: '/tmp/mac-probe.sock', status: { ok: true } }; + }, }); expect(result.socketPath).toBe('/tmp/mac-probe.sock'); + expect(minimumVersion).toBe('2026.10.5'); expect(commands.map(([file]) => file)).toEqual(['curl', 'curl', 'shasum', 'tar', 'codesign']); expect(commands[0][1].at(-1)).toContain('AgentRelay-macOS-arm64-probe.tar.gz'); expect(commands[3][1].at(-1)).toBe('agent_relay/helpers/agent-relay-probe');