diff --git a/packages/connect/README.md b/packages/connect/README.md index 202254850..b101c909f 100644 --- a/packages/connect/README.md +++ b/packages/connect/README.md @@ -7,15 +7,20 @@ 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, +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. - 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..49cd3b462 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,17 @@ async function requireExistingProbe() { return existing; } +function requireSessionOk(response, probe) { + const hint = linuxProbeSessionUpdateHint(probe, response); + 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); +} + function joinSummary(response) { const data = response.data || {}; const hostAgent = safeText(data.host?.agent_name); @@ -195,13 +211,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; @@ -232,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 { @@ -248,13 +266,19 @@ 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; } - 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 cd9903a77..c5c226340 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, nextStep = 'update it and retry.') { 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); ${nextStep}`, 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. 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) { 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,12 @@ 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)) { + 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; } @@ -660,7 +688,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 +735,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 +748,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 +765,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..97ac21376 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,87 @@ 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('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' } }; + expect(linuxProbeSessionUpdateHint(probe, rejection, 'linux')).toBe( + '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(); + expect(linuxProbeSessionUpdateHint(probe, { error: { code: 'connect_not_joined' } }, 'linux')).toBeNull(); + }); + + 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') { + json(response, { ok: true, data: { version: '2026.10.4' } }); + } else { + json(response, { ok: false, error: { code: 'not_a_relay_session' } }, 403); + } + }); + 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'], input); + 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.', + }, + }); + } + }); + it('gives status, send, and leave the same distinct old-probe error', async () => { const { home } = await listen((request, response) => { if (request.url === '/setup/status') { @@ -370,7 +452,9 @@ describe('probe installer', () => { 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', + 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', }); } }); @@ -566,7 +650,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 +728,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 +812,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 +858,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 +1002,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 +1012,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 +1045,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.