From 292e83a36b436934861e326402955cbcfd2009f5 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Mon, 12 Oct 2026 12:41:23 -0700 Subject: [PATCH 1/4] fix(connect): stop at once when a live probe cannot identify the session The installer's readiness check polled /setup/status and only accepted ok:true. A probe answers every route with not_a_relay_session when it cannot tie the caller to a live Claude Code or Codex session, so a working probe looked dead: the installer waited 60 s, killed it, removed the pointer, and printed only a timeout. A live probe that rejects the caller now counts as running, and the session route reports the cause in about a second. Timeouts name the probe's last answer. Co-Authored-By: Claude Opus 5.5 --- packages/connect/README.md | 5 +- packages/connect/src/cli.js | 12 ++- packages/connect/src/install.js | 33 +++++- packages/connect/tests/connect.test.ts | 142 +++++++++++++++++++++++++ 4 files changed, 186 insertions(+), 6 deletions(-) diff --git a/packages/connect/README.md b/packages/connect/README.md index 222d7ead0..dd218766c 100644 --- a/packages/connect/README.md +++ b/packages/connect/README.md @@ -12,7 +12,10 @@ 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 +liveness for up to 15 seconds. A probe that answers but cannot tie the command to a live +Claude Code or Codex session counts as running: the command stops at once with +that error instead of waiting or installing another probe. Run it from the +agent's own shell tool. 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 standalone probe fails with an update-required error so the diff --git a/packages/connect/src/cli.js b/packages/connect/src/cli.js index 2974516a6..82f465f3e 100644 --- a/packages/connect/src/cli.js +++ b/packages/connect/src/cli.js @@ -113,8 +113,18 @@ function errorDetails(error) { connect_join_timeout: ['Relay Connect join timed out; retry the same join safely.', 8], connect_unreachable: ['Relay Connect cannot reach Cloud or Relaycast; retry once.', 8], agent_token_invalid: ['Relay Connect is over; run leave once to clear the local registration.', 3], - not_a_relay_session: ['Run this command from a live Claude Code or Codex session.', 6], + not_a_relay_session: [ + 'Agent Relay is running but could not match this command to a live Claude Code or Codex session. Run it from the agent\'s own shell tool, not a separate terminal; if it already is, update Claude Code or Codex and retry.', + 6, + ], }; + if (code === 'session_file_unreadable') { + return { + message: safeText(error?.response?.error?.message, 'This session\'s file cannot be read; resume the session and retry.'), + exitCode: 6, + code, + }; + } if (known[code]) return { message: known[code][0], exitCode: known[code][1], code }; if (error instanceof UsageError || error instanceof InstallError) { return { message: safeText(error.message, 'Relay Connect failed.'), exitCode: error.exitCode || 1, code }; diff --git a/packages/connect/src/install.js b/packages/connect/src/install.js index 1ebb3d50a..ffe74450b 100644 --- a/packages/connect/src/install.js +++ b/packages/connect/src/install.js @@ -239,6 +239,17 @@ export function linuxProbeSessionUpdateHint(probe, response, platform = process. 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.`; } +// A running probe answers every route with one of these when it cannot tie +// the caller to a live Claude Code or Codex session. The probe is live; only +// the caller is unidentified, so waiting longer or installing another probe +// cannot help. Its version is not readable in that state. +const SESSION_REJECTIONS = new Set(['not_a_relay_session', 'session_file_unreadable']); + +export function isSessionRejection(response) { + return response?.ok === false && SESSION_REJECTIONS.has(response?.error?.code); +} + +/** Any well-formed probe answer to /setup/status, or null when nothing answered. */ async function liveStatus(socketPath, timeoutMs = 5_000) { if (!socketPath) return null; try { @@ -248,7 +259,7 @@ async function liveStatus(socketPath, timeoutMs = 5_000) { path: '/setup/status', timeoutMs, }); - return response?.ok === true ? response : null; + return response && typeof response === 'object' && typeof response.ok === 'boolean' ? response : null; } catch (error) { if (error instanceof InstallError) throw error; return null; @@ -258,7 +269,10 @@ async function liveStatus(socketPath, timeoutMs = 5_000) { export async function findLiveSocket(home = os.homedir(), timeoutMs = 5_000) { const socketPath = await readPointer(home); const status = await liveStatus(socketPath, timeoutMs); - if (!status) return null; + if (isSessionRejection(status)) { + return { socketPath, status, version: undefined, supported: undefined, sessionRejected: true }; + } + if (status?.ok !== true) return null; const version = status?.data?.version; return { socketPath, @@ -281,15 +295,26 @@ export async function waitForLiveSocket({ minimumVersion = MINIMUM_PROBE_VERSION, } = {}) { const deadline = now() + timeoutMs; + let lastAnswer = ''; 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, minimumVersion)) return { socketPath, status }; + // The probe is up but cannot identify the caller: return at once so the + // session route reports that, instead of timing out and rolling back. + if (isSessionRejection(status)) return { socketPath, status, sessionRejected: true }; + if (status?.ok === true && probeVersionSupported(status?.data?.version, minimumVersion)) { + return { socketPath, status }; + } + if (status?.ok === true) lastAnswer = `version ${status?.data?.version || 'unknown'}`; + else if (status) lastAnswer = String(status?.error?.code || 'an error'); const sleepFor = Math.min(1_000, Math.max(0, deadline - now())); if (sleepFor > 0) await sleep(sleepFor); } - throw new InstallError(`Timed out waiting for the Agent Relay probe after ${timeoutMs} ms.`); + const detail = lastAnswer + ? `its last answer was ${lastAnswer.replace(/[^\w .-]/g, '')}` + : 'it never answered; see ~/.agentworkforce/desktop/headless.log'; + throw new InstallError(`Timed out waiting for the Agent Relay probe after ${timeoutMs} ms (${detail}).`); } async function findProbe(directory) { diff --git a/packages/connect/tests/connect.test.ts b/packages/connect/tests/connect.test.ts index 3b9dffc70..26eec0bb7 100644 --- a/packages/connect/tests/connect.test.ts +++ b/packages/connect/tests/connect.test.ts @@ -1225,6 +1225,148 @@ describe('probe installer', () => { expect(checks).toBe(2); }); + it('treats a probe that cannot identify the caller as live at once', async () => { + let clock = 0; + let checks = 0; + const result = await waitForLiveSocket({ + timeoutMs: 60_000, + now: () => clock, + sleep: async (milliseconds) => { + clock += milliseconds; + }, + pointer: async () => '/tmp/relay.sock', + check: async () => { + checks += 1; + return { ok: false, error: { code: 'not_a_relay_session' } }; + }, + }); + expect(result).toMatchObject({ socketPath: '/tmp/relay.sock', sessionRejected: true }); + expect(checks).toBe(1); + expect(clock).toBe(0); + }); + + it('names the probe\'s last answer when readiness times out', async () => { + let clock = 0; + await expect( + waitForLiveSocket({ + timeoutMs: 2_000, + now: () => clock, + sleep: async (milliseconds) => { + clock += milliseconds; + }, + pointer: async () => '/tmp/relay.sock', + check: async () => ({ ok: false, error: { code: 'relay_stopping' } }), + }) + ).rejects.toThrow('after 2000 ms (its last answer was relay_stopping).'); + clock = 0; + await expect( + waitForLiveSocket({ + timeoutMs: 1_000, + now: () => clock, + sleep: async (milliseconds) => { + clock += milliseconds; + }, + pointer: async () => '', + check: async () => null, + }) + ).rejects.toThrow('(it never answered; see ~/.agentworkforce/desktop/headless.log).'); + }); + + it('finds a live probe that rejects the caller instead of installing another', async () => { + const { home, socketPath } = await listen((_request, response) => { + json(response, { ok: false, error: { code: 'not_a_relay_session' } }, 403); + }); + await expect(findLiveSocket(home)).resolves.toMatchObject({ + socketPath, + sessionRejected: true, + version: undefined, + }); + }); + + it('reports an unidentified session inline, as a fresh-HOME probe answers it', async () => { + // A just-started probe that cannot tie the caller to a live session (as on + // a fresh HOME) answers every route, /setup/status included, this way. + const seen: string[] = []; + const { home } = await listen((request, response) => { + seen.push(`${request.method} ${request.url}`); + json( + response, + { + ok: false, + error: { + code: 'not_a_relay_session', + message: 'This command is not running inside a supported live Agent Relay session.', + }, + }, + 403 + ); + }); + const started = Date.now(); + const result = await runCli(home, ['join', 'connect-test']); + expect(Date.now() - started).toBeLessThan(5_000); + expect(result).toEqual({ + code: 6, + stdout: '', + stderr: + "Agent Relay is running but could not match this command to a live Claude Code or Codex session. Run it from the agent's own shell tool, not a separate terminal; if it already is, update Claude Code or Codex and retry.\n", + }); + expect(seen).toEqual(['GET /setup/status', 'POST /connect/join']); + }); + + it('passes the probe\'s explanation through for an unreadable session file', async () => { + const { home } = await listen((_request, response) => { + json( + response, + { + ok: false, + error: { + code: 'session_file_unreadable', + message: 'This Claude Code session is running, but its session file cannot be read.', + }, + }, + 409 + ); + }); + expect(await runCli(home, ['status'])).toEqual({ + code: 6, + stdout: '', + stderr: 'This Claude Code session is running, but its session file cannot be read.\n', + }); + }); + + it('keeps a fresh Linux install whose probe answers but cannot identify the caller', async () => { + const root = await mkdtemp(join(os.tmpdir(), 'connect-linux-unidentified-test-')); + cleanups.push(async () => rm(root, { recursive: true, force: true })); + const home = join(root, 'home'); + const run = async (file: string, args: string[]) => { + if (file === 'tar') { + const destination = args[args.indexOf('-C') + 1]; + const probe = join(destination, 'release/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 installLinux({ + home, + arch: 'x64', + run, + start: async () => ({ pid: undefined, unref() {} }), + wait: (options: Record) => + waitForLiveSocket({ + ...options, + sleep: async () => {}, + pointer: async () => '/tmp/relay.sock', + check: async () => ({ ok: false, error: { code: 'not_a_relay_session' } }), + }), + }); + expect(result).toMatchObject({ sessionRejected: true }); + await expect(readlink(join(home, '.local/bin/agent-relay-probe'))).resolves.toContain( + join(home, '.local/lib/agent-relay/current') + ); + }); + 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 d14d6d3dd32947e4195a5f6be274694cab619a34 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 12 Oct 2026 19:42:49 +0000 Subject: [PATCH 2/4] style: auto-format with Prettier --- packages/connect/src/cli.js | 7 +++++-- packages/connect/tests/connect.test.ts | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/connect/src/cli.js b/packages/connect/src/cli.js index 82f465f3e..4d4ae679a 100644 --- a/packages/connect/src/cli.js +++ b/packages/connect/src/cli.js @@ -114,13 +114,16 @@ function errorDetails(error) { connect_unreachable: ['Relay Connect cannot reach Cloud or Relaycast; retry once.', 8], agent_token_invalid: ['Relay Connect is over; run leave once to clear the local registration.', 3], not_a_relay_session: [ - 'Agent Relay is running but could not match this command to a live Claude Code or Codex session. Run it from the agent\'s own shell tool, not a separate terminal; if it already is, update Claude Code or Codex and retry.', + "Agent Relay is running but could not match this command to a live Claude Code or Codex session. Run it from the agent's own shell tool, not a separate terminal; if it already is, update Claude Code or Codex and retry.", 6, ], }; if (code === 'session_file_unreadable') { return { - message: safeText(error?.response?.error?.message, 'This session\'s file cannot be read; resume the session and retry.'), + message: safeText( + error?.response?.error?.message, + "This session's file cannot be read; resume the session and retry." + ), exitCode: 6, code, }; diff --git a/packages/connect/tests/connect.test.ts b/packages/connect/tests/connect.test.ts index 26eec0bb7..4963fc738 100644 --- a/packages/connect/tests/connect.test.ts +++ b/packages/connect/tests/connect.test.ts @@ -1245,7 +1245,7 @@ describe('probe installer', () => { expect(clock).toBe(0); }); - it('names the probe\'s last answer when readiness times out', async () => { + it("names the probe's last answer when readiness times out", async () => { let clock = 0; await expect( waitForLiveSocket({ @@ -1313,7 +1313,7 @@ describe('probe installer', () => { expect(seen).toEqual(['GET /setup/status', 'POST /connect/join']); }); - it('passes the probe\'s explanation through for an unreadable session file', async () => { + it("passes the probe's explanation through for an unreadable session file", async () => { const { home } = await listen((_request, response) => { json( response, From 35e6b9b7daa0e15ec8cfa8b53d99bc07d91ae5a8 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Mon, 12 Oct 2026 13:09:05 -0700 Subject: [PATCH 3/4] fix(connect): don't report an unverifiable probe as installed; changelog `connect install` returned "ready" for a probe that rejected the caller, whose version cannot be read, so an old macOS probe below the 2026.10.5 minimum escaped the update. Install now stops with exit 6 and says to run it from the agent's own shell (or `!` in Claude Code), where the version can be checked. Join is unchanged: it still fails fast with the session reason. Adds the [Unreleased - Patch] changelog entry for this fix. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 6 +++++- packages/connect/src/cli.js | 3 ++- packages/connect/src/install.js | 15 +++++++++++++++ packages/connect/tests/connect.test.ts | 11 +++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 627626017..0a159f0fb 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] + +### Fixed + +- `npx @agent-relay/connect join` no longer waits 60 seconds and rolls back when the local probe is running but cannot match the command to a live Claude Code or Codex session (for example, when it was run from a separate terminal). It stops within about a second and says to run the command from the agent's own shell. A timeout that does happen now names the probe's last answer. `connect install` no longer reports such a probe as ready when its version cannot be checked. ## [13.4.0] - 2026-10-11 diff --git a/packages/connect/src/cli.js b/packages/connect/src/cli.js index 4d4ae679a..7f21e477d 100644 --- a/packages/connect/src/cli.js +++ b/packages/connect/src/cli.js @@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises'; import os from 'node:os'; import { ensureProbe, + requireVerifiedProbe, InstallError, linuxProbeSessionUpdateHint, requireExistingProbe as findExistingProbe, @@ -213,7 +214,7 @@ async function run(options) { return; } if (options.command === 'install') { - const result = await ensureProbe(); + const result = requireVerifiedProbe(await ensureProbe()); if (options.json) printJson(result.status); else process.stdout.write(`Agent Relay probe ready${result.installed ? ' (installed and started)' : ''}.\n`); diff --git a/packages/connect/src/install.js b/packages/connect/src/install.js index ffe74450b..3ce026e63 100644 --- a/packages/connect/src/install.js +++ b/packages/connect/src/install.js @@ -249,6 +249,21 @@ export function isSessionRejection(response) { return response?.ok === false && SESSION_REJECTIONS.has(response?.error?.code); } +/** + * `connect install` promises a probe that meets the platform minimum. A probe + * that rejected the caller reported no version, so that cannot be checked + * from here; only a call from inside the agent session can read it. + */ +export function requireVerifiedProbe(result) { + if (result?.sessionRejected) { + throw new InstallError( + "Agent Relay is running, but its version cannot be checked from outside a Claude Code or Codex session. Run `npx -y @agent-relay/connect install` from the agent's own shell (in Claude Code, type it at the prompt prefixed with `!`) to verify or update it.", + 6 + ); + } + return result; +} + /** Any well-formed probe answer to /setup/status, or null when nothing answered. */ async function liveStatus(socketPath, timeoutMs = 5_000) { if (!socketPath) return null; diff --git a/packages/connect/tests/connect.test.ts b/packages/connect/tests/connect.test.ts index 4963fc738..ad09f71bc 100644 --- a/packages/connect/tests/connect.test.ts +++ b/packages/connect/tests/connect.test.ts @@ -35,6 +35,7 @@ import { probeVersionSupported, quitRelayDesktop, requireExistingProbe, + requireVerifiedProbe, startDetachedProbe, swapMacApp, verifyChecksum, @@ -1272,6 +1273,16 @@ describe('probe installer', () => { ).rejects.toThrow('(it never answered; see ~/.agentworkforce/desktop/headless.log).'); }); + it('does not report an unverifiable probe as installed', () => { + // An old macOS probe that rejects the caller hides its version, so an + // explicit install must not call it ready (it may be below the minimum). + expect(() => requireVerifiedProbe({ socketPath: '/tmp/relay.sock', sessionRejected: true })).toThrow( + 'cannot be checked from outside a Claude Code or Codex session' + ); + const verified = { socketPath: '/tmp/relay.sock', version: '2026.10.31', installed: false }; + expect(requireVerifiedProbe(verified)).toBe(verified); + }); + it('finds a live probe that rejects the caller instead of installing another', async () => { const { home, socketPath } = await listen((_request, response) => { json(response, { ok: false, error: { code: 'not_a_relay_session' } }, 403); From 60dc36a83fc37c3e85a69800234dd842989c65df Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Mon, 12 Oct 2026 13:17:11 -0700 Subject: [PATCH 4/4] docs(connect): scope the session-rejection guidance per code; test both codes Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/connect/README.md | 8 ++- packages/connect/tests/connect.test.ts | 67 ++++++++++++++------------ 2 files changed, 41 insertions(+), 34 deletions(-) diff --git a/packages/connect/README.md b/packages/connect/README.md index dd218766c..f5c312776 100644 --- a/packages/connect/README.md +++ b/packages/connect/README.md @@ -14,8 +14,12 @@ 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. A probe that answers but cannot tie the command to a live Claude Code or Codex session counts as running: the command stops at once with -that error instead of waiting or installing another probe. Run it from the -agent's own shell tool. When no eligible probe is available, it +that error instead of waiting or installing another probe. For +`not_a_relay_session`, run the command from the agent's own shell tool (in +Claude Code, type it at the prompt prefixed with `!` if a permission check +blocks the agent from running it). For `session_file_unreadable`, follow the +probe's explanation, or resume the session and retry. 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 standalone probe fails with an update-required error so the diff --git a/packages/connect/tests/connect.test.ts b/packages/connect/tests/connect.test.ts index ad09f71bc..a4b4f0521 100644 --- a/packages/connect/tests/connect.test.ts +++ b/packages/connect/tests/connect.test.ts @@ -1345,38 +1345,41 @@ describe('probe installer', () => { }); }); - it('keeps a fresh Linux install whose probe answers but cannot identify the caller', async () => { - const root = await mkdtemp(join(os.tmpdir(), 'connect-linux-unidentified-test-')); - cleanups.push(async () => rm(root, { recursive: true, force: true })); - const home = join(root, 'home'); - const run = async (file: string, args: string[]) => { - if (file === 'tar') { - const destination = args[args.indexOf('-C') + 1]; - const probe = join(destination, 'release/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 installLinux({ - home, - arch: 'x64', - run, - start: async () => ({ pid: undefined, unref() {} }), - wait: (options: Record) => - waitForLiveSocket({ - ...options, - sleep: async () => {}, - pointer: async () => '/tmp/relay.sock', - check: async () => ({ ok: false, error: { code: 'not_a_relay_session' } }), - }), - }); - expect(result).toMatchObject({ sessionRejected: true }); - await expect(readlink(join(home, '.local/bin/agent-relay-probe'))).resolves.toContain( - join(home, '.local/lib/agent-relay/current') - ); - }); + it.each(['not_a_relay_session', 'session_file_unreadable'])( + 'keeps a fresh Linux install whose probe answers %s', + async (rejectionCode) => { + const root = await mkdtemp(join(os.tmpdir(), 'connect-linux-unidentified-test-')); + cleanups.push(async () => rm(root, { recursive: true, force: true })); + const home = join(root, 'home'); + const run = async (file: string, args: string[]) => { + if (file === 'tar') { + const destination = args[args.indexOf('-C') + 1]; + const probe = join(destination, 'release/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 installLinux({ + home, + arch: 'x64', + run, + start: async () => ({ pid: undefined, unref() {} }), + wait: (options: Record) => + waitForLiveSocket({ + ...options, + sleep: async () => {}, + pointer: async () => '/tmp/relay.sock', + check: async () => ({ ok: false, error: { code: rejectionCode } }), + }), + }); + expect(result).toMatchObject({ sessionRejected: true }); + await expect(readlink(join(home, '.local/bin/agent-relay-probe'))).resolves.toContain( + join(home, '.local/lib/agent-relay/current') + ); + } + ); it('enforces a timeout on each Unix-socket request', async () => { const { socketPath } = await listen(() => {