diff --git a/CHANGELOG.md b/CHANGELOG.md index 79de24565..a9c987f57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased - Patch] + +### Fixed + +- `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 diff --git a/package-lock.json b/package-lock.json index a1565401d..fa9d8f2e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4111,9 +4111,9 @@ } }, "node_modules/@relayfile/client": { - "version": "0.10.21", - "resolved": "https://registry.npmjs.org/@relayfile/client/-/client-0.10.21.tgz", - "integrity": "sha512-c6PzrYtqgsjkiaaLTAxY/ovRi6UAF363MsDFDnEdJQctagSBgvUXkyNbUwPjMwKt/o5VxeuxGapJAcui7Bo/0Q==", + "version": "0.10.27", + "resolved": "https://registry.npmjs.org/@relayfile/client/-/client-0.10.27.tgz", + "integrity": "sha512-1ASWmrDDIZlMhQuGodR+vkYuJy6Dmkc06DAwidYKVJzTVgvefrhQaNyP+diOd0HLvKv/VGcDe67cCsqnpOMUBA==", "license": "Apache-2.0", "engines": { "node": ">=18" @@ -11666,7 +11666,7 @@ "@agent-relay/sdk": "10.6.0", "@agent-relay/utils": "10.6.0", "@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", diff --git a/packages/cli/package.json b/packages/cli/package.json index a0c3c7951..32ebffdbf 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/cli/commands/integration-relayfile-contract.test.ts b/packages/cli/src/cli/commands/integration-relayfile-contract.test.ts index 350e90c4a..db43d8559 100644 --- a/packages/cli/src/cli/commands/integration-relayfile-contract.test.ts +++ b/packages/cli/src/cli/commands/integration-relayfile-contract.test.ts @@ -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'; @@ -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 +): Promise { + const socketPath = join(tmpdir(), `rf-negotiation-${process.pid}-${++socketSequence}.sock`); + rmSync(socketPath, { force: true }); + const server = createServer(handler); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, resolve); + }); + + try { + await run(socketPath); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + rmSync(socketPath, { force: true }); + } +} + +function writeJson(response: Parameters[1], status: number, body: unknown): void { + response.writeHead(status, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(body)); +} + +async function withFakeRelayfileBinary( + version: string, + run: (binary: string) => Promise +): Promise { + 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 +): Promise { + 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((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); + }); + + try { + await run(socketPath); + } finally { + await new Promise((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 @@ -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 }); + }); + }); + + 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(); + }); + }); + }); + + 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).