diff --git a/packages/connect/README.md b/packages/connect/README.md index 222d7ead0..5dcff11df 100644 --- a/packages/connect/README.md +++ b/packages/connect/README.md @@ -7,21 +7,58 @@ or a preinstalled skill: Run this for me: `npx -y @agent-relay/connect join ` ``` -The command reuses a live Agent Relay 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 standalone probe 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. -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. +Run it from the agent's own shell tool (in Claude Code, a command blocked by a +permission check can be run with the `!` prefix). The command needs no +download and starts no new binary: the package itself is the local relay. + +## How it works + +`join` names the calling session itself. A Codex session is named by +`CODEX_THREAD_ID`; a Claude Code session is named by `CLAUDE_CODE_SESSION_ID` +when that session has a live record, or else by the nearest parent process +with one. Outside a live session the command stops at once and says what it +checked. + +It then uses one local relay for that session: + +1. **The relay built into this package**, if it already holds this session's + Connect. +2. **A live Agent Relay desktop probe** at version 2026.10.5 or newer on macOS, + or 2026.10.4 or newer on Linux. Its pointer and socket must be owned by the + current user, and the pointer must not be group- or world-writable; unsafe + pointer or socket metadata is never used. +3. **Otherwise, the relay built into this package.** It runs as a per-user + background Node process under the same Node that runs `npx`. + - Its code is copied to `~/.agentworkforce/connect/runtime//`. + - Its credential and delivery ledger are kept in + `~/.agentworkforce/connect/state.json`: directory 0700, file 0600. + - It serves a 0600 socket under `~/.agentworkforce/connect/run/`. + - It logs to `~/.agentworkforce/connect/daemon.log`, never with tokens or + message text. + - If it cannot start, `join` prints the reason and the end of that log at + once. + + It polls the Connect for new messages and writes each into the session. + - **Claude Code:** through the session's own inbox socket. + - **Codex:** with `codex queue`, confirmed from the session's rollout, or + through the Codex app's IPC socket. + + It acknowledges a message only once it is in the session. A message whose + delivery is uncertain is never resent. The relay exits a minute after its + last Connect ends. After a restart, run `status` once to resume delivery. + +`AGENT_RELAY_CONNECT_BACKEND=node` or `=probe` forces one relay. `join` no +longer downloads the desktop probe. `connect install` still installs it for +people who want the full desktop relay, as described below. + +## `connect install` + +`install` reuses a live probe when it answers. If an existing socket or Relay +process may be restarting, it retries liveness for up to 15 seconds. A probe +that answers but cannot tie the command to a live session counts as running. +Otherwise it downloads the current release from +`AgentWorkforce/relay-desktop-releases`, verifies the adjacent SHA-256 file, +and starts the probe without signing in. - Linux x64 and arm64 use the relocatable tarball under `~/.local/lib/agent-relay/current`, a symlink at @@ -34,7 +71,7 @@ it is running; update the running service separately. installed but its probe needs updating, the existing signed DMG path updates 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"` +- For Claude Code, joining (with either relay) 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/cli.js b/packages/connect/src/cli.js index 2974516a6..738a416de 100644 --- a/packages/connect/src/cli.js +++ b/packages/connect/src/cli.js @@ -2,13 +2,20 @@ import { readFile } from 'node:fs/promises'; import os from 'node:os'; +import { randomUUID } from 'node:crypto'; import { ensureProbe, + findLiveSocket, InstallError, linuxProbeSessionUpdateHint, + probeSupportedOnPlatform, + requireSupportedProbe, requireExistingProbe as findExistingProbe, } from './install.js'; import { requestJson, requireOk, SocketResponseError } from './http.js'; +import { identifyCaller, SessionNotFoundError } from './relay/identify.js'; +import { ensureRelay, RelayStartError } from './relay/launch.js'; +import { relayPaths } from './relay/paths.js'; const USAGE = `Usage: connect join [--name ] [--host-claim-stdin] [--json] @@ -113,8 +120,28 @@ 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 (error instanceof SessionNotFoundError || error instanceof RelayStartError) { + return { + message: safeText(error.message.split('\n')[0]) + tailOf(error.message), + exitCode: error.exitCode, + 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 }; @@ -129,6 +156,12 @@ function errorDetails(error) { }; } +// Keeps the log tail a start failure carries, on its own lines. +function tailOf(message) { + const lines = String(message).split('\n').slice(1); + return lines.length ? `\n${lines.map((line) => safeText(line, '')).join('\n')}` : ''; +} + async function requireExistingProbe() { const existing = await findExistingProbe({ home: os.homedir() }); if (!existing) @@ -147,8 +180,15 @@ function requireSessionOk(response, probe) { return requireOk(response); } -function joinSummary(response) { +function joinSummary(response, backend) { const data = response.data || {}; + const claudeNote = + backend.kind === 'node' + ? 'Claude Code note: Relay Connect sets "crossSessionInbound": "accept" in ~/.claude/settings.json so replies can be injected; the previous file is kept as settings.json.relay-desktop-backup.' + : 'Claude Code note: the probe sets "crossSessionInbound": "accept" in ~/.claude/settings.json; undo it by POSTing {"enabled":false} to /setup/direct-delivery over the pointer socket (exact command in the package README).'; + const warnings = Array.isArray(data.warnings) + ? data.warnings.map((warning) => `Warning: ${safeText(warning)}`) + : []; const hostAgent = safeText(data.host?.agent_name); const hostPerson = safeText(data.host?.person, ''); const host = hostPerson && hostPerson !== hostAgent ? `${hostPerson} (${hostAgent})` : hostAgent; @@ -163,7 +203,12 @@ function joinSummary(response) { '- Check membership: npx -y @agent-relay/connect status', '- Leave: npx -y @agent-relay/connect leave', '- An ended or expired notice means the Connect is over; stop sending and do not silently rejoin.', - 'Claude Code note: the probe sets "crossSessionInbound": "accept" in ~/.claude/settings.json; undo it by POSTing {"enabled":false} to /setup/direct-delivery over the pointer socket (exact command in the package README).', + ...(backend.kind === 'node' + ? ['- After a restart, run status once to resume delivery: npx -y @agent-relay/connect status'] + : []), + claudeNote, + ...(backend.note ? [backend.note] : []), + ...warnings, ].join('\n'); } @@ -207,7 +252,7 @@ async function run(options) { return; } - const probe = options.command === 'join' ? await ensureProbe() : await requireExistingProbe(); + const backend = await chooseBackend(options.command); if (options.command === 'join') { const body = { link: options.positionals[0] }; if (options.name) body.name = options.name; @@ -216,19 +261,19 @@ async function run(options) { if (!claim) throw new UsageError('--host-claim-stdin requires a claim on stdin.'); body.host_claim = claim; } - const response = requireSessionOk( - await requestJson(probe.socketPath, { + const response = backend.check( + await backend.call({ method: 'POST', path: '/connect/join', body: JSON.stringify(body), headers: { 'content-type': 'application/json' }, - }), - probe + timeoutMs: 60_000, + }) ); delete body.host_claim; if (options.json) printJson(response); - else process.stdout.write(`${joinSummary(response)}\n`); + else process.stdout.write(`${joinSummary(response, backend)}\n`); return; } @@ -236,13 +281,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 = requireSessionOk( - await requestJson(probe.socketPath, { + const response = backend.check( + await backend.call({ method: 'POST', path: `/connect/send${suffix}`, body: message, - }), - probe + headers: { 'x-agent-relay-request': randomUUID() }, + }) ); if (options.json) printJson(response); else { @@ -253,23 +298,98 @@ async function run(options) { } if (options.command === 'status') { - const response = requireSessionOk( - await requestJson(probe.socketPath, { path: '/connect/status' }), - probe - ); + const response = backend.check(await backend.call({ path: '/connect/status' })); if (options.json) printJson(response); else process.stdout.write(`${statusSummary(response)}\n`); return; } - const response = requireSessionOk( - await requestJson(probe.socketPath, { method: 'POST', path: '/connect/leave' }), - probe - ); + const response = backend.check(await backend.call({ method: 'POST', path: '/connect/leave' })); if (options.json) printJson(response); else process.stdout.write(`Left Relay Connect ${safeText(response.data?.connect_id)}.\n`); } +async function nodeHoldsSession(home, target) { + try { + const state = JSON.parse(await readFile(relayPaths(home).state, 'utf8')); + return Boolean(state?.sessions?.[`${target.kind}:${target.id}`]); + } catch { + return false; + } +} + +/** + * Which local relay serves this command. The Node relay serves any session it + * already holds; otherwise an eligible desktop probe is preferred; otherwise + * `join` starts the Node relay. AGENT_RELAY_CONNECT_BACKEND=node|probe forces one. + */ +async function chooseBackend(command) { + const home = os.homedir(); + const forced = process.env.AGENT_RELAY_CONNECT_BACKEND; + const probeBackend = (probe) => ({ + kind: 'probe', + call: (request) => requestJson(probe.socketPath, request), + check: (response) => requireSessionOk(response, probe), + }); + if (forced === 'probe') { + return probeBackend(command === 'join' ? await ensureProbe() : await requireExistingProbe()); + } + + let target = null; + let identifyError = null; + try { + target = await identifyCaller({ home }); + } catch (error) { + if (!(error instanceof SessionNotFoundError)) throw error; + identifyError = error; + } + const nodeBackend = async (note) => { + const socket = await ensureRelay({ home }); + return { + kind: 'node', + note, + call: (request) => + requestJson(socket, { + ...request, + headers: { ...request.headers, 'x-agent-relay-target': `${target.kind}:${target.id}` }, + }), + check: (response) => requireOk(response), + }; + }; + if (forced === 'node') { + if (!target) throw identifyError; + return await nodeBackend(); + } + if (target && (await nodeHoldsSession(home, target))) return await nodeBackend(); + + let probe = null; + try { + probe = await findLiveSocket(home, 2_000); + } catch { + // An unsafe or unreadable probe pointer is never used; the Node relay is. + probe = null; + } + if (probe && !probe.sessionRejected) { + if (probeSupportedOnPlatform(probe)) return probeBackend(probe); + if (command === 'join' && target) { + return await nodeBackend( + `Note: Agent Relay ${safeText(probe.version)} is too old for Relay Connect, so this Connect uses the relay built into this package.` + ); + } + requireSupportedProbe(probe); + } + if (!target) { + // The probe knows the session but cannot read its record: its reason is the useful one. + if (probe?.status?.error?.code === 'session_file_unreadable') throw new SocketResponseError(probe.status); + throw identifyError; + } + if (command === 'join') return await nodeBackend(); + throw new SocketResponseError({ + ok: false, + error: { code: 'connect_not_joined', message: 'This agent session has not joined a Relay Connect.' }, + }); +} + export async function main(argv = process.argv.slice(2)) { let options; try { diff --git a/packages/connect/src/install.js b/packages/connect/src/install.js index 1ebb3d50a..e95a620c0 100644 --- a/packages/connect/src/install.js +++ b/packages/connect/src/install.js @@ -221,7 +221,7 @@ function minimumProbeVersion(platform) { return platform === 'darwin' ? MINIMUM_MAC_PROBE_VERSION : MINIMUM_PROBE_VERSION; } -function probeSupportedOnPlatform(existing, platform) { +export function probeSupportedOnPlatform(existing, platform = process.platform) { if (!existing) return false; return existing.version ? probeVersionSupported(existing.version, minimumProbeVersion(platform)) @@ -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) { @@ -660,7 +685,7 @@ export async function acquireInstallLock({ } } -function requireSupportedProbe(existing, platform = process.platform) { +export 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.'; diff --git a/packages/connect/src/relay/claude.js b/packages/connect/src/relay/claude.js new file mode 100644 index 000000000..70ba98ffa --- /dev/null +++ b/packages/connect/src/relay/claude.js @@ -0,0 +1,699 @@ +import { randomUUID } from 'node:crypto'; +import { constants, readdirSync, realpathSync, statSync } from 'node:fs'; +import { chmod, lstat, mkdir, open, readdir, readFile, readlink, rename, rm, stat } from 'node:fs/promises'; +import net from 'node:net'; +import os from 'node:os'; +import { basename, dirname, extname, join } from 'node:path'; +import { ancestors, claudeStartMatches, MAX_ANCESTRY, processInfo } from './processes.js'; + +// Claude Code terminal sessions and the inbox route into them, ported from +// relay-desktop probe/src/relay/claude.rs and claude_settings.rs. +// +// Claude Code registers every session in `/sessions/.json` +// and opens an inbox socket for cross-session messaging, authenticated by the +// token in the matching `..key`. A message is two JSON lines on +// that socket: the auth line, then the user message. + +/** The inbox wire version this client speaks (claude.rs KNOWN_PEER_PROTOCOL). */ +export const KNOWN_PEER_PROTOCOL = 1; +/** Claude Code refuses an inbox line past about a million characters. */ +export const MAX_LINE_BYTES = 1_000_000; +const SOCKET_TIMEOUT_MS = 5_000; +const DRAIN_TIMEOUT_MS = 1_000; + +export const INBOUND_KEY = 'crossSessionInbound'; +const ACCEPT = 'accept'; +export const BACKUP_SUFFIX = '.relay-desktop-backup'; +export const MANAGED_SETTINGS = { + darwin: '/Library/Application Support/ClaudeCode/managed-settings.json', + other: '/etc/claude-code/managed-settings.json', +}; + +const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + +/** store.rs `is_uuid`: 36 characters, hex with dashes at 8, 13, 18 and 23. */ +export function isUuid(value) { + return typeof value === 'string' && UUID.test(value); +} + +function outcome(kind, detail) { + return { outcome: kind, detail }; +} + +function expandTilde(path, home) { + if (path === '~') return home; + if (path.startsWith('~/')) return join(home, path.slice(2)); + return path; +} + +function setting(env, key) { + const value = env?.[key]; + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed === '' ? null : trimmed; +} + +/** `CLAUDE_CONFIG_DIR`, else `~/.claude`. */ +export function claudeHome({ home = os.homedir(), env = process.env } = {}) { + const configured = setting(env, 'CLAUDE_CONFIG_DIR'); + return configured ? expandTilde(configured, home) : join(home, '.claude'); +} + +async function sortedEntries(directory) { + const names = await readdir(directory).catch(() => []); + return names.sort().map((name) => join(directory, name)); +} + +function isDirectorySync(path) { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +/** + * Every Claude login of this person's (claude.rs `registries`): the + * configured home, then each sibling `.claude` or `.claude-` folder + * that has a `sessions/` registry, each once by the real path of its + * registry, so a login whose `sessions/` links to another's is read once. + */ +export function claudeHomes({ home = os.homedir(), env = process.env } = {}) { + const configured = claudeHome({ home, env }); + const parent = dirname(configured); + let names = []; + try { + names = readdirSync(parent); + } catch { + names = []; + } + const siblings = names + .filter((name) => name === '.claude' || name.startsWith('.claude-')) + .map((name) => join(parent, name)) + .filter((folder) => isDirectorySync(join(folder, 'sessions'))) + .sort(); + const seen = new Set(); + const homes = []; + for (const folder of [configured, ...siblings]) { + const registry = join(folder, 'sessions'); + let key = registry; + try { + key = realpathSync(registry); + } catch { + // Not there (yet): compared by its own path. + } + if (seen.has(key)) continue; + seen.add(key); + homes.push(folder); + } + return homes; +} + +/** + * The first JSON value in `text`, ignoring whatever follows it (claude.rs + * `first_value`). Claude Code rewrites its registry files in place, so an + * interrupted write can leave stray bytes after a complete record. + */ +export function firstJsonValue(text) { + const start = text.search(/\S/); + if (start < 0) throw new SyntaxError('empty JSON'); + const opening = text[start]; + if (opening !== '{' && opening !== '[') { + try { + return JSON.parse(text); + } catch { + const token = text.slice(start).match(/^("(?:[^"\\]|\\.)*"|[^\s,\]}]+)/); + if (!token) throw new SyntaxError('no JSON value'); + return JSON.parse(token[0]); + } + } + let depth = 0; + let inString = false; + for (let index = start; index < text.length; index += 1) { + const char = text[index]; + if (inString) { + if (char === '\\') index += 1; + else if (char === '"') inString = false; + continue; + } + if (char === '"') inString = true; + else if (char === '{' || char === '[') depth += 1; + else if (char === '}' || char === ']') { + depth -= 1; + if (depth === 0) return JSON.parse(text.slice(start, index + 1)); + } + } + throw new SyntaxError('unterminated JSON value'); +} + +/** `procStart` as text: a string on macOS, a string or a number of ticks on Linux. */ +function recordedStart(value) { + if (typeof value === 'string') return value; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return null; +} + +/** + * One `.json` record (claude.rs `TerminalSession::decode_value`), or + * `null` without what a delivery needs: a UUID session id, a pid and a + * socket path. + */ +export function decodeSessionRecord(record) { + if (!record || typeof record !== 'object' || Array.isArray(record)) return null; + const { sessionId, pid, messagingSocketPath } = record; + if (!isUuid(sessionId)) return null; + if (!Number.isInteger(pid) || pid <= 0 || pid > 0x7fffffff) return null; + if (typeof messagingSocketPath !== 'string' || messagingSocketPath === '') return null; + const text = (key) => (typeof record[key] === 'string' ? record[key] : null); + return { + sessionId, + pid, + messagingSocketPath, + peerProtocol: Number.isInteger(record.peerProtocol) ? record.peerProtocol : 0, + procStart: recordedStart(record.procStart), + cwd: text('cwd'), + name: text('name'), + status: text('status'), + updatedAtMs: null, + registryDir: null, + }; +} + +/** Reads `pid` once per pass, so one registry read asks `ps` once per pid. */ +function infoReader() { + const cache = new Map(); + return (pid) => { + if (!cache.has(pid)) cache.set(pid, processInfo(pid)); + return cache.get(pid); + }; +} + +/** + * Whether the record belongs to a process running now (claude.rs + * `is_alive`): the pid is alive and its start time matches `procStart`. A + * record without a start is taken on its pid alone. + */ +async function isLive(record, info) { + const running = await info(record.pid); + if (!running) return false; + return record.procStart === null || claudeStartMatches(record.procStart, running.start); +} + +async function keyRecords(registryDir, pid) { + const prefix = `${pid}.`; + const records = []; + for (const path of await sortedEntries(registryDir)) { + if (extname(path) !== '.key' || !basename(path).startsWith(prefix)) continue; + try { + records.push(firstJsonValue(await readFile(path, 'utf8'))); + } catch { + // An unreadable key is no key. + } + } + return records; +} + +async function keyNamesRunningProcess(registryDir, pid, info) { + const running = await info(pid); + if (!running) return false; + return (await keyRecords(registryDir, pid)).some((record) => { + const start = recordedStart(record?.procStart); + return start !== null && claudeStartMatches(start, running.start); + }); +} + +/** + * Claude Code's session registry across every login (claude.rs `registry`): + * live sessions newest first, and the `.json` files that do not parse + * although a key of the same pid names the running process. `want` narrows + * the read before any process is looked up; it never changes which of the + * wanted records are live. + */ +async function readRegistry({ home, env, want = () => true, wantPid = () => true }) { + const info = infoReader(); + const sessions = []; + const unreadable = []; + for (const claude of claudeHomes({ home, env })) { + const registryDir = join(claude, 'sessions'); + for (const path of await sortedEntries(registryDir)) { + if (extname(path) !== '.json') continue; + let record; + let error; + try { + record = firstJsonValue(await readFile(path, 'utf8')); + } catch (caught) { + error = caught; + } + if (error) { + const stem = basename(path, '.json'); + const pid = /^\d+$/.test(stem) ? Number.parseInt(stem, 10) : 0; + if (pid > 0 && wantPid(pid) && (await keyNamesRunningProcess(registryDir, pid, info))) { + unreadable.push({ path, pid, error: String(error?.message ?? error) }); + } + continue; + } + const session = decodeSessionRecord(record); + if (!session || !want(session)) continue; + session.registryDir = registryDir; + session.updatedAtMs = (await stat(path).catch(() => null))?.mtimeMs ?? null; + if (!(await isLive(session, info))) continue; + // One entry per process: a start-matched record wins over a stale one + // without a start, in whichever login it is. + const index = sessions.findIndex((other) => other.pid === session.pid); + if (index < 0) sessions.push(session); + else if (sessions[index].procStart === null && session.procStart !== null) { + sessions[index] = session; + } + } + } + sessions.sort((a, b) => (b.updatedAtMs ?? -Infinity) - (a.updatedAtMs ?? -Infinity) || a.pid - b.pid); + return { sessions, unreadable, info }; +} + +/** + * The live session with this id, looked up afresh (its pid and socket change + * when it is resumed in a new process), or `null`. + */ +export async function findClaudeSession({ home = os.homedir(), env = process.env, sessionId }) { + if (!isUuid(sessionId)) return null; + const { sessions } = await readRegistry({ + home, + env, + want: (session) => session.sessionId === sessionId, + }); + return sessions.find((session) => session.sessionId === sessionId) ?? null; +} + +/** + * The Claude Code session the process `pid` runs inside (local.rs + * `caller_target`): the nearest ancestor, `pid` itself included, of this + * user's that is a live registered session with a recorded start time that + * matches. A running session whose own `.json` does not parse ends the + * walk, so an outer session is never named instead. Returns the session id + * or `null`. + */ +export async function claudeCallerSession({ home = os.homedir(), env = process.env, pid }) { + const uid = process.getuid?.(); + const chain = await ancestors(pid, MAX_ANCESTRY); + const pids = new Set(chain.map((entry) => entry.pid)); + const { sessions, unreadable } = await readRegistry({ + home, + env, + want: (session) => pids.has(session.pid), + wantPid: (candidate) => pids.has(candidate), + }); + for (const entry of chain) { + const own = uid === undefined || entry.uid === uid; + const session = sessions.find( + (candidate) => + own && + candidate.pid === entry.pid && + candidate.procStart !== null && + claudeStartMatches(candidate.procStart, entry.start) + ); + if (session) { + // Still the same process after the registry was read. + const again = await processInfo(entry.pid); + const same = + again !== null && + again.start === entry.start && + again.parent === entry.parent && + again.uid === entry.uid; + return same ? session.sessionId : null; + } + // A running session whose own registry file does not parse is the + // caller's nearest session: the walk ends here. + if (own && unreadable.some((file) => file.pid === entry.pid)) return null; + } + return null; +} + +/** + * The inbox token from the session's `..key` whose recorded + * start matches the session's (claude.rs `peer_token`). A key that records + * no start is used only when it is the pid's one key. Never logged. + */ +export async function peerToken(session) { + const keys = []; + for (const record of await keyRecords(session.registryDir, session.pid)) { + const token = record?.peerToken; + if (typeof token !== 'string' || token === '') continue; + keys.push({ token, start: recordedStart(record.procStart) }); + } + if (session.procStart !== null) { + const matching = keys.find((key) => key.start === session.procStart); + if (matching) return matching.token; + } + if (keys.length === 1) { + const [only] = keys; + if (only.start === null) return only.token; + if (session.procStart === null) return only.token; + } + return null; +} + +/** + * The two NDJSON lines of one delivery (claude.rs `peer_frames`), keys in + * the order serde_json writes them: + * ```text + * {"token":"","type":"auth"} + * {"message":{"content":"…","role":"user"},"msg_id":…,"priority":"next","type":"user","uuid":…} + * ``` + */ +export function peerFrames(token, messageId, text) { + const auth = Buffer.from(`${JSON.stringify({ token, type: 'auth' })}\n`); + const message = Buffer.from( + `${JSON.stringify({ + message: { content: text, role: 'user' }, + msg_id: messageId, + priority: 'next', + type: 'user', + uuid: randomUUID().toUpperCase(), + })}\n` + ); + if (auth.length > MAX_LINE_BYTES) { + return { error: "the session's inbox key is too long to send" }; + } + if (message.length > MAX_LINE_BYTES) { + return { + error: `the message is ${message.length} bytes, past the ${MAX_LINE_BYTES} a session accepts`, + }; + } + return { auth, message }; +} + +function connectSocket(path) { + return net.createConnection(path); +} + +/** Writes `bytes`; resolves `null` once handed to the kernel, else the error. */ +function writeAll(socket, bytes, state) { + return new Promise((resolve) => { + let settled = false; + const finish = (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.off('error', onError); + socket.off('close', onClose); + resolve(error ?? null); + }; + const onError = (error) => finish(error); + const onClose = () => finish(state.error ?? new Error('the connection closed')); + const timer = setTimeout(() => finish(new Error('timed out writing to the socket')), SOCKET_TIMEOUT_MS); + if (state.error || socket.destroyed) { + finish(state.error ?? new Error('the connection closed')); + return; + } + socket.on('error', onError); + socket.on('close', onClose); + try { + socket.write(bytes, (error) => finish(error ?? null)); + } catch (error) { + finish(error); + } + }); +} + +function connected(socket) { + return new Promise((resolve) => { + const timer = setTimeout(() => done(new Error('timed out connecting')), SOCKET_TIMEOUT_MS); + const done = (error) => { + clearTimeout(timer); + socket.off('connect', onConnect); + socket.off('error', onError); + resolve(error ?? null); + }; + const onConnect = () => done(null); + const onError = (error) => done(error); + socket.once('connect', onConnect); + socket.once('error', onError); + }); +} + +/** Lets the session read both lines before the connection closes. */ +function drain(socket) { + return new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(done, DRAIN_TIMEOUT_MS); + socket.once('data', done); + socket.once('end', done); + socket.once('close', done); + socket.once('error', done); + }); +} + +async function writeInbox(socketPath, frames, connect) { + let socket; + try { + socket = connect(socketPath); + } catch (error) { + return outcome('retry', `could not reach the session's inbox: ${error?.message ?? error}`); + } + const state = { error: null }; + socket.on('error', (error) => { + state.error = error; + }); + try { + const refused = await connected(socket); + if (refused) { + return outcome('retry', `could not reach the session's inbox: ${refused.message}`); + } + // A write can fail after part of it went out, so only a failure before + // any of the message line was handed to the kernel is certain. + const authError = await writeAll(socket, frames.auth, state); + if (authError) { + return outcome('retry', `could not reach the session's inbox: ${authError.message}`); + } + const messageError = await writeAll(socket, frames.message, state); + if (messageError) { + return outcome( + 'in_doubt', + `the session's inbox connection broke while sending: ${messageError.message}` + ); + } + socket.end(); + await drain(socket); + return outcome('delivered', "handed to the Claude Code session's inbox"); + } finally { + socket.destroy(); + } +} + +/** + * Writes `text` into the Claude Code session's inbox (claude.rs + * `send_to_terminal`). `delivered` means the bytes were handed over, not + * that Claude read or ran them. Claude's inbox gives no read-back, so a + * repeat after a crash is possible; `msg_id` is the Relaycast message id in + * case Claude dedupes on it. + */ +export async function injectClaude({ + home = os.homedir(), + env = process.env, + sessionId, + messageId, + text, + connect = connectSocket, +}) { + if (!isUuid(sessionId)) return outcome('refused', 'invalid session id'); + const session = await findClaudeSession({ home, env, sessionId }); + if (!session) return outcome('waiting', 'that Claude Code session is not running'); + if (session.peerProtocol !== KNOWN_PEER_PROTOCOL) { + return outcome( + 'retry', + `that Claude Code session speaks peer protocol ${session.peerProtocol}, which this app does not know` + ); + } + const token = await peerToken(session); + if (!token) return outcome('retry', "could not read that Claude Code session's inbox key"); + const frames = peerFrames(token, messageId, text); + if (frames.error) return outcome('retry', frames.error); + return writeInbox(session.messagingSocketPath, frames, connect); +} + +/** + * Whether the session can take a message now, without writing to its + * socket: the record is live, it speaks peer protocol 1 and its key is + * readable. + */ +export async function claudePreflight({ home = os.homedir(), env = process.env, sessionId }) { + if (!isUuid(sessionId)) return { ok: false, reason: 'invalid session id' }; + const session = await findClaudeSession({ home, env, sessionId }); + if (!session) return { ok: false, reason: 'that Claude Code session is not running' }; + if (session.peerProtocol !== KNOWN_PEER_PROTOCOL) { + return { + ok: false, + reason: `that Claude Code session speaks peer protocol ${session.peerProtocol}, which this app does not know`, + }; + } + if (!(await peerToken(session))) { + return { ok: false, reason: "could not read that Claude Code session's inbox key" }; + } + return { ok: true, reason: "the Claude Code session's inbox is ready" }; +} + +// MARK: crossSessionInbound + +/** `crossSessionInbound` in a settings file's text, or `null`. */ +export function inboundValue(text) { + if (text === null || text === undefined) return null; + try { + const value = JSON.parse(text)?.[INBOUND_KEY]; + return typeof value === 'string' ? value : null; + } catch { + return null; + } +} + +function unreadableSettings(path) { + const error = new Error( + `Your Claude settings file is not valid JSON, so it was left unchanged. Fix it or add "${INBOUND_KEY}": "${ACCEPT}" by hand.` + ); + error.code = 'claude_settings_unreadable'; + error.path = path; + return error; +} + +/** + * The settings text with `crossSessionInbound` set to `value`, every other + * key kept (claude_settings.rs `with_inbound`); an absent or blank file + * starts empty, and anything but a JSON object is refused. + */ +export function withInbound(value, text, path = 'settings.json') { + let object = {}; + if (text !== null && text !== undefined && text.trim() !== '') { + try { + object = JSON.parse(text); + } catch { + throw unreadableSettings(path); + } + if (!object || typeof object !== 'object' || Array.isArray(object)) { + throw unreadableSettings(path); + } + } + object[INBOUND_KEY] = value; + return `${JSON.stringify(object, null, 2)}\n`; +} + +/** `path` with every symlink followed, also when the target does not exist yet. */ +async function resolveSymlinks(path) { + let current = path; + for (let index = 0; index < 40; index += 1) { + const info = await lstat(current).catch(() => null); + if (!info?.isSymbolicLink()) break; + const target = await readlink(current).catch(() => null); + if (target === null) break; + current = target.startsWith('/') ? target : join(dirname(current), target); + } + return current; +} + +async function readOptional(path) { + try { + return await readFile(path, 'utf8'); + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } +} + +/** + * Replaces `path` atomically with `updated` (claude_settings.rs + * `write_keeping_file`): the file's mode is kept (0600 for a new file), the + * parent is created private, and the original is copied once beside it. + */ +async function writeKeepingFile(path, existing, updated) { + const parent = dirname(path); + await mkdir(parent, { recursive: true, mode: 0o700 }); + const mode = ((await stat(path).catch(() => null))?.mode ?? 0o600) & 0o777; + if (existing !== null) { + const backup = `${path}${BACKUP_SUFFIX}`; + if (!(await lstat(backup).catch(() => null))) { + const handle = await open(backup, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, mode); + try { + await handle.writeFile(existing); + } finally { + await handle.close(); + } + } + } + const temporary = join(parent, `.settings.${process.pid}.${randomUUID()}`); + const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); + try { + await handle.writeFile(updated); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await chmod(temporary, mode); + await rename(temporary, path); + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } +} + +function managedError(file, value) { + const error = new Error( + `Managed Claude settings do not allow direct delivery: ${file} sets "${INBOUND_KEY}" to ${JSON.stringify(value)}, so replies cannot be injected; ask your admin.` + ); + error.code = 'claude_inbound_managed'; + error.path = file; + return error; +} + +/** + * Turns direct delivery on for the Claude login (claude_settings.rs + * `set_own_direct_delivery(true)`): `"crossSessionInbound": "accept"` in + * its `settings.json`, every other key kept, the original backed up once to + * `settings.json.relay-desktop-backup`, a symlinked file rewritten at its + * target. Nothing is written when managed settings or the user's own + * already accept. Managed settings that pin another value throw an Error + * with code `claude_inbound_managed` naming the managed file. + * + * `claudeHome` picks a login other than `CLAUDE_CONFIG_DIR`/`~/.claude` + * (the caller session's `registryDir/..`); `managedSettingsPath` replaces + * the system path (tests). + */ +export async function ensureClaudeDirectDelivery({ + home = os.homedir(), + env = process.env, + platform = process.platform, + claudeHome: login, + managedSettingsPath, +} = {}) { + const managedFile = + managedSettingsPath ?? (platform === 'darwin' ? MANAGED_SETTINGS.darwin : MANAGED_SETTINGS.other); + const settingsFile = join(login ?? claudeHome({ home, env }), 'settings.json'); + const managed = async () => inboundValue(await readFile(managedFile, 'utf8').catch(() => null)); + const user = async () => inboundValue(await readFile(settingsFile, 'utf8').catch(() => null)); + const delivers = async () => ((await managed()) ?? (await user())) === ACCEPT; + + const pinned = await managed(); + if (pinned !== null && pinned !== ACCEPT) throw managedError(managedFile, pinned); + if (await delivers()) return { changed: false, path: settingsFile }; + + const path = await resolveSymlinks(settingsFile); + let existing; + try { + existing = await readOptional(path); + } catch (error) { + const failed = new Error(`Could not save your Claude settings: ${error?.message ?? error}`); + failed.code = 'claude_settings_failed'; + throw failed; + } + const updated = withInbound(ACCEPT, existing, settingsFile); + try { + await writeKeepingFile(path, existing, updated); + } catch (error) { + const failed = new Error(`Could not save your Claude settings: ${error?.message ?? error}`); + failed.code = 'claude_settings_failed'; + throw failed; + } + if (!(await delivers())) throw managedError(managedFile, await managed()); + return { changed: true, path: settingsFile }; +} diff --git a/packages/connect/src/relay/cloud.js b/packages/connect/src/relay/cloud.js new file mode 100644 index 000000000..1119b47de --- /dev/null +++ b/packages/connect/src/relay/cloud.js @@ -0,0 +1,347 @@ +import { readFileSync } from 'node:fs'; + +// Cloud and Relaycast calls for a Connect participant, matching the desktop +// probe (relay-desktop probe/src/relay/connect.rs, client.rs, sdk_gaps.rs). +// Errors carry an HTTP status (null when nothing answered), a code, and a +// credential-free cause for the log. + +const JOIN_TIMEOUT_MS = 45_000; +const CLOUD_TIMEOUT_MS = 12_000; +const RELAYCAST_TIMEOUT_MS = 20_000; +const MAX_BODY_BYTES = 4 * 1024 * 1024; + +let packageVersion = '0.0.0'; +try { + packageVersion = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version; +} catch { + // A runtime copy without package.json still works; the version is only a header. +} +export const VERSION = packageVersion; + +export class RelayHttpError extends Error { + constructor({ status = null, code, message, cause, requestId }) { + super(message || code); + this.name = 'RelayHttpError'; + this.status = status; + this.code = code; + this.logCause = cause || (status ? `http_${status}:${code}` : 'transport'); + this.requestId = requestId; + } +} + +/** https, or http on loopback: the only places a credential may be sent. */ +export function sendable(url) { + try { + const parsed = new URL(url); + if (parsed.username || parsed.password) return false; + if (parsed.protocol === 'https:') return true; + return parsed.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname); + } catch { + return false; + } +} + +/** The Cloud base: agentrelay.com, or a loopback http override for local end-to-end tests. */ +export function cloudBase(env = process.env) { + const override = env.AGENT_RELAY_CONNECT_CLOUD_URL; + if (override) { + try { + const url = new URL(override); + const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); + if ((url.protocol === 'http:' && loopback) || url.protocol === 'https:') { + return override.replace(/\/+$/, ''); + } + } catch { + // Fall through to the shipped destination. + } + } + return 'https://agentrelay.com'; +} + +/** The Connect id in a link or bare id (connect.rs `connect_id`). */ +export function connectIdFrom(link) { + let id; + if (typeof link !== 'string') return null; + if (link.includes('://')) { + let url; + try { + url = new URL(link); + } catch { + return null; + } + if (url.protocol !== 'https:' || url.hostname !== 'agentrelay.com') return null; + const parts = url.pathname.split('/').filter(Boolean); + id = parts.at(-1) === 'join' ? parts.at(-2) : parts.at(-1); + } else { + id = link.trim(); + } + return id && id.length <= 128 && /^[A-Za-z0-9_-]+$/.test(id) ? id : null; +} + +function transportCause(error) { + if (error?.name === 'TimeoutError' || error?.name === 'AbortError') return 'timeout'; + const code = error?.cause?.code || error?.code; + if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return 'dns'; + if (code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'EHOSTUNREACH' || code === 'ENETUNREACH') { + return 'connect'; + } + if (typeof code === 'string' && code.startsWith('ERR_TLS')) return 'tls'; + if (typeof code === 'string' && /CERT|SSL/.test(code)) return 'tls'; + return 'transport'; +} + +async function readBody(response) { + const reader = response.body?.getReader(); + if (!reader) return ''; + const chunks = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_BODY_BYTES) { + await reader.cancel().catch(() => {}); + throw Object.assign(new Error('oversized'), { code: 'oversized' }); + } + chunks.push(Buffer.from(value)); + } + return Buffer.concat(chunks).toString('utf8'); +} + +/** + * One JSON request. Resolves `{status, body}` for any HTTP answer; rejects a + * RelayHttpError with `status: null` when nothing usable answered. + */ +export async function requestJson( + url, + { method = 'GET', token, headers = {}, body, timeoutMs, fetchImpl = fetch } = {} +) { + if (!sendable(url)) { + throw new RelayHttpError({ + code: 'connect_unreachable', + message: 'Refusing to send to this address.', + cause: 'unsendable_url', + }); + } + const finalHeaders = { + accept: 'application/json', + 'user-agent': `AgentRelayConnect/${VERSION}`, + ...headers, + }; + if (token) finalHeaders.authorization = `Bearer ${token}`; + if (body !== undefined) finalHeaders['content-type'] = 'application/json'; + let response; + try { + response = await fetchImpl(url, { + method, + headers: finalHeaders, + body: body === undefined ? undefined : JSON.stringify(body), + redirect: 'manual', + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (error) { + const cause = transportCause(error); + throw new RelayHttpError({ + code: cause === 'timeout' ? 'timeout' : 'connect_unreachable', + message: cause === 'timeout' ? 'The request timed out.' : 'Could not be reached.', + cause, + }); + } + let text; + try { + text = await readBody(response); + } catch (error) { + throw new RelayHttpError({ + code: 'connect_unreachable', + message: 'The answer could not be read.', + cause: + error?.code === 'oversized' ? 'oversized' : transportCause(error) === 'timeout' ? 'timeout' : 'read', + }); + } + let parsed = null; + if (text.trim()) { + try { + parsed = JSON.parse(text); + } catch { + parsed = null; + } + } + return { + status: response.status, + body: parsed, + requestId: response.headers.get('x-request-id') || undefined, + }; +} + +function answerError(answer, fallbackCode, fallbackMessage) { + const error = answer.body?.error || {}; + const code = typeof error.code === 'string' && error.code ? error.code : fallbackCode; + const message = + typeof error.message === 'string' && + error.message && + error.message.length <= 300 && + !error.message.includes('\n') + ? error.message + : fallbackMessage; + return new RelayHttpError({ status: answer.status, code, message, requestId: answer.requestId }); +} + +function ok(answer) { + return answer.status >= 200 && answer.status < 300; +} + +function inner(body) { + return body && typeof body === 'object' && body.data !== undefined && body.data !== null ? body.data : body; +} + +// Cloud ------------------------------------------------------------------------------- + +export async function cloudJoin({ connectId, name, hostClaim, idempotencyKey, env, fetchImpl }) { + const body = {}; + if (name) body.agent_name = name; + if (hostClaim) body.host_claim = hostClaim; + let answer; + try { + answer = await requestJson(`${cloudBase(env)}/cloud/connect/${connectId}/join`, { + method: 'POST', + headers: { 'idempotency-key': idempotencyKey }, + body, + timeoutMs: JOIN_TIMEOUT_MS, + fetchImpl, + }); + } catch (error) { + if (error.code === 'timeout') { + throw new RelayHttpError({ + status: 504, + code: 'connect_join_timeout', + message: 'Relay Connect join timed out. Retry the same join; it is safe to retry.', + cause: 'timeout', + }); + } + throw new RelayHttpError({ + status: 503, + code: 'connect_unreachable', + message: 'Relay Connect could not be reached. Retry the same join; it is safe to retry.', + cause: error.logCause, + }); + } + if (!ok(answer)) throw answerError(answer, 'connect_join_failed', 'Could not join this Connect.'); + const data = inner(answer.body); + const participant = data?.participant; + const valid = + data && + typeof data.connect_id === 'string' && + typeof data.expires_at === 'string' && + !Number.isNaN(Date.parse(data.expires_at)) && + participant && + typeof participant.token === 'string' && + participant.token && + typeof participant.agent_id === 'string' && + participant.agent_id && + typeof participant.agent_name === 'string' && + sendable(participant.relay_base_url || '') && + Array.isArray(data.participants) && + data.participants.some((entry) => entry?.agent_name === participant.agent_name); + if (!valid) { + throw new RelayHttpError({ + status: 502, + code: 'invalid_join_response', + message: 'Relay Connect returned an incomplete join response.', + cause: 'invalid_join_response', + }); + } + return data; +} + +/** Whether Cloud still lists the Connect: `{active:true}`, `{gone:{code,message}}`, or throws. */ +export async function cloudAvailability({ connectId, env, fetchImpl }) { + let answer; + try { + answer = await requestJson(`${cloudBase(env)}/connect/${connectId}`, { + timeoutMs: CLOUD_TIMEOUT_MS, + fetchImpl, + }); + } catch (error) { + throw new RelayHttpError({ + status: 503, + code: 'connect_unreachable', + message: 'Relay Connect could not be reached to check whether this Connect is still open. Retry once.', + cause: error.logCause, + }); + } + if (ok(answer)) return { active: true }; + const fallback = answer.status === 404 ? 'connect_not_found' : answer.status === 410 ? 'connect_ended' : ''; + const code = answer.body?.error?.code || fallback; + if ( + [404, 410].includes(answer.status) && + ['connect_not_found', 'connect_ended', 'connect_expired'].includes(code) + ) { + const messages = { + connect_expired: 'This Connect has expired.', + connect_not_found: 'This Connect was not found.', + connect_ended: 'This Connect has ended.', + }; + return { gone: { code, message: answerError(answer, code, messages[code]).message } }; + } + throw new RelayHttpError({ + status: 502, + code: 'connect_upstream_error', + message: `Relay Connect answered HTTP ${answer.status} when checking whether this Connect is still open. Retry once.`, + cause: code ? `http_${answer.status}:${code}` : `http_${answer.status}`, + }); +} + +/** Tells Cloud this participant left, freeing its name. Best effort; resolves the answer. */ +export async function cloudLeave({ connectId, token, env, fetchImpl }) { + const answer = await requestJson(`${cloudBase(env)}/cloud/connect/${connectId}/leave`, { + method: 'POST', + token, + timeoutMs: CLOUD_TIMEOUT_MS, + fetchImpl, + }); + if (ok(answer)) return inner(answer.body); + throw answerError(answer, 'connect_leave_failed', 'Relay Connect could not record the leave.'); +} + +// Relaycast, as the participant ------------------------------------------------------------ + +export function relaycast({ baseUrl, token, fetchImpl }) { + const base = baseUrl.replace(/\/+$/, ''); + const call = async (method, path, { body, headers, timeoutMs = RELAYCAST_TIMEOUT_MS } = {}) => { + const answer = await requestJson(`${base}${path}`, { + method, + token, + body, + headers: { + 'x-relaycast-origin-client': 'agent-relay-connect', + 'x-relaycast-origin-version': VERSION, + ...headers, + }, + timeoutMs, + fetchImpl, + }); + if (!ok(answer)) + throw answerError(answer, 'relaycast_error', `Relaycast answered HTTP ${answer.status}.`); + return inner(answer.body); + }; + return { + dm: (to, text, idempotencyKey) => + call('POST', '/v1/dm', { + body: { to, text, mode: 'wait' }, + headers: idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}, + }), + agents: () => call('GET', '/v1/agents?status=all'), + deliveries: () => call('GET', '/v1/deliveries?limit=50'), + ack: (id) => call('POST', `/v1/deliveries/${encodeURIComponent(id)}/ack`), + fail: (id, error) => + call('POST', `/v1/deliveries/${encodeURIComponent(id)}/fail`, { + body: { error: String(error).slice(0, 500), retryable: false }, + }), + heartbeat: () => call('POST', '/v1/agents/heartbeat', { body: {} }), + disconnect: () => call('POST', '/v1/agents/disconnect', { body: {} }), + }; +} + +export function isAuthRejection(error) { + return error?.status === 401 || error?.status === 403; +} diff --git a/packages/connect/src/relay/codex.js b/packages/connect/src/relay/codex.js new file mode 100644 index 000000000..2be16d7a4 --- /dev/null +++ b/packages/connect/src/relay/codex.js @@ -0,0 +1,1191 @@ +import { spawn } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { accessSync, constants, readdirSync, realpathSync, statSync } from 'node:fs'; +import { open, readdir } from 'node:fs/promises'; +import net from 'node:net'; +import os from 'node:os'; +import { join } from 'node:path'; +import { argvOf, isTrustedCodex, listProcesses, openFiles } from './processes.js'; +import { queryReadOnly, sqliteBackend } from './sqlite.js'; + +// Codex threads and the routes a message takes into them, ported from +// relay-desktop probe/src/relay/codex.rs and codex/{router,queue,rollout, +// ipc,processes,threads}.rs. +// +// A message is delivered when the thread's rollout records it, or when a +// Codex app window that owns the thread confirms the turn. A live terminal +// Codex gets every message through its own queue (`codex queue`); that +// handoff is not delivery and stays accepted until the rollout records it. + +const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + +/** queue.rs `is_thread_id` (store.rs `is_uuid`). */ +export function isThreadId(value) { + return typeof value === 'string' && UUID.test(value); +} + +function outcome(kind, detail) { + return { outcome: kind, detail }; +} + +// MARK: Message reference (delivery/policy.rs MessageRef) + +/** The first 12 hex digits of sha256(message id). */ +export function messageRef(messageId) { + return createHash('sha256').update(String(messageId), 'utf8').digest('hex').slice(0, 12); +} + +const REF = /^[0-9a-f]{12}$/; + +/** + * The reference carried by `text`, if its first line is a relay attribution + * (`[via Agent Relay …]` or `[Relay Connect — from · ref ]`). + */ +export function messageRefInText(text) { + if (typeof text !== 'string') return null; + if (!(text.startsWith('[via Agent Relay') || text.startsWith('[Relay Connect '))) return null; + let first = text.split('\n', 1)[0]; + if (first.endsWith('\r')) first = first.slice(0, -1); + if (!first.endsWith(']')) return null; + const inner = first.slice(0, -1); + const marker = inner.lastIndexOf('ref '); + if (marker < 0) return null; + const candidate = inner.slice(marker + 4); + return REF.test(candidate) ? candidate : null; +} + +// MARK: Paths and the codex binary (codex.rs) + +function expand(value, home) { + if (value === '~') return home; + if (value.startsWith('~/')) return join(home, value.slice(2)); + return value; +} + +function setting(env, key) { + const value = env?.[key]; + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed === '' ? null : trimmed; +} + +/** `{codexHome, sqliteHome, ipcSocket}`: `CODEX_HOME` else `~/.codex`; `CODEX_SQLITE_HOME` else Codex home. */ +export function codexPaths({ home = os.homedir(), env = process.env } = {}) { + const configured = setting(env, 'CODEX_HOME'); + const codexHome = configured ? expand(configured, home) : join(home, '.codex'); + const sqlite = setting(env, 'CODEX_SQLITE_HOME'); + return { + codexHome, + sqliteHome: sqlite ? expand(sqlite, home) : codexHome, + ipcSocket: join(codexHome, 'ipc/ipc.sock'), + }; +} + +function isExecutableFile(path) { + try { + if (!statSync(path).isFile()) return false; + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +/** A version manager's shim re-resolves `codex` per directory; only a real install is used. */ +function isShim(path) { + const shim = (value) => value.includes('/mise/shims/'); + if (shim(path)) return true; + try { + return shim(realpathSync(path)); + } catch { + return false; + } +} + +function versionKey(version) { + const trimmed = (version ?? '').replace(/^v+/, ''); + const dash = trimmed.indexOf('-'); + const release = dash < 0 ? trimmed : trimmed.slice(0, dash); + const prerelease = dash < 0 ? '' : trimmed.slice(dash + 1); + return { + numbers: release.split('.').map((part) => (/^\d+$/.test(part) ? Number(part) : 0)), + isRelease: prerelease === '', + prerelease, + }; +} + +function compareVersionKeys(a, b) { + const length = Math.max(a.numbers.length, b.numbers.length); + for (let index = 0; index < length; index += 1) { + // Rust compares Vec lexicographically: a shorter prefix is smaller. + if (index >= a.numbers.length) return -1; + if (index >= b.numbers.length) return 1; + if (a.numbers[index] !== b.numbers[index]) return a.numbers[index] - b.numbers[index]; + } + if (a.isRelease !== b.isRelease) return a.isRelease ? 1 : -1; + return a.prerelease < b.prerelease ? -1 : a.prerelease > b.prerelease ? 1 : 0; +} + +/** `//bin/codex` for every version directory, newest first. */ +function versions(root) { + let names = []; + try { + names = readdirSync(root); + } catch { + return []; + } + return names + .map((name) => ({ name, key: versionKey(name) })) + .sort((a, b) => compareVersionKeys(b.key, a.key)) + .map(({ name }) => join(root, name, 'bin/codex')); +} + +const SYSTEM_CODEX = { + darwin: [ + '/Applications/ChatGPT.app/Contents/Resources/codex', + '/Applications/Codex.app/Contents/Resources/codex', + ], + shared: ['/opt/homebrew/bin/codex', '/usr/local/bin/codex'], +}; + +/** + * Where a `codex` may be installed, in order (codex.rs + * `executable_candidates`): the Codex desktop app's own binary, mise, the + * user's bin, nvm installs, Homebrew, then `PATH`; never a shim. + * `system` replaces the fixed system locations (tests). + */ +export function codexCandidates({ + home = os.homedir(), + env = process.env, + platform = process.platform, + system, +} = {}) { + const apps = system ? [] : platform === 'darwin' ? SYSTEM_CODEX.darwin : []; + const tail = system ?? SYSTEM_CODEX.shared; + const mise = join(home, '.local/share/mise/installs/codex'); + const candidates = [ + ...apps, + join(mise, 'latest/bin/codex'), + join(home, '.local/bin/codex'), + ...versions(mise), + ...versions(join(home, '.nvm/versions/node')), + ...tail, + ]; + const path = env?.PATH; + if (typeof path === 'string') { + for (const directory of path.split(':')) { + if (directory.startsWith('/')) candidates.push(join(directory, 'codex')); + } + } + return candidates.filter((candidate) => !isShim(candidate)); +} + +/** + * The `codex` to run: `RELAY_DESKTOP_CODEX` (exactly that file, or none), + * else the first installed candidate. + */ +export function codexExecutable(options = {}) { + const { home = os.homedir(), env = process.env } = options; + const explicit = setting(env, 'RELAY_DESKTOP_CODEX'); + if (explicit) { + const path = expand(explicit, home); + return isExecutableFile(path) && !isShim(path) ? path : null; + } + return codexCandidates(options).find((candidate) => isExecutableFile(candidate)) ?? null; +} + +// MARK: SQLite reads (threads.rs, queue.rs) + +/** + * The thread's row id in Codex's spelling (threads.rs `read_one`): missing, + * archived and sub-agent threads are not found. `undefined` when the + * catalog cannot be read at all. + */ +async function readThreadId(paths, id) { + if (!isThreadId(id)) return null; + const file = join(paths.sqliteHome, 'state_5.sqlite'); + const columns = await queryReadOnly(file, 'pragma table_info(threads)'); + if (columns.status !== 'ok') return undefined; + const names = new Set(columns.rows.map((row) => row.name)); + if (!['id', 'title', 'archived', 'source'].every((column) => names.has(column))) return undefined; + const found = await queryReadOnly( + file, + "select id from threads where id = ? collate nocase and archived = 0 and source in ('cli', 'vscode', 'exec') limit 1", + [id] + ); + if (found.status !== 'ok') return undefined; + return typeof found.rows[0]?.id === 'string' ? found.rows[0].id : null; +} + +/** The thread's recorded rollout path (threads.rs `rollout_column`). */ +async function rolloutColumn(paths, id) { + const found = await queryReadOnly( + join(paths.sqliteHome, 'state_5.sqlite'), + 'select rollout_path as rollout_path from threads where id = ? collate nocase', + [id] + ); + if (found.status !== 'ok') return null; + const path = found.rows[0]?.rollout_path; + return typeof path === 'string' && path !== '' ? path : null; +} + +/** The reference of a relay-created queue item (queue.rs `owned_ref`). */ +export function ownedRef(payloadJson) { + const find = (value) => { + if (typeof value === 'string') return messageRefInText(value); + if (Array.isArray(value)) { + for (const item of value) { + const found = find(item); + if (found) return found; + } + return null; + } + if (value && typeof value === 'object') { + for (const item of Object.values(value)) { + const found = find(item); + if (found) return found; + } + } + return null; + }; + try { + return find(JSON.parse(payloadJson)); + } catch { + return messageRefInText(payloadJson); + } +} + +/** + * The relay's items `sql` selects as `(id, payload_json)`: `false`/`true` + * for whether any matches `keep`, `null` when the queue exists but cannot + * be read. No queue database means nothing is queued; no SQLite reader on + * this computer reads as nothing queued too, leaving the rollout and the + * 45 s rule to decide (docs/connect-node-local-relay.md, "SQLite"). + */ +async function queueHas(paths, sql, params, keep) { + const result = await queryReadOnly(join(paths.sqliteHome, 'queue_1.sqlite'), sql, params); + if (result.status === 'missing' || result.status === 'unavailable') return false; + if (result.status !== 'ok') return null; + return result.rows.some((row) => { + if (typeof row.payload_json !== 'string') return false; + const owned = ownedRef(row.payload_json); + return owned !== null && keep(owned); + }); +} + +/** Whether the relay's queue item carrying `ref` still waits (queue.rs `is_waiting`). */ +function queueWaiting(paths, thread, ref) { + return queueHas( + paths, + 'select id, payload_json from queued_items where thread_id = ? collate nocase and instr(payload_json, ?) > 0', + [thread, `ref ${ref}]`], + (owned) => owned === ref + ); +} + +/** Whether any relay message still waits in the thread's queue (queue.rs `relay_waiting`). */ +function relayWaiting(paths, thread) { + return queueHas( + paths, + 'select id, payload_json from queued_items where thread_id = ? collate nocase', + [thread], + () => true + ); +} + +// MARK: Rollout (rollout.rs) + +const MARKERS = [ + '"task_started"', + '"task_complete"', + '"turn_aborted"', + '"client_id"', + '"item_completed"', +].map((marker) => Buffer.from(marker)); + +class RolloutSummary { + constructor() { + this.activeTurn = null; + this.outcomes = new Map(); + this.turnByClientMessage = new Map(); + this.turnByMessageRef = new Map(); + this.relayTurns = new Set(); + } + + clone() { + const copy = new RolloutSummary(); + copy.activeTurn = this.activeTurn; + copy.outcomes = new Map(this.outcomes); + copy.turnByClientMessage = new Map(this.turnByClientMessage); + copy.turnByMessageRef = new Map(this.turnByMessageRef); + copy.relayTurns = new Set(this.relayTurns); + return copy; + } + + consume(line) { + if (!MARKERS.some((marker) => line.includes(marker))) return; + let row; + try { + row = JSON.parse(line.toString('utf8')); + } catch { + return; + } + if (row?.type !== 'event_msg') return; + const payload = row.payload ?? {}; + const turn = payload.turn_id; + if (typeof turn !== 'string') return; + const item = payload.item ?? {}; + switch (payload.type) { + case 'task_started': + this.activeTurn = turn; + break; + case 'task_complete': + this.finish(turn, { completed: true, reply: payload.last_agent_message ?? null }); + break; + case 'turn_aborted': + this.finish(turn, { completed: false, reason: payload.reason ?? null }); + break; + case 'item_completed': + if (item.type === 'UserMessage') { + if (typeof item.client_id === 'string') this.turnByClientMessage.set(item.client_id, turn); + const contents = Array.isArray(item.content) ? item.content : []; + const reference = contents + .map((content) => content?.text) + .filter((text) => typeof text === 'string') + .map(messageRefInText) + .find(Boolean); + if (reference) { + this.relayTurns.add(turn); + this.turnByMessageRef.set(reference, turn); + } + } else if (item.type === 'HookPrompt') { + const fragments = Array.isArray(item.fragments) ? item.fragments : []; + const reference = fragments + .map((fragment) => fragment?.text) + .filter((text) => typeof text === 'string') + .map(messageRefInText) + .find(Boolean); + if (reference) this.turnByMessageRef.set(reference, turn); + } + break; + default: + break; + } + } + + finish(turn, result) { + if (this.activeTurn === turn) this.activeTurn = null; + this.outcomes.set(turn, result); + } +} + +const CACHED_ROLLOUTS = 256; +const rollouts = new Map(); + +function lines(buffer) { + const found = []; + let start = 0; + for (let index = buffer.indexOf(10); index >= 0; index = buffer.indexOf(10, start)) { + found.push(buffer.subarray(start, index)); + start = index + 1; + } + return found; +} + +/** + * The summary of the rollout at `path` (rollout.rs `RolloutSummary::load`). + * Codex only appends, so what was read before is kept and only the bytes + * written since are parsed; a replaced or shortened file is read again. + */ +async function loadRollout(path) { + const handle = await open(path, 'r'); + try { + const info = await handle.stat(); + const cached = rollouts.get(path); + rollouts.delete(path); + const entry = + cached && cached.dev === info.dev && cached.ino === info.ino && cached.offset <= info.size + ? cached + : { dev: info.dev, ino: info.ino, offset: 0, summary: new RolloutSummary() }; + const length = Math.max(0, info.size - entry.offset); + const added = Buffer.alloc(length); + let read = 0; + while (read < length) { + const { bytesRead } = await handle.read(added, read, length - read, entry.offset + read); + if (bytesRead === 0) break; + read += bytesRead; + } + const bytes = added.subarray(0, read); + const complete = bytes.lastIndexOf(10) + 1; + for (const line of lines(bytes.subarray(0, complete))) entry.summary.consume(line); + entry.offset += complete; + const summary = entry.summary.clone(); + // A line still being written counts for this answer only. + summary.consume(bytes.subarray(complete)); + if (rollouts.size >= CACHED_ROLLOUTS) rollouts.clear(); + rollouts.set(path, entry); + return summary; + } finally { + await handle.close(); + } +} + +const SESSION_DEPTH = 4; + +async function findRollout(directory, suffix, depth) { + if (depth > SESSION_DEPTH) return null; + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return null; + } + const directories = []; + for (const entry of entries) { + if (entry.isFile() && entry.name.startsWith('rollout-') && entry.name.toLowerCase().endsWith(suffix)) { + return join(directory, entry.name); + } + if (entry.isDirectory()) directories.push(join(directory, entry.name)); + } + directories.sort().reverse(); + for (const child of directories) { + const found = await findRollout(child, suffix, depth + 1); + if (found) return found; + } + return null; +} + +/** + * Where Codex keeps the thread's rollout: the thread row's `rollout_path`, + * else `sessions/YYYY/MM/DD/rollout-*-.jsonl` (rollout.rs + * `rollout_path`). + */ +export async function rolloutPath(paths, thread) { + if (!isThreadId(thread)) return null; + const column = await rolloutColumn(paths, thread); + if (column) return column; + return findRollout(join(paths.codexHome, 'sessions'), `-${thread.toLowerCase()}.jsonl`, 0); +} + +async function recorded(paths, thread, ref) { + const path = await rolloutPath(paths, thread); + if (!path) return false; + try { + return (await loadRollout(path)).turnByMessageRef.has(ref); + } catch { + return false; + } +} + +/** No turn running. A thread with no rollout yet is idle; one that cannot be read is not. */ +async function idle(paths, thread) { + const path = await rolloutPath(paths, thread); + if (!path) return true; + try { + return (await loadRollout(path)).activeTurn === null; + } catch (error) { + return error?.code === 'ENOENT'; + } +} + +// MARK: Caller identification + +/** + * The Codex thread the CLI runs in, from its own `CODEX_THREAD_ID`: a UUID + * whose thread exists under Codex home (a catalog row, or a rollout file). + * Returns `{threadId}` (lowercase), or `{threadId: null, reason}`. + */ +export async function codexCallerThread({ env = process.env, pid: _pid, home = os.homedir() } = {}) { + const raw = typeof env?.CODEX_THREAD_ID === 'string' ? env.CODEX_THREAD_ID.trim() : ''; + if (raw === '') return { threadId: null, reason: 'no Codex thread id in env' }; + const thread = raw.toLowerCase(); + if (!isThreadId(thread)) return { threadId: null, reason: 'CODEX_THREAD_ID is not a thread id' }; + const paths = codexPaths({ home, env }); + if (await readThreadId(paths, thread)) return { threadId: thread }; + if (await rolloutPath(paths, thread)) return { threadId: thread }; + return { + threadId: null, + reason: `Codex has no thread ${thread} under ${paths.codexHome}`, + }; +} + +// MARK: Live terminal (codex/processes.rs terminal_thread_is_live) + +const LOCK = /(?:^|\/)thread-writer-locks\/([0-9a-fA-F-]{36})\.lock$/; + +/** The thread after the last `resume` in `argv`. */ +function resumeArgument(argv) { + const index = argv.lastIndexOf('resume'); + return index < 0 ? null : (argv[index + 1] ?? null); +} + +/** + * Whether an interactive Codex TUI of this user, the one that drains + * `codex queue`, runs `thread` now: it holds the thread's writer lock or + * runs `resume `. Conductor's and Paseo's app-servers also drain + * the queue in the probe; neither is a guest surface, so they are not + * looked for here. + */ +export async function terminalThreadIsLive(thread) { + const wanted = String(thread).toLowerCase(); + if (!isThreadId(wanted)) return false; + const uid = process.getuid?.(); + for (const candidate of await listProcesses()) { + if (uid !== undefined && candidate.uid !== uid) continue; + if (!(await isTrustedCodex(candidate.executable))) continue; + const argv = await argvOf(candidate.pid); + if (argv.some((argument) => argument === 'app-server' || argument === 'exec' || argument === 'queue')) { + continue; + } + if (resumeArgument(argv)?.toLowerCase() === wanted) return true; + const files = await openFiles(candidate.pid); + if (files.some((file) => file.match(LOCK)?.[1]?.toLowerCase() === wanted)) return true; + } + return false; +} + +// MARK: Running codex (codex/runner.rs) + +const OUTPUT_LIMIT = 64 * 1024; + +/** + * Runs `file args` from the temporary directory with no input, in its own + * process group, stopping the group at `timeoutMs`: `{code, stdout, + * stderr}`, `{timedOut: true}` or `{notStarted: reason}`. + */ +export function runCommand(file, args, { timeoutMs = 20_000, env = {} } = {}) { + return new Promise((resolve) => { + let child; + try { + child = spawn(file, args, { + cwd: os.tmpdir(), + env: { ...process.env, ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + }); + } catch (error) { + resolve({ notStarted: String(error?.message ?? error) }); + return; + } + const collect = (stream) => { + const chunks = []; + let size = 0; + stream.on('data', (chunk) => { + if (size >= OUTPUT_LIMIT) return; + chunks.push(chunk.subarray(0, OUTPUT_LIMIT - size)); + size += chunk.length; + }); + return () => Buffer.concat(chunks).toString('utf8'); + }; + const stdout = collect(child.stdout); + const stderr = collect(child.stderr); + let timedOut = false; + const group = (signal) => { + try { + process.kill(-child.pid, signal); + } catch { + // The group is gone. + } + }; + const timer = setTimeout(() => { + timedOut = true; + group('SIGTERM'); + setTimeout(() => group('SIGKILL'), 5_000).unref(); + }, timeoutMs); + child.once('error', (error) => { + clearTimeout(timer); + resolve({ notStarted: String(error?.message ?? error) }); + }); + child.once('close', (code) => { + clearTimeout(timer); + if (timedOut) resolve({ timedOut: true }); + else resolve({ code: code ?? -1, stdout: stdout(), stderr: stderr() }); + }); + }); +} + +/** Only exit code zero and Codex's own confirmation count as queued (queue.rs `outcome`). */ +export function queueOutcome(code, stdout = '', stderr = '') { + const CONFIRMATION = 'Queued message '; + if (code === 0) { + const at = stdout.indexOf(CONFIRMATION); + if (at >= 0) { + const item = stdout.slice(at + CONFIRMATION.length).match(/^\S*/)[0]; + return { queued: true, item: item === '' ? null : item }; + } + } + const source = stderr === '' ? stdout : stderr; + const line = source + .split('\n') + .map((value) => value.trim()) + .find((value) => value !== ''); + if (!line) return { queued: false, reason: `codex exited with code ${code}` }; + return { queued: false, reason: [...line].slice(0, 300).join('') }; +} + +// MARK: Codex app IPC (codex/ipc.rs) + +export const MAX_FRAME_BYTES = 268_435_456; +const CLIENT_TYPE = 'agent-relay-desktop'; +const DISCOVERY_TIMEOUT_MS = 12_000; +const INITIALIZE_TIMEOUT_MS = 5_000; +const START_TURN_TIMEOUT_MS = 30_000; +const METHOD_VERSIONS = { + initialize: 0, + 'thread-owner-discovery': 1, + 'thread-follower-start-turn': 2, +}; + +class IpcError extends Error { + constructor(kind, reason) { + const text = { + not_running: 'The Codex app is not running.', + timed_out: 'The Codex app did not answer in time.', + rejected: `The Codex app refused the message: ${reason}`, + io: `Could not talk to the Codex app: ${reason}`, + }[kind]; + super(text); + this.kind = kind; + this.reason = reason; + } +} + +/** A 4-byte little-endian length, then the payload. */ +export function encodeFrame(payload) { + const bytes = Buffer.isBuffer(payload) ? payload : Buffer.from(payload); + if (bytes.length === 0 || bytes.length > MAX_FRAME_BYTES) { + throw new IpcError('io', `a ${bytes.length}-byte frame`); + } + const header = Buffer.alloc(4); + header.writeUInt32LE(bytes.length, 0); + return Buffer.concat([header, bytes]); +} + +/** Accumulates socket bytes and hands back whole frame payloads. */ +export class FrameBuffer { + constructor() { + this.pending = Buffer.alloc(0); + } + + append(data) { + this.pending = Buffer.concat([this.pending, data]); + const payloads = []; + while (this.pending.length >= 4) { + const length = this.pending.readUInt32LE(0); + if (length === 0 || length > MAX_FRAME_BYTES) throw new Error(`a ${length}-byte frame`); + if (this.pending.length < 4 + length) break; + payloads.push(this.pending.subarray(4, 4 + length)); + this.pending = this.pending.subarray(4 + length); + } + return payloads; + } +} + +function ipcRequest(method, params, source, requestId, target, timeoutMs) { + const request = { + type: 'request', + requestId, + sourceClientId: source, + version: METHOD_VERSIONS[method], + method, + params, + }; + if (target) request.targetClientId = target; + if (timeoutMs !== undefined) request.timeoutMs = timeoutMs; + return request; +} + +function decodeIncoming(payload) { + let value; + try { + value = JSON.parse(payload.toString('utf8')); + } catch { + return null; + } + if (typeof value?.type !== 'string') return null; + const text = (item) => (typeof item === 'string' ? item : null); + return { + kind: value.type, + requestId: text(value.requestId), + method: text(value.method), + resultType: text(value.resultType), + error: text(value.error), + handledByClient: text(value.handledByClientId), + clientId: text(value.result?.clientId), + turnId: text(value.result?.result?.turn?.id), + }; +} + +/** One registered connection to the Codex app's router. */ +class IpcSession { + constructor(socket) { + this.socket = socket; + this.buffer = new FrameBuffer(); + this.queued = []; + this.failure = null; + this.wake = null; + this.clientId = ''; + socket.on('data', (data) => { + try { + this.queued.push(...this.buffer.append(data)); + } catch { + this.failure = new IpcError('io', 'received a malformed frame'); + } + this.notify(); + }); + const closed = () => { + this.failure ??= new IpcError('io', 'the Codex app closed the connection'); + this.notify(); + }; + socket.on('end', closed); + socket.on('close', closed); + socket.on('error', (error) => { + this.failure ??= new IpcError('io', error?.message ?? String(error)); + this.notify(); + }); + } + + static connect(path, connect = (socketPath) => net.createConnection(socketPath)) { + return new Promise((resolve, reject) => { + let socket; + try { + socket = connect(path); + } catch (error) { + reject(new IpcError('io', error?.message ?? String(error))); + return; + } + const onError = (error) => { + socket.destroy(); + reject( + error?.code === 'ENOENT' || error?.code === 'ECONNREFUSED' + ? new IpcError('not_running') + : new IpcError('io', error?.message ?? String(error)) + ); + }; + socket.once('error', onError); + socket.once('connect', () => { + socket.off('error', onError); + const session = new IpcSession(socket); + session.register().then( + () => resolve(session), + (error) => { + session.close(); + reject(error); + } + ); + }); + }); + } + + notify() { + const wake = this.wake; + this.wake = null; + wake?.(); + } + + close() { + this.socket.destroy(); + } + + send(message) { + const frame = encodeFrame(Buffer.from(JSON.stringify(message))); + return new Promise((resolve, reject) => { + if (this.failure) { + reject(this.failure); + return; + } + this.socket.write(frame, (error) => (error ? reject(new IpcError('io', error.message)) : resolve())); + }); + } + + /** The next message, or `null` once `deadline` passes; client discovery is declined here. */ + async nextMessage(deadline) { + for (;;) { + while (this.queued.length > 0) { + const message = decodeIncoming(this.queued.shift()); + if (!message) continue; + if (message.kind === 'client-discovery-request' && message.requestId) { + await this.send({ + type: 'client-discovery-response', + requestId: message.requestId, + response: { canHandle: false }, + }); + continue; + } + return message; + } + if (this.failure) throw this.failure; + const remaining = deadline - Date.now(); + if (remaining <= 0) return null; + await new Promise((resolve) => { + const timer = setTimeout(() => { + this.wake = null; + resolve(); + }, remaining); + this.wake = () => { + clearTimeout(timer); + resolve(); + }; + }); + } + } + + async request(method, params, source, target, timeoutMs, waitMs) { + const id = randomUUID(); + await this.send(ipcRequest(method, params, source, id, target, timeoutMs)); + const deadline = Date.now() + waitMs; + for (;;) { + const message = await this.nextMessage(deadline); + if (!message) throw new IpcError('timed_out'); + if (message.kind === 'response' && message.requestId === id) return message; + } + } + + async register() { + const response = await this.request( + 'initialize', + { clientType: CLIENT_TYPE }, + 'initializing-client', + null, + undefined, + INITIALIZE_TIMEOUT_MS + ); + if (response.resultType === 'success' && response.clientId) { + this.clientId = response.clientId; + return; + } + throw new IpcError('rejected', response.error ?? 'initialize failed'); + } + + /** The window that owns each of `threads`; a thread no window has open is left out. */ + async owners(threads) { + const byRequest = new Map(); + for (const thread of threads) { + const id = randomUUID(); + await this.send( + ipcRequest('thread-owner-discovery', { hostId: 'local', conversationId: thread }, this.clientId, id) + ); + byRequest.set(id, thread); + } + const owners = new Map(); + const deadline = Date.now() + DISCOVERY_TIMEOUT_MS; + while (byRequest.size > 0) { + const message = await this.nextMessage(deadline); + if (!message) break; + if (message.kind !== 'response' || !byRequest.has(message.requestId)) continue; + const thread = byRequest.get(message.requestId); + byRequest.delete(message.requestId); + if (message.resultType === 'success' && message.handledByClient) { + owners.set(thread, message.handledByClient); + } + } + return owners; + } + + async startTurn(thread, text, messageId, owner) { + const started = await this.request( + 'thread-follower-start-turn', + { + conversationId: thread, + turnStart: { + request: { + threadId: thread, + clientUserMessageId: messageId, + input: [{ type: 'text', text, text_elements: [] }], + }, + context: {}, + }, + }, + this.clientId, + owner, + START_TURN_TIMEOUT_MS, + START_TURN_TIMEOUT_MS + 2_000 + ); + if (started.resultType !== 'success') { + throw new IpcError('rejected', started.error ?? 'unknown error'); + } + return started.turnId; + } +} + +/** + * A turn through the Codex app window that owns `thread` (router.rs + * `window_turn`): `{sent: false}` when the app is not running or no window + * owns it, `{sent: true}`, `{refused: reason}` when the app refused, or + * `{inDoubt: reason}` for any other failure after the turn was asked for. + */ +export async function windowTurn(socketPath, thread, text, messageId, connect) { + let attempted = false; + let session; + try { + session = await IpcSession.connect(socketPath, connect); + const owners = await session.owners([thread]); + const owner = owners.get(thread); + if (!owner) return { sent: false }; + attempted = true; + await session.startTurn(thread, text, messageId, owner); + return { sent: true }; + } catch (error) { + if (error instanceof IpcError && error.kind === 'rejected') return { refused: error.reason }; + if (attempted) return { inDoubt: error?.message ?? String(error) }; + return { sent: false }; + } finally { + session?.close(); + } +} + +// MARK: Router (codex/router.rs) + +export const NOT_RUNNING = 'Waiting — session not running'; +export const NOT_CONSUMED = 'Waiting — terminal session has not consumed the message'; +export const BEHIND_TURN = "Waiting — queued behind the session's active turn"; +export const HANDED_NOT_RUNNING = 'Waiting — handed off; session not running'; +export const RECORDING = 'Waiting — Codex is recording the message'; +export const BUSY = 'Waiting — session busy'; +export const QUEUE_UNREADABLE = 'Waiting — Codex queue state is unavailable'; + +/** How long a handoff is given to show up in the rollout. */ +export const HANDOFF_GRACE_MS = 45_000; +/** How long a handoff is remembered at all. */ +const HANDOFF_MEMORY_MS = 60 * 60 * 1_000; +const QUEUE_TIMEOUT_MS = 20_000; +const SUPPORT_TIMEOUT_MS = 10_000; + +/** + * Delivers into Codex threads, remembering its handoffs in memory. + * + * Options: `home`, `env` (CODEX_HOME, CODEX_SQLITE_HOME, RELAY_DESKTOP_CODEX, + * PATH), `platform`, `run(file, args, {timeoutMs, env})` returning + * `{code, stdout, stderr}` / `{timedOut}` / `{notStarted}`, `now()` in ms, + * and for tests `systemCodex` (replaces the fixed install locations), + * `terminalLive(thread)` and `connectIpc(path)`. + */ +export function createCodexRouter({ + home = os.homedir(), + env = process.env, + platform = process.platform, + run = runCommand, + now = Date.now, + systemCodex, + terminalLive = terminalThreadIsLive, + connectIpc, +} = {}) { + const paths = codexPaths({ home, env }); + const handedOff = new Map(); + const queueSupport = new Map(); + + const runIn = async (file, args, timeoutMs) => { + try { + const result = await run(file, args, { + timeoutMs, + env: { CODEX_HOME: paths.codexHome, CODEX_SQLITE_HOME: paths.sqliteHome }, + }); + return result ?? { notStarted: 'no result' }; + } catch (error) { + return { notStarted: String(error?.message ?? error) }; + } + }; + + const executable = () => codexExecutable({ home, env, platform, system: systemCodex }); + + /** Asks the binary itself (`codex queue --help`), once per build. */ + const supportsQueue = async (file) => { + let modified = null; + try { + modified = statSync(file).mtimeMs; + } catch { + // Asked anyway; not remembered under a stale key. + } + const key = `${file}\0${modified}`; + if (queueSupport.has(key)) return queueSupport.get(key); + const result = await runIn(file, ['queue', '--help'], SUPPORT_TIMEOUT_MS); + if (typeof result.code !== 'number') return false; + queueSupport.set(key, result.code === 0); + return result.code === 0; + }; + + /** The thread id as Codex's own database spells it. */ + const spelled = async (thread) => (await readThreadId(paths, thread)) || thread; + + const prune = (at) => { + for (const [id, seen] of handedOff) { + if (at - seen >= HANDOFF_MEMORY_MS) handedOff.delete(id); + } + }; + + const remember = (messageId) => { + const at = now(); + handedOff.set(messageId, at); + prune(at); + }; + + const rememberOnce = (messageId) => { + const at = now(); + if (!handedOff.has(messageId)) handedOff.set(messageId, at); + prune(at); + }; + + const recentlyHandedOff = (messageId) => { + const at = handedOff.get(messageId); + return at !== undefined && now() - at < HANDOFF_GRACE_MS; + }; + + const missingHandoffOutcome = (messageId) => { + const at = now(); + const seen = handedOff.get(messageId); + if (seen !== undefined && at - seen >= HANDOFF_GRACE_MS) { + handedOff.delete(messageId); + return outcome( + 'in_doubt', + 'Codex accepted this handoff, but it is no longer queued and was not recorded' + ); + } + if (seen === undefined) { + handedOff.set(messageId, at); + prune(at); + } + return outcome('accepted', RECORDING); + }; + + const delivered = (messageId, detail) => { + handedOff.delete(messageId); + // TODO(phase 2): the probe also removes the relay's own queue copy of a + // message that arrived another way (queue.rs `remove_owned`). This + // client opens Codex's databases read-only, so that copy is left for + // Codex; the rollout, not the queue, decides delivery. + return outcome('delivered', detail); + }; + + const handed = async (thread, messageId) => { + remember(messageId); + if (!(await terminalLive(thread))) return outcome('accepted', HANDED_NOT_RUNNING); + if (await idle(paths, thread)) return outcome('accepted', NOT_CONSUMED); + return outcome('waiting_for_turn', BEHIND_TURN); + }; + + const queueSend = async (thread, text) => { + if (!isThreadId(thread)) return { error: 'failed', reason: 'not a Codex thread id' }; + // TODO: the probe queues a Conductor chat's thread with Conductor's own + // bundled Codex (processes.rs `conductor_executable`); not ported. + const file = executable(); + if (!file || !(await supportsQueue(file))) return { error: 'not_installed' }; + const result = await runIn( + file, + ['queue', '--thread', await spelled(thread), `--message=${text}`], + QUEUE_TIMEOUT_MS + ); + if (result.timedOut) return { error: 'timed_out' }; + if (typeof result.code !== 'number') return { error: 'not_installed' }; + const parsed = queueOutcome(result.code, result.stdout ?? '', result.stderr ?? ''); + return parsed.queued ? { ok: true } : { error: 'failed', reason: parsed.reason }; + }; + + const handoffToTerminal = async (thread, messageId, text, ref, queued) => { + // The queue cannot say whether an earlier handoff is still there: + // another one could run the message twice. + if (queued === null) return outcome('waiting', QUEUE_UNREADABLE); + const sent = await queueSend(thread, text); + if (sent.ok) return handed(thread, messageId); + if (sent.error === 'timed_out') { + if ((await queueWaiting(paths, thread, ref)) === true) return handed(thread, messageId); + return outcome('in_doubt', 'Codex queue did not answer in time'); + } + if (sent.error === 'not_installed') return outcome('retry', 'the codex command line is not installed'); + return outcome('retry', sent.reason); + }; + + return { + paths, + + /** Puts `text` into the thread (router.rs `inject`). */ + async inject({ threadId, messageId, text }) { + if (!isThreadId(threadId)) return outcome('refused', 'not a Codex thread id'); + const thread = threadId; + const ref = messageRef(messageId); + if (await recorded(paths, thread, ref)) { + return delivered(messageId, "recorded in the Codex thread's rollout"); + } + const queued = await queueWaiting(paths, thread, ref); + if (queued === true) return handed(thread, messageId); + if (recentlyHandedOff(messageId)) return outcome('accepted', RECORDING); + // TODO: the probe's Conductor Stop-hook hold (router.rs + // `stop_hook_route`) would go here; it needs an installed hook binary + // and is not ported (docs/connect-node-local-relay.md, "Not ported"). + // + // A live TUI owns its turn scheduling: hand it the message now, and + // only through its queue, which Codex runs in order. + if (await terminalLive(thread)) { + return handoffToTerminal(thread, messageId, text, ref, queued); + } + // A turn through another host would overtake relay messages still in + // the queue: join that queue behind them instead. + const waiting = await relayWaiting(paths, thread); + if (waiting === null) return outcome('waiting', QUEUE_UNREADABLE); + if (waiting === true) return handoffToTerminal(thread, messageId, text, ref, queued); + // TODO(phase 2): the shared app-server route (`codex app-server + // proxy`, `thread/loaded/list` then `turn/start`) comes before the + // window route in the probe. + const window = await windowTurn(paths.ipcSocket, await spelled(thread), text, messageId, connectIpc); + if (window.sent) return delivered(messageId, 'the Codex app started the turn'); + if (window.refused !== undefined) return outcome('retry', window.refused); + if (window.inDoubt !== undefined) return outcome('in_doubt', window.inDoubt); + if (!(await idle(paths, thread))) return outcome('waiting', BUSY); + return outcome('waiting', NOT_RUNNING); + }, + + /** + * Observes a handoff a prior attempt already proved accepted, never + * writing another queue item (router.rs `observe`). + */ + async observe({ threadId, messageId }) { + if (!isThreadId(threadId)) return outcome('refused', 'not a Codex thread id'); + const ref = messageRef(messageId); + if (await recorded(paths, threadId, ref)) { + return delivered(messageId, "recorded in the Codex thread's rollout"); + } + const queued = await queueWaiting(paths, threadId, ref); + if (queued === true) return handed(threadId, messageId); + if (queued === false) return missingHandoffOutcome(messageId); + // An unreadable queue cannot prove the handoff vanished: keep its + // first observation time so a later readable absence can cross the + // grace without ever resending. + rememberOnce(messageId); + return outcome('accepted', QUEUE_UNREADABLE); + }, + + /** Whether the thread can take a message now, without sending one. */ + async preflight({ threadId } = {}) { + if (!isThreadId(threadId)) return { ok: false, reason: 'not a Codex thread id' }; + const file = executable(); + if (file && (await supportsQueue(file))) { + return { ok: true, reason: `${file} supports codex queue` }; + } + try { + if (statSync(paths.ipcSocket)) { + return { ok: true, reason: `the Codex app is listening on ${paths.ipcSocket}` }; + } + } catch { + // No app socket. + } + if (!file) { + return { + ok: false, + reason: + 'Codex cannot receive injected messages: no codex command line is installed and the Codex app is not running.', + }; + } + const version = await runIn(file, ['--version'], SUPPORT_TIMEOUT_MS); + const named = + typeof version.stdout === 'string' ? version.stdout.match(/\d+\.\d+\.\d+[^\s]*/)?.[0] : null; + return { + ok: false, + reason: `Codex ${named ?? '(unknown version)'} (${file}) cannot receive injected messages; update Codex.`, + }; + }, + }; +} + +/** Which SQLite reader this process uses (`node:sqlite`, `sqlite3` or none). */ +export async function codexSqliteBackend() { + return (await sqliteBackend()).kind; +} diff --git a/packages/connect/src/relay/connect.js b/packages/connect/src/relay/connect.js new file mode 100644 index 000000000..526b928e7 --- /dev/null +++ b/packages/connect/src/relay/connect.js @@ -0,0 +1,776 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { + cloudAvailability, + cloudJoin, + cloudLeave, + connectIdFrom, + isAuthRejection, + relaycast, + RelayHttpError, +} from './cloud.js'; +import { handoffTtlMs } from './state.js'; + +// The Connect half of the Node relay: the local join/send/status/leave +// routes and the poll → inject → settle pass, ported from the desktop probe +// (relay-desktop probe/src/relay/connect.rs and outbound.rs). Every rule that +// decides when a delivery is acked, failed, retried or left alone matches the +// probe's ledger; see docs/connect-node-local-relay.md. + +export const HEARTBEAT_MS = 30_000; +const BACKOFF_MIN_MS = 2_000; +const BACKOFF_MAX_MS = 15 * 60_000; +const NOTICE_TTL_MS = 60 * 60_000; +const INJECTED_REASONS = new Set(['dm', 'mention', 'thread-reply']); + +export class ConnectError extends Error { + constructor(status, code, message) { + super(message); + this.name = 'ConnectError'; + this.status = status; + this.code = code; + } +} + +export function messageRef(messageId) { + return createHash('sha256').update(messageId).digest('hex').slice(0, 12); +} + +/** One line, no brackets: the probe's `policy::one_line`. */ +export function oneLine(value) { + return String(value) + .replace(/\[/g, '(') + .replace(/\]/g, ')') + .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +export function joinRequestHash(connectId, name, hostClaim) { + const request = JSON.stringify(['relay-connect-join:v1', connectId, name ?? null, hostClaim ?? null]); + return createHash('sha256').update(request).digest('hex'); +} + +function str(value) { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +/** A `GET /v1/deliveries` item, as the probe's `Delivery::from_queue_item` reads it. */ +export function parseDelivery(item) { + const id = str(item?.id); + const messageId = str(item?.message_id); + if (!id || !messageId) return null; + const message = item.message || {}; + const attachments = [ + ...(Array.isArray(message.attachments) ? message.attachments : []), + ...(Array.isArray(item.attachments) ? item.attachments : []), + ]; + return { + id, + messageId, + status: typeof item.status === 'string' ? item.status : '', + reason: str(item.reason), + channelId: str(item.channel_id), + sender: str(message.agent_name), + senderAddress: str(message.agent_address), + text: typeof message.text === 'string' ? message.text : null, + threadId: str(message.thread_id), + relayfilePath: str(message.metadata?.relayfile?.path), + attachments, + }; +} + +export function decide(delivery) { + if (!delivery.reason || !INJECTED_REASONS.has(delivery.reason)) { + return { inject: false, reason: `not addressed to this agent (${delivery.reason || 'unknown'})` }; + } + const text = delivery.text && delivery.text.trim() ? delivery.text : null; + if (!text && delivery.attachments.length === 0) return { inject: false, reason: 'the message has no text' }; + return { inject: true }; +} + +function withAttachments(body, attachments) { + const lines = []; + const seen = new Set(); + for (const attachment of attachments) { + const name = oneLine(attachment?.filename || attachment?.name || attachment?.file_name || 'file'); + const where = + str(attachment?.url) || + str(attachment?.download_url) || + str(attachment?.file_id) || + str(attachment?.id); + const key = `${name}|${where}`; + if (seen.has(key)) continue; + seen.add(key); + const type = str(attachment?.content_type) || str(attachment?.mime_type); + lines.push(`- ${name}${type ? ` (${oneLine(type)})` : ''}${where ? ` ${oneLine(where)}` : ''}`); + } + if (lines.length === 0) return body; + return `${body}${body ? '\n\n' : ''}Attachments:\n${lines.join('\n')}`; +} + +export function injectedText(delivery, hint) { + const sender = oneLine(delivery.sender || 'participant'); + let body = delivery.text || ''; + if (delivery.relayfilePath) { + const line = `Relayfile path: ${delivery.relayfilePath}`; + if (!body.includes(line)) body = `${body}\n\n${line}`; + } + body = withAttachments(body, delivery.attachments); + return `[Relay Connect — from ${sender} · ref ${messageRef(delivery.messageId)}]\n${hint || ''}\n\n${body}`; +} + +function goneError(code, message) { + return new ConnectError(code === 'connect_not_found' ? 404 : 410, code, message); +} + +function expired(session, now) { + const at = Date.parse(session.expires_at); + return Number.isNaN(at) || at <= now; +} + +export function joinPublic(session) { + return { + connect_id: session.connect_id, + agent_name: session.agent_name, + role: session.role, + task: session.task, + expires_at: session.expires_at, + host: session.host, + participants: session.participants.map((p) => ({ agent_name: p.agent_name, role: p.role })), + }; +} + +export function participantNames(session, roster) { + const names = [...session.participants.map((p) => p.agent_name), ...session.observed_participants]; + for (const agent of roster || []) { + const name = agent?.name; + if (typeof name === 'string' && name && !names.includes(name)) names.push(name); + } + return names; +} + +export function statusPublic(session, roster) { + const roles = new Map(session.participants.map((p) => [p.agent_name, p.role])); + return { + connect_id: session.connect_id, + agent_name: session.agent_name, + role: session.role, + task: session.task, + expires_at: session.expires_at, + participants: participantNames(session, roster).map((name) => ({ + agent_name: name, + role: roles.get(name) ?? null, + online: roster + ? roster.some((agent) => agent?.name === name && ['active', 'online'].includes(agent?.status)) + : null, + })), + }; +} + +function closeNotice(session, code) { + const ended = code !== 'connect_expired'; + return { + id: `connect-${ended ? 'ended' : 'expired'}-${session.connect_id}`, + target: session.target, + text: ended + ? '[Relay Connect] This Connect ended. Its local credential was removed.' + : '[Relay Connect] This Connect expired. Its local credential was removed.', + }; +} + +/** + * The Connect service. `injector` routes text into a session: + * `inject(target, {messageId, text})`, `observe(...)`, `preflight(target)`, + * `prepare(target)` (Claude Code's direct-delivery setting), all resolving + * `{outcome, detail}` where an outcome applies. + */ +export class ConnectService { + constructor({ + state, + injector, + log = () => {}, + env = process.env, + now = Date.now, + fetchImpl, + replyHint = () => '', + onChange = () => {}, + }) { + this.state = state; + this.injector = injector; + this.log = log; + this.env = env; + this.now = now; + this.fetchImpl = fetchImpl; + this.replyHint = replyHint; + this.onChange = onChange; + this.locks = new Map(); + this.lastHeartbeat = new Map(); + this.backoff = new Map(); + } + + client(session) { + return relaycast({ baseUrl: session.base_url, token: session.token, fetchImpl: this.fetchImpl }); + } + + logFailure(operation, connectId, code, cause) { + this.log( + `relay connect ${operation} failed code=${code} connect_ref=${messageRef(connectId)} cause=${cause}` + ); + } + + /** Serializes one credential's passes and route calls, like the probe's agent locks. */ + async withLock(key, task) { + const previous = this.locks.get(key) || Promise.resolve(); + let release; + const current = new Promise((resolve) => { + release = resolve; + }); + const chained = previous.then(() => current); + this.locks.set(key, chained); + await previous; + try { + return await task(); + } finally { + release(); + if (this.locks.get(key) === chained) this.locks.delete(key); + } + } + + async close(session, code) { + const removed = await this.state.removeSession(session.target, session.agent_id); + if (removed) { + await this.state.queueNotice({ ...closeNotice(session, code), queued_at_ms: this.now() }); + this.onChange(); + } + return removed; + } + + async availability(session) { + try { + return await cloudAvailability({ + connectId: session.connect_id, + env: this.env, + fetchImpl: this.fetchImpl, + }); + } catch (error) { + this.logFailure('availability', session.connect_id, error.code, error.logCause); + throw new ConnectError(error.status || 503, error.code, error.message); + } + } + + async current(target) { + const session = this.state.session(target); + if (!session) + throw new ConnectError(404, 'connect_not_joined', 'This agent session has not joined a Relay Connect.'); + if (expired(session, this.now())) { + await this.close(session, 'connect_expired'); + throw new ConnectError(410, 'connect_expired', 'This Connect has expired.'); + } + return session; + } + + // Routes ------------------------------------------------------------------------------- + + async join(target, fields) { + const extra = Object.keys(fields || {}).filter((key) => !['link', 'name', 'host_claim'].includes(key)); + if (extra.length) + throw new ConnectError(400, 'invalid_request', 'Use link, optional name and optional host_claim.'); + const link = typeof fields.link === 'string' ? fields.link.trim() : ''; + if (!link) throw new ConnectError(400, 'invalid_request', 'A Relay Connect link or id is required.'); + const requestedId = connectIdFrom(link); + if (!requestedId) throw new ConnectError(400, 'invalid_link', 'Use a Relay Connect link or id.'); + let name = null; + if (fields.name !== undefined && fields.name !== null) { + name = typeof fields.name === 'string' ? fields.name.trim().toLowerCase() : ''; + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) || name.length < 2 || name.length > 48) { + throw new ConnectError( + 400, + 'invalid_name', + 'Names are 2-48 lowercase letters, numbers, or single hyphens.' + ); + } + } + let claim = null; + if (fields.host_claim !== undefined) { + if (typeof fields.host_claim !== 'string' || !fields.host_claim) { + throw new ConnectError(400, 'invalid_request', 'host_claim must be a non-empty string.'); + } + claim = fields.host_claim; + } + + const existing = this.state.session(target); + if (existing) { + if (expired(existing, this.now())) await this.close(existing, 'connect_expired'); + else if (existing.connect_id === requestedId) return { ...joinPublic(existing), warnings: [] }; + else + throw new ConnectError( + 409, + 'connect_already_joined', + 'This agent session is already in a different Relay Connect.' + ); + } + + // Before Cloud: a session that cannot receive replies should not join. + if (this.injector.prepare) { + try { + await this.injector.prepare(target); + } catch (error) { + throw new ConnectError(409, error.code || 'session_not_injectable', error.message); + } + } + + const idempotencyKey = await this.state.beginJoinAttempt( + target, + joinRequestHash(requestedId, name, claim), + `node-v1-${randomUUID()}` + ); + if (!idempotencyKey) { + throw new ConnectError( + 409, + 'connect_join_pending', + 'A previous join has an unknown outcome. Retry with the same link, name, and host claim.' + ); + } + let joined; + try { + joined = await cloudJoin({ + connectId: requestedId, + name, + hostClaim: claim, + idempotencyKey, + env: this.env, + fetchImpl: this.fetchImpl, + }); + } catch (error) { + const status = error.status ?? 503; + if (status >= 500 || error.code === 'connect_join_timeout') + this.logFailure('join', requestedId, error.code, error.logCause); + if (status < 500 && status !== 429) await this.state.clearJoinAttempt(target, idempotencyKey); + throw new ConnectError(status, error.code, error.message); + } + if (joined.connect_id !== requestedId) { + throw new ConnectError(502, 'invalid_join_response', 'Relay Connect returned a different Connect.'); + } + const participant = joined.participant; + const session = { + target, + connect_id: joined.connect_id, + task: typeof joined.task === 'string' ? joined.task : '', + expires_at: joined.expires_at, + host: joined.host || {}, + agent_name: participant.agent_name, + agent_id: participant.agent_id, + role: participant.role || 'guest', + token: participant.token, + base_url: participant.relay_base_url, + participants: joined.participants + .filter((p) => typeof p?.agent_name === 'string') + .map((p) => ({ agent_name: p.agent_name, role: typeof p.role === 'string' ? p.role : null })), + observed_participants: [], + joined_at_ms: this.now(), + }; + if (expired(session, this.now())) + throw new ConnectError(410, 'connect_expired', 'This Connect has expired.'); + try { + await this.state.insertSession(session); + } catch { + throw new ConnectError(500, 'connect_save_failed', 'This Connect could not be saved locally.'); + } + this.onChange(); + const warnings = []; + if (this.injector.preflight) { + const check = await this.injector + .preflight(target) + .catch((error) => ({ ok: false, reason: error.message })); + if (!check.ok) warnings.push(`Replies may not reach this session yet: ${check.reason}`); + } + return { ...joinPublic(session), warnings }; + } + + async send(target, text, { to, requestKey } = {}) { + if (typeof text !== 'string' || !text) + throw new ConnectError(400, 'invalid_text', 'The message must be non-empty UTF-8 text.'); + const session = await this.current(target); + const roster = await this.roster(session); + const names = participantNames(session, roster); + let recipients; + if (to !== undefined && to !== null) { + if (to === session.agent_name) + throw new ConnectError(400, 'invalid_recipient', 'Cannot send to this session itself.'); + if (!names.includes(to)) + throw new ConnectError(404, 'participant_not_found', 'That participant is not in this Connect.'); + recipients = [to]; + } else { + recipients = names.filter((name) => name !== session.agent_name); + } + const key = requestKey || randomUUID(); + const sent = []; + const client = this.client(session); + for (const name of recipients) { + let result; + try { + result = await client.dm(name, text, `connect-send:${key}:${name}`); + } catch (error) { + const failure = await this.sendFailure(session, name, error); + if (sent.length) + failure.message = `${failure.message} Already sent to ${sent.map((s) => s.to).join(', ')}.`; + throw failure; + } + sent.push({ to: name, message_id: str(result?.message?.id) || str(result?.id) }); + } + return { sent }; + } + + async sendFailure(session, name, error) { + if (error.status === 404 && ['agent_not_found', 'address_not_found'].includes(error.code)) { + return new ConnectError(404, error.code, error.message); + } + if (error.status === 404 || error.status === 410) { + await this.close(session, 'connect_ended'); + return new ConnectError(410, 'connect_ended', 'This Connect expired or ended.'); + } + if (isAuthRejection(error)) { + let availability; + try { + availability = await this.availability(session); + } catch (availabilityError) { + return availabilityError; + } + if (availability.gone) { + await this.close(session, availability.gone.code); + return goneError(availability.gone.code, availability.gone.message); + } + this.logFailure('send', session.connect_id, 'connect_auth_rejected', error.logCause); + return new ConnectError( + 503, + 'connect_auth_rejected', + "Relay Connect refused this session's credential although the Connect is still open. This can happen briefly after the host changes; retry once." + ); + } + if (error.status === null || error.status === undefined) { + this.logFailure('send', session.connect_id, 'connect_unreachable', error.logCause); + return new ConnectError( + 503, + 'connect_unreachable', + `Relay Connect could not be reached; the message to ${name} may or may not have been delivered. Ask before resending.` + ); + } + if (error.status >= 500) { + this.logFailure('send', session.connect_id, 'connect_upstream_error', error.logCause); + return new ConnectError( + 502, + 'connect_upstream_error', + `Relay Connect's server failed (HTTP ${error.status}); the message to ${name} may already have been delivered. Ask before resending.` + ); + } + return new ConnectError(error.status, error.code, error.message); + } + + /** The Relaycast roster, or null when it cannot be read; closes a gone Connect. */ + async roster(session) { + try { + const agents = await this.client(session).agents(); + return Array.isArray(agents) ? agents : Array.isArray(agents?.agents) ? agents.agents : null; + } catch (error) { + if (error.status === 404 || error.status === 410) { + await this.close(session, 'connect_ended'); + throw new ConnectError(410, 'connect_ended', 'This Connect expired or ended.'); + } + if (isAuthRejection(error)) { + const availability = await this.availability(session).catch(() => null); + if (availability?.gone) { + await this.close(session, availability.gone.code); + throw goneError(availability.gone.code, availability.gone.message); + } + } + return null; + } + } + + async status(target) { + const session = await this.current(target); + const roster = await this.roster(session); + const status = statusPublic(session, roster); + const delivery = this.state.deliveryPublic(session); + if (delivery) status.delivery = delivery; + return status; + } + + async leave(target) { + const session = await this.current(target); + return await this.withLock(`connect:${session.agent_id}`, async () => { + // Cloud's leave frees the name and deletes this participant's Relaycast + // agent. Without it (an older Cloud, or a failure), at least mark the + // agent offline. Both are best effort: leaving locally always succeeds. + let cloudLeft = false; + try { + await cloudLeave({ + connectId: session.connect_id, + token: session.token, + env: this.env, + fetchImpl: this.fetchImpl, + }); + cloudLeft = true; + } catch (error) { + this.logFailure( + 'leave', + session.connect_id, + error.code || 'connect_leave_failed', + error.logCause || 'unknown' + ); + } + if (!cloudLeft) { + try { + await this.client(session).disconnect(); + } catch (error) { + this.logFailure( + 'disconnect', + session.connect_id, + error.code || 'relaycast_error', + error.logCause || 'unknown' + ); + } + } + await this.state.removeSession(session.target, session.agent_id); + this.onChange(); + return { left: true, connect_id: session.connect_id }; + }); + } + + // The poll pass -------------------------------------------------------------------------- + + gate(connectId) { + const entry = this.backoff.get(connectId); + return !entry || entry.until <= this.now(); + } + + observe(connectId, error) { + const limited = error?.status === 429 || ['rate_limit_exceeded', 'workspace_busy'].includes(error?.code); + if (!limited) { + if (!error) this.backoff.delete(connectId); + return; + } + const previous = this.backoff.get(connectId); + const delay = previous ? Math.min(BACKOFF_MAX_MS, previous.delay * 2) : BACKOFF_MIN_MS; + this.backoff.set(connectId, { delay, until: this.now() + delay }); + } + + async pollAll() { + await this.deliverNotices(); + for (const session of this.state.sessions()) { + await this.withLock(`connect:${session.agent_id}`, () => this.pollOne(session)); + } + } + + async deliverNotices() { + for (const notice of [...this.state.data.notices]) { + let result; + try { + result = await this.injector.inject(notice.target, { messageId: notice.id, text: notice.text }); + } catch (error) { + result = { outcome: 'in_doubt', detail: error.message }; + } + const keep = + ['retry', 'waiting'].includes(result.outcome) && + this.now() - (notice.queued_at_ms || 0) < NOTICE_TTL_MS; + if (!keep) await this.state.dropNotice(notice.id); + } + } + + async settle(client, connectId, delivery, settled) { + try { + if (settled.kind === 'handled' && settled.outcome === 'delivered') await client.ack(delivery.id); + else + await client.fail( + delivery.id, + settled.detail || (settled.kind === 'handled' ? 'in doubt, not resent' : 'refused by the session') + ); + this.observe(connectId, null); + } catch (error) { + this.observe(connectId, error); + } + } + + async ack(client, connectId, deliveryId) { + try { + await client.ack(deliveryId); + } catch (error) { + this.observe(connectId, error); + } + } + + async fail(client, connectId, deliveryId, detail) { + try { + await client.fail(deliveryId, detail); + } catch (error) { + this.observe(connectId, error); + } + } + + async pollOne(snapshot) { + const session = this.state.session(snapshot.target); + if (!session || session.agent_id !== snapshot.agent_id) return; + if (expired(session, this.now())) { + await this.close(session, 'connect_expired'); + return; + } + const client = this.client(session); + const connectId = session.connect_id; + + const last = this.lastHeartbeat.get(session.agent_id); + if ((last === undefined || this.now() - last >= HEARTBEAT_MS) && this.gate(connectId)) { + this.lastHeartbeat.set(session.agent_id, this.now()); + try { + await client.heartbeat(); + this.observe(connectId, null); + } catch (error) { + this.observe(connectId, error); + if (error.status === 404 || error.status === 410) { + await this.close(session, 'connect_ended'); + return; + } + if (isAuthRejection(error)) { + const availability = await this.availability(session).catch(() => null); + if (availability?.gone) { + await this.close(session, availability.gone.code); + return; + } + } + } + } + if (!this.gate(connectId)) return; + + let items; + try { + items = await client.deliveries(); + this.observe(connectId, null); + } catch (error) { + this.observe(connectId, error); + if (error.status === 404 || error.status === 410) { + await this.close(session, 'connect_ended'); + return; + } + if (isAuthRejection(error)) { + const availability = await this.availability(session).catch(() => null); + if (availability?.gone) await this.close(session, availability.gone.code); + } + return; + } + const deliveries = ( + Array.isArray(items) ? items : Array.isArray(items?.deliveries) ? items.deliveries : [] + ) + .map(parseDelivery) + .filter((delivery) => delivery && ['queued', 'delivered'].includes(delivery.status)); + const ttlMs = handoffTtlMs(this.env.AGENT_RELAY_CONNECT_HANDOFF_TIMEOUT_SECONDS); + + for (const delivery of deliveries) { + const current = this.state.session(session.target); + if (!current || current.agent_id !== session.agent_id) break; + const ledger = `${session.agent_id}:${delivery.id}`; + const settled = this.state.settled(ledger); + if (settled) { + await this.settle(client, connectId, delivery, settled); + continue; + } + if (delivery.sender === session.agent_name) { + await this.ack(client, connectId, delivery.id); + continue; + } + if (delivery.sender) await this.state.observeParticipant(session, delivery.sender); + if (!decide(delivery).inject) { + await this.ack(client, connectId, delivery.id); + continue; + } + try { + await this.state.rememberPending(ledger, delivery.messageId); + } catch (error) { + this.log( + `relay connect ledger write failed connect_ref=${messageRef(connectId)} cause=${error.code || 'io'}` + ); + break; + } + const action = this.state.pendingAction(ledger, ttlMs); + const alreadyAccepted = this.state.pendingWasAccepted(ledger); + if (action.wait) { + // One target is an ordered stream: a backing-off head holds later + // items, unless the provider's queue already holds it. + if (alreadyAccepted) continue; + break; + } + const hint = alreadyAccepted || !delivery.sender ? '' : this.replyHint(delivery.sender, session.target); + const text = injectedText(delivery, hint); + let result; + try { + result = alreadyAccepted + ? await this.injector.observe(session.target, { messageId: delivery.messageId, text }) + : await this.injector.inject(session.target, { messageId: delivery.messageId, text }); + } catch (error) { + result = { outcome: 'crashed', detail: error.message }; + } + const stop = await this.record(client, connectId, ledger, delivery, result, action.finalAttempt); + if (stop) break; + } + } + + /** Persists one handoff outcome, then settles it. Returns true when the pass must stop. */ + async record(client, connectId, ledger, delivery, result, finalAttempt) { + const reason = result?.detail || 'no detail'; + try { + switch (result?.outcome) { + case 'delivered': + await this.state.rememberHandled( + ledger, + delivery.messageId, + 'delivered', + 'Injected into the session.' + ); + await this.ack(client, connectId, delivery.id); + return false; + case 'in_doubt': { + const detail = `In doubt; not resent — ${reason}`; + await this.state.rememberHandled(ledger, delivery.messageId, 'in_doubt', detail); + await this.fail(client, connectId, delivery.id, detail); + return false; + } + case 'refused': { + const detail = `Refused by the session — ${reason}`; + await this.state.rememberFailed(ledger, delivery.messageId, 'refused', detail); + await this.fail(client, connectId, delivery.id, detail); + return false; + } + case 'retry': + case 'waiting': { + const detail = await this.state.rememberUnacceptedAttempt( + ledger, + delivery.messageId, + result.outcome, + reason, + finalAttempt + ); + if (detail) { + await this.fail(client, connectId, delivery.id, detail); + return false; + } + return true; + } + case 'accepted': + case 'waiting_for_turn': + await this.state.rememberAccepted(ledger, result.outcome === 'waiting_for_turn', reason); + return false; + default: { + const detail = 'In doubt; the local session handoff stopped unexpectedly and was not resent.'; + await this.state.rememberHandled(ledger, delivery.messageId, 'in_doubt', detail); + await this.fail(client, connectId, delivery.id, detail); + return false; + } + } + } catch (error) { + this.log( + `relay connect ledger write failed connect_ref=${messageRef(connectId)} cause=${error.code || 'io'}` + ); + return true; + } + } +} + +export { RelayHttpError }; diff --git a/packages/connect/src/relay/daemon.js b/packages/connect/src/relay/daemon.js new file mode 100644 index 000000000..296529133 --- /dev/null +++ b/packages/connect/src/relay/daemon.js @@ -0,0 +1,300 @@ +#!/usr/bin/env node +// The Node relay daemon: one per user, started detached by `connect join` +// under the Node that is already running npx. It serves the /connect/* +// routes on a 0600 Unix socket, polls Relaycast for each joined Connect, +// injects into the session, and exits once no Connect is left. + +import { constants } from 'node:fs'; +import { chmod, open, readFile, rm, stat } from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { ConnectError, ConnectService } from './connect.js'; +import { VERSION } from './cloud.js'; +import { privateDirectory, relayPaths } from './paths.js'; +import { RelayState } from './state.js'; + +export const POLL_MS = 3_000; +export const IDLE_AFTER_LAST_MS = 60_000; +export const IDLE_NEVER_USED_MS = 15 * 60_000; +const MAX_BODY_BYTES = 1_000_000; +const TARGET = /^(claude|codex):([A-Za-z0-9][A-Za-z0-9_-]{7,63})$/; + +function log(line) { + process.stderr.write(`${new Date().toISOString()} ${line}\n`); +} + +function shellQuote(value) { + return /^[A-Za-z0-9_./:=@%+-]+$/.test(value) ? value : `'${String(value).replace(/'/g, `'\\''`)}'`; +} + +/** The reply command shown above an injected message: this runtime's CLI, under this Node. */ +export function replyHintFor( + sender, + nodePath = process.execPath, + cliPath = fileURLToPath(new URL('../cli.js', import.meta.url)) +) { + return `[reply: printf '%s' 'your reply' | ${shellQuote(nodePath)} ${shellQuote(cliPath)} send --to ${shellQuote(sender)}]`; +} + +function respond(response, status, payload) { + const body = JSON.stringify(payload); + response.writeHead(status, { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(body), + }); + response.end(body); +} + +function readBody(request) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + request.on('data', (chunk) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + reject(new ConnectError(413, 'too_large', 'The request is too large.')); + request.destroy(); + return; + } + chunks.push(chunk); + }); + request.on('end', () => resolve(Buffer.concat(chunks))); + request.on('error', reject); + }); +} + +function targetOf(request) { + const match = TARGET.exec(String(request.headers['x-agent-relay-target'] || '')); + if (!match) { + throw new ConnectError( + 403, + 'not_a_relay_session', + 'This command is not running inside a supported live Agent Relay session.' + ); + } + return { kind: match[1], id: match[2] }; +} + +/** Takes the single-instance lock, replacing a stale one. Resolves a release function or null. */ +async function acquireLock(path) { + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); + await handle.writeFile(`${process.pid}\n`); + await handle.close(); + return async () => { + const owner = await readFile(path, 'utf8').catch(() => ''); + if (owner.trim() === String(process.pid)) await rm(path, { force: true }); + }; + } catch (error) { + if (error?.code !== 'EEXIST') throw error; + const owner = Number.parseInt(await readFile(path, 'utf8').catch(() => ''), 10); + let alive = false; + if (Number.isInteger(owner) && owner > 0) { + try { + process.kill(owner, 0); + alive = true; + } catch (killError) { + alive = killError?.code === 'EPERM'; + } + } + if (alive) return null; + await rm(path, { force: true }); + } + } + return null; +} + +export async function startDaemon({ + home = os.homedir(), + env = process.env, + injector, + fetchImpl, + now = Date.now, +} = {}) { + const paths = relayPaths(home); + await privateDirectory(paths.root); + await privateDirectory(paths.run); + const release = await acquireLock(paths.lock); + if (!release) { + const error = new Error('Another Agent Relay Connect relay is already running for this user.'); + error.code = 'already_running'; + throw error; + } + + const state = await RelayState.load(paths.state, { now }); + let everUsed = state.sessions().length > 0; + let lastActiveAt = now(); + const service = new ConnectService({ + state, + injector: + injector || (await import('./injector.js')).createInjector({ home, env, platform: process.platform }), + log, + env, + now, + fetchImpl, + replyHint: (sender) => replyHintFor(sender), + onChange: () => { + if (state.sessions().length) everUsed = true; + schedule(0); + }, + }); + + let timer = null; + let polling = false; + let stopped = false; + const schedule = (delay = POLL_MS) => { + if (stopped) return; + if (timer) clearTimeout(timer); + timer = setTimeout(tick, delay); + timer.unref?.(); + }; + const busy = () => state.sessions().length > 0 || state.data.notices.length > 0; + async function tick() { + timer = null; + if (stopped) return; + if (!polling) { + polling = true; + try { + await service.pollAll(); + } catch (error) { + log(`relay connect poll failed cause=${error?.code || error?.name || 'error'}`); + } finally { + polling = false; + } + } + if (busy()) { + lastActiveAt = now(); + } else if (now() - lastActiveAt >= (everUsed ? IDLE_AFTER_LAST_MS : IDLE_NEVER_USED_MS)) { + log('relay connect idle; exiting'); + await stop(); + return; + } + schedule(); + } + + const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url || '/', 'http://relay'); + const route = `${request.method} ${url.pathname}`; + if (route === 'GET /health') { + respond(response, 200, { + ok: true, + data: { version: VERSION, pid: process.pid, sessions: state.sessions().length }, + }); + return; + } + if (route === 'POST /shutdown') { + if (busy() && url.searchParams.get('force') !== '1') { + throw new ConnectError(409, 'relay_busy', 'The relay still has an active Connect.'); + } + respond(response, 200, { ok: true, data: { stopping: true } }); + setImmediate(() => stop()); + return; + } + const target = targetOf(request); + const body = await readBody(request); + let data; + if (route === 'POST /connect/join') { + let fields; + try { + fields = JSON.parse(body.toString('utf8') || '{}'); + } catch { + throw new ConnectError(400, 'invalid_request', 'The body must be JSON.'); + } + if (!fields || typeof fields !== 'object' || Array.isArray(fields)) { + throw new ConnectError(400, 'invalid_request', 'The body must be a JSON object.'); + } + data = await service.join(target, fields); + } else if (route === 'POST /connect/send') { + const to = url.searchParams.getAll('to'); + if (to.length > 1) throw new ConnectError(400, 'invalid_request', 'Use at most one to.'); + let text; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(body); + } catch { + throw new ConnectError(400, 'invalid_text', 'The message must be non-empty UTF-8 text.'); + } + data = await service.send(target, text, { + to: to[0], + requestKey: String(request.headers['x-agent-relay-request'] || '') || undefined, + }); + } else if (route === 'GET /connect/status') { + data = await service.status(target); + } else if (route === 'POST /connect/leave') { + data = await service.leave(target); + } else { + throw new ConnectError(404, 'not_found', 'Unknown route.'); + } + lastActiveAt = now(); + respond(response, 200, { ok: true, data }); + } catch (error) { + const status = error instanceof ConnectError ? error.status : 500; + const code = error instanceof ConnectError ? error.code : 'relay_error'; + const message = + error instanceof ConnectError ? error.message : 'The relay could not handle this request.'; + if (!(error instanceof ConnectError)) + log(`relay connect route failed cause=${error?.code || error?.name || 'error'}`); + respond(response, status, { ok: false, error: { code, message } }); + } + }); + + // A socket left by a relay that died is replaced; the lock says it is ours. + await rm(paths.socket, { force: true }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(paths.socket, resolve); + }); + await chmod(paths.socket, 0o600); + const socketInfo = await stat(paths.socket); + + async function stop() { + if (stopped) return; + stopped = true; + if (timer) clearTimeout(timer); + await new Promise((resolve) => server.close(() => resolve())); + try { + const current = await stat(paths.socket); + if (current.ino === socketInfo.ino) await rm(paths.socket, { force: true }); + } catch { + // Already gone. + } + await release(); + stopResolve(); + } + let stopResolve; + const stopped$ = new Promise((resolve) => { + stopResolve = resolve; + }); + + log(`relay connect started version=${VERSION} pid=${process.pid} sessions=${state.sessions().length}`); + schedule(0); + return { socket: paths.socket, stop, stopped: stopped$, service, state }; +} + +async function main() { + let daemon; + try { + daemon = await startDaemon(); + } catch (error) { + const message = error?.message || String(error); + log(`relay connect failed to start: ${message}`); + if (process.send) process.send({ type: 'error', code: error?.code || 'start_failed', message }); + process.exitCode = error?.code === 'already_running' ? 0 : 1; + process.disconnect?.(); + return; + } + if (process.send) { + process.send({ type: 'ready', version: VERSION, socket: daemon.socket, pid: process.pid }); + process.disconnect?.(); + } + for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) { + process.on(signal, () => daemon.stop()); + } + await daemon.stopped; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + await main(); +} diff --git a/packages/connect/src/relay/identify.js b/packages/connect/src/relay/identify.js new file mode 100644 index 000000000..b4b024d25 --- /dev/null +++ b/packages/connect/src/relay/identify.js @@ -0,0 +1,41 @@ +import os from 'node:os'; +import { claudeCallerSession, findClaudeSession } from './claude.js'; +import { codexCallerThread } from './codex.js'; + +// The CLI runs inside the session that called it, so it names that session +// itself: Codex by CODEX_THREAD_ID; Claude Code by CLAUDE_CODE_SESSION_ID when +// that session has a live record, else by the nearest ancestor with a live +// session record (the desktop probe's `caller_target` rules). + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export class SessionNotFoundError extends Error { + constructor(checks) { + super( + `Run this from a live Claude Code or Codex session (${checks.join('; ')}). In Claude Code, if a permission check blocks the command, run it yourself with the ! prefix.` + ); + this.name = 'SessionNotFoundError'; + this.code = 'not_a_relay_session'; + this.exitCode = 6; + } +} + +export async function identifyCaller({ home = os.homedir(), env = process.env, pid = process.pid } = {}) { + const checks = []; + if (env.CODEX_THREAD_ID) { + const codex = await codexCallerThread({ env, pid, home }); + if (codex?.threadId) return { kind: 'codex', id: codex.threadId }; + checks.push(`CODEX_THREAD_ID is set but ${codex?.reason || 'its thread was not found'}`); + } else { + checks.push('no Codex thread id in the environment'); + } + const declared = env.CLAUDE_CODE_SESSION_ID; + if (declared && UUID.test(declared)) { + const record = await findClaudeSession({ home, env, sessionId: declared }).catch(() => null); + if (record) return { kind: 'claude', id: record.sessionId || declared }; + } + const claude = await claudeCallerSession({ home, env, pid }); + if (claude) return { kind: 'claude', id: typeof claude === 'string' ? claude : claude.sessionId }; + checks.push('no Claude Code session record for any parent process'); + throw new SessionNotFoundError(checks); +} diff --git a/packages/connect/src/relay/injector.js b/packages/connect/src/relay/injector.js new file mode 100644 index 000000000..d64a33e69 --- /dev/null +++ b/packages/connect/src/relay/injector.js @@ -0,0 +1,27 @@ +import { claudePreflight, ensureClaudeDirectDelivery, injectClaude } from './claude.js'; +import { createCodexRouter } from './codex.js'; + +/** Routes Connect text into the joined Claude Code or Codex session. */ +export function createInjector({ home, env, platform = process.platform, run }) { + const codex = createCodexRouter({ home, env, platform, run }); + return { + async prepare(target) { + if (target.kind === 'claude') await ensureClaudeDirectDelivery({ home, env, platform }); + }, + async inject(target, { messageId, text }) { + if (target.kind === 'claude') + return await injectClaude({ home, env, sessionId: target.id, messageId, text }); + return await codex.inject({ threadId: target.id, messageId, text }); + }, + async observe(target, { messageId, text }) { + // Only a provider queue (Codex) ever accepts without delivering. + if (target.kind === 'claude') + return { outcome: 'in_doubt', detail: 'Claude Code reported no acceptance to observe.' }; + return await codex.observe({ threadId: target.id, messageId, text }); + }, + async preflight(target) { + if (target.kind === 'claude') return await claudePreflight({ home, env, sessionId: target.id }); + return await codex.preflight({ threadId: target.id }); + }, + }; +} diff --git a/packages/connect/src/relay/launch.js b/packages/connect/src/relay/launch.js new file mode 100644 index 000000000..531d38462 --- /dev/null +++ b/packages/connect/src/relay/launch.js @@ -0,0 +1,181 @@ +import { spawn } from 'node:child_process'; +import { constants } from 'node:fs'; +import { access, cp, open, readFile, rename, rm, stat } from 'node:fs/promises'; +import os from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { requestJson } from '../http.js'; +import { VERSION } from './cloud.js'; +import { privateDirectory, relayPaths } from './paths.js'; + +// Starts the Node relay daemon for `connect join`, or finds the running one. +// The daemon runs from a copy of this package under ~/.agentworkforce so an +// npx cache cleanup cannot remove its code, under the Node already running +// this CLI: nothing is downloaded and no new binary is launched. + +const HANDSHAKE_TIMEOUT_MS = 10_000; + +export class RelayStartError extends Error { + constructor(message, exitCode = 1) { + super(message); + this.name = 'RelayStartError'; + this.exitCode = exitCode; + this.code = 'relay_start_failed'; + } +} + +function versionParts(value) { + return String(value || '') + .split(/[.-]/) + .slice(0, 3) + .map((part) => Number.parseInt(part, 10) || 0); +} + +export function olderVersion(a, b) { + const left = versionParts(a); + const right = versionParts(b); + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) return left[index] < right[index]; + } + return false; +} + +/** The running daemon's health, or null. */ +export async function relayHealth(socket, timeoutMs = 2_000) { + try { + const info = await stat(socket); + if (!info.isSocket()) return null; + const uid = process.getuid?.(); + if (uid !== undefined && info.uid !== uid) return null; + const response = await requestJson(socket, { path: '/health', timeoutMs }); + return response?.ok === true ? response.data : null; + } catch { + return null; + } +} + +/** Copies this package's code to runtime// once; returns the daemon entry path. */ +export async function stageRuntime(paths, version = VERSION) { + const codeDir = dirname(dirname(fileURLToPath(import.meta.url))); + const packageRoot = dirname(codeDir); + const destination = join(paths.runtime, version); + const entry = join(destination, basename(codeDir), 'relay/daemon.js'); + try { + await access(entry, constants.R_OK); + return entry; + } catch { + // Not staged yet. + } + await privateDirectory(paths.runtime); + const staging = `${destination}.${process.pid}.tmp`; + await rm(staging, { recursive: true, force: true }); + await cp(codeDir, join(staging, basename(codeDir)), { recursive: true }); + await cp(join(packageRoot, 'package.json'), join(staging, 'package.json')); + try { + await rename(staging, destination); + } catch (error) { + await rm(staging, { recursive: true, force: true }); + // Another CLI staged the same version first. + if (!['EEXIST', 'ENOTEMPTY'].includes(error?.code)) throw error; + } + return entry; +} + +async function logTail(path, lines = 12) { + try { + const text = await readFile(path, 'utf8'); + return text.trimEnd().split('\n').slice(-lines).join('\n'); + } catch { + return ''; + } +} + +function handshake(child, timeoutMs) { + return new Promise((resolve) => { + let settled = false; + const finish = (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + }; + const timer = setTimeout(() => finish({ type: 'timeout' }), timeoutMs); + child.once('message', (message) => + finish(message && typeof message === 'object' ? message : { type: 'invalid' }) + ); + child.once('error', (error) => finish({ type: 'error', message: error.message })); + child.once('exit', (code, signal) => finish({ type: 'exit', code, signal })); + }); +} + +/** + * Resolves the daemon socket, starting the daemon when none answers. Failures + * reject with the daemon's own reason and the end of its log. + */ +export async function ensureRelay({ + home = os.homedir(), + env = process.env, + timeoutMs = HANDSHAKE_TIMEOUT_MS, + spawnProcess = spawn, +} = {}) { + const paths = relayPaths(home); + const running = await relayHealth(paths.socket); + if (running) { + // An older idle relay steps aside for this version; a busy one keeps serving. + if (!olderVersion(running.version, VERSION) || running.sessions > 0) return paths.socket; + await requestJson(paths.socket, { method: 'POST', path: '/shutdown', timeoutMs: 2_000 }).catch( + () => null + ); + for (let attempt = 0; attempt < 20 && (await relayHealth(paths.socket, 500)); attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + await privateDirectory(paths.root); + const entry = await stageRuntime(paths); + const log = await open(paths.log, 'a', 0o600); + let child; + let handshakeResult; + try { + child = spawnProcess(process.execPath, [entry], { + detached: true, + stdio: ['ignore', log.fd, log.fd, 'ipc'], + env: { ...env, HOME: home }, + }); + // Listen before anything else awaits: a daemon that fails at once must not be missed. + handshakeResult = handshake(child, timeoutMs); + } finally { + await log.close(); + } + const result = await handshakeResult; + if (child.connected) child.disconnect(); + child.unref(); + + if (result.type === 'ready') return result.socket || paths.socket; + // Two CLIs can race to start it; the loser finds the winner here. + if (result.type === 'error' && result.code === 'already_running') { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (await relayHealth(paths.socket, 500)) return paths.socket; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + if (result.type === 'timeout' && child.pid) { + try { + process.kill(child.pid, 'SIGTERM'); + } catch { + // It already exited. + } + } + const reason = + result.type === 'error' + ? result.message + : result.type === 'exit' + ? `it exited${result.signal ? ` on ${result.signal}` : ` with code ${result.code}`} before it was ready` + : result.type === 'timeout' + ? `it did not report ready within ${Math.round(timeoutMs / 1000)} s` + : `it could not be started (${result.message || 'unknown error'})`; + const tail = await logTail(paths.log); + throw new RelayStartError( + `The Agent Relay Connect relay could not start: ${reason}.${tail ? `\nLast lines of ${paths.log}:\n${tail}` : ''}` + ); +} diff --git a/packages/connect/src/relay/paths.js b/packages/connect/src/relay/paths.js new file mode 100644 index 000000000..e8a2f0528 --- /dev/null +++ b/packages/connect/src/relay/paths.js @@ -0,0 +1,54 @@ +import { constants } from 'node:fs'; +import { chmod, lstat, mkdir, open, rename, rm } from 'node:fs/promises'; +import os from 'node:os'; +import { dirname, join } from 'node:path'; + +/** Where the Node relay keeps its state, socket, log and runtime copy. */ +export function relayPaths(home = os.homedir()) { + const root = join(home, '.agentworkforce/connect'); + return { + root, + state: join(root, 'state.json'), + run: join(root, 'run'), + socket: join(root, 'run/connect.sock'), + lock: join(root, 'run/daemon.lock'), + log: join(root, 'daemon.log'), + runtime: join(root, 'runtime'), + }; +} + +/** Creates `path` as a 0700 directory this user owns, or refuses. */ +export async function privateDirectory(path) { + await mkdir(path, { recursive: true, mode: 0o700 }); + const info = await lstat(path); + const uid = process.getuid?.(); + if (!info.isDirectory() || (uid !== undefined && info.uid !== uid)) { + throw new Error(`${path} must be a directory this user owns.`); + } + if ((info.mode & 0o077) !== 0) await chmod(path, 0o700); +} + +/** Writes `text` to `path` with mode 0600: temp file, fsync, rename, fsync the directory. */ +export async function writeAtomic(path, text) { + const directory = dirname(path); + await privateDirectory(directory); + const temporary = join(directory, `.${process.pid}.${Date.now()}.tmp`); + const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); + try { + await handle.writeFile(text); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await rename(temporary, path); + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } + const directoryHandle = await open(directory, constants.O_RDONLY).catch(() => null); + if (directoryHandle) { + await directoryHandle.sync().catch(() => {}); + await directoryHandle.close(); + } +} diff --git a/packages/connect/src/relay/processes.js b/packages/connect/src/relay/processes.js new file mode 100644 index 000000000..c27cfcdc1 --- /dev/null +++ b/packages/connect/src/relay/processes.js @@ -0,0 +1,268 @@ +import { execFile } from 'node:child_process'; +import { readdir, readFile, readlink, realpath, stat } from 'node:fs/promises'; +import { basename } from 'node:path'; + +// The kernel's view of this computer's processes, ported from relay-desktop +// probe/src/relay/process.rs: parent, owner and start time, executable, +// arguments and open files. Linux reads /proc; macOS asks `ps` and `lsof`, +// since Node has no libproc. Every read is best effort: a process that is +// gone or unreadable is `null` / empty, never an exception. + +/** How far up a process tree a caller is followed (process.rs MAX_ANCESTRY). */ +export const MAX_ANCESTRY = 64; + +const LINUX = process.platform === 'linux'; +const COMMAND_TIMEOUT_MS = 10_000; + +/** Runs a read-only system command; `null` when it fails or exits non-zero. */ +function run(file, args) { + return new Promise((resolve) => { + execFile( + file, + args, + { + // `lstart` must be UTC and in English to compare with Claude's + // recorded `procStart`. + env: { ...process.env, TZ: 'UTC', LC_ALL: 'C' }, + timeout: COMMAND_TIMEOUT_MS, + maxBuffer: 16 * 1024 * 1024, + }, + (error, stdout) => resolve(error ? null : String(stdout)) + ); + }); +} + +function validPid(pid) { + return Number.isInteger(pid) && pid > 0; +} + +/** + * The fields of `/proc//stat` after the command name, which may itself + * contain spaces and parentheses: index 1 is the parent, 19 the start time. + */ +export function statFields(stat) { + const end = stat.lastIndexOf(')'); + return end < 0 + ? [] + : stat + .slice(end + 1) + .trim() + .split(/\s+/); +} + +async function linuxInfo(pid) { + try { + const [text, info] = await Promise.all([readFile(`/proc/${pid}/stat`, 'utf8'), stat(`/proc/${pid}`)]); + const fields = statFields(text); + const parent = Number.parseInt(fields[1] ?? '', 10); + const start = fields[19]; + if (!Number.isInteger(parent) || !start || !/^\d+$/.test(start)) return null; + return { pid, parent, uid: info.uid, start }; + } catch { + return null; + } +} + +async function macInfo(pid) { + const output = await run('ps', ['-o', 'ppid=,uid=,lstart=', '-p', String(pid)]); + const match = output?.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/); + if (!match) return null; + return { + pid, + parent: Number.parseInt(match[1], 10), + uid: Number.parseInt(match[2], 10), + start: match[3].trim().replace(/\s+/g, ' '), + }; +} + +/** + * One process as the kernel reports it: `{pid, parent, uid, start}`, or + * `null` when it is gone. `start` is clock ticks since boot on Linux + * (`/proc//stat` field 22) and the UTC `ctime`-style start + * (`Mon Oct 12 15:22:47 2026`, whole seconds) on macOS: the two forms + * Claude Code records as `procStart`. Two reads with equal values are the + * same process. + */ +export async function processInfo(pid) { + if (!validPid(pid)) return null; + return LINUX ? linuxInfo(pid) : macInfo(pid); +} + +/** The parent pid, or `null`. */ +export async function parentOf(pid) { + return (await processInfo(pid))?.parent ?? null; +} + +/** The start time as Claude Code records it (see `processInfo`), or `null`. */ +export async function processStart(pid) { + return (await processInfo(pid))?.start ?? null; +} + +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +/** + * Claude Code's macOS `procStart`, `Wed Sep 30 10:00:00 2026` in UTC, as + * whole seconds since the epoch (process.rs `claude_start_seconds`). + */ +export function claudeStartSeconds(text) { + const parts = String(text).trim().split(/\s+/); + if (parts.length !== 5) return null; + const [, monthName, dayText, time, yearText] = parts; + const month = MONTHS.indexOf(monthName); + if (month < 0 || !/^\d+$/.test(dayText) || !/^-?\d+$/.test(yearText)) return null; + const clock = time.split(':'); + if (clock.length !== 3 || clock.some((part) => !/^\d+$/.test(part))) return null; + const [hour, minute, second] = clock.map((part) => Number.parseInt(part, 10)); + const day = Number.parseInt(dayText, 10); + const year = Number.parseInt(yearText, 10); + if (hour > 23 || minute > 59 || second > 59) return null; + const at = Date.UTC(year, month, day, hour, minute, second); + const date = new Date(at); + // Reject what chrono would: 31 Feb does not roll into March. + if (date.getUTCDate() !== day || date.getUTCMonth() !== month || at < 0) return null; + return at / 1000; +} + +/** + * Whether Claude Code's recorded `procStart` names the process whose + * `processStart` is `start`, so a recycled pid is never mistaken for the + * session (process.rs `claude_start_matches`): equal ticks on Linux, the + * same whole second on macOS. + */ +export function claudeStartMatches(recorded, start, platform = process.platform) { + if (recorded == null || start == null) return false; + if (platform === 'linux') return String(recorded).trim() === String(start).trim(); + const seconds = claudeStartSeconds(recorded); + return seconds !== null && seconds === claudeStartSeconds(start); +} + +/** + * `pid` itself, then its parent, grandparent and so on, nearest first: + * at most `max` entries, stopping before pid 1 or at a process that is + * gone. Each entry is `processInfo`'s record. + */ +export async function ancestors(pid, max = MAX_ANCESTRY) { + const chain = []; + let current = pid; + for (let index = 0; index < max; index += 1) { + if (!validPid(current) || current <= 1) break; + const info = await processInfo(current); + if (!info) break; + chain.push(info); + current = info.parent; + } + return chain; +} + +/** The absolute paths of the files `pid` has open. */ +export async function openFiles(pid) { + if (!validPid(pid)) return []; + if (LINUX) { + const directory = `/proc/${pid}/fd`; + const entries = await readdir(directory).catch(() => []); + const targets = await Promise.all( + entries.map((entry) => readlink(`${directory}/${entry}`).catch(() => null)) + ); + return targets.filter((target) => typeof target === 'string' && target.startsWith('/')); + } + const output = await run('lsof', ['-w', '-p', String(pid), '-Fn']); + if (!output) return []; + return output + .split('\n') + .filter((line) => line.startsWith('n/')) + .map((line) => line.slice(1)); +} + +/** Whether `pid` has `path` open. */ +export async function holdsFile(pid, path) { + const files = await openFiles(pid); + if (files.includes(path)) return true; + // macOS reports `/private/var/...` for `/var/...` and the like. + const real = await realpath(path).catch(() => null); + return real !== null && files.includes(real); +} + +/** + * The process's arguments. Exact on Linux (`/proc//cmdline`); on + * macOS `ps -o args=` joins them with spaces, so they are split on + * whitespace and an argument containing a space is split too. + */ +export async function argvOf(pid) { + if (!validPid(pid)) return []; + if (LINUX) { + const bytes = await readFile(`/proc/${pid}/cmdline`).catch(() => null); + if (!bytes) return []; + return bytes + .toString('utf8') + .split('\0') + .filter((part) => part.length > 0); + } + const output = await run('ps', ['-o', 'args=', '-p', String(pid)]); + return output ? output.trim().split(/\s+/).filter(Boolean) : []; +} + +/** Whether a process with this pid exists (it may belong to another user). */ +export function isAlive(pid) { + if (!validPid(pid)) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === 'EPERM'; + } +} + +/** + * Every process as `{pid, uid, executable}`: the kernel's executable path + * on Linux (`/proc//exe`), the full command path `ps -o comm=` + * reports on macOS. + */ +export async function listProcesses() { + if (LINUX) { + const entries = await readdir('/proc').catch(() => []); + const found = await Promise.all( + entries + .filter((entry) => /^\d+$/.test(entry)) + .map(async (entry) => { + const pid = Number.parseInt(entry, 10); + try { + const [executable, info] = await Promise.all([ + readlink(`/proc/${pid}/exe`), + stat(`/proc/${pid}`), + ]); + return { pid, uid: info.uid, executable: executable.replace(/ \(deleted\)$/, '') }; + } catch { + return null; + } + }) + ); + return found.filter(Boolean); + } + const output = await run('ps', ['-axo', 'pid=,uid=,comm=']); + if (!output) return []; + return output + .split('\n') + .map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/)) + .filter(Boolean) + .map((match) => ({ + pid: Number.parseInt(match[1], 10), + uid: Number.parseInt(match[2], 10), + executable: match[3], + })); +} + +/** + * Whether `executable` is a Codex this user may run as theirs: named + * `codex`, and on Linux owned by root or this user and writable by no one + * else (process.rs `is_trusted_codex`). The macOS OpenAI code-signature + * check is deliberately not ported (docs/connect-node-local-relay.md, + * "Not ported"). + */ +export async function isTrustedCodex(executable) { + if (basename(executable) !== 'codex') return false; + if (!LINUX) return true; + const info = await stat(executable).catch(() => null); + if (!info?.isFile()) return false; + const uid = process.getuid?.(); + return (info.uid === 0 || info.uid === uid) && (info.mode & 0o022) === 0; +} diff --git a/packages/connect/src/relay/sqlite.js b/packages/connect/src/relay/sqlite.js new file mode 100644 index 000000000..c2e2e3656 --- /dev/null +++ b/packages/connect/src/relay/sqlite.js @@ -0,0 +1,129 @@ +import { execFile } from 'node:child_process'; +import { stat } from 'node:fs/promises'; + +// Read-only access to Codex's SQLite files (queue_1.sqlite, state_5.sqlite) +// without an npm dependency: `node:sqlite` when this Node has it (22.13+, +// 23.4+ without a flag), else the `sqlite3` command line when it is on PATH, +// else nothing, and callers degrade (docs/connect-node-local-relay.md, +// "SQLite"). + +const BUSY_TIMEOUT_MS = 2_000; + +let backend; + +async function loadNodeSqlite() { + // node:sqlite prints an ExperimentalWarning when it loads; it is noise for + // a guest's terminal, so only that warning is held back, only here. + const original = process.emitWarning; + process.emitWarning = function emitWarning(warning, ...rest) { + const text = typeof warning === 'string' ? warning : (warning?.message ?? ''); + if (/sqlite/i.test(text)) return undefined; + return original.call(this, warning, ...rest); + }; + try { + const module = await import('node:sqlite'); + return typeof module.DatabaseSync === 'function' ? module.DatabaseSync : null; + } catch { + return null; + } finally { + process.emitWarning = original; + } +} + +function run(file, args, timeout = 10_000) { + return new Promise((resolve) => { + execFile(file, args, { timeout, maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => + resolve(error ? null : String(stdout)) + ); + }); +} + +/** + * Which reader this process uses: `{kind: 'node:sqlite', DatabaseSync}`, + * `{kind: 'sqlite3'}` or `{kind: null}`. Decided once. + */ +export async function sqliteBackend() { + if (backend) return backend; + const forced = process.env.AGENT_RELAY_CONNECT_SQLITE; + const DatabaseSync = forced === 'sqlite3' || forced === 'none' ? null : await loadNodeSqlite(); + if (DatabaseSync) { + backend = { kind: 'node:sqlite', DatabaseSync }; + } else if (forced !== 'none' && (await run('sqlite3', ['-version'])) !== null) { + backend = { kind: 'sqlite3' }; + } else { + backend = { kind: null }; + } + return backend; +} + +/** Forgets the chosen backend (tests). */ +export function resetSqliteBackend() { + backend = undefined; +} + +function literal(value) { + if (value === null || value === undefined) return 'null'; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return `'${String(value).replace(/'/g, "''")}'`; +} + +/** `sql` with each `?` replaced by a quoted literal, for the sqlite3 CLI. */ +function inline(sql, params) { + let index = 0; + return sql.replace(/\?/g, () => literal(params[index++])); +} + +/** + * Runs one read-only query against `file`: + * - `{status: 'missing'}` when there is no such file; + * - `{status: 'unavailable'}` when no SQLite reader exists on this computer; + * - `{status: 'unreadable'}` when it exists but cannot be read now; + * - `{status: 'ok', rows}` with each row as a plain object by column name. + * + * Statements come from this package only; `params` are bound (node:sqlite) + * or quoted (sqlite3 CLI). + */ +export async function queryReadOnly(file, sql, params = []) { + try { + await stat(file); + } catch (error) { + return error?.code === 'ENOENT' ? { status: 'missing' } : { status: 'unreadable' }; + } + const reader = await sqliteBackend(); + if (reader.kind === 'node:sqlite') { + let database; + try { + database = new reader.DatabaseSync(file, { readOnly: true }); + database.exec(`pragma busy_timeout = ${BUSY_TIMEOUT_MS}`); + const rows = database.prepare(sql).all(...params); + return { status: 'ok', rows: rows.map((row) => ({ ...row })) }; + } catch { + return { status: 'unreadable' }; + } finally { + try { + database?.close(); + } catch { + // Already closed or never opened. + } + } + } + if (reader.kind === 'sqlite3') { + const output = await run('sqlite3', [ + '-readonly', + '-json', + '-cmd', + `.timeout ${BUSY_TIMEOUT_MS}`, + file, + inline(sql, params), + ]); + if (output === null) return { status: 'unreadable' }; + if (output.trim() === '') return { status: 'ok', rows: [] }; + try { + const rows = JSON.parse(output); + return Array.isArray(rows) ? { status: 'ok', rows } : { status: 'unreadable' }; + } catch { + return { status: 'unreadable' }; + } + } + return { status: 'unavailable' }; +} diff --git a/packages/connect/src/relay/state.js b/packages/connect/src/relay/state.js new file mode 100644 index 000000000..b40ea16db --- /dev/null +++ b/packages/connect/src/relay/state.js @@ -0,0 +1,276 @@ +import { readFile } from 'node:fs/promises'; +import { writeAtomic } from './paths.js'; + +// The ledger mirrors the desktop probe's connect-sessions.json (relay-desktop +// probe/src/relay/connect.rs): one Connect per session target, and per +// delivery a pending, handled (delivered or in doubt) or failed record keyed +// by `agent_id:delivery_id`. Only the daemon writes it. + +export const RETRY_MIN_MS = 5_000; +export const RETRY_MAX_MS = 30_000; +export const HANDOFF_TTL_MS = 60 * 60 * 1_000; +export const JOIN_ATTEMPT_TTL_MS = 15 * 60 * 1_000; +const OUTCOMES_KEPT = 64; + +export function handoffTtlMs(value) { + const seconds = Number.parseInt(value ?? '', 10); + return Number.isInteger(seconds) && seconds >= 60 && seconds <= 24 * 60 * 60 + ? seconds * 1_000 + : HANDOFF_TTL_MS; +} + +export function targetKey(target) { + return `${target.kind}:${target.id}`; +} + +function empty() { + return { + version: 1, + sessions: {}, + join_attempts: {}, + pending: {}, + pending_delivery: {}, + handled: {}, + failures: {}, + notices: [], + outcomes: [], + }; +} + +export class RelayState { + constructor(path, data = empty(), { now = Date.now, write = writeAtomic } = {}) { + this.path = path; + this.data = data; + this.now = now; + this.write = write; + } + + static async load(path, options = {}) { + let data = empty(); + try { + const parsed = JSON.parse(await readFile(path, 'utf8')); + if (parsed && parsed.version === 1) data = { ...empty(), ...parsed }; + } catch (error) { + if (error?.code !== 'ENOENT') throw new Error(`Could not read ${path}: ${error.message}`); + } + return new RelayState(path, data, options); + } + + async persist() { + await this.write(this.path, `${JSON.stringify(this.data, null, 2)}\n`); + } + + // Sessions ----------------------------------------------------------------- + + session(target) { + return this.data.sessions[targetKey(target)] || null; + } + + sessions() { + return Object.values(this.data.sessions); + } + + async insertSession(session) { + const key = targetKey(session.target); + const previous = this.data.sessions[key]; + const previousAttempt = this.data.join_attempts[key]; + this.data.sessions[key] = session; + delete this.data.join_attempts[key]; + try { + await this.persist(); + } catch (error) { + if (previous) this.data.sessions[key] = previous; + else delete this.data.sessions[key]; + if (previousAttempt) this.data.join_attempts[key] = previousAttempt; + throw error; + } + } + + /** Removes the session and every ledger entry for its credential. */ + async removeSession(target, agentId) { + const key = targetKey(target); + const current = this.data.sessions[key]; + if (!current || (agentId && current.agent_id !== agentId)) return null; + delete this.data.sessions[key]; + const prefix = `${current.agent_id}:`; + for (const map of ['pending', 'pending_delivery', 'handled', 'failures']) { + for (const ledger of Object.keys(this.data[map])) { + if (ledger.startsWith(prefix)) delete this.data[map][ledger]; + } + } + await this.persist(); + return current; + } + + async observeParticipant(session, name) { + const current = this.session(session.target); + if (!current || current.agent_id !== session.agent_id) return; + const known = current.participants.some((p) => p.agent_name === name); + if (known || current.observed_participants.includes(name) || name === current.agent_name) return; + current.observed_participants.push(name); + await this.persist(); + } + + // Join attempts -------------------------------------------------------------- + + /** The idempotency key for this join, reusing a live attempt with the same request. */ + async beginJoinAttempt(target, requestHash, newKey) { + const key = targetKey(target); + const now = this.now(); + const current = this.data.join_attempts[key]; + if (current && current.expires_at_ms > now) { + if (current.request_hash !== requestHash) return null; + return current.idempotency_key; + } + this.data.join_attempts[key] = { + request_hash: requestHash, + idempotency_key: newKey, + expires_at_ms: now + JOIN_ATTEMPT_TTL_MS, + }; + await this.persist(); + return newKey; + } + + async clearJoinAttempt(target, idempotencyKey) { + const key = targetKey(target); + if (this.data.join_attempts[key]?.idempotency_key !== idempotencyKey) return; + delete this.data.join_attempts[key]; + await this.persist(); + } + + // Delivery ledger ---------------------------------------------------------------- + + settled(ledger) { + if (this.data.handled[ledger]) return { kind: 'handled', ...this.data.handled[ledger] }; + if (this.data.failures[ledger]) return { kind: 'failed', ...this.data.failures[ledger] }; + return null; + } + + async rememberPending(ledger, messageId) { + if (!this.data.pending[ledger]) this.data.pending[ledger] = messageId; + if (!this.data.pending_delivery[ledger]) { + this.data.pending_delivery[ledger] = { + kind: 'waiting', + detail: 'Queued for local delivery.', + first_seen_ms: this.now(), + last_attempt_ms: 0, + next_attempt_ms: 0, + attempts: 0, + accepted: false, + behind_turn: false, + }; + } + await this.persist(); + } + + /** `{wait:true}`, or `{wait:false, finalAttempt}` once the handoff TTL has passed. */ + pendingAction(ledger, ttlMs) { + const pending = this.data.pending_delivery[ledger]; + if (!pending) return { wait: false, finalAttempt: false }; + const now = this.now(); + if (!pending.accepted && now - pending.first_seen_ms >= ttlMs) return { wait: false, finalAttempt: true }; + if (pending.next_attempt_ms > now) return { wait: true }; + return { wait: false, finalAttempt: false }; + } + + pendingWasAccepted(ledger) { + return this.data.pending_delivery[ledger]?.accepted === true; + } + + async rememberWaiting(ledger, kind, detail) { + const pending = this.data.pending_delivery[ledger]; + if (!pending) throw new Error('pending delivery disappeared before its outcome was recorded'); + const now = this.now(); + pending.kind = kind; + pending.accepted = pending.accepted || kind === 'accepted'; + pending.detail = detail; + pending.last_attempt_ms = now; + pending.attempts += 1; + // Observing an accepted handoff only reads the provider's queue and + // transcript, so it never backs off. + const shift = pending.accepted ? 0 : Math.min(3, Math.max(0, pending.attempts - 1)); + pending.next_attempt_ms = now + Math.min(RETRY_MAX_MS, RETRY_MIN_MS * 2 ** shift); + await this.persist(); + } + + /** Returns a failure detail when this was the final attempt, otherwise null after backing off. */ + async rememberUnacceptedAttempt(ledger, messageId, kind, reason, finalAttempt) { + if (finalAttempt) { + const detail = `The local session remained unavailable without accepting the handoff — ${reason}`; + await this.rememberFailed(ledger, messageId, 'unavailable', detail); + return detail; + } + const detail = + kind === 'retry' + ? `Local delivery will retry — ${reason}` + : `Waiting for the local session — ${reason}`; + await this.rememberWaiting(ledger, kind, detail); + return null; + } + + /** Records that the provider's queue holds the message. Returns whether that changed. */ + async rememberAccepted(ledger, behindTurn, reason) { + const pending = this.data.pending_delivery[ledger]; + if (!pending) throw new Error('pending delivery disappeared before its outcome was recorded'); + const changed = !pending.accepted || pending.behind_turn !== behindTurn; + pending.behind_turn = behindTurn; + pending.accepted_at_ms ??= this.now(); + await this.rememberWaiting(ledger, 'accepted', `Accepted by the local session queue — ${reason}`); + return changed; + } + + async rememberHandled(ledger, messageId, outcome, detail) { + delete this.data.pending[ledger]; + delete this.data.pending_delivery[ledger]; + this.data.handled[ledger] = { outcome, message_id: messageId, detail, at_ms: this.now() }; + this.recordOutcome(ledger, outcome, detail); + await this.persist(); + } + + async rememberFailed(ledger, messageId, outcome, detail) { + delete this.data.pending[ledger]; + delete this.data.pending_delivery[ledger]; + this.data.failures[ledger] = { outcome, message_id: messageId, detail, at_ms: this.now() }; + this.recordOutcome(ledger, outcome, detail); + await this.persist(); + } + + recordOutcome(ledger, outcome, detail) { + this.data.outcomes.unshift({ ledger, outcome, detail, at_ms: this.now() }); + this.data.outcomes.length = Math.min(this.data.outcomes.length, OUTCOMES_KEPT); + } + + deliveryPublic(session) { + const prefix = `${session.agent_id}:`; + const pending = Object.entries(this.data.pending_delivery) + .filter(([ledger]) => ledger.startsWith(prefix)) + .map(([ledger, entry]) => ({ + delivery_id: ledger.slice(prefix.length), + state: entry.kind, + detail: entry.detail, + attempts: entry.attempts, + })); + const recent = this.data.outcomes + .filter((entry) => entry.ledger.startsWith(prefix)) + .slice(0, 10) + .map((entry) => ({ + delivery_id: entry.ledger.slice(prefix.length), + outcome: entry.outcome, + detail: entry.detail, + })); + return pending.length || recent.length ? { pending, recent } : null; + } + + // Notices for sessions whose Connect closed ----------------------------------------- + + async queueNotice(notice) { + if (this.data.notices.some((queued) => queued.id === notice.id)) return; + this.data.notices.push(notice); + await this.persist(); + } + + async dropNotice(id) { + this.data.notices = this.data.notices.filter((notice) => notice.id !== id); + await this.persist(); + } +} diff --git a/packages/connect/tests/connect.test.ts b/packages/connect/tests/connect.test.ts index 3b9dffc70..f733917c7 100644 --- a/packages/connect/tests/connect.test.ts +++ b/packages/connect/tests/connect.test.ts @@ -86,8 +86,12 @@ async function runCli(home: string, args: string[], input = '') { if (error.code !== 'EEXIST') throw error; }); return await new Promise<{ code: number | null; stdout: string; stderr: string }>((resolveRun) => { + // Never let the session running these tests be identified as the caller. + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !/^(CLAUDE|CODEX)/.test(key)) + ); const child = spawn(process.execPath, [launcher, ...args], { - env: { ...process.env, HOME: home }, + env: { ...env, HOME: home }, stdio: ['pipe', 'pipe', 'pipe'], }); const stdout: Buffer[] = []; @@ -1225,6 +1229,150 @@ 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); + // The CLI names the session itself; with no session and a probe that + // cannot place it either, it stops with what it checked. + expect(result).toEqual({ + code: 6, + stdout: '', + stderr: + 'Run this from a live Claude Code or Codex session (no Codex thread id in the environment; no Claude Code session record for any parent process). In Claude Code, if a permission check blocks the command, run it yourself with the ! prefix.\n', + }); + expect(seen).toEqual(['GET /setup/status']); + }); + + 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. diff --git a/packages/connect/tests/relay-claude.test.ts b/packages/connect/tests/relay-claude.test.ts new file mode 100644 index 000000000..cd9b32e3c --- /dev/null +++ b/packages/connect/tests/relay-claude.test.ts @@ -0,0 +1,395 @@ +import { spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import net from 'node:net'; +import os from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + claudeCallerSession, + claudeHomes, + claudePreflight, + ensureClaudeDirectDelivery, + findClaudeSession, + firstJsonValue, + injectClaude, +} from '../src/relay/claude.js'; +import { processStart } from '../src/relay/processes.js'; + +const SESSION = '0f8fad5b-d9cb-469f-a165-70867728950e'; +const OTHER_SESSION = '7c9e6679-7425-40de-944b-e07fc1f90ae7'; +const cleanups: Array<() => Promise | void> = []; + +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); + +async function tempHome() { + // Short, so the inbox socket path stays under the Unix socket limit. + const home = await mkdtemp(join(process.env.TMPDIR || os.tmpdir(), 'rc-')); + cleanups.push(() => rm(home, { recursive: true, force: true })); + const env = { PATH: process.env.PATH ?? '' }; + const sessions = join(home, '.claude/sessions'); + await mkdir(sessions, { recursive: true }); + return { home, env, sessions }; +} + +type Record = { + pid: number; + sessionId?: string; + procStart?: string | null; + socket: string; + peerProtocol?: number; +}; + +async function writeRecord(sessions: string, record: Record, trailing = '') { + const body = { + pid: record.pid, + sessionId: record.sessionId ?? SESSION, + cwd: '/work/project', + procStart: record.procStart, + peerProtocol: record.peerProtocol ?? 1, + kind: 'interactive', + messagingSocketPath: record.socket, + name: 'project', + status: 'idle', + }; + await writeFile(join(sessions, `${record.pid}.json`), JSON.stringify(body) + trailing); +} + +async function writeKey( + sessions: string, + pid: number, + hash: string, + token: string, + procStart: string | null +) { + const path = join(sessions, `${pid}.${hash}.key`); + await writeFile(path, JSON.stringify({ peerToken: token, procStart })); + await chmod(path, 0o600); +} + +/** A fake Claude Code inbox: records every line received on each connection. */ +async function fakeInbox(home: string) { + const path = join(home, 'in.sock'); + const connections: string[][] = []; + const server = net.createServer((socket) => { + let data = ''; + socket.on('data', (chunk) => { + data += chunk; + }); + socket.on('end', () => { + connections.push(data.split('\n').filter(Boolean)); + socket.end(); + }); + }); + await new Promise((resolve) => server.listen(path, resolve)); + cleanups.push(() => new Promise((resolve) => server.close(() => resolve()))); + return { path, connections }; +} + +/** A socket stand-in whose `failAt`-th write fails with EPIPE. */ +class FailingSocket extends EventEmitter { + writes: Buffer[] = []; + destroyed = false; + constructor(private failAt: number) { + super(); + setImmediate(() => this.emit('connect')); + } + write(bytes: Buffer, callback: (error?: Error) => void) { + this.writes.push(bytes); + if (this.writes.length === this.failAt) { + setImmediate(() => { + const error = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }); + this.emit('error', error); + callback(error); + }); + } else { + setImmediate(() => callback()); + } + return true; + } + end() { + setImmediate(() => this.emit('end')); + } + destroy() { + this.destroyed = true; + } +} + +describe('Claude Code sessions', () => { + it('reads the first JSON value of a record and ignores trailing junk', () => { + expect(firstJsonValue('{"a":"}{","b":[1,{"c":2}]}\u0000\u0000}garbage')).toEqual({ + a: '}{', + b: [1, { c: 2 }], + }); + expect(firstJsonValue(' {"x":"a\\"b"} {"y":1}')).toEqual({ x: 'a"b' }); + expect(() => firstJsonValue('{"unterminated":')).toThrow(); + expect(() => firstJsonValue('')).toThrow(); + }); + + it('lists the configured login and sibling logins once each', async () => { + const { home, env } = await tempHome(); + await mkdir(join(home, '.claude-work/sessions'), { recursive: true }); + await mkdir(join(home, '.claude-empty'), { recursive: true }); + expect(claudeHomes({ home, env })).toEqual([join(home, '.claude'), join(home, '.claude-work')]); + expect(claudeHomes({ home, env: { CLAUDE_CONFIG_DIR: '~/.claude-work' } })).toEqual([ + join(home, '.claude-work'), + join(home, '.claude'), + ]); + }); + + it('delivers the auth line and the user message with the key matching the start time', async () => { + const { home, env, sessions } = await tempHome(); + const inbox = await fakeInbox(home); + const start = (await processStart(process.pid))!; + await writeRecord( + sessions, + { pid: process.pid, procStart: start, socket: inbox.path }, + '\u0000\u0000"}stray' + ); + await writeKey(sessions, process.pid, 'aaaa', 'stale-token', 'Thu Jan 1 00:00:00 1970'); + await writeKey(sessions, process.pid, 'bbbb', 'live-token', start); + + const found = await findClaudeSession({ home, env, sessionId: SESSION }); + expect(found).toMatchObject({ + sessionId: SESSION, + pid: process.pid, + peerProtocol: 1, + cwd: '/work/project', + }); + expect(await claudePreflight({ home, env, sessionId: SESSION })).toMatchObject({ ok: true }); + + const result = await injectClaude({ + home, + env, + sessionId: SESSION, + messageId: 'msg_1', + text: 'hello\nthere', + }); + expect(result.outcome).toBe('delivered'); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(inbox.connections).toHaveLength(1); + const [auth, message] = inbox.connections[0]; + expect(auth).toBe('{"token":"live-token","type":"auth"}'); + const parsed = JSON.parse(message); + expect(Object.keys(parsed)).toEqual(['message', 'msg_id', 'priority', 'type', 'uuid']); + expect(parsed).toMatchObject({ + type: 'user', + msg_id: 'msg_1', + priority: 'next', + message: { role: 'user', content: 'hello\nthere' }, + }); + expect(parsed.uuid).toMatch(/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/); + }); + + it('leaves a message waiting when the recorded process is gone', async () => { + const { home, env, sessions } = await tempHome(); + const inbox = await fakeInbox(home); + const child = spawn('sleep', ['30'], { stdio: 'ignore' }); + await new Promise((resolve) => setTimeout(resolve, 200)); + const start = (await processStart(child.pid!))!; + child.kill('SIGKILL'); + await new Promise((resolve) => child.once('exit', resolve)); + await writeRecord(sessions, { pid: child.pid!, procStart: start, socket: inbox.path }); + await writeKey(sessions, child.pid!, 'aaaa', 'token', start); + + expect(await injectClaude({ home, env, sessionId: SESSION, messageId: 'm', text: 'x' })).toEqual({ + outcome: 'waiting', + detail: 'that Claude Code session is not running', + }); + expect((await claudePreflight({ home, env, sessionId: SESSION })).ok).toBe(false); + expect(inbox.connections).toHaveLength(0); + }); + + it('does not take a recycled pid for the session', async () => { + const { home, env, sessions } = await tempHome(); + const inbox = await fakeInbox(home); + const recycled = process.platform === 'linux' ? '1' : 'Thu Jan 1 00:00:01 1970'; + await writeRecord(sessions, { pid: process.pid, procStart: recycled, socket: inbox.path }); + expect((await injectClaude({ home, env, sessionId: SESSION, messageId: 'm', text: 'x' })).outcome).toBe( + 'waiting' + ); + }); + + it('retries when the inbox key is missing, and refuses an id that is not a session id', async () => { + const { home, env, sessions } = await tempHome(); + const inbox = await fakeInbox(home); + await writeRecord(sessions, { + pid: process.pid, + procStart: await processStart(process.pid), + socket: inbox.path, + }); + expect(await injectClaude({ home, env, sessionId: SESSION, messageId: 'm', text: 'x' })).toEqual({ + outcome: 'retry', + detail: "could not read that Claude Code session's inbox key", + }); + expect(await claudePreflight({ home, env, sessionId: SESSION })).toEqual({ + ok: false, + reason: "could not read that Claude Code session's inbox key", + }); + expect((await injectClaude({ home, env, sessionId: 'nope', messageId: 'm', text: 'x' })).outcome).toBe( + 'refused' + ); + expect(inbox.connections).toHaveLength(0); + }); + + it('retries an unknown peer protocol', async () => { + const { home, env, sessions } = await tempHome(); + const start = (await processStart(process.pid))!; + await writeRecord(sessions, { + pid: process.pid, + procStart: start, + socket: join(home, 'x.sock'), + peerProtocol: 2, + }); + await writeKey(sessions, process.pid, 'aaaa', 'token', start); + const result = await injectClaude({ home, env, sessionId: SESSION, messageId: 'm', text: 'x' }); + expect(result.outcome).toBe('retry'); + expect(result.detail).toContain('peer protocol 2'); + }); + + it('retries when the inbox cannot be reached or the auth line fails, and is in doubt mid-message', async () => { + const { home, env, sessions } = await tempHome(); + const start = (await processStart(process.pid))!; + await writeRecord(sessions, { pid: process.pid, procStart: start, socket: join(home, 'gone.sock') }); + await writeKey(sessions, process.pid, 'aaaa', 'token', start); + + const unreachable = await injectClaude({ home, env, sessionId: SESSION, messageId: 'm', text: 'x' }); + expect(unreachable.outcome).toBe('retry'); + expect(unreachable.detail).toMatch(/^could not reach the session's inbox: /); + + let authFails!: FailingSocket; + const auth = await injectClaude({ + home, + env, + sessionId: SESSION, + messageId: 'm', + text: 'x', + connect: () => (authFails = new FailingSocket(1)) as unknown as net.Socket, + }); + expect(auth.outcome).toBe('retry'); + expect(auth.detail).toContain('EPIPE'); + expect(authFails.writes).toHaveLength(1); + expect(authFails.destroyed).toBe(true); + + const mid = await injectClaude({ + home, + env, + sessionId: SESSION, + messageId: 'm', + text: 'x', + connect: () => new FailingSocket(2) as unknown as net.Socket, + }); + expect(mid.outcome).toBe('in_doubt'); + expect(mid.detail).toMatch(/^the session's inbox connection broke while sending: /); + }); + + it('names the session of the nearest ancestor with a matching record', async () => { + const { home, env, sessions } = await tempHome(); + const parentStart = (await processStart(process.ppid))!; + await writeRecord(sessions, { + pid: process.ppid, + sessionId: OTHER_SESSION, + procStart: parentStart, + socket: '/x', + }); + expect(await claudeCallerSession({ home, env, pid: process.pid })).toBe(OTHER_SESSION); + + // A record of the calling process itself is nearer and wins. + await writeRecord(sessions, { + pid: process.pid, + procStart: await processStart(process.pid), + socket: '/y', + }); + expect(await claudeCallerSession({ home, env, pid: process.pid })).toBe(SESSION); + + // A record without a start time can never be replied from. + await writeRecord(sessions, { pid: process.pid, procStart: null, socket: '/y' }); + expect(await claudeCallerSession({ home, env, pid: process.pid })).toBe(OTHER_SESSION); + + // A running session whose record does not parse ends the walk. + await writeFile(join(sessions, `${process.pid}.json`), '{ broken'); + await writeKey(sessions, process.pid, 'cccc', 'token', await processStart(process.pid)); + expect(await claudeCallerSession({ home, env, pid: process.pid })).toBeNull(); + }); +}); + +describe('ensureClaudeDirectDelivery', () => { + it('sets accept keeping other keys, keeps the mode and backs the original up once', async () => { + const { home, env } = await tempHome(); + const settings = join(home, '.claude/settings.json'); + const original = '{"model": "opus", "env": {"TOKEN": "x"}, "permissions": {"allow": ["Bash(ls)"]}}'; + await writeFile(settings, original); + await chmod(settings, 0o600); + const managedSettingsPath = join(home, 'managed-settings.json'); + + expect(await ensureClaudeDirectDelivery({ home, env, managedSettingsPath })).toMatchObject({ + changed: true, + }); + const updated = JSON.parse(await readFile(settings, 'utf8')); + expect(updated).toEqual({ + model: 'opus', + env: { TOKEN: 'x' }, + permissions: { allow: ['Bash(ls)'] }, + crossSessionInbound: 'accept', + }); + const backup = `${settings}.relay-desktop-backup`; + expect(await readFile(backup, 'utf8')).toBe(original); + expect((await stat(settings)).mode & 0o777).toBe(0o600); + expect((await stat(backup)).mode & 0o777).toBe(0o600); + + expect(await ensureClaudeDirectDelivery({ home, env, managedSettingsPath })).toMatchObject({ + changed: false, + }); + + await writeFile(settings, '{"crossSessionInbound": "hold", "model": "opus"}'); + expect(await ensureClaudeDirectDelivery({ home, env, managedSettingsPath })).toMatchObject({ + changed: true, + }); + expect(await readFile(backup, 'utf8')).toBe(original); + expect(JSON.parse(await readFile(settings, 'utf8'))).toEqual({ + crossSessionInbound: 'accept', + model: 'opus', + }); + }); + + it('creates a private settings file where there is none, and refuses one that is not JSON', async () => { + const { home, env } = await tempHome(); + const managedSettingsPath = join(home, 'managed-settings.json'); + expect(await ensureClaudeDirectDelivery({ home, env, managedSettingsPath })).toMatchObject({ + changed: true, + }); + const settings = join(home, '.claude/settings.json'); + expect(JSON.parse(await readFile(settings, 'utf8'))).toEqual({ crossSessionInbound: 'accept' }); + expect((await stat(settings)).mode & 0o777).toBe(0o600); + + await writeFile(settings, '{ broken'); + await expect(ensureClaudeDirectDelivery({ home, env, managedSettingsPath })).rejects.toMatchObject({ + code: 'claude_settings_unreadable', + }); + expect(await readFile(settings, 'utf8')).toBe('{ broken'); + }); + + it('refuses when managed settings pin another value, naming the managed file', async () => { + const { home, env } = await tempHome(); + const settings = join(home, '.claude/settings.json'); + await writeFile(settings, '{"model": "opus"}'); + const managedSettingsPath = join(home, 'managed-settings.json'); + await writeFile(managedSettingsPath, '{"crossSessionInbound": "hold"}'); + + const error = await ensureClaudeDirectDelivery({ home, env, managedSettingsPath }).catch( + (caught) => caught + ); + expect(error).toBeInstanceOf(Error); + expect(error.code).toBe('claude_inbound_managed'); + expect(error.message).toContain(managedSettingsPath); + expect(await readFile(settings, 'utf8')).toBe('{"model": "opus"}'); + + await writeFile(managedSettingsPath, '{"crossSessionInbound": "accept"}'); + expect(await ensureClaudeDirectDelivery({ home, env, managedSettingsPath })).toMatchObject({ + changed: false, + }); + expect(await readFile(settings, 'utf8')).toBe('{"model": "opus"}'); + }); +}); diff --git a/packages/connect/tests/relay-codex.test.ts b/packages/connect/tests/relay-codex.test.ts new file mode 100644 index 000000000..a48c3759f --- /dev/null +++ b/packages/connect/tests/relay-codex.test.ts @@ -0,0 +1,460 @@ +import { existsSync } from 'node:fs'; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import net from 'node:net'; +import os from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + BEHIND_TURN, + BUSY, + FrameBuffer, + HANDED_NOT_RUNNING, + NOT_CONSUMED, + NOT_RUNNING, + RECORDING, + codexCallerThread, + codexPaths, + codexSqliteBackend, + createCodexRouter, + encodeFrame, + messageRef, + messageRefInText, + queueOutcome, +} from '../src/relay/codex.js'; +import { resetSqliteBackend } from '../src/relay/sqlite.js'; + +const THREAD = '01a0bcd4-0b35-7d42-8d7d-6375f7732587'; +const cleanups: Array<() => Promise | void> = []; + +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); + +/** A Connect message as the daemon injects it: the attribution line first. */ +function connectText(messageId: string, body = 'Please review this.') { + return `[Relay Connect — from guest · ref ${messageRef(messageId)}]\n[reply: npx -y @agent-relay/connect send guest "..."]\n\n${body}`; +} + +/** A fake `codex` that supports `queue` (or not) and writes queue rows like Codex. */ +function fakeCodex({ queue = true, version = '0.130.0' } = {}) { + return `#!${process.execPath} --no-warnings +const { appendFileSync } = require('node:fs'); +const { randomUUID } = require('node:crypto'); +const args = process.argv.slice(2); +appendFileSync(process.env.CODEX_HOME + '/fake-codex.log', JSON.stringify(args) + '\\n'); +(async () => { + if (args[0] === '--version') { console.log('codex-cli ${version}'); return; } + if (args[0] === 'queue' && args[1] === '--help') { + if (!${queue}) { console.error("error: unrecognized subcommand 'queue'"); process.exit(2); } + console.log('Usage: codex queue --thread --message '); + return; + } + if (args[0] === 'queue' && args[1] === '--thread' && args[3].startsWith('--message=')) { + const { DatabaseSync } = await import('node:sqlite'); + const db = new DatabaseSync(process.env.CODEX_SQLITE_HOME + '/queue_1.sqlite'); + db.exec('create table if not exists queued_items (id text, thread_id text, payload_json text, created_at_ms integer)'); + const id = randomUUID(); + const payload = JSON.stringify({ items: [{ type: 'text', text: args[3].slice('--message='.length) }] }); + db.prepare('insert into queued_items values (?, ?, ?, ?)').run(id, args[2], payload, Date.now()); + db.close(); + console.log('Queued message ' + id + ' for thread ' + args[2] + '.'); + return; + } + console.error('unexpected arguments'); + process.exit(1); +})(); +`; +} + +async function setup({ queue = true } = {}) { + const home = await mkdtemp(join(process.env.TMPDIR || os.tmpdir(), 'cx-')); + cleanups.push(() => rm(home, { recursive: true, force: true })); + const bin = join(home, 'bin'); + await mkdir(bin, { recursive: true }); + await writeFile(join(bin, 'codex'), fakeCodex({ queue })); + await chmod(join(bin, 'codex'), 0o755); + const env = { CODEX_HOME: join(home, '.codex'), PATH: `${bin}:/usr/bin:/bin` }; + const paths = codexPaths({ home, env }); + await mkdir(paths.codexHome, { recursive: true }); + const clock = { now: 1_000_000 }; + const log = async () => + (await readFile(join(paths.codexHome, 'fake-codex.log'), 'utf8').catch(() => '')) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); + return { home, env, paths, clock, log, now: () => clock.now }; +} + +async function writeRollout(codexHome: string, thread: string, events: object[]) { + const directory = join(codexHome, 'sessions/2026/10/12'); + await mkdir(directory, { recursive: true }); + const path = join(directory, `rollout-2026-10-12T10-00-00-${thread}.jsonl`); + const lines = [ + { type: 'session_meta', payload: { id: thread } }, + { type: 'response_item', payload: { type: 'message', content: 'model output' } }, + ...events, + ].map((event) => JSON.stringify(event)); + await writeFile(path, lines.join('\n') + '\n'); + return path; +} + +function userMessage(turn: string, text: string) { + return { + type: 'event_msg', + payload: { + type: 'item_completed', + turn_id: turn, + item: { type: 'UserMessage', content: [{ type: 'text', text }] }, + }, + }; +} + +async function clearQueue(sqliteHome: string) { + const { DatabaseSync } = await import('node:sqlite'); + const db = new DatabaseSync(join(sqliteHome, 'queue_1.sqlite')); + db.exec('delete from queued_items'); + db.close(); +} + +describe('message references', () => { + it('reads the reference from a relay attribution line only', () => { + const ref = messageRef('message-connect-1'); + expect(ref).toMatch(/^[0-9a-f]{12}$/); + expect(messageRefInText(`[Relay Connect — from guest · ref ${ref}]\n\nPlease review this.`)).toBe(ref); + expect( + messageRefInText(`[via Agent Relay — from @alice to @codex · message m1 · ref ${ref}]\r\nhi`) + ).toBe(ref); + expect(messageRefInText(`hello [Relay Connect — from guest · ref ${ref}]`)).toBeNull(); + expect(messageRefInText(`[Relay Connect — from guest · ref ABCDEF123456]`)).toBeNull(); + }); + + it('reads codex queue confirmations like the probe', () => { + expect(queueOutcome(0, `Queued message 01a0bd9b-5bf2 for thread ${THREAD}.\n`)).toEqual({ + queued: true, + item: '01a0bd9b-5bf2', + }); + expect(queueOutcome(0, '', '')).toEqual({ queued: false, reason: 'codex exited with code 0' }); + expect(queueOutcome(1, 'Queued message x', '\nerror: no session found\nmore')).toEqual({ + queued: false, + reason: 'error: no session found', + }); + expect(queueOutcome(2, '', 'x'.repeat(400)).reason).toHaveLength(300); + }); +}); + +describe('Codex router', () => { + it('is delivered once the rollout records the message, without running codex', async () => { + const { home, env, paths, log, now } = await setup(); + await writeRollout(paths.codexHome, THREAD, [ + { type: 'event_msg', payload: { type: 'task_started', turn_id: 't1' } }, + userMessage('t1', connectText('m1')), + ]); + const router = createCodexRouter({ home, env, now, systemCodex: [], terminalLive: () => true }); + expect(await router.inject({ threadId: THREAD, messageId: 'm1', text: connectText('m1') })).toMatchObject( + { + outcome: 'delivered', + } + ); + expect( + await router.observe({ threadId: THREAD, messageId: 'm1', text: connectText('m1') }) + ).toMatchObject({ + outcome: 'delivered', + }); + expect(await log()).toEqual([]); + }); + + it('hands a live terminal the message through codex queue, then observes it until the rollout or 45 s', async () => { + const { home, env, paths, clock, log, now } = await setup(); + const router = createCodexRouter({ home, env, now, systemCodex: [], terminalLive: () => true }); + const text = connectText('m2', '--dangerously-bypass-approvals-and-sandbox'); + + expect(await router.inject({ threadId: THREAD, messageId: 'm2', text })).toEqual({ + outcome: 'accepted', + detail: NOT_CONSUMED, + }); + expect(await log()).toEqual([ + ['queue', '--help'], + ['queue', '--thread', THREAD, `--message=${text}`], + ]); + + // Still queued: observed, and a second inject never writes again. + clock.now += 30_000; + expect(await router.observe({ threadId: THREAD, messageId: 'm2', text })).toEqual({ + outcome: 'accepted', + detail: NOT_CONSUMED, + }); + expect((await router.inject({ threadId: THREAD, messageId: 'm2', text })).outcome).toBe('accepted'); + expect((await log()).filter((args) => args[1] === '--thread')).toHaveLength(1); + + // Codex took it from the queue but has not recorded it yet. + await clearQueue(paths.sqliteHome); + clock.now += 44_000; + expect(await router.observe({ threadId: THREAD, messageId: 'm2', text })).toEqual({ + outcome: 'accepted', + detail: RECORDING, + }); + clock.now += 1_000; + expect(await router.observe({ threadId: THREAD, messageId: 'm2', text })).toMatchObject({ + outcome: 'in_doubt', + }); + }); + + it('confirms a queued message from the rollout, and waits behind a running turn', async () => { + const { home, env, paths, clock, now } = await setup(); + await writeRollout(paths.codexHome, THREAD, [ + { type: 'event_msg', payload: { type: 'task_started', turn_id: 't1' } }, + ]); + const router = createCodexRouter({ home, env, now, systemCodex: [], terminalLive: () => true }); + const text = connectText('m3'); + expect(await router.inject({ threadId: THREAD, messageId: 'm3', text })).toEqual({ + outcome: 'waiting_for_turn', + detail: BEHIND_TURN, + }); + + await clearQueue(paths.sqliteHome); + await writeRollout(paths.codexHome, THREAD, [ + { type: 'event_msg', payload: { type: 'task_started', turn_id: 't1' } }, + { type: 'event_msg', payload: { type: 'task_complete', turn_id: 't1', last_agent_message: 'done' } }, + userMessage('t2', text), + ]); + clock.now += 120_000; + expect(await router.observe({ threadId: THREAD, messageId: 'm3', text })).toMatchObject({ + outcome: 'delivered', + }); + }); + + it('queues behind relay messages already waiting even when no terminal runs the thread', async () => { + const { home, env, now } = await setup(); + const router = createCodexRouter({ home, env, now, systemCodex: [], terminalLive: () => true }); + await router.inject({ threadId: THREAD, messageId: 'first', text: connectText('first') }); + const offline = createCodexRouter({ home, env, now, systemCodex: [], terminalLive: () => false }); + expect( + await offline.inject({ threadId: THREAD, messageId: 'second', text: connectText('second') }) + ).toEqual({ + outcome: 'accepted', + detail: HANDED_NOT_RUNNING, + }); + }); + + it('leaves a message waiting when no terminal and no Codex app has the thread', async () => { + const { home, env, paths, log, now } = await setup(); + // The real process scan: no Codex holds this test thread's writer lock. + const router = createCodexRouter({ home, env, now, systemCodex: [] }); + expect(await router.inject({ threadId: THREAD, messageId: 'm4', text: connectText('m4') })).toEqual({ + outcome: 'waiting', + detail: NOT_RUNNING, + }); + await writeRollout(paths.codexHome, THREAD, [ + { type: 'event_msg', payload: { type: 'task_started', turn_id: 't1' } }, + ]); + expect(await router.inject({ threadId: THREAD, messageId: 'm4', text: connectText('m4') })).toEqual({ + outcome: 'waiting', + detail: BUSY, + }); + expect((await log()).filter((args) => args[1] === '--thread')).toEqual([]); + expect((await router.inject({ threadId: 'not-a-thread', messageId: 'm', text: 'x' })).outcome).toBe( + 'refused' + ); + }); + + it('starts a turn through the Codex app window that owns the thread', async () => { + const { home, env, paths, now } = await setup(); + await mkdir(join(paths.codexHome, 'ipc'), { recursive: true }); + const seen: any[] = []; + let refuse: string | null = null; + const server = net.createServer((socket) => { + const buffer = new FrameBuffer(); + const send = (value: object) => socket.write(encodeFrame(Buffer.from(JSON.stringify(value)))); + socket.on('data', (chunk) => { + for (const payload of buffer.append(chunk)) { + const request = JSON.parse(payload.toString()); + seen.push(request); + const id = request.requestId; + if (request.method === 'initialize') { + // A discovery request and a broadcast arrive first. + send({ type: 'client-discovery-request', requestId: 'd1' }); + send({ type: 'broadcast', method: 'thread-stream-state-changed' }); + send({ type: 'response', requestId: id, resultType: 'success', result: { clientId: 'relay-1' } }); + } else if (request.method === 'thread-owner-discovery') { + send( + request.params.conversationId === THREAD + ? { type: 'response', requestId: id, resultType: 'success', handledByClientId: 'owner-1' } + : { type: 'response', requestId: id, resultType: 'error', error: 'no-client-found' } + ); + } else if (request.method === 'thread-follower-start-turn') { + send( + refuse + ? { type: 'response', requestId: id, resultType: 'error', error: refuse } + : { + type: 'response', + requestId: id, + resultType: 'success', + result: { result: { turn: { id: 'turn-1', status: 'inProgress' } } }, + } + ); + } + } + }); + }); + await new Promise((resolve) => server.listen(paths.ipcSocket, resolve)); + cleanups.push(() => new Promise((resolve) => server.close(() => resolve()))); + + const router = createCodexRouter({ home, env, now, systemCodex: [], terminalLive: () => false }); + const text = connectText('m5'); + expect(await router.inject({ threadId: THREAD, messageId: 'm5', text })).toMatchObject({ + outcome: 'delivered', + }); + + const [initialize, decline, discovery, start] = seen; + expect(initialize).toMatchObject({ + type: 'request', + method: 'initialize', + version: 0, + sourceClientId: 'initializing-client', + params: { clientType: 'agent-relay-desktop' }, + }); + expect(decline).toEqual({ + type: 'client-discovery-response', + requestId: 'd1', + response: { canHandle: false }, + }); + expect(discovery).toMatchObject({ + method: 'thread-owner-discovery', + version: 1, + sourceClientId: 'relay-1', + params: { hostId: 'local', conversationId: THREAD }, + }); + expect(discovery.targetClientId).toBeUndefined(); + expect(start).toMatchObject({ + method: 'thread-follower-start-turn', + version: 2, + sourceClientId: 'relay-1', + targetClientId: 'owner-1', + timeoutMs: 30_000, + params: { + conversationId: THREAD, + turnStart: { + request: { + threadId: THREAD, + clientUserMessageId: 'm5', + input: [{ type: 'text', text, text_elements: [] }], + }, + context: {}, + }, + }, + }); + + refuse = 'thread is archived'; + expect(await router.inject({ threadId: THREAD, messageId: 'm6', text: connectText('m6') })).toEqual({ + outcome: 'retry', + detail: 'thread is archived', + }); + const other = '7c9e6679-7425-40de-944b-e07fc1f90ae7'; + expect((await router.inject({ threadId: other, messageId: 'm7', text: connectText('m7') })).outcome).toBe( + 'waiting' + ); + expect(await router.preflight({ threadId: THREAD })).toMatchObject({ ok: true }); + }); + + it('preflights the queue command and names an old codex', async () => { + const { home, env, now } = await setup(); + const router = createCodexRouter({ home, env, now, systemCodex: [] }); + expect(await router.preflight({ threadId: THREAD })).toMatchObject({ ok: true }); + + const old = join(home, 'old-codex'); + await mkdir(old); + await writeFile(join(old, 'codex'), fakeCodex({ queue: false, version: '0.98.0' })); + await chmod(join(old, 'codex'), 0o755); + const outdated = createCodexRouter({ + home, + env: { ...env, RELAY_DESKTOP_CODEX: join(old, 'codex') }, + now, + systemCodex: [], + }); + const check = await outdated.preflight({ threadId: THREAD }); + expect(check.ok).toBe(false); + expect(check.reason).toBe( + `Codex 0.98.0 (${join(old, 'codex')}) cannot receive injected messages; update Codex.` + ); + expect(await outdated.inject({ threadId: THREAD, messageId: 'm', text: connectText('m') })).toMatchObject( + { + outcome: 'waiting', + } + ); + const live = createCodexRouter({ + home, + env: { ...env, RELAY_DESKTOP_CODEX: join(old, 'codex') }, + now, + systemCodex: [], + terminalLive: () => true, + }); + expect(await live.inject({ threadId: THREAD, messageId: 'm', text: connectText('m') })).toEqual({ + outcome: 'retry', + detail: 'the codex command line is not installed', + }); + }); +}); + +describe('Codex caller thread', () => { + it('needs a UUID thread id that Codex knows', async () => { + const { home, env, paths } = await setup(); + expect(await codexCallerThread({ env, home })).toEqual({ + threadId: null, + reason: 'no Codex thread id in env', + }); + expect( + (await codexCallerThread({ env: { ...env, CODEX_THREAD_ID: 'my-session' }, home })).threadId + ).toBeNull(); + const missing = await codexCallerThread({ env: { ...env, CODEX_THREAD_ID: THREAD }, home }); + expect(missing.threadId).toBeNull(); + expect(missing.reason).toContain(THREAD); + + await writeRollout(paths.codexHome, THREAD, []); + expect(await codexCallerThread({ env: { ...env, CODEX_THREAD_ID: THREAD.toUpperCase() }, home })).toEqual( + { + threadId: THREAD, + } + ); + + const other = '7c9e6679-7425-40de-944b-e07fc1f90ae7'; + const { DatabaseSync } = await import('node:sqlite'); + const db = new DatabaseSync(join(paths.sqliteHome, 'state_5.sqlite')); + db.exec('create table threads (id text, title text, archived integer, source text, rollout_path text)'); + db.prepare('insert into threads values (?, ?, 0, ?, ?)').run(other, 'Fix CI', 'cli', ''); + db.close(); + expect(await codexCallerThread({ env: { ...env, CODEX_THREAD_ID: other }, home })).toEqual({ + threadId: other, + }); + }); +}); + +describe('SQLite fallbacks', () => { + const backends = ['sqlite3', 'none'].filter((kind) => kind !== 'sqlite3' || existsSync('/usr/bin/sqlite3')); + + for (const kind of backends) { + it(`works with ${kind === 'none' ? 'no SQLite reader' : 'the sqlite3 command line'}`, async () => { + const previous = process.env.AGENT_RELAY_CONNECT_SQLITE; + process.env.AGENT_RELAY_CONNECT_SQLITE = kind; + resetSqliteBackend(); + cleanups.push(() => { + if (previous === undefined) delete process.env.AGENT_RELAY_CONNECT_SQLITE; + else process.env.AGENT_RELAY_CONNECT_SQLITE = previous; + resetSqliteBackend(); + }); + expect(await codexSqliteBackend()).toBe(kind === 'none' ? null : 'sqlite3'); + + const { home, env, paths, clock, now } = await setup(); + const router = createCodexRouter({ home, env, now, systemCodex: [], terminalLive: () => true }); + const text = connectText('m8'); + expect((await router.inject({ threadId: THREAD, messageId: 'm8', text })).outcome).toBe('accepted'); + const queued = await router.observe({ threadId: THREAD, messageId: 'm8', text }); + // Without a reader the queue reads as empty: the rollout and the 45 s rule decide. + expect(queued).toEqual({ outcome: 'accepted', detail: kind === 'none' ? RECORDING : NOT_CONSUMED }); + await writeRollout(paths.codexHome, THREAD, [userMessage('t1', text)]); + clock.now += 60_000; + expect(await router.observe({ threadId: THREAD, messageId: 'm8', text })).toMatchObject({ + outcome: 'delivered', + }); + }); + } +}); diff --git a/packages/connect/tests/relay-core.test.ts b/packages/connect/tests/relay-core.test.ts new file mode 100644 index 000000000..96da2fbcc --- /dev/null +++ b/packages/connect/tests/relay-core.test.ts @@ -0,0 +1,305 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ConnectService, injectedText, messageRef, parseDelivery } from '../src/relay/connect.js'; +import { RelayState } from '../src/relay/state.js'; +import { delivery, FakeConnectCloud } from './support/fake-connect-cloud.js'; + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()?.(); +}); + +const TARGET = { kind: 'claude', id: '11111111-2222-3333-4444-555555555555' }; + +type Outcome = { outcome: string; detail: string }; + +class FakeInjector { + injected: Array<{ messageId: string; text: string }> = []; + observed: string[] = []; + next: Outcome[] = []; + observeNext: Outcome[] = []; + onInject?: (messageId: string) => Promise; + prepared = 0; + async prepare() { + this.prepared += 1; + } + async inject(_target: unknown, message: { messageId: string; text: string }) { + await this.onInject?.(message.messageId); + this.injected.push(message); + return this.next.shift() || { outcome: 'delivered', detail: 'ok' }; + } + async observe(_target: unknown, message: { messageId: string }) { + this.observed.push(message.messageId); + return this.observeNext.shift() || { outcome: 'accepted', detail: 'still queued' }; + } + async preflight() { + return { ok: true, reason: '' }; + } +} + +async function setup() { + const cloud = await new FakeConnectCloud().start(); + const root = await mkdtemp(join(os.tmpdir(), 'connect-core-test-')); + cleanups.push(async () => { + await cloud.stop(); + await rm(root, { recursive: true, force: true }); + }); + let clock = Date.parse('2026-10-12T12:00:00Z'); + const now = () => clock; + const state = await RelayState.load(join(root, 'state.json'), { now }); + const injector = new FakeInjector(); + const logs: string[] = []; + const service = new ConnectService({ + state, + injector, + env: { AGENT_RELAY_CONNECT_CLOUD_URL: cloud.base }, + now, + log: (line: string) => logs.push(line), + replyHint: (sender: string) => `[reply: connect send --to ${sender}]`, + }); + return { + cloud, + state, + injector, + service, + logs, + statePath: join(root, 'state.json'), + advance: (ms: number) => { + clock += ms; + }, + }; +} + +describe('Node relay Connect service', () => { + it('formats injected text exactly like the probe', () => { + const parsed = parseDelivery( + delivery('d1', 'Hello [there]', { message: { agent_name: 'host [x]', text: 'Hello [there]' } }) + )!; + expect(injectedText(parsed, '[reply: hint]')).toBe( + `[Relay Connect — from host (x) · ref ${messageRef('msg-d1')}]\n[reply: hint]\n\nHello [there]` + ); + expect(messageRef('msg-d1')).toMatch(/^[0-9a-f]{12}$/); + }); + + it('joins once, persists the credential, and answers a repeat join locally', async () => { + const { cloud, service, injector, statePath } = await setup(); + const joined = await service.join(TARGET, { + link: 'https://agentrelay.com/connect/conn-1/join', + name: 'guest-one', + }); + expect(joined).toMatchObject({ + connect_id: 'conn-1', + agent_name: 'guest-one', + role: 'guest', + warnings: [], + }); + expect(injector.prepared).toBe(1); + const again = await service.join(TARGET, { link: 'conn-1' }); + expect(again.connect_id).toBe('conn-1'); + expect(cloud.callsTo('POST', '/cloud/connect/conn-1/join')).toHaveLength(1); + const saved = JSON.parse(await readFile(statePath, 'utf8')); + expect(saved.sessions[`claude:${TARGET.id}`].token).toBe('participant-token'); + await expect(service.join(TARGET, { link: 'conn-2' })).rejects.toMatchObject({ + code: 'connect_already_joined', + }); + }); + + it('reuses the join idempotency key after a 5xx and clears it after a 4xx', async () => { + const { cloud, service } = await setup(); + cloud.joinStatus = 503; + cloud.joinError = { code: 'connect_unavailable', message: 'down' }; + await expect(service.join(TARGET, { link: 'conn-1' })).rejects.toMatchObject({ + code: 'connect_unavailable', + }); + await expect(service.join(TARGET, { link: 'conn-1' })).rejects.toMatchObject({ + code: 'connect_unavailable', + }); + const keys = cloud + .callsTo('POST', '/cloud/connect/conn-1/join') + .map((call) => call.headers['idempotency-key']); + expect(keys[0]).toBe(keys[1]); + await expect(service.join(TARGET, { link: 'conn-1', name: 'other-name' })).rejects.toMatchObject({ + code: 'connect_join_pending', + }); + cloud.joinStatus = 403; + cloud.joinError = { code: 'connect_claim_invalid', message: 'bad claim' }; + await expect(service.join(TARGET, { link: 'conn-1' })).rejects.toMatchObject({ + code: 'connect_claim_invalid', + }); + cloud.joinError = null; + await service.join(TARGET, { link: 'conn-1' }); + const last = cloud.callsTo('POST', '/cloud/connect/conn-1/join').at(-1)!; + expect(last.headers['idempotency-key']).not.toBe(keys[0]); + }); + + it('injects, persists before injecting, acks, and never injects a settled delivery again', async () => { + const { cloud, service, injector, statePath } = await setup(); + await service.join(TARGET, { link: 'conn-1' }); + cloud.deliveries.push(delivery('d1', 'first')); + injector.onInject = async () => { + const saved = JSON.parse(await readFile(statePath, 'utf8')); + expect(saved.pending['agent-guest:d1']).toBe('msg-d1'); + }; + await service.pollAll(); + expect(injector.injected.map((m) => m.messageId)).toEqual(['msg-d1']); + expect(injector.injected[0].text).toContain('[reply: connect send --to host-agent]'); + expect(cloud.callsTo('POST', '/v1/deliveries/d1/ack')).toHaveLength(1); + + // Relaycast redrives an item whose ack was lost: re-acked from the ledger. + cloud.deliveries[0].status = 'delivered'; + await service.pollAll(); + expect(injector.injected).toHaveLength(1); + expect(cloud.callsTo('POST', '/v1/deliveries/d1/ack')).toHaveLength(2); + }); + + it('fails an in-doubt handoff without retry and never resends it', async () => { + const { cloud, service, injector } = await setup(); + await service.join(TARGET, { link: 'conn-1' }); + cloud.deliveries.push(delivery('d1', 'first')); + injector.next.push({ outcome: 'in_doubt', detail: 'socket closed mid-write' }); + await service.pollAll(); + const fail = cloud.callsTo('POST', '/v1/deliveries/d1/fail'); + expect(fail).toHaveLength(1); + expect(fail[0].body).toEqual({ + error: 'In doubt; not resent — socket closed mid-write', + retryable: false, + }); + cloud.deliveries[0].status = 'queued'; + await service.pollAll(); + expect(injector.injected).toHaveLength(1); + }); + + it('backs off a waiting head and keeps later items behind it, then gives up after the handoff TTL', async () => { + const { cloud, service, injector, advance } = await setup(); + await service.join(TARGET, { link: 'conn-1' }); + cloud.deliveries.push(delivery('d1', 'first'), delivery('d2', 'second')); + injector.next.push({ outcome: 'waiting', detail: 'session not running' }); + await service.pollAll(); + expect(injector.injected.map((m) => m.messageId)).toEqual(['msg-d1']); + await service.pollAll(); + expect(injector.injected).toHaveLength(1); + advance(5_000); + injector.next.push({ outcome: 'waiting', detail: 'session not running' }); + await service.pollAll(); + expect(injector.injected.map((m) => m.messageId)).toEqual(['msg-d1', 'msg-d1']); + advance(60 * 60_000); + injector.next.push({ outcome: 'waiting', detail: 'session not running' }); + await service.pollAll(); + expect(cloud.callsTo('POST', '/v1/deliveries/d1/fail')[0].body.error).toContain( + 'The local session remained unavailable without accepting the handoff' + ); + expect(injector.injected.at(-1)!.messageId).toBe('msg-d2'); + }); + + it('only observes a delivery the provider queue accepted, and lets later items pass', async () => { + const { cloud, service, injector, advance } = await setup(); + await service.join(TARGET, { link: 'conn-1' }); + cloud.deliveries.push(delivery('d1', 'first'), delivery('d2', 'second')); + injector.next.push({ outcome: 'accepted', detail: 'queued' }); + await service.pollAll(); + expect(injector.injected.map((m) => m.messageId)).toEqual(['msg-d1', 'msg-d2']); + advance(5_000); + injector.observeNext.push({ outcome: 'delivered', detail: 'recorded in the rollout' }); + await service.pollAll(); + expect(injector.observed).toEqual(['msg-d1']); + expect(injector.injected).toHaveLength(2); + expect(cloud.callsTo('POST', '/v1/deliveries/d1/ack')).toHaveLength(1); + }); + + it('acks its own messages and messages not addressed to it without injecting', async () => { + const { cloud, service, injector } = await setup(); + await service.join(TARGET, { link: 'conn-1' }); + cloud.deliveries.push( + delivery('own', 'mine', { message: { agent_name: 'guest-one', text: 'mine' } }), + delivery('chan', 'channel chatter', { reason: 'channel' }) + ); + await service.pollAll(); + expect(injector.injected).toEqual([]); + expect(cloud.callsTo('POST', '/v1/deliveries/own/ack')).toHaveLength(1); + expect(cloud.callsTo('POST', '/v1/deliveries/chan/ack')).toHaveLength(1); + }); + + it('sends with a per-recipient idempotency key and names each failure cause', async () => { + const { cloud, service, logs } = await setup(); + await service.join(TARGET, { link: 'conn-1' }); + await expect( + service.send(TARGET, 'hi', { to: 'host-agent', requestKey: 'req-1' }) + ).resolves.toMatchObject({ + sent: [{ to: 'host-agent' }], + }); + expect(cloud.callsTo('POST', '/v1/dm')[0].headers['idempotency-key']).toBe( + 'connect-send:req-1:host-agent' + ); + + cloud.apiErrors.set('POST /v1/dm', { status: 401, code: 'unauthorized' }); + await expect(service.send(TARGET, 'hi', { to: 'host-agent' })).rejects.toMatchObject({ + status: 503, + code: 'connect_auth_rejected', + }); + cloud.apiErrors.set('POST /v1/dm', { status: 500, code: 'internal_error' }); + await expect(service.send(TARGET, 'hi', { to: 'host-agent' })).rejects.toMatchObject({ + status: 502, + code: 'connect_upstream_error', + }); + expect( + logs.some( + (line) => line.includes('code=connect_auth_rejected') && line.includes('cause=http_401:unauthorized') + ) + ).toBe(true); + expect(logs.join('\n')).not.toContain('participant-token'); + + cloud.apiErrors.set('POST /v1/dm', { status: 401, code: 'unauthorized' }); + cloud.availability = { status: 410, code: 'connect_ended' }; + await expect(service.send(TARGET, 'hi', { to: 'host-agent' })).rejects.toMatchObject({ + code: 'connect_ended', + }); + await expect(service.status(TARGET)).rejects.toMatchObject({ code: 'connect_not_joined' }); + }); + + it('reports presence from the roster', async () => { + const { service } = await setup(); + await service.join(TARGET, { link: 'conn-1' }); + const status = await service.status(TARGET); + expect(status.participants).toEqual([ + { agent_name: 'host-agent', role: 'host', online: true }, + { agent_name: 'guest-one', role: 'guest', online: true }, + ]); + }); + + it('leaves through Cloud and Relaycast, then forgets the credential', async () => { + const { cloud, service, statePath } = await setup(); + await service.join(TARGET, { link: 'conn-1' }); + await expect(service.leave(TARGET)).resolves.toEqual({ left: true, connect_id: 'conn-1' }); + expect(cloud.callsTo('POST', '/cloud/connect/conn-1/leave')[0].headers.authorization).toBe( + 'Bearer participant-token' + ); + // Cloud deleted the agent, so no Relaycast disconnect follows. + expect(cloud.callsTo('POST', '/v1/agents/disconnect')).toHaveLength(0); + const saved = JSON.parse(await readFile(statePath, 'utf8')); + expect(saved.sessions).toEqual({}); + }); + + it('still leaves locally when Cloud has no leave route yet', async () => { + const { cloud, service, logs } = await setup(); + await service.join(TARGET, { link: 'conn-1' }); + cloud.apiErrors.set('POST /cloud/connect/conn-1/leave', { status: 404, code: 'connect_route_not_found' }); + await expect(service.leave(TARGET)).resolves.toMatchObject({ left: true }); + expect(logs.some((line) => line.includes('relay connect leave failed'))).toBe(true); + expect(cloud.callsTo('POST', '/v1/agents/disconnect')).toHaveLength(1); + }); + + it('closes an expired Connect and injects one notice', async () => { + const { cloud, service, injector, advance } = await setup(); + cloud.expiresAt = '2026-10-12T12:30:00Z'; + await service.join(TARGET, { link: 'conn-1' }); + advance(31 * 60_000); + await service.pollAll(); + await service.pollAll(); + expect(injector.injected.map((m) => m.text)).toEqual([ + '[Relay Connect] This Connect expired. Its local credential was removed.', + ]); + await expect(service.status(TARGET)).rejects.toMatchObject({ code: 'connect_not_joined' }); + }); +}); diff --git a/packages/connect/tests/relay-daemon.test.ts b/packages/connect/tests/relay-daemon.test.ts new file mode 100644 index 000000000..fc63ca148 --- /dev/null +++ b/packages/connect/tests/relay-daemon.test.ts @@ -0,0 +1,201 @@ +import { EventEmitter } from 'node:events'; +import { mkdtemp, readFile, rm, stat, writeFile, mkdir } from 'node:fs/promises'; +import os from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { requestJson } from '../src/http.js'; +import { startDaemon, replyHintFor } from '../src/relay/daemon.js'; +import { ensureRelay, olderVersion, RelayStartError } from '../src/relay/launch.js'; +import { relayPaths } from '../src/relay/paths.js'; +import { FakeConnectCloud } from './support/fake-connect-cloud.js'; + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()?.(); +}); + +// Unix socket paths are limited to ~104 bytes; macOS's per-user TMPDIR is long. +const shortTmp = process.platform === 'darwin' ? '/tmp' : os.tmpdir(); + +async function tempHome() { + const root = await mkdtemp(join(shortTmp, 'crd-')); + cleanups.push(async () => rm(root, { recursive: true, force: true })); + return root; +} + +const fakeInjector = { + async prepare() {}, + async inject() { + return { outcome: 'delivered', detail: 'ok' }; + }, + async observe() { + return { outcome: 'delivered', detail: 'ok' }; + }, + async preflight() { + return { ok: true, reason: '' }; + }, +}; + +const TARGET = 'codex:019a0000-0000-7000-8000-000000000001'; + +describe('Node relay daemon', () => { + it('serves the Connect routes on a private socket and requires a session target', async () => { + const home = await tempHome(); + const cloud = await new FakeConnectCloud().start(); + cleanups.push(() => cloud.stop()); + const daemon = await startDaemon({ + home, + env: { AGENT_RELAY_CONNECT_CLOUD_URL: cloud.base }, + injector: fakeInjector, + }); + cleanups.push(() => daemon.stop()); + + expect(((await stat(daemon.socket)).mode & 0o777).toString(8)).toBe('600'); + expect(((await stat(relayPaths(home).root)).mode & 0o777).toString(8)).toBe('700'); + const health = await requestJson(daemon.socket, { path: '/health' }); + expect(health).toMatchObject({ ok: true, data: { sessions: 0 } }); + + const anonymous = await requestJson(daemon.socket, { + method: 'POST', + path: '/connect/join', + body: JSON.stringify({ link: 'conn-1' }), + }); + expect(anonymous).toMatchObject({ ok: false, error: { code: 'not_a_relay_session' } }); + + const joined = await requestJson(daemon.socket, { + method: 'POST', + path: '/connect/join', + body: JSON.stringify({ link: 'conn-1' }), + headers: { 'x-agent-relay-target': TARGET }, + }); + expect(joined).toMatchObject({ ok: true, data: { connect_id: 'conn-1', agent_name: 'guest-one' } }); + + const status = await requestJson(daemon.socket, { + path: '/connect/status', + headers: { 'x-agent-relay-target': TARGET }, + }); + expect(status).toMatchObject({ ok: true, data: { connect_id: 'conn-1' } }); + + const busy = await requestJson(daemon.socket, { method: 'POST', path: '/shutdown' }); + expect(busy).toMatchObject({ ok: false, error: { code: 'relay_busy' } }); + + const sent = await requestJson(daemon.socket, { + method: 'POST', + path: '/connect/send?to=host-agent', + body: 'hello host', + headers: { 'x-agent-relay-target': TARGET }, + }); + expect(sent).toMatchObject({ ok: true, data: { sent: [{ to: 'host-agent' }] } }); + expect(cloud.callsTo('POST', '/v1/dm')[0].body).toEqual({ + to: 'host-agent', + text: 'hello host', + mode: 'wait', + }); + + const state = JSON.parse(await readFile(relayPaths(home).state, 'utf8')); + expect(Object.keys(state.sessions)).toEqual([TARGET]); + }); + + it('refuses a second instance and cleans up its socket and lock on stop', async () => { + const home = await tempHome(); + const daemon = await startDaemon({ home, env: {}, injector: fakeInjector }); + await expect(startDaemon({ home, env: {}, injector: fakeInjector })).rejects.toMatchObject({ + code: 'already_running', + }); + await daemon.stop(); + await expect(stat(daemon.socket)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(stat(relayPaths(home).lock)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('replaces a lock left by a relay that died', async () => { + const home = await tempHome(); + const paths = relayPaths(home); + await mkdir(paths.run, { recursive: true, mode: 0o700 }); + await writeFile(paths.lock, '999999\n'); + const daemon = await startDaemon({ home, env: {}, injector: fakeInjector }); + cleanups.push(() => daemon.stop()); + expect((await readFile(paths.lock, 'utf8')).trim()).toBe(String(process.pid)); + }); + + it('shows a reply command that runs this runtime under this Node', () => { + expect(replyHintFor('host-agent', '/opt/node/bin/node', '/home/a b/cli.js')).toBe( + "[reply: printf '%s' 'your reply' | /opt/node/bin/node '/home/a b/cli.js' send --to host-agent]" + ); + }); +}); + +describe('Node relay launcher', () => { + function fakeChild(behaviour: (child: EventEmitter & Record) => void) { + return () => { + const child = Object.assign(new EventEmitter(), { + pid: undefined, + connected: false, + disconnect() {}, + unref() {}, + }); + setImmediate(() => behaviour(child)); + return child; + }; + } + + it("reports the daemon's own start error and the end of its log, without a blind wait", async () => { + const home = await tempHome(); + const paths = relayPaths(home); + const started = Date.now(); + await expect( + ensureRelay({ + home, + env: {}, + spawnProcess: fakeChild((child) => { + writeFile(paths.log, 'boot line\nrelay connect failed to start: EACCES on run dir\n').then(() => + child.emit('message', { type: 'error', code: 'start_failed', message: 'EACCES on run dir' }) + ); + }) as never, + }) + ).rejects.toThrow( + /could not start: EACCES on run dir\.\nLast lines of .*daemon\.log:\nboot line\nrelay connect failed to start/ + ); + expect(Date.now() - started).toBeLessThan(3_000); + }); + + it('reports a daemon that exits before it is ready', async () => { + const home = await tempHome(); + await expect( + ensureRelay({ + home, + env: {}, + spawnProcess: fakeChild((child) => child.emit('exit', null, 'SIGKILL')) as never, + }) + ).rejects.toBeInstanceOf(RelayStartError); + await expect( + ensureRelay({ + home, + env: {}, + spawnProcess: fakeChild((child) => child.emit('exit', null, 'SIGKILL')) as never, + }) + ).rejects.toThrow('it exited on SIGKILL before it was ready'); + }); + + it('reuses a running relay instead of starting another', async () => { + const home = await tempHome(); + const daemon = await startDaemon({ home, env: {}, injector: fakeInjector }); + cleanups.push(() => daemon.stop()); + let spawned = 0; + const socket = await ensureRelay({ + home, + env: {}, + spawnProcess: (() => { + spawned += 1; + throw new Error('should not spawn'); + }) as never, + }); + expect(socket).toBe(daemon.socket); + expect(spawned).toBe(0); + }); + + it('compares versions numerically', () => { + expect(olderVersion('13.4.0', '13.10.0')).toBe(true); + expect(olderVersion('13.10.0', '13.4.0')).toBe(false); + expect(olderVersion('13.4.0', '13.4.0')).toBe(false); + }); +}); diff --git a/packages/connect/tests/relay-processes.test.ts b/packages/connect/tests/relay-processes.test.ts new file mode 100644 index 000000000..9c92f1c27 --- /dev/null +++ b/packages/connect/tests/relay-processes.test.ts @@ -0,0 +1,82 @@ +import { spawn } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + ancestors, + argvOf, + claudeStartMatches, + claudeStartSeconds, + holdsFile, + isAlive, + parentOf, + processStart, +} from '../src/relay/processes.js'; + +const cleanups: Array<() => Promise | void> = []; + +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); + +async function tempDir() { + const dir = await mkdtemp(join(process.env.TMPDIR || os.tmpdir(), 'relay-proc-')); + cleanups.push(() => rm(dir, { recursive: true, force: true })); + return dir; +} + +describe('processes', () => { + it('walks the current process up through its parent', async () => { + const chain = await ancestors(process.pid); + expect(chain[0].pid).toBe(process.pid); + expect(chain.map((entry) => entry.pid)).toContain(process.ppid); + expect(await parentOf(process.pid)).toBe(process.ppid); + expect(chain.length).toBeLessThanOrEqual(64); + expect(await ancestors(process.pid, 1)).toHaveLength(1); + }); + + it('reads a stable start time that matches itself as Claude would record it', async () => { + const first = await processStart(process.pid); + const second = await processStart(process.pid); + expect(first).toBeTruthy(); + expect(second).toBe(first); + expect(claudeStartMatches(first, second)).toBe(true); + if (process.platform === 'darwin') { + expect(first).toMatch(/^[A-Z][a-z]{2} [A-Z][a-z]{2} +\d{1,2} \d{2}:\d{2}:\d{2} \d{4}$/); + } else { + expect(first).toMatch(/^\d+$/); + } + }); + + it('compares macOS start strings to the second, and Linux ticks exactly', () => { + expect(claudeStartSeconds('Wed Sep 30 10:00:00 2026')).toBe(Date.UTC(2026, 8, 30, 10, 0, 0) / 1000); + expect(claudeStartSeconds('Mon Oct 5 01:02:03 2026')).toBe(Date.UTC(2026, 9, 5, 1, 2, 3) / 1000); + expect(claudeStartSeconds('Mon Feb 31 01:02:03 2026')).toBeNull(); + expect(claudeStartSeconds('garbage')).toBeNull(); + expect(claudeStartMatches('Mon Oct 5 01:02:03 2026', 'Mon Oct 5 01:02:03 2026', 'darwin')).toBe(true); + expect(claudeStartMatches('Mon Oct 5 01:02:04 2026', 'Mon Oct 5 01:02:03 2026', 'darwin')).toBe(false); + expect(claudeStartMatches(' 12345 ', '12345', 'linux')).toBe(true); + expect(claudeStartMatches('12346', '12345', 'linux')).toBe(false); + }); + + it('sees a file a child holds open, its arguments, and its death', async () => { + const dir = await tempDir(); + const file = join(dir, 'held.lock'); + await writeFile(file, ''); + const child = spawn('/bin/sh', ['-c', 'exec sleep 30 3<"$0"', file], { stdio: 'ignore' }); + cleanups.push(() => { + child.kill('SIGKILL'); + }); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(isAlive(child.pid!)).toBe(true); + expect(await holdsFile(child.pid!, file)).toBe(true); + expect(await holdsFile(child.pid!, join(dir, 'other.lock'))).toBe(false); + expect(await argvOf(child.pid!)).toEqual(['sleep', '30']); + child.kill('SIGKILL'); + await new Promise((resolve) => child.once('exit', resolve)); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(isAlive(child.pid!)).toBe(false); + expect(await processStart(child.pid!)).toBeNull(); + }); +}); diff --git a/packages/connect/tests/support/fake-connect-cloud.ts b/packages/connect/tests/support/fake-connect-cloud.ts new file mode 100644 index 000000000..6850b1eb6 --- /dev/null +++ b/packages/connect/tests/support/fake-connect-cloud.ts @@ -0,0 +1,148 @@ +import http from 'node:http'; + +export type Delivery = { + id: string; + message_id: string; + status: string; + reason: string; + message: { agent_name: string; text: string }; +}; + +type Call = { method: string; path: string; headers: http.IncomingHttpHeaders; body: any }; + +/** Cloud's public Connect routes plus the Relaycast routes a participant uses, on loopback http. */ +export class FakeConnectCloud { + server!: http.Server; + base = ''; + calls: Call[] = []; + deliveries: Delivery[] = []; + roster: Array<{ name: string; status: string }> = [ + { name: 'host-agent', status: 'online' }, + { name: 'guest-one', status: 'online' }, + ]; + joinStatus = 200; + joinError: { code: string; message: string } | null = null; + availability: { status: number; code?: string } = { status: 200 }; + apiErrors = new Map(); + expiresAt = '2099-01-01T00:00:00Z'; + + async start() { + this.server = http.createServer((request, response) => { + const chunks: Buffer[] = []; + request.on('data', (chunk) => chunks.push(chunk)); + request.on('end', () => this.handle(request, response, Buffer.concat(chunks).toString('utf8'))); + }); + await new Promise((resolve) => this.server.listen(0, '127.0.0.1', resolve)); + const address = this.server.address() as { port: number }; + this.base = `http://127.0.0.1:${address.port}`; + return this; + } + + async stop() { + await new Promise((resolve) => this.server.close(() => resolve())); + } + + callsTo(method: string, path: string) { + return this.calls.filter((call) => call.method === method && call.path === path); + } + + private send(response: http.ServerResponse, status: number, value: unknown) { + response.writeHead(status, { 'content-type': 'application/json' }); + response.end(JSON.stringify(value)); + } + + private handle(request: http.IncomingMessage, response: http.ServerResponse, text: string) { + const path = request.url || '/'; + let body: any = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = text; + } + this.calls.push({ method: request.method || 'GET', path, headers: request.headers, body }); + const forced = this.apiErrors.get(`${request.method} ${path.split('?')[0]}`); + if (forced) { + this.send(response, forced.status, { ok: false, error: { code: forced.code, message: forced.code } }); + return; + } + const join = /^\/cloud\/connect\/([^/]+)\/join$/.exec(path); + if (join && request.method === 'POST') { + if (this.joinError) { + this.send(response, this.joinStatus, { ok: false, error: this.joinError }); + return; + } + const name = body?.agent_name || 'guest-one'; + this.send(response, 200, { + ok: true, + data: { + connect_id: join[1], + task: 'Review the patch', + expires_at: this.expiresAt, + host: { person: 'Host Person', agent_name: 'host-agent' }, + participant: { + agent_name: name, + agent_id: 'agent-guest', + role: 'guest', + token: 'participant-token', + relay_base_url: this.base, + }, + participants: [ + { agent_name: 'host-agent', role: 'host' }, + { agent_name: name, role: 'guest' }, + ], + }, + }); + return; + } + if (/^\/cloud\/connect\/[^/]+\/leave$/.test(path)) { + this.send(response, 200, { ok: true, data: { left: true } }); + return; + } + if (/^\/connect\/[^/]+$/.test(path)) { + const { status, code } = this.availability; + if (status === 200) this.send(response, 200, { ok: true, data: { active: true } }); + else + this.send(response, status, { ok: false, error: { code: code || 'connect_ended', message: 'gone' } }); + return; + } + if (path === '/v1/deliveries?limit=50') { + this.send(response, 200, { ok: true, data: this.deliveries }); + return; + } + const settle = /^\/v1\/deliveries\/([^/]+)\/(ack|fail)$/.exec(path); + if (settle) { + const id = decodeURIComponent(settle[1]); + const delivery = this.deliveries.find((item) => item.id === id); + if (delivery) delivery.status = settle[2] === 'ack' ? 'acked' : 'failed'; + this.send(response, 200, { ok: true, data: { id, status: delivery?.status } }); + return; + } + if (path === '/v1/dm') { + this.send(response, 200, { + ok: true, + data: { conversation_id: 'c1', message: { id: `m-${this.calls.length}` } }, + }); + return; + } + if (path === '/v1/agents?status=all') { + this.send(response, 200, { ok: true, data: this.roster }); + return; + } + if (path === '/v1/agents/heartbeat' || path === '/v1/agents/disconnect') { + this.send(response, 200, { ok: true, data: {} }); + return; + } + this.send(response, 404, { ok: false, error: { code: 'not_found', message: path } }); + } +} + +export function delivery(id: string, text: string, overrides: Partial = {}): Delivery { + return { + id, + message_id: `msg-${id}`, + status: 'queued', + reason: 'dm', + message: { agent_name: 'host-agent', text }, + ...overrides, + }; +}