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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ All notable changes to Agent Relay will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [Unreleased - Patch]

### Fixed

- `agent-relay integration` now discovers relayfile control-plane capabilities before sending API v3 headers, fails fast with upgrade and restart guidance for incompatible daemons, and safely replaces stale daemons when a compatible binary is installed.

## [10.6.3] - 2026-07-17

Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"@agent-relay/sdk": "10.6.3",
"@agent-relay/utils": "10.6.3",
"@modelcontextprotocol/sdk": "^1.0.0",
"@relayfile/client": "^0.10.21",
"@relayfile/client": "^0.10.27",
"@relayflows/cli": "^1.0.1",
"@xterm/headless": "^6.0.0",
"commander": "^12.1.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type ChildProcess, spawn } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { createServer, type RequestListener } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

Expand All @@ -8,6 +9,184 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { MIN_RELAYFILE_VERSION, assertRelayfileVersion, defaultRelayfileBridge } from './integration.js';
import { RelayfileControlPlaneClient } from '@relayfile/client';

let socketSequence = 0;

async function withControlPlaneServer(
handler: RequestListener,
run: (socketPath: string) => Promise<void>
): Promise<void> {
const socketPath = join(tmpdir(), `rf-negotiation-${process.pid}-${++socketSequence}.sock`);
rmSync(socketPath, { force: true });
const server = createServer(handler);

await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(socketPath, resolve);
});

try {
await run(socketPath);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
rmSync(socketPath, { force: true });
}
}

function writeJson(response: Parameters<RequestListener>[1], status: number, body: unknown): void {
response.writeHead(status, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(body));
}

async function withFakeRelayfileBinary(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The helper functions withFakeRelayfileBinary (lines 42-94) and withExternalControlPlaneDaemon (lines 96-188) embed significant amounts of JavaScript code as multi-line strings. This practice can make the code harder to read, debug, and maintain, as it lacks syntax highlighting and linting within the string literal.

For better maintainability, consider extracting these scripts into separate fixture files (e.g., in a __fixtures__ directory) and loading them with fs.readFileSync. This would improve code organization and developer experience.

version: string,
run: (binary: string) => Promise<void>
): Promise<void> {
const fixtureDir = mkdtempSync(join(tmpdir(), 'rf-negotiation-bin-'));
const binary = join(fixtureDir, 'relayfile');
writeFileSync(
binary,
`#!/usr/bin/env node
const { rmSync } = require('node:fs');
const { createServer } = require('node:http');

const args = process.argv.slice(2);
if (args[0] === '--version') {
process.stdout.write(${JSON.stringify(`${version}\n`)});
process.exit(0);
}
if (args[0] !== 'control-plane' || args[1] !== 'serve') process.exit(2);
const socketIndex = args.indexOf('--sock');
const socketPath = socketIndex >= 0 ? args[socketIndex + 1] : undefined;
if (!socketPath) process.exit(2);

rmSync(socketPath, { force: true });
let closeTimer;
const server = createServer((request, response) => {
if (request.method === 'GET' && request.url === '/v1/hello') {
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({
daemonVersion: ${JSON.stringify(version)},
apiVersion: 3,
supportedApiVersions: [1, 2, 3],
}));
} else {
response.writeHead(404, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'not found' } }));
}
clearTimeout(closeTimer);
closeTimer = setTimeout(() => server.close(() => process.exit(0)), 500);
});
server.listen(socketPath);
setTimeout(() => server.close(() => process.exit(0)), 5000);
process.on('SIGTERM', () => server.close(() => process.exit(0)));
`,
'utf8'
);
chmodSync(binary, 0o755);

try {
await run(binary);
} finally {
rmSync(fixtureDir, { recursive: true, force: true });
}
}

async function withExternalControlPlaneDaemon(
daemonVersion: string,
apiVersion: number,
supportedApiVersions: number[],
run: (socketPath: string) => Promise<void>
): Promise<void> {
const socketPath = join(tmpdir(), `rf-external-${process.pid}-${++socketSequence}.sock`);
rmSync(socketPath, { force: true });
const child = spawn(
process.execPath,
[
'-e',
`const { rmSync } = require('node:fs');
const { createServer } = require('node:http');
const [socketPath, daemonVersion, apiVersionRaw, supportedRaw] = process.argv.slice(1);
const apiVersion = Number(apiVersionRaw);
const supportedApiVersions = JSON.parse(supportedRaw);
rmSync(socketPath, { force: true });
const server = createServer((request, response) => {
if (request.method === 'GET' && request.url === '/v1/hello') {
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ daemonVersion, apiVersion, supportedApiVersions }));
return;
}
response.writeHead(404, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'not found' } }));
});
const stop = () => server.close(() => {
rmSync(socketPath, { force: true });
process.exit(0);
});
process.on('SIGTERM', stop);
server.listen(socketPath, () => process.stdout.write('ready\\n'));
`,
socketPath,
daemonVersion,
String(apiVersion),
JSON.stringify(supportedApiVersions),
],
{ stdio: ['ignore', 'pipe', 'pipe'] }
);

await new Promise<void>((resolve, reject) => {
let stderr = '';
const fail = (message: string) => {
cleanup();
reject(new Error(message));
};
const onData = (chunk: Buffer) => {
if (String(chunk).includes('ready')) {
cleanup();
resolve();
}
};
const onError = (error: Error) => fail(`failed to start fake relayfile daemon: ${error.message}`);
const onExit = (code: number | null, signal: NodeJS.Signals | null) =>
fail(`fake relayfile daemon exited before ready (code ${code}, signal ${signal}): ${stderr}`);
const cleanup = () => {
child.stdout?.off('data', onData);
child.stderr?.off('data', onStderr);
child.off('error', onError);
child.off('exit', onExit);
};
const onStderr = (chunk: Buffer) => {
stderr += String(chunk);
};
child.stdout?.on('data', onData);
child.stderr?.on('data', onStderr);
child.once('error', onError);
child.once('exit', onExit);
});
Comment on lines +138 to +166

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Bound the fake daemon readiness wait.

If the child stays alive without emitting ready, this promise never settles and the suite hangs until its global timeout. Add a short timer that rejects and kills the child, clearing it from cleanup().

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { type ChildProcess, spawn } from 'node:child_process';
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/cli/commands/integration-relayfile-contract.test.ts` around
lines 138 - 166, Add a short readiness timeout to the promise around the fake
daemon startup, rejecting with a clear error and killing the child when “ready”
is not emitted. Track the timer and clear it in cleanup(), while preserving the
existing success, error, and exit handling in the child readiness flow.


try {
await run(socketPath);
} finally {
await new Promise<void>((resolve) => {
if (child.exitCode !== null || child.signalCode !== null) {
resolve();
return;
}
const timeout = setTimeout(() => {
child.kill('SIGKILL');
resolve();
}, 2000);
child.once('exit', () => {
clearTimeout(timeout);
resolve();
});
child.kill('SIGTERM');
});
rmSync(socketPath, { force: true });
}
}

// ────────────────────────────────────────────────────────────────────────────
// Pure version-gate unit tests — always run, no daemon required. These lock the
// daemon-version compat check (`/v1/hello` → daemonVersion) that turns "daemon
Expand Down Expand Up @@ -42,6 +221,130 @@ describe('assertRelayfileVersion', () => {
});
});

describe('relayfile control-plane hello negotiation', () => {
it('discovers supported APIs without a version header, then uses v3 for normal requests', async () => {
const requests: Array<{ method?: string; path?: string; version?: string }> = [];

await withControlPlaneServer(
(request, response) => {
requests.push({
method: request.method,
path: request.url,
version: request.headers['x-relayfile-api-version'] as string | undefined,
});

if (request.method === 'GET' && request.url === '/v1/hello') {
writeJson(response, 200, {
daemonVersion: '0.10.27',
apiVersion: 3,
supportedApiVersions: [1, 2, 3],
});
return;
}
if (request.method === 'GET' && request.url?.startsWith('/v1/integrations/provider-status?')) {
writeJson(response, 200, { provider: 'slack', state: 'connected' });
return;
}
writeJson(response, 404, { error: { code: 'NOT_FOUND', message: 'not found' } });
},
async (socketPath) => {
const bridge = defaultRelayfileBridge({ socketPath, autoStart: false });
await bridge.ensureCompatible();
await expect(bridge.isConnected('slack')).resolves.toBe(true);
}
);

const helloRequests = requests.filter(({ path }) => path === '/v1/hello');
expect(helloRequests.length).toBeGreaterThan(0);
expect(helloRequests.every(({ method, version }) => method === 'GET' && version === undefined)).toBe(
true
);
expect(requests.find(({ path }) => path?.startsWith('/v1/integrations/provider-status?'))?.version).toBe(
'3'
);
});

it('fails legacy v1 discovery once, before sending a versioned operation', async () => {
const requests: Array<{ method?: string; path?: string; version?: string }> = [];

await withControlPlaneServer(
(request, response) => {
requests.push({
method: request.method,
path: request.url,
version: request.headers['x-relayfile-api-version'] as string | undefined,
});
writeJson(response, 200, {
daemonVersion: '0.10.19',
apiVersion: 1,
supportedApiVersions: [1],
});
},
async (socketPath) => {
const bridge = defaultRelayfileBridge({ socketPath, autoStart: false });
await expect(bridge.ensureCompatible()).rejects.toMatchObject({
code: 'VERSION_INCOMPATIBLE',
message: expect.stringMatching(
/relayfile daemon 0\.10\.19.*speaks API v1.*supports v1.*requires API v3.*npm install -g relayfile@latest.*control-plane serve/s
),
});
}
);

expect(requests).toEqual([{ method: 'GET', path: '/v1/hello', version: undefined }]);
});

it('retries a transient missing socket while an auto-started daemon becomes ready', async () => {
await withFakeRelayfileBinary('0.10.27', async (binary) => {
const socketPath = join(tmpdir(), `rf-transient-${process.pid}-${++socketSequence}.sock`);
rmSync(socketPath, { force: true });
const bridge = defaultRelayfileBridge({
socketPath,
binary,
autoStart: true,
startTimeoutMs: 2000,
requestTimeoutMs: 250,
});

await expect(bridge.ensureCompatible()).resolves.toBeUndefined();
rmSync(socketPath, { force: true });
});
});
Comment on lines +297 to +312

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the missing-socket retry deterministic.

The fixture may create its socket before the first request, allowing this test to pass without exercising a retry. Delay socket creation beyond the initial request attempt while keeping it within startTimeoutMs.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { type ChildProcess, spawn } from 'node:child_process';
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/cli/commands/integration-relayfile-contract.test.ts` around
lines 297 - 312, Make the auto-started daemon fixture in the “retries a
transient missing socket” test create its socket only after the initial request
attempt, while ensuring creation occurs before startTimeoutMs expires. Keep the
existing ensureCompatible assertion and cleanup unchanged so the test
deterministically exercises missing-socket retry behavior.


it('replaces a stale v1 daemon only when the installed binary can serve v3', async () => {
await withFakeRelayfileBinary('0.10.27', async (binary) => {
await withExternalControlPlaneDaemon('0.10.19', 1, [1], async (socketPath) => {
const bridge = defaultRelayfileBridge({
socketPath,
binary,
autoStart: true,
startTimeoutMs: 2000,
requestTimeoutMs: 1000,
});
await expect(bridge.ensureCompatible()).resolves.toBeUndefined();
});
Comment on lines +314 to +325

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Assert the stale daemon’s actual process state.

These results do not prove the lifecycle contract: an unlink-only replacement or premature termination of the retained daemon could still pass. Expose the child to the callback, then assert it exited after replacement and remains alive after incompatibility.

Also applies to: 329-345

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { type ChildProcess, spawn } from 'node:child_process';
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/cli/commands/integration-relayfile-contract.test.ts` around
lines 314 - 325, Update the integration tests around defaultRelayfileBridge and
withExternalControlPlaneDaemon to expose the daemon child process to the
callback, then assert the stale daemon has actually exited after a compatible
replacement. In the incompatibility case, assert the retained daemon process
remains alive, covering both replacement termination and preservation lifecycle
behavior.

});
});

it('leaves a stale v1 daemon in place when the installed binary is also too old', async () => {
await withFakeRelayfileBinary('0.10.19', async (binary) => {
await withExternalControlPlaneDaemon('0.10.19', 1, [1], async (socketPath) => {
const bridge = defaultRelayfileBridge({
socketPath,
binary,
autoStart: true,
startTimeoutMs: 2000,
requestTimeoutMs: 1000,
});
await expect(bridge.ensureCompatible()).rejects.toMatchObject({
code: 'VERSION_INCOMPATIBLE',
message: expect.stringContaining('Installed relayfile binary: 0.10.19'),
});
});
});
});
});

// ────────────────────────────────────────────────────────────────────────────
// Real-daemon contract tests — boot `relayfile control-plane serve` and exercise
// the ACTUAL bridge over the unix socket (no spawn-per-op, no stdout parsing).
Expand Down
Loading