From c5f57f8867c26ec29d497ba97895d9ca52115460 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Fri, 17 Jul 2026 10:38:26 +0200 Subject: [PATCH 1/3] fix(control-plane): negotiate stale daemons safely --- cmd/relayfile-cli/control_plane.go | 9 +- cmd/relayfile-cli/control_plane_test.go | 25 +- .../relayfile-control-plane-v1.openapi.yaml | 7 +- packages/client/CHANGELOG.md | 2 +- packages/client/src/client.test.ts | 165 ++++++++++- packages/client/src/client.ts | 278 ++++++++++++++++-- .../client/src/generated/control-plane.ts | 7 +- 7 files changed, 453 insertions(+), 40 deletions(-) diff --git a/cmd/relayfile-cli/control_plane.go b/cmd/relayfile-cli/control_plane.go index 9f46f241..6d45f2a0 100644 --- a/cmd/relayfile-cli/control_plane.go +++ b/cmd/relayfile-cli/control_plane.go @@ -270,8 +270,15 @@ func handleControlPlaneHello(w http.ResponseWriter, r *http.Request) { writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed") return } + // GET /v1/hello is the discovery endpoint. It must answer regardless of the + // caller's requested API version so newer clients can inspect an older + // daemon's supportedApiVersions and report (or repair) incompatibility. + if r.Method == http.MethodGet { + writeControlPlaneJSON(w, http.StatusOK, controlPlaneHello()) + return + } var requested uint32 - if r.Method == http.MethodPost && r.Body != nil { + if r.Body != nil { var req helloRequest if err := decodeControlPlaneJSON(r, &req); err != nil { writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, err.Error()) diff --git a/cmd/relayfile-cli/control_plane_test.go b/cmd/relayfile-cli/control_plane_test.go index 87e5e295..0f4d3e61 100644 --- a/cmd/relayfile-cli/control_plane_test.go +++ b/cmd/relayfile-cli/control_plane_test.go @@ -42,13 +42,22 @@ func TestControlPlaneHelloVersionAndCLIVersion(t *testing.T) { t.Fatalf("unexpected supported API versions: %#v", hello.SupportedAPIVersions) } + var discovery helloResponse + status = controlPlaneJSONWithRequestedVersion(t, client, http.MethodGet, baseURL+"/v1/hello?apiVersion=4", nil, &discovery, "4") + if status != http.StatusOK { + t.Fatalf("discovery hello status = %d, want %d", status, http.StatusOK) + } + if discovery.DaemonVersion != hello.DaemonVersion || discovery.APIVersion != hello.APIVersion { + t.Fatalf("unexpected discovery hello response: %#v", discovery) + } + var errResp map[string]controlPlaneError - status = controlPlaneJSONWithoutVersion(t, client, http.MethodGet, baseURL+"/v1/hello?apiVersion=4", nil, &errResp) + status = controlPlaneJSONWithoutVersion(t, client, http.MethodGet, baseURL+"/v1/integrations/providers?apiVersion=4", nil, &errResp) if status != http.StatusUpgradeRequired { - t.Fatalf("incompatible hello status = %d, want %d", status, http.StatusUpgradeRequired) + t.Fatalf("incompatible non-hello status = %d, want %d", status, http.StatusUpgradeRequired) } if errResp["error"].Code != controlPlaneErrVersionIncompatible { - t.Fatalf("unexpected incompatible error: %#v", errResp) + t.Fatalf("unexpected non-hello incompatible error: %#v", errResp) } } @@ -456,15 +465,15 @@ func startControlPlaneTestServer(t *testing.T) (*http.Client, string, func()) { func controlPlaneJSON(t *testing.T, client *http.Client, method, url string, body any, out any) int { t.Helper() - return controlPlaneJSONWithVersionHeader(t, client, method, url, body, out, true) + return controlPlaneJSONWithRequestedVersion(t, client, method, url, body, out, "1") } func controlPlaneJSONWithoutVersion(t *testing.T, client *http.Client, method, url string, body any, out any) int { t.Helper() - return controlPlaneJSONWithVersionHeader(t, client, method, url, body, out, false) + return controlPlaneJSONWithRequestedVersion(t, client, method, url, body, out, "") } -func controlPlaneJSONWithVersionHeader(t *testing.T, client *http.Client, method, url string, body any, out any, sendVersion bool) int { +func controlPlaneJSONWithRequestedVersion(t *testing.T, client *http.Client, method, url string, body any, out any, version string) int { t.Helper() var reader *bytes.Reader if body == nil { @@ -480,8 +489,8 @@ func controlPlaneJSONWithVersionHeader(t *testing.T, client *http.Client, method if err != nil { t.Fatalf("new request failed: %v", err) } - if sendVersion { - req.Header.Set("X-Relayfile-API-Version", "1") + if version != "" { + req.Header.Set("X-Relayfile-API-Version", version) } if body != nil { req.Header.Set("Content-Type", "application/json") diff --git a/openapi/relayfile-control-plane-v1.openapi.yaml b/openapi/relayfile-control-plane-v1.openapi.yaml index b8d12263..2e366243 100644 --- a/openapi/relayfile-control-plane-v1.openapi.yaml +++ b/openapi/relayfile-control-plane-v1.openapi.yaml @@ -6,7 +6,7 @@ info: Versioned local control-plane contract for the relayfile daemon/CLI. The service listens on a user-owned Unix domain socket (`RELAYFILE_SOCK`, or `$XDG_RUNTIME_DIR/relayfile.sock`) and uses - `X-Relayfile-API-Version: 2` for client compatibility checks. + `X-Relayfile-API-Version: 3` for compatibility checks after discovery. servers: - url: http://relayfile.local description: Logical HTTP origin over the relayfile Unix domain socket. @@ -15,6 +15,9 @@ paths: get: operationId: hello summary: Negotiate control-plane API compatibility + description: | + Unversioned discovery endpoint. The daemon always returns its current + and supported API versions; any version header or query is ignored. parameters: - $ref: "#/components/parameters/ApiVersionHeader" - $ref: "#/components/parameters/ApiVersionQuery" @@ -25,8 +28,6 @@ paths: application/json: schema: $ref: "#/components/schemas/HelloResponse" - "426": - $ref: "#/components/responses/VersionIncompatible" post: operationId: helloPost summary: Negotiate control-plane API compatibility diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index 4084225c..554d3b6c 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this package will be documented in this file. ## [Unreleased] -_No unreleased changes._ +- Fixed control-plane negotiation to discover older daemons without version-gating hello, fail fast on incompatible APIs, and replace stale managed daemons after binary upgrades. ## [0.10.26] - 2026-07-15 diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 24c57556..5db38278 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -51,6 +51,15 @@ describe('defaultRelayfileSocketPath', () => { }); describe('RelayfileControlPlaneClient lifecycle', () => { + type LifecycleInternals = { + spawnDaemon(): { getError(): Error | undefined }; + installedBinaryVersion(): Promise; + stopStaleDaemon(): Promise; + }; + + const lifecycleInternals = (client: RelayfileControlPlaneClient) => + client as unknown as LifecycleInternals; + it('require-daemon mode (autoStart:false) fails fast with an actionable error', async () => { const client = new RelayfileControlPlaneClient({ socketPath: join(tmpdir(), `rf-absent-${process.pid}.sock`), @@ -60,14 +69,35 @@ describe('RelayfileControlPlaneClient lifecycle', () => { await expect(client.ensureReady()).rejects.toThrow(/control-plane serve|not running/); }); - it('rejects a daemon whose supportedApiVersions excludes this client', async () => { - const client = new RelayfileControlPlaneClient({ socketPath: '/nope.sock', autoStart: false }); + it('fails fast with actionable version details for a v1-only daemon', async () => { + const client = new RelayfileControlPlaneClient({ + socketPath: '/nope.sock', + autoStart: false, + startTimeoutMs: 5000, + }); + vi.spyOn(lifecycleInternals(client), 'installedBinaryVersion').mockResolvedValue('0.10.26'); + const spawnDaemon = vi.spyOn(lifecycleInternals(client), 'spawnDaemon'); vi.spyOn(client, 'hello').mockResolvedValue({ - daemonVersion: '0.10.17', + daemonVersion: '0.10.19', apiVersion: 1, supportedApiVersions: [1], }); - await expect(client.ensureReady()).rejects.toMatchObject({ code: 'VERSION_INCOMPATIBLE' }); + const started = Date.now(); + const error = await client.ensureReady().catch((err: unknown) => err); + expect(Date.now() - started).toBeLessThan(500); + expect(error).toMatchObject({ code: 'VERSION_INCOMPATIBLE' }); + expect(error).toHaveProperty( + 'message', + expect.stringMatching(/daemon 0\.10\.19.*API v1.*supports v1.*requires API v3/i) + ); + expect(error).toHaveProperty('message', expect.stringContaining('Installed relayfile binary: 0.10.26')); + expect(error).toHaveProperty('message', expect.stringContaining('npm install -g relayfile@latest')); + expect(error).toHaveProperty( + 'message', + expect.stringContaining('control-plane serve --sock /nope.sock') + ); + expect(error).toHaveProperty('message', expect.stringContaining('kill $(lsof -t -- /nope.sock)')); + expect(spawnDaemon).not.toHaveBeenCalled(); }); it('rejects a daemon older than the minimum version', async () => { @@ -80,6 +110,132 @@ describe('RelayfileControlPlaneClient lifecycle', () => { await expect(client.ensureReady()).rejects.toThrow(/0\.10\.17 is required/); }); + it('does not send an API version header on discovery hello', async () => { + const client = new RelayfileControlPlaneClient({ socketPath: '/nope.sock', autoStart: false }); + const rawRequest = vi.spyOn( + client as unknown as { rawRequest: (opts: unknown) => Promise }, + 'rawRequest' + ); + rawRequest.mockResolvedValue({ + daemonVersion: '0.10.26', + apiVersion: RELAYFILE_API_VERSION, + supportedApiVersions: [RELAYFILE_API_VERSION], + }); + + await client.hello(); + + expect(rawRequest).toHaveBeenCalledWith({ + method: 'GET', + path: '/v1/hello', + includeVersionHeader: false, + freshConnection: true, + }); + }); + + it('does not retry a version rejection returned while an auto-started daemon connects', async () => { + const client = new RelayfileControlPlaneClient({ + socketPath: '/nope.sock', + autoStart: true, + startTimeoutMs: 5000, + }); + vi.spyOn(lifecycleInternals(client), 'spawnDaemon').mockReturnValue({ + getError: () => undefined, + }); + vi.spyOn(lifecycleInternals(client), 'installedBinaryVersion').mockResolvedValue('0.10.19'); + const hello = vi + .spyOn(client, 'hello') + .mockRejectedValueOnce( + new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', 'socket absent', undefined, true) + ) + .mockRejectedValueOnce( + new RelayfileControlPlaneError( + 'VERSION_INCOMPATIBLE', + 'relayfile daemon speaks API v1; this client needs v3', + 426 + ) + ); + + const started = Date.now(); + const error = await client.ensureReady().catch((err: unknown) => err); + + expect(Date.now() - started).toBeLessThan(1000); + expect(error).toMatchObject({ code: 'VERSION_INCOMPATIBLE', status: 426 }); + expect(error).toHaveProperty('message', expect.stringContaining('supported versions were not reported')); + expect(hello).toHaveBeenCalledTimes(2); + }); + + it('does not auto-start on non-transient daemon responses', async () => { + const client = new RelayfileControlPlaneClient({ socketPath: '/nope.sock', autoStart: true }); + const spawnDaemon = vi.spyOn(lifecycleInternals(client), 'spawnDaemon'); + vi.spyOn(client, 'hello').mockRejectedValue( + new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', 'server returned invalid JSON', 500) + ); + + await expect(client.ensureReady()).rejects.toMatchObject({ + code: 'DAEMON_UNAVAILABLE', + status: 500, + }); + expect(spawnDaemon).not.toHaveBeenCalled(); + }); + + it('still retries transient connection failures while an auto-started daemon boots', async () => { + const client = new RelayfileControlPlaneClient({ + socketPath: '/nope.sock', + autoStart: true, + startTimeoutMs: 1000, + }); + vi.spyOn(lifecycleInternals(client), 'spawnDaemon').mockReturnValue({ + getError: () => undefined, + }); + const transient = new RelayfileControlPlaneError( + 'DAEMON_UNAVAILABLE', + 'socket absent', + undefined, + true + ); + const hello = vi + .spyOn(client, 'hello') + .mockRejectedValueOnce(transient) + .mockRejectedValueOnce(transient) + .mockResolvedValueOnce({ + daemonVersion: '0.10.26', + apiVersion: RELAYFILE_API_VERSION, + supportedApiVersions: [1, 2, RELAYFILE_API_VERSION], + }); + + await expect(client.ensureReady()).resolves.toBeUndefined(); + expect(hello).toHaveBeenCalledTimes(3); + }); + + it('replaces a stale daemon when auto-start owns lifecycle and the local binary is newer', async () => { + const client = new RelayfileControlPlaneClient({ + socketPath: '/nope.sock', + autoStart: true, + startTimeoutMs: 1000, + }); + const spawnDaemon = vi.spyOn(lifecycleInternals(client), 'spawnDaemon').mockReturnValue({ + getError: () => undefined, + }); + const stopStaleDaemon = vi + .spyOn(lifecycleInternals(client), 'stopStaleDaemon') + .mockResolvedValue(undefined); + vi.spyOn(lifecycleInternals(client), 'installedBinaryVersion').mockResolvedValue('0.10.26'); + const stale = { daemonVersion: '0.10.19', apiVersion: 1, supportedApiVersions: [1] }; + const hello = vi + .spyOn(client, 'hello') + .mockResolvedValueOnce(stale) + .mockResolvedValueOnce({ + daemonVersion: '0.10.26', + apiVersion: RELAYFILE_API_VERSION, + supportedApiVersions: [1, 2, RELAYFILE_API_VERSION], + }); + + await expect(client.ensureReady()).resolves.toBeUndefined(); + expect(stopStaleDaemon).toHaveBeenCalledTimes(1); + expect(spawnDaemon).toHaveBeenCalledTimes(1); + expect(hello).toHaveBeenCalledTimes(2); + }); + it('auto-start with a missing binary fails fast with DAEMON_UNAVAILABLE (no crash)', async () => { const client = new RelayfileControlPlaneClient({ socketPath: join(tmpdir(), `rf-missing-bin-${process.pid}.sock`), @@ -236,6 +392,7 @@ describeContract('control-plane client (real daemon)', () => { webhookToken: 'tok', subscriptionId: 'sub', webhookSubscriptionId: 'whsub', + webhookSubscriptionWorkspaceId: 'ws_pin', }); const after = await client.listBindings(); const binding = after.find((b) => b.pathGlob === pathGlob); diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index f70234a7..3b0787a6 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { request } from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -21,12 +22,14 @@ import type { components } from './generated/control-plane.js'; export const RELAYFILE_API_VERSION = 3; /** - * Minimum `relayfile` daemon version this client requires. The control-plane - * first shipped in 0.10.17, so anything that answers `/v1/hello` is already >= - * this — API compatibility is enforced by RELAYFILE_API_VERSION. + * Minimum `relayfile` daemon version this client requires. API compatibility is + * enforced independently through supportedApiVersions. */ export const MIN_RELAYFILE_VERSION = '0.10.17'; +/** First published relayfile binary that can replace a stale daemon for API v3. */ +const MIN_RELAYFILE_VERSION_FOR_CURRENT_API = '0.10.21'; + const SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+].*)?$/; /** First semver-looking token in a string (tolerates a leading `v` and surrounding words). */ @@ -83,7 +86,9 @@ export class RelayfileControlPlaneError extends Error { constructor( public readonly code: string, message: string, - public readonly status?: number + public readonly status?: number, + /** True only for connection failures that may clear while a daemon starts. */ + public readonly transient = false ) { super(message); this.name = 'RelayfileControlPlaneError'; @@ -133,10 +138,40 @@ interface RequestOptions { path: string; query?: Record; body?: unknown; + /** Discovery hello intentionally omits the API header until negotiation completes. */ + includeVersionHeader?: boolean; + /** Do not reuse a socket that may still point at an unlinked stale daemon. */ + freshConnection?: boolean; } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const TRANSIENT_CONNECTION_CODES = new Set(['ENOENT', 'ECONNREFUSED', 'ETIMEDOUT']); + +function isTransientConnectionCode(code: string | undefined): boolean { + return code != null && TRANSIENT_CONNECTION_CODES.has(code); +} + +function isTransientControlPlaneError(err: unknown): err is RelayfileControlPlaneError { + return ( + err instanceof RelayfileControlPlaneError && err.code === 'DAEMON_UNAVAILABLE' && err.transient + ); +} + +function shellQuote(value: string): string { + return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`; +} + +interface SpawnedDaemon { + getError(): Error | undefined; +} + +interface CommandResult { + stdout: string; + stderr: string; + code: number | null; +} + export class RelayfileControlPlaneClient { private readonly socketPath: string; private readonly binary: string; @@ -183,8 +218,13 @@ export class RelayfileControlPlaneClient { method: opts.method, path, timeout: this.requestTimeoutMs, + // Discovery must open a fresh socket. A pooled keep-alive connection + // would keep reaching the unlinked stale daemon after replacement. + agent: opts.freshConnection ? false : undefined, headers: { - 'X-Relayfile-API-Version': String(RELAYFILE_API_VERSION), + ...(opts.includeVersionHeader === false + ? {} + : { 'X-Relayfile-API-Version': String(RELAYFILE_API_VERSION) }), Accept: 'application/json', ...(payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } @@ -194,8 +234,15 @@ export class RelayfileControlPlaneClient { (res) => { // The response stream can emit 'error' (e.g. socket reset mid-body); // without a listener that's an unhandled error -> crash. - res.on('error', (err) => - fail(new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', err.message)) + res.on('error', (err: NodeJS.ErrnoException) => + fail( + new RelayfileControlPlaneError( + 'DAEMON_UNAVAILABLE', + err.message, + undefined, + isTransientConnectionCode(err.code) + ) + ) ); const chunks: Buffer[] = []; res.on('data', (c) => chunks.push(c as Buffer)); @@ -234,7 +281,9 @@ export class RelayfileControlPlaneClient { req.destroy( new RelayfileControlPlaneError( 'DAEMON_UNAVAILABLE', - `relayfile control-plane request timed out after ${this.requestTimeoutMs}ms (${opts.method} ${opts.path})` + `relayfile control-plane request timed out after ${this.requestTimeoutMs}ms (${opts.method} ${opts.path})`, + undefined, + true ) ); }); @@ -250,7 +299,8 @@ export class RelayfileControlPlaneClient { err.code === 'ENOENT' || err.code === 'ECONNREFUSED' ? `relayfile control-plane is not running at ${this.socketPath} (${err.code})` : err.message, - undefined + undefined, + isTransientConnectionCode(err.code) ) ); }); @@ -276,7 +326,10 @@ export class RelayfileControlPlaneClient { try { hello = await this.hello(); } catch (err) { - if (!(err instanceof RelayfileControlPlaneError) || err.code !== 'DAEMON_UNAVAILABLE') throw err; + if (err instanceof RelayfileControlPlaneError && err.code === 'VERSION_INCOMPATIBLE') { + throw await this.versionRejectionError(err); + } + if (!isTransientControlPlaneError(err)) throw err; if (!this.autoStart) { throw new RelayfileControlPlaneError( 'DAEMON_UNAVAILABLE', @@ -287,21 +340,43 @@ export class RelayfileControlPlaneClient { hello = await this.startDaemonAndConnect(); } if (!hello.supportedApiVersions?.includes(RELAYFILE_API_VERSION)) { - throw new RelayfileControlPlaneError( - 'VERSION_INCOMPATIBLE', - `relayfile daemon speaks API v${hello.apiVersion} (supports ${ - hello.supportedApiVersions?.join(', ') || 'none' - }); this client needs v${RELAYFILE_API_VERSION}. Upgrade relayfile (or agent-relay).` - ); + const installedVersion = await this.installedBinaryVersion(); + const daemonVersion = firstSemver(hello.daemonVersion); + const canReplaceStaleDaemon = + this.autoStart && + installedVersion != null && + daemonVersion !== '' && + compareSemver(installedVersion, MIN_RELAYFILE_VERSION_FOR_CURRENT_API) >= 0 && + compareSemver(installedVersion, daemonVersion) > 0; + + if (!canReplaceStaleDaemon) { + throw this.versionMismatchError(hello, installedVersion); + } + + try { + await this.stopStaleDaemon(); + hello = await this.startDaemonAndConnect(installedVersion); + } catch (err) { + if (err instanceof RelayfileControlPlaneError && err.code === 'VERSION_INCOMPATIBLE') { + throw err; + } + throw this.versionMismatchError( + hello, + installedVersion, + `Automatic stale-daemon replacement failed: ${ + err instanceof Error ? err.message : String(err) + }` + ); + } } assertRelayfileVersion(hello.daemonVersion); } - private async startDaemonAndConnect(): Promise { - let child; + private spawnDaemon(): SpawnedDaemon { // A missing binary / bad RELAYFILE_BIN surfaces as an async 'error' event on // the child (ENOENT), not a throw from spawn(). let spawnError: Error | undefined; + let child; try { child = spawn(this.binary, ['control-plane', 'serve', '--sock', this.socketPath], { detached: true, @@ -310,17 +385,28 @@ export class RelayfileControlPlaneClient { child.on('error', (err) => { spawnError = err; }); + child.once('exit', (code, signal) => { + spawnError = new Error( + `relayfile control-plane exited before readiness (${signal ? `signal ${signal}` : `code ${code}`})` + ); + }); } catch (err) { throw new RelayfileControlPlaneError( 'DAEMON_UNAVAILABLE', `failed to start relayfile control-plane (${err instanceof Error ? err.message : String(err)}). ` + - `Install relayfile or set RELAYFILE_BIN.` + `Install relayfile or set RELAYFILE_BIN.` ); } child.unref?.(); + return { getError: () => spawnError }; + } + + private async startDaemonAndConnect(installedVersion?: string): Promise { + const spawned = this.spawnDaemon(); const deadline = Date.now() + this.startTimeoutMs; let lastErr: unknown; while (Date.now() < deadline) { + const spawnError = spawned.getError(); if (spawnError) { throw new RelayfileControlPlaneError( 'DAEMON_UNAVAILABLE', @@ -329,11 +415,27 @@ export class RelayfileControlPlaneClient { ); } await sleep(100); + let hello: HelloResponse; try { - return await this.hello(); + hello = await this.hello(); } catch (err) { + if (err instanceof RelayfileControlPlaneError && err.code === 'VERSION_INCOMPATIBLE') { + throw await this.versionRejectionError(err, installedVersion); + } + if (!isTransientControlPlaneError(err)) throw err; lastErr = err; + continue; } + + if (hello.supportedApiVersions?.includes(RELAYFILE_API_VERSION)) { + return hello; + } + + const mismatch = this.versionMismatchError( + hello, + installedVersion ?? (await this.installedBinaryVersion()) + ); + throw mismatch; } throw new RelayfileControlPlaneError( 'DAEMON_UNAVAILABLE', @@ -342,10 +444,144 @@ export class RelayfileControlPlaneClient { ); } + private async stopStaleDaemon(): Promise { + const result = await this.runCommand('lsof', ['-t', '--', this.socketPath], 1000); + const pids = [ + ...new Set( + (result?.code === 0 ? result.stdout : '') + .split(/\s+/) + .map((value) => Number.parseInt(value, 10)) + .filter((pid) => Number.isSafeInteger(pid) && pid > 1 && pid !== process.pid) + ), + ]; + if (pids.length !== 1) { + throw new Error( + pids.length === 0 + ? `could not identify the process serving ${this.socketPath}` + : `refusing to stop multiple processes serving ${this.socketPath}: ${pids.join(', ')}` + ); + } + + try { + process.kill(pids[0]!, 'SIGTERM'); + } catch (err) { + throw new Error( + `could not stop stale relayfile control-plane pid ${pids[0]}: ${ + err instanceof Error ? err.message : String(err) + }` + ); + } + + const deadline = Date.now() + this.startTimeoutMs; + while (existsSync(this.socketPath) && Date.now() < deadline) { + await sleep(50); + } + if (existsSync(this.socketPath)) { + throw new Error( + `stale relayfile control-plane pid ${pids[0]} did not release ${this.socketPath} within ${this.startTimeoutMs}ms` + ); + } + } + + private versionMismatchError( + hello: HelloResponse, + installedVersion: string | undefined, + detail?: string + ): RelayfileControlPlaneError { + const supported = hello.supportedApiVersions?.length + ? hello.supportedApiVersions.map((version) => `v${version}`).join(', ') + : 'none reported'; + return new RelayfileControlPlaneError( + 'VERSION_INCOMPATIBLE', + `relayfile daemon ${hello.daemonVersion} speaks API v${hello.apiVersion} ` + + `(supports ${supported}); this client requires API v${RELAYFILE_API_VERSION}. ` + + `Installed relayfile binary: ${installedVersion ?? 'version unknown'}. ` + + `${detail ? `${detail} ` : ''}` + + `Upgrade with \`npm install -g relayfile@latest\`, then restart the running control-plane with ` + + `\`${this.restartCommand()}\`.` + ); + } + + private async versionRejectionError( + err: RelayfileControlPlaneError, + installedVersion?: string + ): Promise { + installedVersion ??= await this.installedBinaryVersion(); + const spoken = err.message.match(/speaks API v(\d+)/i)?.[1]; + return new RelayfileControlPlaneError( + 'VERSION_INCOMPATIBLE', + `relayfile daemon version unknown (discovery hello was rejected) speaks API ` + + `${spoken ? `v${spoken}` : 'version unknown'} (supported versions were not reported); ` + + `this client requires API v${RELAYFILE_API_VERSION}. Installed relayfile binary: ` + + `${installedVersion ?? 'version unknown'}. Upgrade with \`npm install -g relayfile@latest\`, ` + + `then restart the running control-plane with \`${this.restartCommand()}\`. ` + + `Daemon response: ${err.message}`, + err.status + ); + } + + private restartCommand(): string { + const socket = shellQuote(this.socketPath); + return ( + `kill $(lsof -t -- ${socket}) && ` + + `${shellQuote(this.binary)} control-plane serve --sock ${socket}` + ); + } + + private async installedBinaryVersion(): Promise { + const result = await this.runCommand( + this.binary, + ['--version'], + Math.min(this.requestTimeoutMs, 2000) + ); + return firstSemver(`${result?.stdout ?? ''} ${result?.stderr ?? ''}`) || undefined; + } + + private runCommand(command: string, args: string[], timeoutMs: number): Promise { + return new Promise((resolve) => { + let child; + try { + child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + } catch { + resolve(undefined); + return; + } + + let stdout = ''; + let stderr = ''; + let settled = false; + let timeout: NodeJS.Timeout | undefined; + const finish = (result?: CommandResult) => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + resolve(result); + }; + child.stdout?.on('data', (chunk) => { + stdout += String(chunk); + }); + child.stderr?.on('data', (chunk) => { + stderr += String(chunk); + }); + child.once('error', () => finish()); + child.once('close', (code) => finish({ stdout, stderr, code })); + timeout = setTimeout(() => { + child.kill(); + finish(); + }, timeoutMs); + timeout.unref?.(); + }); + } + // ── endpoints ────────────────────────────────────────────────────────────── hello(): Promise { - return this.rawRequest({ method: 'GET', path: '/v1/hello' }); + return this.rawRequest({ + method: 'GET', + path: '/v1/hello', + includeVersionHeader: false, + freshConnection: true, + }); } resolvePath(provider: string, resource: string): Promise { diff --git a/packages/client/src/generated/control-plane.ts b/packages/client/src/generated/control-plane.ts index 348d396f..b0984b5a 100644 --- a/packages/client/src/generated/control-plane.ts +++ b/packages/client/src/generated/control-plane.ts @@ -11,7 +11,11 @@ export interface paths { path?: never; cookie?: never; }; - /** Negotiate control-plane API compatibility */ + /** + * Negotiate control-plane API compatibility + * @description Unversioned discovery endpoint. The daemon always returns its current + * and supported API versions; any version header or query is ignored. + */ get: operations["hello"]; put?: never; /** Negotiate control-plane API compatibility */ @@ -397,7 +401,6 @@ export interface operations { "application/json": components["schemas"]["HelloResponse"]; }; }; - 426: components["responses"]["VersionIncompatible"]; }; }; helloPost: { From f3c5801982b66a5786a9bf23d1d9757b9c7b9562 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 17 Jul 2026 08:44:07 +0000 Subject: [PATCH 2/3] chore(release): v0.10.27-rc.0 --- package-lock.json | 150 +++++++++++++++++++--- package.json | 2 +- packages/agents/package.json | 6 +- packages/cli/package.json | 2 +- packages/client/package.json | 2 +- packages/core/package.json | 2 +- packages/file-observer/package.json | 2 +- packages/local-mount/package.json | 2 +- packages/mount-darwin-arm64/package.json | 2 +- packages/mount-darwin-x64/package.json | 2 +- packages/mount-linux-arm64/package.json | 2 +- packages/mount-linux-x64/package.json | 2 +- packages/sdk/typescript/package-lock.json | 44 +++---- packages/sdk/typescript/package.json | 12 +- 14 files changed, 175 insertions(+), 57 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8dd4d2d5..1bb67419 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "relayfile", - "version": "0.10.26", + "version": "0.10.27-rc.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "relayfile", - "version": "0.10.26", + "version": "0.10.27-rc.0", "license": "Apache-2.0", "workspaces": [ "packages/core", @@ -383,6 +383,37 @@ "integrity": "sha512-6Fn4oDsYeNRPe+k7hVfS3Ae3yIocNjuvscVvRswn74CzxSC1X9+1wDhQ5eCvE+S1m1ixAjYGFC9/MNwuhFwjHw==", "dev": true }, + "node_modules/@agent-assistant/turn-context/node_modules/@relayfile/core": { + "version": "0.10.26", + "resolved": "https://registry.npmjs.org/@relayfile/core/-/core-0.10.26.tgz", + "integrity": "sha512-kiwcyaLa1PQdlglXkBLCX6V7j2OjV6Cd+Oy3xT/HrajH+l623iTrNcp1mLJLE46LQJWGsHFmt6aCh3pmRtajgA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@agent-assistant/turn-context/node_modules/@relayfile/sdk": { + "version": "0.10.26", + "resolved": "https://registry.npmjs.org/@relayfile/sdk/-/sdk-0.10.26.tgz", + "integrity": "sha512-ixqy5WKsv+Sqi7dZn8ad2uMmBo8lmXZk0j23p9q5Wf06pgsN+XblJZuFa5pPKmY/qv9SWRhmWQlTfhXoNW7zrw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@relayfile/core": "0.10.26", + "ignore": "^7.0.5", + "tar": "^7.5.10" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@relayfile/mount-darwin-arm64": "0.10.26", + "@relayfile/mount-darwin-x64": "0.10.26", + "@relayfile/mount-linux-arm64": "0.10.26", + "@relayfile/mount-linux-x64": "0.10.26" + } + }, "node_modules/@agent-assistant/turn-context/node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -592,6 +623,37 @@ "integrity": "sha512-6Fn4oDsYeNRPe+k7hVfS3Ae3yIocNjuvscVvRswn74CzxSC1X9+1wDhQ5eCvE+S1m1ixAjYGFC9/MNwuhFwjHw==", "dev": true }, + "node_modules/@agent-relay/hooks/node_modules/@relayfile/core": { + "version": "0.10.26", + "resolved": "https://registry.npmjs.org/@relayfile/core/-/core-0.10.26.tgz", + "integrity": "sha512-kiwcyaLa1PQdlglXkBLCX6V7j2OjV6Cd+Oy3xT/HrajH+l623iTrNcp1mLJLE46LQJWGsHFmt6aCh3pmRtajgA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@agent-relay/hooks/node_modules/@relayfile/sdk": { + "version": "0.10.26", + "resolved": "https://registry.npmjs.org/@relayfile/sdk/-/sdk-0.10.26.tgz", + "integrity": "sha512-ixqy5WKsv+Sqi7dZn8ad2uMmBo8lmXZk0j23p9q5Wf06pgsN+XblJZuFa5pPKmY/qv9SWRhmWQlTfhXoNW7zrw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@relayfile/core": "0.10.26", + "ignore": "^7.0.5", + "tar": "^7.5.10" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@relayfile/mount-darwin-arm64": "0.10.26", + "@relayfile/mount-darwin-x64": "0.10.26", + "@relayfile/mount-linux-arm64": "0.10.26", + "@relayfile/mount-linux-x64": "0.10.26" + } + }, "node_modules/@agent-relay/hooks/node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -2449,6 +2511,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2462,6 +2525,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2475,6 +2539,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2488,6 +2553,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -7609,7 +7675,7 @@ }, "packages/agents": { "name": "@relayfile/agents", - "version": "0.10.26", + "version": "0.10.27-rc.0", "license": "Apache-2.0", "dependencies": { "ajv": "^8.17.1", @@ -7620,7 +7686,7 @@ "@langchain/core": "^0.3.25", "@langchain/langgraph": "^0.2.31", "@openai/agents": "^0.0.13", - "@relayfile/sdk": "0.10.26", + "@relayfile/sdk": "0.10.27-rc.0", "@types/node": "^20.19.43", "ai": "^4.0.20", "typescript": "^5.5.4" @@ -7631,7 +7697,7 @@ "@langchain/core": ">=0.3", "@langchain/langgraph": ">=0.2", "@openai/agents": ">=0", - "@relayfile/sdk": "^0.10.26", + "@relayfile/sdk": "^0.10.27-rc.0", "ai": ">=4" }, "peerDependenciesMeta": { @@ -8044,7 +8110,7 @@ }, "packages/cli": { "name": "relayfile", - "version": "0.10.26", + "version": "0.10.27-rc.0", "hasInstallScript": true, "license": "Apache-2.0", "bin": { @@ -8056,7 +8122,7 @@ }, "packages/client": { "name": "@relayfile/client", - "version": "0.10.26", + "version": "0.10.27-rc.0", "license": "Apache-2.0", "devDependencies": { "@types/node": "^22.20.0", @@ -8070,7 +8136,7 @@ }, "packages/core": { "name": "@relayfile/core", - "version": "0.10.26", + "version": "0.10.27-rc.0", "license": "Apache-2.0", "devDependencies": { "@types/node": "^22.0.0", @@ -8083,7 +8149,7 @@ }, "packages/file-observer": { "name": "@relayfile/file-observer", - "version": "0.10.26", + "version": "0.10.27-rc.0", "license": "Apache-2.0", "dependencies": { "class-variance-authority": "^0.7.0", @@ -8891,7 +8957,7 @@ }, "packages/local-mount": { "name": "@relayfile/local-mount", - "version": "0.10.26", + "version": "0.10.27-rc.0", "license": "Apache-2.0", "dependencies": { "@parcel/watcher": "^2.5.6", @@ -8920,10 +8986,10 @@ }, "packages/sdk/typescript": { "name": "@relayfile/sdk", - "version": "0.10.26", + "version": "0.10.27-rc.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/core": "0.10.26", + "@relayfile/core": "0.10.27-rc.0", "ignore": "^7.0.5", "tar": "^7.5.10" }, @@ -8935,11 +9001,63 @@ "node": ">=18" }, "optionalDependencies": { - "@relayfile/mount-darwin-arm64": "0.10.26", - "@relayfile/mount-darwin-x64": "0.10.26", - "@relayfile/mount-linux-arm64": "0.10.26", - "@relayfile/mount-linux-x64": "0.10.26" + "@relayfile/mount-darwin-arm64": "0.10.27-rc.0", + "@relayfile/mount-darwin-x64": "0.10.27-rc.0", + "@relayfile/mount-linux-arm64": "0.10.27-rc.0", + "@relayfile/mount-linux-x64": "0.10.27-rc.0" } + }, + "packages/sdk/typescript/node_modules/@relayfile/mount-darwin-arm64": { + "version": "0.10.27-rc.0", + "resolved": "https://registry.npmjs.org/@relayfile/mount-darwin-arm64/-/mount-darwin-arm64-0.10.27-rc.0.tgz", + "integrity": "sha512-915HSeHxS/m1gbRPpbKjqdS3tzSMvA/KmwgUKJxW96rf8rm89PV9XQIAoX6wE+EsuZO2Uc5wzU3wS6E6+U8kJg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "packages/sdk/typescript/node_modules/@relayfile/mount-darwin-x64": { + "version": "0.10.27-rc.0", + "resolved": "https://registry.npmjs.org/@relayfile/mount-darwin-x64/-/mount-darwin-x64-0.10.27-rc.0.tgz", + "integrity": "sha512-hYHK5Rn85+z9OIal8zdahohQroG3wW49KpzruWiXBg9FrHoMmZEejm/nDSztGLsC697barcxjUWGNZLJIZTW0w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "packages/sdk/typescript/node_modules/@relayfile/mount-linux-arm64": { + "version": "0.10.27-rc.0", + "resolved": "https://registry.npmjs.org/@relayfile/mount-linux-arm64/-/mount-linux-arm64-0.10.27-rc.0.tgz", + "integrity": "sha512-pDrRl3Bp2U8yQLk1rY4TLMvCbZ8eex5zjcZ179CJ6ky0zQRjWIrXHTCJRmxe1zGuhIAdqHa+6sFFEKwm/9IDVQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "packages/sdk/typescript/node_modules/@relayfile/mount-linux-x64": { + "version": "0.10.27-rc.0", + "resolved": "https://registry.npmjs.org/@relayfile/mount-linux-x64/-/mount-linux-x64-0.10.27-rc.0.tgz", + "integrity": "sha512-eRwrcaGaJudhF5pKFoBYYwK5cEogHgN6oSZ5gewoKMgQ7gz0c/uXZK/AMKkcSWxNjaQzpJgcfL1Ch93phV/y+w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] } } } diff --git a/package.json b/package.json index 763591b6..ea74aa5d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "relayfile", "license": "Apache-2.0", "private": true, - "version": "0.10.26", + "version": "0.10.27-rc.0", "description": "relayfile — real-time filesystem for humans and agents", "workspaces": [ "packages/core", diff --git a/packages/agents/package.json b/packages/agents/package.json index 7dcb8f2c..55ad92a1 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/agents", - "version": "0.10.26", + "version": "0.10.27-rc.0", "description": "Thin agent-framework adapters for Relayfile. One connect() call, ready tools, proven writeback lifecycle.", "license": "Apache-2.0", "type": "module", @@ -48,7 +48,7 @@ "@langchain/core": ">=0.3", "@langchain/langgraph": ">=0.2", "@openai/agents": ">=0", - "@relayfile/sdk": "^0.10.26", + "@relayfile/sdk": "^0.10.27-rc.0", "ai": ">=4" }, "peerDependenciesMeta": { @@ -75,7 +75,7 @@ "@langchain/core": "^0.3.25", "@langchain/langgraph": "^0.2.31", "@openai/agents": "^0.0.13", - "@relayfile/sdk": "0.10.26", + "@relayfile/sdk": "0.10.27-rc.0", "@types/node": "^20.19.43", "ai": "^4.0.20", "typescript": "^5.5.4" diff --git a/packages/cli/package.json b/packages/cli/package.json index a76a0429..6a68fbd1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "relayfile", - "version": "0.10.26", + "version": "0.10.27-rc.0", "description": "CLI for relayfile — real-time filesystem for humans and agents", "bin": { "relayfile": "scripts/run.js" diff --git a/packages/client/package.json b/packages/client/package.json index 7dad887d..14805842 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/client", - "version": "0.10.26", + "version": "0.10.27-rc.0", "description": "Typed client for the relayfile local control-plane (unix-socket HTTP/JSON), generated from the authoritative OpenAPI contract", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/core/package.json b/packages/core/package.json index 9f353eb4..0b08893d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/core", - "version": "0.10.26", + "version": "0.10.27-rc.0", "description": "Shared business logic for relayfile — file operations, ACL, queries, events, and writeback lifecycle", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/file-observer/package.json b/packages/file-observer/package.json index 6eaf079d..ff987a84 100644 --- a/packages/file-observer/package.json +++ b/packages/file-observer/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/file-observer", - "version": "0.10.26", + "version": "0.10.27-rc.0", "description": "RelayFile observer dashboard for browsing synced workspace files and metadata", "files": [ "src", diff --git a/packages/local-mount/package.json b/packages/local-mount/package.json index b2d0b429..7be786c7 100644 --- a/packages/local-mount/package.json +++ b/packages/local-mount/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/local-mount", - "version": "0.10.26", + "version": "0.10.27-rc.0", "description": "Create a symlink/copy mount of a project directory with .agentignore/.agentreadonly semantics, then launch a CLI inside it and sync writable changes back on exit", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/mount-darwin-arm64/package.json b/packages/mount-darwin-arm64/package.json index a9b7f28f..4aca0b10 100644 --- a/packages/mount-darwin-arm64/package.json +++ b/packages/mount-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/mount-darwin-arm64", - "version": "0.10.26", + "version": "0.10.27-rc.0", "description": "relayfile-mount binary for darwin arm64. Installed automatically as an optional dependency of @relayfile/sdk.", "files": [ "bin" diff --git a/packages/mount-darwin-x64/package.json b/packages/mount-darwin-x64/package.json index 222ac47b..dec8b760 100644 --- a/packages/mount-darwin-x64/package.json +++ b/packages/mount-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/mount-darwin-x64", - "version": "0.10.26", + "version": "0.10.27-rc.0", "description": "relayfile-mount binary for darwin x64. Installed automatically as an optional dependency of @relayfile/sdk.", "files": [ "bin" diff --git a/packages/mount-linux-arm64/package.json b/packages/mount-linux-arm64/package.json index 56eb133e..41d3ae9c 100644 --- a/packages/mount-linux-arm64/package.json +++ b/packages/mount-linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/mount-linux-arm64", - "version": "0.10.26", + "version": "0.10.27-rc.0", "description": "relayfile-mount binary for linux arm64. Installed automatically as an optional dependency of @relayfile/sdk.", "files": [ "bin" diff --git a/packages/mount-linux-x64/package.json b/packages/mount-linux-x64/package.json index 5689859c..2fd30a76 100644 --- a/packages/mount-linux-x64/package.json +++ b/packages/mount-linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/mount-linux-x64", - "version": "0.10.26", + "version": "0.10.27-rc.0", "description": "relayfile-mount binary for linux x64. Installed automatically as an optional dependency of @relayfile/sdk.", "files": [ "bin" diff --git a/packages/sdk/typescript/package-lock.json b/packages/sdk/typescript/package-lock.json index 32061616..16c421b7 100644 --- a/packages/sdk/typescript/package-lock.json +++ b/packages/sdk/typescript/package-lock.json @@ -1,15 +1,15 @@ { "name": "@relayfile/sdk", - "version": "0.10.26", + "version": "0.10.27-rc.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@relayfile/sdk", - "version": "0.10.26", + "version": "0.10.27-rc.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/core": "0.10.26", + "@relayfile/core": "0.10.27-rc.0", "ignore": "^7.0.5", "tar": "^7.5.10" }, @@ -21,10 +21,10 @@ "node": ">=18" }, "optionalDependencies": { - "@relayfile/mount-darwin-arm64": "0.10.26", - "@relayfile/mount-darwin-x64": "0.10.26", - "@relayfile/mount-linux-arm64": "0.10.26", - "@relayfile/mount-linux-x64": "0.10.26" + "@relayfile/mount-darwin-arm64": "0.10.27-rc.0", + "@relayfile/mount-darwin-x64": "0.10.27-rc.0", + "@relayfile/mount-linux-arm64": "0.10.27-rc.0", + "@relayfile/mount-linux-x64": "0.10.27-rc.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -489,18 +489,18 @@ "license": "MIT" }, "node_modules/@relayfile/core": { - "version": "0.10.26", - "resolved": "https://registry.npmjs.org/@relayfile/core/-/core-0.10.26.tgz", - "integrity": "sha512-kiwcyaLa1PQdlglXkBLCX6V7j2OjV6Cd+Oy3xT/HrajH+l623iTrNcp1mLJLE46LQJWGsHFmt6aCh3pmRtajgA==", + "version": "0.10.27-rc.0", + "resolved": "https://registry.npmjs.org/@relayfile/core/-/core-0.10.27-rc.0.tgz", + "integrity": "sha512-U42eTHQVsA+kzThGXu+Jwmf8Ld5pLjB488AJksVEPFZDcXOihFg3maCJ3uOBviKFG7825CbKw2xXWagk56M0fw==", "license": "Apache-2.0", "engines": { "node": ">=18" } }, "node_modules/@relayfile/mount-darwin-arm64": { - "version": "0.10.26", - "resolved": "https://registry.npmjs.org/@relayfile/mount-darwin-arm64/-/mount-darwin-arm64-0.10.26.tgz", - "integrity": "sha512-gOnhvvZx92Fn3/M94zIaQj/iCSeh2maMDNSPYoRiinnDFmaxHWiCOKe+wcbMJ/fWkLp75wUqMZMZOcFxOMeLGg==", + "version": "0.10.27-rc.0", + "resolved": "https://registry.npmjs.org/@relayfile/mount-darwin-arm64/-/mount-darwin-arm64-0.10.27-rc.0.tgz", + "integrity": "sha512-915HSeHxS/m1gbRPpbKjqdS3tzSMvA/KmwgUKJxW96rf8rm89PV9XQIAoX6wE+EsuZO2Uc5wzU3wS6E6+U8kJg==", "cpu": [ "arm64" ], @@ -511,9 +511,9 @@ ] }, "node_modules/@relayfile/mount-darwin-x64": { - "version": "0.10.26", - "resolved": "https://registry.npmjs.org/@relayfile/mount-darwin-x64/-/mount-darwin-x64-0.10.26.tgz", - "integrity": "sha512-3xH5rKBa8K0Gt0xxhctSbj8xHsW5sgM28vFPE5z7ZBDB4yTTiZ1bzaulOoim8aQqNZjedU3tIzI1nQgcENXvzw==", + "version": "0.10.27-rc.0", + "resolved": "https://registry.npmjs.org/@relayfile/mount-darwin-x64/-/mount-darwin-x64-0.10.27-rc.0.tgz", + "integrity": "sha512-hYHK5Rn85+z9OIal8zdahohQroG3wW49KpzruWiXBg9FrHoMmZEejm/nDSztGLsC697barcxjUWGNZLJIZTW0w==", "cpu": [ "x64" ], @@ -524,9 +524,9 @@ ] }, "node_modules/@relayfile/mount-linux-arm64": { - "version": "0.10.26", - "resolved": "https://registry.npmjs.org/@relayfile/mount-linux-arm64/-/mount-linux-arm64-0.10.26.tgz", - "integrity": "sha512-QbDlZmWI7wnJP7h2HU1tQ6d46Wq6FGhCDzR51nz+G3v/wnmcrU1G1VT8mO3ZkjERUgP/cpdhbEkzUG29Dd/dUA==", + "version": "0.10.27-rc.0", + "resolved": "https://registry.npmjs.org/@relayfile/mount-linux-arm64/-/mount-linux-arm64-0.10.27-rc.0.tgz", + "integrity": "sha512-pDrRl3Bp2U8yQLk1rY4TLMvCbZ8eex5zjcZ179CJ6ky0zQRjWIrXHTCJRmxe1zGuhIAdqHa+6sFFEKwm/9IDVQ==", "cpu": [ "arm64" ], @@ -537,9 +537,9 @@ ] }, "node_modules/@relayfile/mount-linux-x64": { - "version": "0.10.26", - "resolved": "https://registry.npmjs.org/@relayfile/mount-linux-x64/-/mount-linux-x64-0.10.26.tgz", - "integrity": "sha512-YIa8cULsOO9sGR7lwcoKj0p7naVq5+qPUxqR8EHFgrLrEIAjcsKqVtj87MGJYXKbrVvREp5mFeY7r8JT8NsUgQ==", + "version": "0.10.27-rc.0", + "resolved": "https://registry.npmjs.org/@relayfile/mount-linux-x64/-/mount-linux-x64-0.10.27-rc.0.tgz", + "integrity": "sha512-eRwrcaGaJudhF5pKFoBYYwK5cEogHgN6oSZ5gewoKMgQ7gz0c/uXZK/AMKkcSWxNjaQzpJgcfL1Ch93phV/y+w==", "cpu": [ "x64" ], diff --git a/packages/sdk/typescript/package.json b/packages/sdk/typescript/package.json index 5333cf7d..12526e87 100644 --- a/packages/sdk/typescript/package.json +++ b/packages/sdk/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/sdk", - "version": "0.10.26", + "version": "0.10.27-rc.0", "description": "TypeScript SDK for relayfile — real-time filesystem for humans and agents", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -59,15 +59,15 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@relayfile/core": "0.10.26", + "@relayfile/core": "0.10.27-rc.0", "ignore": "^7.0.5", "tar": "^7.5.10" }, "optionalDependencies": { - "@relayfile/mount-darwin-arm64": "0.10.26", - "@relayfile/mount-darwin-x64": "0.10.26", - "@relayfile/mount-linux-arm64": "0.10.26", - "@relayfile/mount-linux-x64": "0.10.26" + "@relayfile/mount-darwin-arm64": "0.10.27-rc.0", + "@relayfile/mount-darwin-x64": "0.10.27-rc.0", + "@relayfile/mount-linux-arm64": "0.10.27-rc.0", + "@relayfile/mount-linux-x64": "0.10.27-rc.0" }, "devDependencies": { "typescript": "^5.7.3", From 7c70068150b94897da3930978e258ea101fd1c57 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Fri, 17 Jul 2026 11:18:27 +0200 Subject: [PATCH 3/3] fix(client): harden control-plane recovery --- .../relayfile-control-plane-v1.openapi.yaml | 13 +- packages/client/src/client.test.ts | 115 +++++++++++++++- packages/client/src/client.ts | 124 ++++++++++++++++-- .../client/src/generated/control-plane.ts | 12 +- 4 files changed, 233 insertions(+), 31 deletions(-) diff --git a/openapi/relayfile-control-plane-v1.openapi.yaml b/openapi/relayfile-control-plane-v1.openapi.yaml index 2e366243..28005d46 100644 --- a/openapi/relayfile-control-plane-v1.openapi.yaml +++ b/openapi/relayfile-control-plane-v1.openapi.yaml @@ -18,9 +18,9 @@ paths: description: | Unversioned discovery endpoint. The daemon always returns its current and supported API versions; any version header or query is ignored. - parameters: - - $ref: "#/components/parameters/ApiVersionHeader" - - $ref: "#/components/parameters/ApiVersionQuery" + No version parameters are declared here on purpose: discovery must stay + callable by clients of any API version, including versions this contract + does not know about. responses: "200": description: Daemon version and supported API versions @@ -301,13 +301,6 @@ components: type: integer format: uint32 const: 3 - ApiVersionQuery: - name: apiVersion - in: query - required: false - schema: - type: integer - format: uint32 responses: VersionIncompatible: description: Client and daemon API versions are incompatible diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 5db38278..5a68f3ef 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -1,5 +1,7 @@ import { type ChildProcess, spawn } from 'node:child_process'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { EventEmitter } from 'node:events'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import type { ClientRequest, IncomingMessage, RequestOptions as NodeRequestOptions } from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -55,11 +57,37 @@ describe('RelayfileControlPlaneClient lifecycle', () => { spawnDaemon(): { getError(): Error | undefined }; installedBinaryVersion(): Promise; stopStaleDaemon(): Promise; + socketOwnerPids(): Promise; + linuxSocketOwnerPids(procRoot?: string): number[]; + runCommand( + command: string, + args: string[], + timeoutMs: number + ): Promise<{ stdout: string; stderr: string; code: number | null } | undefined>; + createHTTPRequest( + options: NodeRequestOptions, + onResponse: (response: IncomingMessage) => void + ): ClientRequest; }; const lifecycleInternals = (client: RelayfileControlPlaneClient) => client as unknown as LifecycleInternals; + const fakeRequest = (onEnd: () => void): ClientRequest => { + const request = Object.assign(new EventEmitter(), { + write: () => true, + end: () => { + queueMicrotask(onEnd); + return request; + }, + destroy: (error?: Error) => { + if (error) queueMicrotask(() => request.emit('error', error)); + return request; + }, + }); + return request as unknown as ClientRequest; + }; + it('require-daemon mode (autoStart:false) fails fast with an actionable error', async () => { const client = new RelayfileControlPlaneClient({ socketPath: join(tmpdir(), `rf-absent-${process.pid}.sock`), @@ -132,6 +160,42 @@ describe('RelayfileControlPlaneClient lifecycle', () => { }); }); + it.each(['ECONNRESET', 'EPIPE'])('marks request %s errors as startup-transient', async (code) => { + const client = new RelayfileControlPlaneClient({ socketPath: '/nope.sock', autoStart: false }); + let request!: ClientRequest; + request = fakeRequest(() => { + request.emit('error', Object.assign(new Error(`${code} request`), { code })); + }); + vi.spyOn(lifecycleInternals(client), 'createHTTPRequest').mockReturnValue(request); + + await expect(client.hello()).rejects.toMatchObject({ + code: 'DAEMON_UNAVAILABLE', + transient: true, + }); + }); + + it.each(['ECONNRESET', 'EPIPE'])( + 'marks response-stream %s errors as startup-transient', + async (code) => { + const client = new RelayfileControlPlaneClient({ socketPath: '/nope.sock', autoStart: false }); + vi.spyOn(lifecycleInternals(client), 'createHTTPRequest').mockImplementation( + (_options, onResponse) => + fakeRequest(() => { + const response = Object.assign(new EventEmitter(), { statusCode: 200 }); + onResponse(response as unknown as IncomingMessage); + queueMicrotask(() => + response.emit('error', Object.assign(new Error(`${code} response`), { code })) + ); + }) + ); + + await expect(client.hello()).rejects.toMatchObject({ + code: 'DAEMON_UNAVAILABLE', + transient: true, + }); + } + ); + it('does not retry a version rejection returned while an auto-started daemon connects', async () => { const client = new RelayfileControlPlaneClient({ socketPath: '/nope.sock', @@ -236,6 +300,55 @@ describe('RelayfileControlPlaneClient lifecycle', () => { expect(hello).toHaveBeenCalledTimes(2); }); + it('finds an exact Linux socket owner through procfs without lsof', () => { + const procRoot = mkdtempSync(join(tmpdir(), 'relayfile-proc-')); + const socketPath = join(procRoot, 'control-plane.sock'); + const ownerPid = 424242; + try { + mkdirSync(join(procRoot, 'net'), { recursive: true }); + mkdirSync(join(procRoot, String(ownerPid), 'fd'), { recursive: true }); + writeFileSync( + join(procRoot, 'net', 'unix'), + `Num RefCount Protocol Flags Type St Inode Path\n` + + `0000000000000000: 00000002 00000000 00010000 0001 01 998877 ${socketPath}\n` + ); + symlinkSync('socket:[998877]', join(procRoot, String(ownerPid), 'fd', '7')); + + const client = new RelayfileControlPlaneClient({ socketPath, autoStart: true }); + + expect(lifecycleInternals(client).linuxSocketOwnerPids(procRoot)).toEqual([ownerPid]); + } finally { + rmSync(procRoot, { recursive: true, force: true }); + } + }); + + it('blames an unavailable lsof when the fallback cannot identify the stale daemon', async () => { + const client = new RelayfileControlPlaneClient({ + socketPath: '/nope.sock', + autoStart: true, + startTimeoutMs: 1000, + }); + vi.spyOn(lifecycleInternals(client), 'installedBinaryVersion').mockResolvedValue('0.10.26'); + vi.spyOn( + client as unknown as { runCommand: () => Promise }, + 'runCommand' + ).mockResolvedValue(undefined); + vi.spyOn(client, 'hello').mockResolvedValue({ + daemonVersion: '0.10.19', + apiVersion: 1, + supportedApiVersions: [1], + }); + + const error = await client.ensureReady().catch((err: unknown) => err); + + expect(error).toMatchObject({ code: 'VERSION_INCOMPATIBLE' }); + expect(error).toHaveProperty('message', expect.stringContaining('could not run `lsof`')); + expect(error).toHaveProperty( + 'message', + expect.stringContaining('Automatic stale-daemon replacement failed') + ); + }); + it('auto-start with a missing binary fails fast with DAEMON_UNAVAILABLE (no crash)', async () => { const client = new RelayfileControlPlaneClient({ socketPath: join(tmpdir(), `rf-missing-bin-${process.pid}.sock`), diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 3b0787a6..9eb322c0 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -1,6 +1,11 @@ import { spawn } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { request } from 'node:http'; +import { existsSync, readFileSync, readdirSync, readlinkSync } from 'node:fs'; +import { + request, + type ClientRequest, + type IncomingMessage, + type RequestOptions as NodeRequestOptions, +} from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -146,7 +151,15 @@ interface RequestOptions { const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -const TRANSIENT_CONNECTION_CODES = new Set(['ENOENT', 'ECONNREFUSED', 'ETIMEDOUT']); +const TRANSIENT_CONNECTION_CODES = new Set([ + 'ENOENT', + 'ECONNREFUSED', + 'ETIMEDOUT', + // A daemon that binds the socket before it is ready to serve can accept and + // then drop the connection mid-request; both surface during startup polling. + 'ECONNRESET', + 'EPIPE', +]); function isTransientConnectionCode(code: string | undefined): boolean { return code != null && TRANSIENT_CONNECTION_CODES.has(code); @@ -188,6 +201,13 @@ export class RelayfileControlPlaneClient { this.requestTimeoutMs = options.requestTimeoutMs ?? 10000; } + private createHTTPRequest( + options: NodeRequestOptions, + onResponse: (response: IncomingMessage) => void + ): ClientRequest { + return request(options, onResponse); + } + /** Low-level request over the unix socket. Throws RelayfileControlPlaneError. */ private rawRequest(opts: RequestOptions): Promise { const query = opts.query @@ -212,7 +232,7 @@ export class RelayfileControlPlaneClient { resolve(value); }; - const req = request( + const req = this.createHTTPRequest( { socketPath: this.socketPath, method: opts.method, @@ -445,15 +465,7 @@ export class RelayfileControlPlaneClient { } private async stopStaleDaemon(): Promise { - const result = await this.runCommand('lsof', ['-t', '--', this.socketPath], 1000); - const pids = [ - ...new Set( - (result?.code === 0 ? result.stdout : '') - .split(/\s+/) - .map((value) => Number.parseInt(value, 10)) - .filter((pid) => Number.isSafeInteger(pid) && pid > 1 && pid !== process.pid) - ), - ]; + const pids = await this.socketOwnerPids(); if (pids.length !== 1) { throw new Error( pids.length === 0 @@ -483,6 +495,92 @@ export class RelayfileControlPlaneClient { } } + private async socketOwnerPids(): Promise { + if (process.platform === 'linux') { + const procPids = this.linuxSocketOwnerPids(); + if (procPids.length > 0) return procPids; + } + if (process.platform === 'win32') { + throw new Error( + 'automatic stale-daemon replacement is not supported on Windows; stop the running relayfile control-plane manually' + ); + } + + const result = await this.runCommand('lsof', ['-t', '--', this.socketPath], 1000); + if (!result) { + throw new Error( + `could not run \`lsof\` to identify the process serving ${this.socketPath} ` + + `(it is not installed, not on PATH, or did not exit within 1000ms); ` + + `install lsof or stop the stale relayfile control-plane manually` + ); + } + if (result.code !== 0) { + throw new Error( + `lsof could not identify the process serving ${this.socketPath} (exit ${result.code}${ + result.stderr.trim() ? `: ${result.stderr.trim()}` : '' + })` + ); + } + return [ + ...new Set( + result.stdout + .split(/\s+/) + .map((value) => Number.parseInt(value, 10)) + .filter((pid) => Number.isSafeInteger(pid) && pid > 1 && pid !== process.pid) + ), + ]; + } + + /** Resolve a Unix-socket owner without external tools on standard Linux hosts. */ + private linuxSocketOwnerPids(procRoot = '/proc'): number[] { + let socketInodes: Set; + try { + socketInodes = new Set( + readFileSync(join(procRoot, 'net', 'unix'), 'utf8') + .split('\n') + .slice(1) + .map((line) => line.trim().split(/\s+/)) + .filter((fields) => fields.length >= 8 && fields.slice(7).join(' ') === this.socketPath) + .map((fields) => fields[6]!) + ); + } catch { + return []; + } + if (socketInodes.size === 0) return []; + + const pids = new Set(); + let processes: string[]; + try { + processes = readdirSync(procRoot).filter((name) => /^\d+$/.test(name)); + } catch { + return []; + } + for (const processName of processes) { + const pid = Number.parseInt(processName, 10); + if (pid <= 1 || pid === process.pid) continue; + const fdDir = join(procRoot, processName, 'fd'); + let descriptors: string[]; + try { + descriptors = readdirSync(fdDir); + } catch { + continue; + } + for (const descriptor of descriptors) { + try { + const target = readlinkSync(join(fdDir, descriptor)); + const inode = target.match(/^socket:\[(\d+)\]$/)?.[1]; + if (inode && socketInodes.has(inode)) { + pids.add(pid); + break; + } + } catch { + // File descriptors may disappear while /proc is scanned. + } + } + } + return [...pids]; + } + private versionMismatchError( hello: HelloResponse, installedVersion: string | undefined, diff --git a/packages/client/src/generated/control-plane.ts b/packages/client/src/generated/control-plane.ts index b0984b5a..83f92051 100644 --- a/packages/client/src/generated/control-plane.ts +++ b/packages/client/src/generated/control-plane.ts @@ -15,6 +15,9 @@ export interface paths { * Negotiate control-plane API compatibility * @description Unversioned discovery endpoint. The daemon always returns its current * and supported API versions; any version header or query is ignored. + * No version parameters are declared here on purpose: discovery must stay + * callable by clients of any API version, including versions this contract + * does not know about. */ get: operations["hello"]; put?: never; @@ -371,7 +374,6 @@ export interface components { }; parameters: { ApiVersionHeader: 3; - ApiVersionQuery: number; }; requestBodies: never; headers: never; @@ -381,12 +383,8 @@ export type $defs = Record; export interface operations { hello: { parameters: { - query?: { - apiVersion?: components["parameters"]["ApiVersionQuery"]; - }; - header?: { - "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; - }; + query?: never; + header?: never; path?: never; cookie?: never; };