Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 8 additions & 1 deletion packages/connect/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions packages/connect/src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises';
import os from 'node:os';
import {
ensureProbe,
requireVerifiedProbe,
InstallError,
linuxProbeSessionUpdateHint,
requireExistingProbe as findExistingProbe,
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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`);
Expand Down
48 changes: 44 additions & 4 deletions packages/connect/src/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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 };
}
Comment thread
khaliqgant marked this conversation as resolved.
if (status?.ok !== true) return null;
const version = status?.data?.version;
return {
socketPath,
Expand All @@ -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) {
Expand Down
156 changes: 156 additions & 0 deletions packages/connect/tests/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
probeVersionSupported,
quitRelayDesktop,
requireExistingProbe,
requireVerifiedProbe,
startDetachedProbe,
swapMacApp,
verifyChecksum,
Expand Down Expand Up @@ -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<string, unknown>) =>
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.
Expand Down
Loading