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/README.md b/packages/connect/README.md index 222d7ead0..f5c312776 100644 --- a/packages/connect/README.md +++ b/packages/connect/README.md @@ -12,7 +12,14 @@ 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. 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/src/cli.js b/packages/connect/src/cli.js index 2974516a6..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, @@ -113,8 +114,21 @@ 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 }; @@ -200,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 1ebb3d50a..3ce026e63 100644 --- a/packages/connect/src/install.js +++ b/packages/connect/src/install.js @@ -239,6 +239,32 @@ 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); +} + +/** + * `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; try { @@ -248,7 +274,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 +284,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 +310,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..a4b4f0521 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, @@ -1225,6 +1226,161 @@ 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('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); + }); + 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.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(() => { // Deliberately leave the response open until the client destroys it.