diff --git a/CHANGELOG.md b/CHANGELOG.md index 2214e5d2e..60592441c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `agent-relay/node-embedded` starts and controls the real `relay node up` broker lifecycle inside long-lived Node.js hosts without exiting the host process, installing global signal handlers, taking over console output, or leaking ambient host environment into broker binary/state resolution and the broker child. - `AgentRelayBrokerSDK` (Swift) reaches broker-control/observability parity with the TypeScript harness driver: `listAgents`, `sendInput`, `resizePty`, `flushPending`, `snapshot`, full-payload `sendMessage` (with `mode`), `setModel`, `subscribeChannels`/`unsubscribeChannels`, `getStatus`, `getMetrics`, `getCrashInsights`, `preflight`, and `renewLease` on `AgentRelayBrokerClient`, plus the `Codable` response types (`ListAgent`, `BrokerStatus`, `PtySnapshot`, `MetricsResponse`, `CrashInsightsResponse`, and related). ### Fixed diff --git a/package-lock.json b/package-lock.json index bc19d03bc..07fed7f51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agent-relay/monorepo", - "version": "10.1.0", + "version": "10.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agent-relay/monorepo", - "version": "10.1.0", + "version": "10.2.0", "license": "Apache-2.0", "workspaces": [ "packages/*" @@ -1941,7 +1941,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -1959,7 +1958,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1977,7 +1975,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1995,7 +1992,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -2013,7 +2009,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -2031,7 +2026,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -2049,7 +2043,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2067,7 +2060,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2085,7 +2077,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2103,7 +2094,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2121,7 +2111,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2139,7 +2128,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2157,7 +2145,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2175,7 +2162,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2193,7 +2179,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2211,7 +2196,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2229,7 +2213,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2247,7 +2230,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2265,7 +2247,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2283,7 +2264,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2301,7 +2281,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2319,7 +2298,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -2337,7 +2315,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -2355,7 +2332,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -2373,7 +2349,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -2391,7 +2366,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -5299,7 +5273,6 @@ "version": "0.0.7", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", - "dev": true, "optional": true, "engines": { "node": ">=10.0.0" @@ -5627,7 +5600,6 @@ "version": "0.0.10", "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", - "dev": true, "hasInstallScript": true, "optional": true, "dependencies": { @@ -8063,7 +8035,6 @@ "version": "2.27.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", - "dev": true, "license": "MIT", "optional": true }, @@ -10248,44 +10219,44 @@ }, "packages/brand": { "name": "@agent-relay/brand", - "version": "10.1.0" + "version": "10.2.0" }, "packages/broker-darwin-arm64": { "name": "@agent-relay/broker-darwin-arm64", - "version": "10.1.0", + "version": "10.2.0", "license": "MIT" }, "packages/broker-darwin-x64": { "name": "@agent-relay/broker-darwin-x64", - "version": "10.1.0", + "version": "10.2.0", "license": "MIT" }, "packages/broker-linux-arm64": { "name": "@agent-relay/broker-linux-arm64", - "version": "10.1.0", + "version": "10.2.0", "license": "MIT" }, "packages/broker-linux-x64": { "name": "@agent-relay/broker-linux-x64", - "version": "10.1.0", + "version": "10.2.0", "license": "MIT" }, "packages/broker-win32-x64": { "name": "@agent-relay/broker-win32-x64", - "version": "10.1.0", + "version": "10.2.0", "license": "MIT" }, "packages/cli": { "name": "agent-relay", - "version": "10.1.0", + "version": "10.3.0", "license": "Apache-2.0", "dependencies": { - "@agent-relay/cloud": "10.1.0", - "@agent-relay/config": "10.1.0", - "@agent-relay/fleet": "10.1.0", - "@agent-relay/harness-driver": "10.1.0", - "@agent-relay/sdk": "10.1.0", - "@agent-relay/utils": "10.1.0", + "@agent-relay/cloud": "10.2.0", + "@agent-relay/config": "10.3.0", + "@agent-relay/fleet": "10.2.0", + "@agent-relay/harness-driver": "10.3.0", + "@agent-relay/sdk": "10.2.0", + "@agent-relay/utils": "10.2.0", "@modelcontextprotocol/sdk": "^1.0.0", "@relayfile/client": "^0.10.21", "@relayflows/cli": "^1.0.1", @@ -10313,9 +10284,9 @@ }, "packages/cloud": { "name": "@agent-relay/cloud", - "version": "10.1.0", + "version": "10.2.0", "dependencies": { - "@agent-relay/config": "10.1.0", + "@agent-relay/config": "10.2.0", "@aws-sdk/client-s3": "3.1020.0", "ignore": "^7.0.5", "tar": "^7.5.10" @@ -10329,9 +10300,18 @@ "ssh2": "^1.17.0" } }, + "packages/cloud/node_modules/@agent-relay/config": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@agent-relay/config/-/config-10.2.0.tgz", + "integrity": "sha512-/Pz8TvyOz49gWJ8hov8ymsOkRmApoYtUoQz3VFa11NU2y4lyHpYPG9PsM3NlPZ46DGzT7c72zvSDjI+pl5l+Lg==", + "dependencies": { + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.1" + } + }, "packages/config": { "name": "@agent-relay/config", - "version": "10.1.0", + "version": "10.3.0", "dependencies": { "zod": "^3.23.8", "zod-to-json-schema": "^3.23.1" @@ -10344,20 +10324,38 @@ }, "packages/evals": { "name": "@agent-relay/evals", - "version": "10.1.0", + "version": "10.2.0", "license": "Apache-2.0", "dependencies": { - "@agent-relay/harness-driver": "10.1.0", - "@agent-relay/integration-prompts": "10.1.0" + "@agent-relay/harness-driver": "10.2.0", + "@agent-relay/integration-prompts": "10.2.0" + } + }, + "packages/evals/node_modules/@agent-relay/harness-driver": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@agent-relay/harness-driver/-/harness-driver-10.2.0.tgz", + "integrity": "sha512-EJRbOwBF8IHPrHjyJVEkQ1XJb3WwnZ4GeUe7iy22Sdh+n0CX5EfjxOs4K5hSdKmwH29VbwjXE+lzPXbj7qMixQ==", + "license": "Apache-2.0", + "dependencies": { + "@agent-relay/sdk": "10.2.0", + "ws": "^8.18.3", + "zod": "^3.23.8" + }, + "optionalDependencies": { + "@agent-relay/broker-darwin-arm64": "10.2.0", + "@agent-relay/broker-darwin-x64": "10.2.0", + "@agent-relay/broker-linux-arm64": "10.2.0", + "@agent-relay/broker-linux-x64": "10.2.0", + "@agent-relay/broker-win32-x64": "10.2.0" } }, "packages/fleet": { "name": "@agent-relay/fleet", - "version": "10.1.0", + "version": "10.2.0", "license": "Apache-2.0", "dependencies": { - "@agent-relay/harness-driver": "10.1.0", - "@agent-relay/harnesses": "10.1.0", + "@agent-relay/harness-driver": "10.2.0", + "@agent-relay/harnesses": "10.2.0", "@relaycast/sdk": "^6.0.0", "ws": "^8.18.3", "zod": "^3.23.8" @@ -10366,51 +10364,96 @@ "@types/ws": "^8.18.1" } }, + "packages/fleet/node_modules/@agent-relay/harness-driver": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@agent-relay/harness-driver/-/harness-driver-10.2.0.tgz", + "integrity": "sha512-EJRbOwBF8IHPrHjyJVEkQ1XJb3WwnZ4GeUe7iy22Sdh+n0CX5EfjxOs4K5hSdKmwH29VbwjXE+lzPXbj7qMixQ==", + "license": "Apache-2.0", + "dependencies": { + "@agent-relay/sdk": "10.2.0", + "ws": "^8.18.3", + "zod": "^3.23.8" + }, + "optionalDependencies": { + "@agent-relay/broker-darwin-arm64": "10.2.0", + "@agent-relay/broker-darwin-x64": "10.2.0", + "@agent-relay/broker-linux-arm64": "10.2.0", + "@agent-relay/broker-linux-x64": "10.2.0", + "@agent-relay/broker-win32-x64": "10.2.0" + } + }, "packages/harness-driver": { "name": "@agent-relay/harness-driver", - "version": "10.1.0", + "version": "10.3.0", "license": "Apache-2.0", "dependencies": { - "@agent-relay/sdk": "10.1.0", + "@agent-relay/sdk": "10.2.0", "ws": "^8.18.3", "zod": "^3.23.8" }, "optionalDependencies": { - "@agent-relay/broker-darwin-arm64": "10.1.0", - "@agent-relay/broker-darwin-x64": "10.1.0", - "@agent-relay/broker-linux-arm64": "10.1.0", - "@agent-relay/broker-linux-x64": "10.1.0", - "@agent-relay/broker-win32-x64": "10.1.0" + "@agent-relay/broker-darwin-arm64": "10.2.0", + "@agent-relay/broker-darwin-x64": "10.2.0", + "@agent-relay/broker-linux-arm64": "10.2.0", + "@agent-relay/broker-linux-x64": "10.2.0", + "@agent-relay/broker-win32-x64": "10.2.0" } }, "packages/harnesses": { "name": "@agent-relay/harnesses", - "version": "10.1.0", + "version": "10.2.0", + "license": "Apache-2.0", + "dependencies": { + "@agent-relay/harness-driver": "10.2.0", + "@agent-relay/sdk": "10.2.0" + } + }, + "packages/harnesses/node_modules/@agent-relay/harness-driver": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@agent-relay/harness-driver/-/harness-driver-10.2.0.tgz", + "integrity": "sha512-EJRbOwBF8IHPrHjyJVEkQ1XJb3WwnZ4GeUe7iy22Sdh+n0CX5EfjxOs4K5hSdKmwH29VbwjXE+lzPXbj7qMixQ==", "license": "Apache-2.0", "dependencies": { - "@agent-relay/harness-driver": "10.1.0", - "@agent-relay/sdk": "10.1.0" + "@agent-relay/sdk": "10.2.0", + "ws": "^8.18.3", + "zod": "^3.23.8" + }, + "optionalDependencies": { + "@agent-relay/broker-darwin-arm64": "10.2.0", + "@agent-relay/broker-darwin-x64": "10.2.0", + "@agent-relay/broker-linux-arm64": "10.2.0", + "@agent-relay/broker-linux-x64": "10.2.0", + "@agent-relay/broker-win32-x64": "10.2.0" } }, "packages/integration-prompts": { "name": "@agent-relay/integration-prompts", - "version": "10.1.0", + "version": "10.2.0", "license": "Apache-2.0" }, "packages/policy": { "name": "@agent-relay/policy", - "version": "10.1.0", + "version": "10.2.0", "dependencies": { - "@agent-relay/config": "10.1.0" + "@agent-relay/config": "10.2.0" }, "devDependencies": { "@types/node": "^22.19.3", "vitest": "^4.1.0" } }, + "packages/policy/node_modules/@agent-relay/config": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@agent-relay/config/-/config-10.2.0.tgz", + "integrity": "sha512-/Pz8TvyOz49gWJ8hov8ymsOkRmApoYtUoQz3VFa11NU2y4lyHpYPG9PsM3NlPZ46DGzT7c72zvSDjI+pl5l+Lg==", + "dependencies": { + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.1" + } + }, "packages/sdk": { "name": "@agent-relay/sdk", - "version": "10.1.0", + "version": "10.2.0", "dependencies": { "@relaycast/sdk": "^6.0.0" }, @@ -10420,9 +10463,9 @@ }, "packages/utils": { "name": "@agent-relay/utils", - "version": "10.1.0", + "version": "10.2.0", "dependencies": { - "@agent-relay/config": "10.1.0", + "@agent-relay/config": "10.2.0", "compare-versions": "^6.1.1" }, "devDependencies": { @@ -10431,6 +10474,15 @@ "vitest": "^4.1.0" } }, + "packages/utils/node_modules/@agent-relay/config": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@agent-relay/config/-/config-10.2.0.tgz", + "integrity": "sha512-/Pz8TvyOz49gWJ8hov8ymsOkRmApoYtUoQz3VFa11NU2y4lyHpYPG9PsM3NlPZ46DGzT7c72zvSDjI+pl5l+Lg==", + "dependencies": { + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.1" + } + }, "web": { "version": "0.0.1", "extraneous": true, diff --git a/packages/cli/README.md b/packages/cli/README.md index 61928cbab..67f59d8f9 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -52,6 +52,37 @@ Node workflow runs use Relayflows for YAML, TypeScript, and Python workflow file Hosted equivalents live under `agent-relay cloud …`. +## Embedded node lifecycle + +Long-lived Node.js hosts can start the same foreground broker lifecycle without +letting the CLI own `process.exit`, global signal handlers, `process.env`, or +console output: + +```ts +import { startEmbeddedNode } from 'agent-relay/node-embedded'; + +const started = await startEmbeddedNode( + { config: '/absolute/path/to/agent-relay.mjs' }, + { onOutput: ({ level, message }) => hostLogger[level](message) } +); +if (!started.ok) { + throw new Error(started.message); +} + +process.once('SIGTERM', () => void started.handle.stop('SIGTERM')); +await started.handle.completion; +``` + +The host owns signal forwarding and calls the idempotent `handle.stop()` when +it wants Relay to shut down. Embedded startup is foreground-only; +`background: true` returns a structured code-2 failure because detached mode +belongs to the process-oriented CLI. Use `downEmbeddedNode` and +`statusEmbeddedNode` when controlling a broker through its persisted state. +When `runtime.env` is supplied, that cloned environment is authoritative for +broker binary lookup, state lookup, and the spawned broker process. +`onOutput` streams every entry; returned result snapshots retain the most recent +1,000 entries so a long-lived host does not accumulate an unbounded log array. + ## Packages - `@agent-relay/sdk`: messaging, delivery contracts, and actions. diff --git a/packages/cli/package.json b/packages/cli/package.json index 95a49135a..7773e5729 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "agent-relay", - "version": "10.2.0", + "version": "10.3.0", "description": "Real-time agent-to-agent communication system", "type": "module", "main": "dist/index.cjs", @@ -22,6 +22,10 @@ "types": "./dist/cli/agent-relay-mcp.d.ts", "import": "./dist/cli/agent-relay-mcp.js" }, + "./node-embedded": { + "types": "./dist/node-embedded.d.ts", + "import": "./dist/node-embedded.js" + }, "./package.json": "./package.json" }, "bin": { @@ -44,9 +48,9 @@ }, "dependencies": { "@agent-relay/cloud": "10.2.0", - "@agent-relay/config": "10.2.0", + "@agent-relay/config": "10.3.0", "@agent-relay/fleet": "10.2.0", - "@agent-relay/harness-driver": "10.2.0", + "@agent-relay/harness-driver": "10.3.0", "@agent-relay/sdk": "10.2.0", "@agent-relay/utils": "10.2.0", "@modelcontextprotocol/sdk": "^1.0.0", diff --git a/packages/cli/src/cli/commands/core-runtime-env.test.ts b/packages/cli/src/cli/commands/core-runtime-env.test.ts new file mode 100644 index 000000000..1d5b49f8c --- /dev/null +++ b/packages/cli/src/cli/commands/core-runtime-env.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const createRuntimeClientMock = vi.hoisted(() => vi.fn()); + +vi.mock('../lib/client-factory.js', () => ({ + createRuntimeClient: createRuntimeClientMock, + spawnAgentWithClient: vi.fn(async () => undefined), +})); + +import { createDefaultRelay } from './core.js'; + +describe('createDefaultRelay runtime environment', () => { + beforeEach(() => { + createRuntimeClientMock.mockReset(); + createRuntimeClientMock.mockResolvedValue({ + getStatus: vi.fn(async () => ({})), + getSession: vi.fn(async () => ({})), + shutdown: vi.fn(async () => undefined), + }); + }); + + it('uses an explicit embedded env as the broker child parent env', async () => { + const env = { + AGENT_RELAY_STATE_DIR: '/embedded/state', + EMBEDDED_ONLY: '1', + } as NodeJS.ProcessEnv; + + await createDefaultRelay('/tmp/project', 3889, 'embedded-node', false, { env }); + + expect(createRuntimeClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/tmp/project', + binaryArgs: { + persist: true, + apiPort: 3889, + stateDir: '/embedded/state', + }, + env, + parentEnv: env, + }) + ); + }); + + it('keeps normal CLI process-env inheritance when no runtime env is supplied', async () => { + await createDefaultRelay('/tmp/project'); + + const options = createRuntimeClientMock.mock.calls[0]?.[0] as Record; + expect(options.env).toBe(process.env); + expect(options).not.toHaveProperty('parentEnv'); + }); +}); diff --git a/packages/cli/src/cli/commands/core.test.ts b/packages/cli/src/cli/commands/core.test.ts index 56bf6fa40..bf813f5da 100644 --- a/packages/cli/src/cli/commands/core.test.ts +++ b/packages/cli/src/cli/commands/core.test.ts @@ -823,10 +823,12 @@ describe('registerCoreCommands', () => { [runtimePath]: '', }); + const lifecycleEvents: string[] = []; let running = true; const killImpl = vi.fn((pid: number, signal?: NodeJS.Signals | number) => { if (signal === 0 || signal === undefined) { if (!running) { + lifecycleEvents.push('exit-confirmed'); const err = new Error('not running') as Error & { code?: string }; err.code = 'ESRCH'; throw err; @@ -834,9 +836,15 @@ describe('registerCoreCommands', () => { return; } if (signal === 'SIGTERM') { + lifecycleEvents.push('sigterm'); running = false; } }); + const unlinkSync = fs.unlinkSync; + fs.unlinkSync = vi.fn((filePath: string) => { + lifecycleEvents.push(`cleanup:${filePath}`); + unlinkSync(filePath); + }); const { program } = createHarness({ fs, killImpl }); @@ -847,6 +855,10 @@ describe('registerCoreCommands', () => { expect(fs.unlinkSync).toHaveBeenCalledWith(connectionPath); expect(fs.unlinkSync).toHaveBeenCalledWith(relaySockPath); expect(fs.unlinkSync).toHaveBeenCalledWith(runtimePath); + expect(lifecycleEvents.indexOf('exit-confirmed')).toBeGreaterThan(lifecycleEvents.indexOf('sigterm')); + expect(lifecycleEvents.indexOf(`cleanup:${connectionPath}`)).toBeGreaterThan( + lifecycleEvents.indexOf('exit-confirmed') + ); }); it('down reports not running when connection metadata is missing', async () => { diff --git a/packages/cli/src/cli/commands/core.ts b/packages/cli/src/cli/commands/core.ts index e610358bc..7ab6ea952 100644 --- a/packages/cli/src/cli/commands/core.ts +++ b/packages/cli/src/cli/commands/core.ts @@ -109,6 +109,15 @@ export interface CoreDependencies { log: (...args: unknown[]) => void; error: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; + /** Stdio ownership for the optional Python node-provider child. */ + pythonProviderStdio?: 'inherit' | 'ignore'; + /** Optional structured logger factory for long-lived node capability providers. */ + createNodeLogger?: (component: string) => { + debug: (message: string, extra?: Record) => void; + info: (message: string, extra?: Record) => void; + warn: (message: string, extra?: Record) => void; + error: (message: string, extra?: Record) => void; + }; exit: ExitFn; } @@ -142,18 +151,36 @@ function resolveCliVersion(fileSystem: CoreFileSystem): string { } } -async function createDefaultRelay( +export interface CoreRelayRuntimeOptions { + /** Environment inherited by the broker process. Defaults to process.env for the CLI. */ + env?: NodeJS.ProcessEnv; + /** Human-readable broker startup step sink used by embedders. */ + onStep?: (message: string) => void; + /** Broker stderr sink used by embedders. */ + onStderr?: (line: string) => void; +} + +/** + * Construct the production relay client used by `up`. + * + * The optional runtime overrides let an in-process host isolate environment + * and output ownership. The normal CLI deliberately omits them and retains + * its existing process.env / console behavior. + */ +export async function createDefaultRelay( cwd: string, apiPort = 0, brokerName?: string, - verbose = false + verbose = false, + runtime: CoreRelayRuntimeOptions = {} ): Promise { const binaryArgs: BrokerInitArgs = {}; if (apiPort > 0) { binaryArgs.persist = true; binaryArgs.apiPort = apiPort; } - const stateDir = process.env.AGENT_RELAY_STATE_DIR; + const env = runtime.env ?? process.env; + const stateDir = env.AGENT_RELAY_STATE_DIR; if (stateDir) { binaryArgs.stateDir = stateDir; } @@ -161,11 +188,16 @@ async function createDefaultRelay( cwd, binaryArgs, brokerName, + env, + // An explicit runtime environment belongs to an embedder and must be the + // broker child's complete base environment. The terminal CLI omits this + // override and retains normal process.env inheritance. + ...(runtime.env !== undefined ? { parentEnv: env } : {}), preferConnect: apiPort > 0, ...(verbose ? { - onStep: (message: string) => console.error(`[agent-relay][verbose] ${message}`), - onStderr: (line: string) => console.error(`[broker] ${line}`), + onStep: runtime.onStep ?? ((message: string) => console.error(`[agent-relay][verbose] ${message}`)), + onStderr: runtime.onStderr ?? ((line: string) => console.error(`[broker] ${line}`)), } : {}), }); diff --git a/packages/cli/src/cli/lib/broker-lifecycle.ts b/packages/cli/src/cli/lib/broker-lifecycle.ts index 9e6d31649..8a28b0b6d 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.ts @@ -16,7 +16,7 @@ import { } from './node-definition-loader.js'; import { startReflexCapture, type RunningReflexCapture } from './reflex-capture.js'; -type UpOptions = { +export type UpOptions = { spawn?: boolean; background?: boolean; verbose?: boolean; @@ -41,7 +41,7 @@ type UpOptions = { logJson?: boolean; }; -type DownOptions = { +export type DownOptions = { force?: boolean; all?: boolean; timeout?: string; @@ -62,6 +62,8 @@ const NODE_DELIVERY_READY_TIMEOUT_MS = 10_000; // RELAY_NODE_TOKEN. const NODE_TOKEN_WAIT_MS = 15_000; +export type StatusOptions = { stateDir?: string; waitFor?: string }; + export interface BrokerConnection { url: string; port: number; @@ -504,7 +506,7 @@ async function startNodeCapabilityProviders( // flag, keep the prior behavior: the registration summary via log, warnings // via warn. ...(nodeLoggingEnabled(options) - ? { logger: createLogger('fleet') } + ? { logger: deps.createNodeLogger?.('fleet') ?? createLogger('fleet') } : { warn: (message) => deps.warn(message), log: (message) => deps.log(message) }), }) ); @@ -565,7 +567,10 @@ function startPythonNodeProvider( ...(credentials.baseUrl ? { RELAY_BASE_URL: credentials.baseUrl } : {}), }; try { - const child = deps.spawnProcess(python, [configPath], { stdio: 'inherit', env }); + const child = deps.spawnProcess(python, [configPath], { + stdio: deps.pythonProviderStdio ?? 'inherit', + env, + }); deps.log( `Serving Python node provider: ${python} ${path.basename(configPath)} (pid: ${child.pid ?? 'unknown'}).` ); @@ -616,15 +621,27 @@ function readBrokerPid(dataDir: string, _deps: CoreDependencies): number | null return conn?.pid ?? null; } -function isProcessRunning(pid: number, deps: CoreDependencies): boolean { +type ProcessLiveness = 'running' | 'stopped' | 'unknown'; + +function getProcessLiveness(pid: number, deps: CoreDependencies): ProcessLiveness { try { deps.killProcess(pid, 0); - return true; - } catch { - return false; + return 'running'; + } catch (err: unknown) { + return errorCode(err) === 'ESRCH' ? 'stopped' : 'unknown'; } } +function isProcessRunning(pid: number, deps: CoreDependencies): boolean { + return getProcessLiveness(pid, deps) === 'running'; +} + +function isProcessConfirmedStopped(pid: number, deps: CoreDependencies): boolean { + // EPERM and other probe failures do not prove the process exited. Only + // ESRCH is authoritative enough to permit deleting its lifecycle state. + return getProcessLiveness(pid, deps) === 'stopped'; +} + type ProcessInfo = { pid: number; command: string; @@ -807,6 +824,21 @@ async function waitForProcessExit(pid: number, timeoutMs: number, deps: CoreDepe return false; } +async function waitForConfirmedProcessExit( + pid: number, + timeoutMs: number, + deps: CoreDependencies +): Promise { + const startedAt = deps.now(); + while (deps.now() - startedAt < timeoutMs) { + if (isProcessConfirmedStopped(pid, deps)) { + return true; + } + await deps.sleep(100); + } + return isProcessConfirmedStopped(pid, deps); +} + async function recoverHalfStartedBroker( paths: CoreProjectPaths, deps: CoreDependencies @@ -1411,17 +1443,22 @@ export async function runDownCommand(options: DownOptions, deps: CoreDependencie return; } - if (!isProcessRunning(pid, deps)) { + const initialLiveness = getProcessLiveness(pid, deps); + if (initialLiveness === 'stopped') { cleanupBrokerFiles(paths, deps); deps.log('Cleaned up stale state (process was not running)'); return; } + if (initialLiveness === 'unknown') { + deps.error(`Unable to determine whether broker process ${pid} is running; retaining state.`); + return; + } try { deps.log(`Stopping broker (pid: ${pid})...`); deps.killProcess(pid, 'SIGTERM'); - const exited = await waitForProcessExit(pid, timeout, deps); + let exited = await waitForConfirmedProcessExit(pid, timeout, deps); if (!exited) { // eslint-disable-next-line max-depth if (options.force) { @@ -1429,16 +1466,30 @@ export async function runDownCommand(options: DownOptions, deps: CoreDependencie // eslint-disable-next-line max-depth try { deps.killProcess(pid, 'SIGKILL'); - await waitForProcessExit(pid, 2000, deps); - } catch { - // Ignore kill errors. + } catch (err: unknown) { + // ESRCH means the process won the race and exited before SIGKILL. + // Any other failure must retain state unless a strict liveness probe + // independently confirms the process is gone. + // eslint-disable-next-line max-depth + if (errorCode(err) !== 'ESRCH' && !isProcessConfirmedStopped(pid, deps)) { + deps.error(`Error force stopping broker: ${toErrorMessage(err)}`); + return; + } + } + exited = await waitForConfirmedProcessExit(pid, 2000, deps); + // eslint-disable-next-line max-depth + if (!exited) { + deps.error(`Forced shutdown failed; broker process ${pid} is still running.`); + return; } } else { - deps.log(`Graceful shutdown timed out after ${timeout}ms. Use --force to kill.`); + deps.error(`Graceful shutdown timed out after ${timeout}ms. Use --force to kill.`); return; } } + // State is control authority for down/status. Never remove it until an + // ESRCH-backed probe has confirmed the broker process exited. cleanupBrokerFiles(paths, deps); deps.log('Stopped'); } catch (err: unknown) { @@ -1452,10 +1503,7 @@ export async function runDownCommand(options: DownOptions, deps: CoreDependencie } } -export async function runStatusCommand( - deps: CoreDependencies, - options?: { stateDir?: string; waitFor?: string } -): Promise { +export async function runStatusCommand(deps: CoreDependencies, options?: StatusOptions): Promise { const paths = deps.getProjectPaths(); if (options?.stateDir) { paths.dataDir = path.resolve(options.stateDir); diff --git a/packages/cli/src/cli/lib/client-factory.test.ts b/packages/cli/src/cli/lib/client-factory.test.ts index 5b5e95614..d0dec06e9 100644 --- a/packages/cli/src/cli/lib/client-factory.test.ts +++ b/packages/cli/src/cli/lib/client-factory.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const spawnSpy = vi.fn(); const connectSpy = vi.fn(); @@ -26,6 +26,8 @@ vi.mock('@agent-relay/harness-driver', () => { import { createRuntimeClient, spawnAgentWithClient } from './client-factory.js'; +const originalEnv = { ...process.env }; + describe('client-factory', () => { beforeEach(() => { spawnSpy.mockClear(); @@ -33,6 +35,11 @@ describe('client-factory', () => { mockSpawnedClient.spawnPty.mockClear(); mockConnectedClient.spawnPty.mockClear(); delete process.env.AGENT_RELAY_BIN; + delete process.env.AGENT_RELAY_STATE_DIR; + }); + + afterEach(() => { + process.env = { ...originalEnv }; }); it('builds HarnessDriverClient with defaults', async () => { @@ -69,6 +76,28 @@ describe('client-factory', () => { ); }); + it('resolves the broker binary from the isolated env instead of the host env', async () => { + process.env.AGENT_RELAY_BIN = '/host/agent-relay-broker'; + const env = { + AGENT_RELAY_BIN: '/embedded/agent-relay-broker', + EMBEDDED_ONLY: '1', + } as NodeJS.ProcessEnv; + + await createRuntimeClient({ + cwd: '/tmp/project', + env, + parentEnv: env, + }); + + expect(spawnSpy).toHaveBeenCalledWith( + expect.objectContaining({ + binaryPath: '/embedded/agent-relay-broker', + env, + parentEnv: env, + }) + ); + }); + it('prefers connecting to an existing broker when requested', async () => { const client = await createRuntimeClient({ cwd: '/tmp/project', @@ -80,6 +109,20 @@ describe('client-factory', () => { expect(client).toBe(mockConnectedClient); }); + it('resolves an existing broker connection from the isolated env', async () => { + process.env.AGENT_RELAY_STATE_DIR = '/host/state'; + const env = { AGENT_RELAY_STATE_DIR: '/embedded/state' } as NodeJS.ProcessEnv; + + await createRuntimeClient({ + cwd: '/tmp/project', + env, + preferConnect: true, + }); + + expect(connectSpy).toHaveBeenCalledWith({ cwd: '/tmp/project', env }); + expect(spawnSpy).not.toHaveBeenCalled(); + }); + it('spawns through spawnPty', async () => { const spawnPty = vi.fn(async () => undefined); const options = { diff --git a/packages/cli/src/cli/lib/client-factory.ts b/packages/cli/src/cli/lib/client-factory.ts index ab59313b8..9d4d813d7 100644 --- a/packages/cli/src/cli/lib/client-factory.ts +++ b/packages/cli/src/cli/lib/client-factory.ts @@ -7,6 +7,8 @@ export interface CreateRuntimeClientOptions { binaryArgs?: BrokerInitArgs; brokerName?: string; env?: NodeJS.ProcessEnv; + /** Base environment inherited by the broker child. Defaults to process.env. */ + parentEnv?: NodeJS.ProcessEnv; preferConnect?: boolean; /** Forward broker stderr lines to this callback (e.g. for `--verbose`). */ onStderr?: (line: string) => void; @@ -33,20 +35,26 @@ export async function createRuntimeClient(options: CreateRuntimeClientOptions): const { cwd, channels = ['general'], - binaryPath = process.env.AGENT_RELAY_BIN, + binaryPath: configuredBinaryPath, binaryArgs, brokerName, - env = process.env, + env: configuredEnv, + parentEnv, preferConnect = false, onStderr, onStep, } = options; + const env = configuredEnv ?? process.env; + const binaryPath = configuredBinaryPath ?? env.AGENT_RELAY_BIN; if (preferConnect) { try { // Await so an async connect rejection is caught here, not leaked to the // caller — otherwise the fallback spawn below never runs. - return await HarnessDriverClient.connect({ cwd }); + return await HarnessDriverClient.connect({ + cwd, + ...(configuredEnv !== undefined ? { env } : {}), + }); } catch { // Fall through to spawning a fresh broker. } @@ -59,6 +67,7 @@ export async function createRuntimeClient(options: CreateRuntimeClientOptions): channels, cwd, env: env as Record, + ...(parentEnv ? { parentEnv } : {}), onStderr, onStep, }); diff --git a/packages/cli/src/cli/lib/node-embedded.ts b/packages/cli/src/cli/lib/node-embedded.ts new file mode 100644 index 000000000..b35708d9c --- /dev/null +++ b/packages/cli/src/cli/lib/node-embedded.ts @@ -0,0 +1,344 @@ +import { fileURLToPath } from 'node:url'; + +import { getProjectPaths } from '@agent-relay/config'; + +import { + createDefaultRelay, + withDefaults, + type CoreDependencies, + type CoreProjectPaths, +} from '../commands/core.js'; +import { + runDownCommand, + runStatusCommand, + runUpCommand, + type DownOptions, + type StatusOptions, + type UpOptions, +} from './broker-lifecycle.js'; + +export type EmbeddedNodeOutputLevel = 'info' | 'warn' | 'error'; + +export interface EmbeddedNodeOutput { + level: EmbeddedNodeOutputLevel; + message: string; +} + +export interface EmbeddedNodeRuntimeOptions { + /** + * Environment visible to the broker and node providers. It is cloned before + * use, so lifecycle setup never mutates the host's process.env object. + */ + env?: NodeJS.ProcessEnv; + /** Receive lifecycle and provider output without global console writes. */ + onOutput?: (output: EmbeddedNodeOutput) => void; +} + +/** + * Embedded startup deliberately owns the foreground lifecycle in this host. + * Detached `--background` mode belongs to the process-oriented CLI and is + * rejected at runtime too, including for untyped JavaScript callers. + */ +export type EmbeddedNodeStartOptions = Omit & { + background?: false; +}; + +export type EmbeddedNodeDownOptions = DownOptions; +export type EmbeddedNodeStatusOptions = StatusOptions; + +export interface EmbeddedNodeCommandSuccess { + ok: true; + code: 0; + output: EmbeddedNodeOutput[]; +} + +export interface EmbeddedNodeCommandFailure { + ok: false; + code: number; + message: string; + output: EmbeddedNodeOutput[]; +} + +export type EmbeddedNodeCommandResult = EmbeddedNodeCommandSuccess | EmbeddedNodeCommandFailure; + +export interface EmbeddedNodeHandle { + /** Resolves after the lifecycle has fully stopped and released its resources. */ + readonly completion: Promise; + /** + * Run the same shutdown path the CLI uses for SIGTERM/SIGINT, without + * installing a handler on the host process. Repeated calls are idempotent. + */ + stop(signal?: 'SIGINT' | 'SIGTERM'): Promise; +} + +export type EmbeddedNodeStartResult = + | (EmbeddedNodeCommandSuccess & { handle: EmbeddedNodeHandle }) + | EmbeddedNodeCommandFailure; + +/** A distinguishable replacement for the CLI's process-terminating exit dependency. */ +export class BrokerLifecycleExitError extends Error { + constructor(readonly code: number) { + super(`Broker lifecycle requested exit code ${code}`); + this.name = 'BrokerLifecycleExitError'; + } +} + +type SignalHandler = () => void | Promise; + +const MAX_BUFFERED_OUTPUT_ENTRIES = 1_000; +const OUTPUT_TRIM_BATCH_SIZE = 250; + +interface OutputRecorder { + emit(level: EmbeddedNodeOutputLevel, args: unknown[]): void; + snapshot(): EmbeddedNodeOutput[]; + resultFrom(error?: unknown): EmbeddedNodeCommandResult; +} + +interface Deferred { + promise: Promise; + resolve(value: T): void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function formatOutputArg(value: unknown): string { + if (typeof value === 'string') return value; + if (value instanceof Error) return value.message; + try { + const json = JSON.stringify(value); + return json === undefined ? String(value) : json; + } catch { + return String(value); + } +} + +function createOutputRecorder(runtime: EmbeddedNodeRuntimeOptions): OutputRecorder { + const output: EmbeddedNodeOutput[] = []; + const emit = (level: EmbeddedNodeOutputLevel, args: unknown[]): void => { + const entry = { level, message: args.map(formatOutputArg).join(' ') } satisfies EmbeddedNodeOutput; + output.push(entry); + if (output.length > MAX_BUFFERED_OUTPUT_ENTRIES) { + output.splice(0, OUTPUT_TRIM_BATCH_SIZE); + } + runtime.onOutput?.(entry); + }; + return { + emit, + snapshot: () => output.map((entry) => ({ ...entry })), + resultFrom: (error?: unknown): EmbeddedNodeCommandResult => { + const snapshot = output.map((entry) => ({ ...entry })); + if (error === undefined) { + // Some lifecycle commands report operational failures through + // deps.error and return normally (notably down). Broker stderr is + // recorded as neutral info below, so only lifecycle error output + // participates in this fallback result inference. + const reportedError = snapshot.find((entry) => entry.level === 'error'); + if (!reportedError) return { ok: true, code: 0, output: snapshot }; + return { ok: false, code: 1, message: reportedError.message, output: snapshot }; + } + const code = error instanceof BrokerLifecycleExitError ? error.code : 1; + if (code === 0) return { ok: true, code: 0, output: snapshot }; + const reportedError = snapshot.find((entry) => entry.level === 'error'); + const message = + reportedError?.message ?? + (error instanceof Error ? error.message : `Broker lifecycle exited with code ${code}`); + return { ok: false, code, message, output: snapshot }; + }, + }; +} + +function createEmbeddedDependencies( + runtime: EmbeddedNodeRuntimeOptions, + recorder: OutputRecorder, + signalHandlers: Map, + ready: Deferred, + releaseHold: Deferred, + dependencyOverrides: Partial +): CoreDependencies { + const isolatedEnvProvided = runtime.env !== undefined || dependencyOverrides.env !== undefined; + const env = { ...(runtime.env ?? dependencyOverrides.env ?? process.env) }; + const embeddedGetProjectPaths: CoreDependencies['getProjectPaths'] | undefined = + dependencyOverrides.getProjectPaths ?? + (isolatedEnvProvided ? () => getProjectPaths(undefined, env) as unknown as CoreProjectPaths : undefined); + const createRelay: CoreDependencies['createRelay'] = + dependencyOverrides.createRelay ?? + ((cwd, apiPort, brokerName, verbose) => + createDefaultRelay(cwd, apiPort, brokerName, verbose, { + env, + onStep: (message) => recorder.emit('info', [`[agent-relay][verbose] ${message}`]), + onStderr: (line) => recorder.emit('info', [`[broker] ${line}`]), + })); + + return withDefaults({ + ...dependencyOverrides, + ...(embeddedGetProjectPaths ? { getProjectPaths: embeddedGetProjectPaths } : {}), + env, + // Resolve relative to this package, never process.argv[1] (the embedding + // host's entry script), so the matching bundled MCP helper is discoverable. + cliScript: dependencyOverrides.cliScript ?? fileURLToPath(new URL('../index.js', import.meta.url)), + createRelay, + log: (...args) => recorder.emit('info', args), + warn: (...args) => recorder.emit('warn', args), + error: (...args) => recorder.emit('error', args), + pythonProviderStdio: 'ignore', + createNodeLogger: () => ({ + debug: (message, extra) => recorder.emit('info', [message, ...(extra ? [extra] : [])]), + info: (message, extra) => recorder.emit('info', [message, ...(extra ? [extra] : [])]), + warn: (message, extra) => recorder.emit('warn', [message, ...(extra ? [extra] : [])]), + error: (message, extra) => recorder.emit('error', [message, ...(extra ? [extra] : [])]), + }), + onSignal: (signal, handler) => { + signalHandlers.set(signal, handler); + }, + holdOpen: () => { + ready.resolve(); + return releaseHold.promise; + }, + exit: (code): never => { + throw new BrokerLifecycleExitError(code); + }, + }); +} + +function unsupportedBackgroundResult(runtime: EmbeddedNodeRuntimeOptions): EmbeddedNodeCommandFailure { + const message = + 'Embedded node startup does not support background mode; omit `background` and own the returned lifecycle handle.'; + const output = [{ level: 'error', message }] satisfies EmbeddedNodeOutput[]; + runtime.onOutput?.(output[0]!); + return { ok: false, code: 2, message, output }; +} + +export async function startEmbeddedNode( + options: EmbeddedNodeStartOptions = {}, + runtime: EmbeddedNodeRuntimeOptions = {} +): Promise { + return startEmbeddedNodeWithDependencies(options, runtime); +} + +/** Internal dependency seam used by the embeddability tests. */ +export async function startEmbeddedNodeWithDependencies( + options: EmbeddedNodeStartOptions, + runtime: EmbeddedNodeRuntimeOptions = {}, + dependencyOverrides: Partial = {} +): Promise { + const requestedBackground = (options as { background?: unknown }).background; + // Match runUpCommand's truthiness check exactly so untyped JavaScript + // callers cannot bypass embedded ownership with values such as "true" or 1. + if (requestedBackground) { + return unsupportedBackgroundResult(runtime); + } + + const recorder = createOutputRecorder(runtime); + const signalHandlers = new Map(); + const ready = deferred(); + const releaseHold = deferred(); + const deps = createEmbeddedDependencies( + runtime, + recorder, + signalHandlers, + ready, + releaseHold, + dependencyOverrides + ); + + const commandCompletion = runUpCommand( + { + ...options, + discoverConfig: true, + // Keep foreground ownership authoritative for the entire lifecycle. + background: false, + }, + deps + ).then( + () => recorder.resultFrom(), + (error) => recorder.resultFrom(error) + ); + const startup = await Promise.race([ + ready.promise.then(() => ({ ready: true as const })), + commandCompletion.then((result) => ({ ready: false as const, result })), + ]); + if (!startup.ready) { + return startup.result.ok + ? { + ok: false, + code: 1, + message: 'Broker lifecycle ended before reaching the ready state.', + output: startup.result.output, + } + : startup.result; + } + + let stopPromise: Promise | undefined; + const handle: EmbeddedNodeHandle = { + completion: commandCompletion, + stop: (signal = 'SIGTERM') => { + if (!stopPromise) { + stopPromise = (async () => { + let signalResult: EmbeddedNodeCommandResult | undefined; + const handler = signalHandlers.get(signal); + if (!handler) { + signalResult = recorder.resultFrom( + new Error(`Broker lifecycle did not register a ${signal} shutdown handler.`) + ); + } else { + try { + await handler(); + } catch (error) { + signalResult = recorder.resultFrom(error); + } + } + releaseHold.resolve(); + const completion = await commandCompletion; + return signalResult && !signalResult.ok ? signalResult : completion; + })(); + } + return stopPromise; + }, + }; + + return { ok: true, code: 0, output: recorder.snapshot(), handle }; +} + +async function runEmbeddedCommand( + command: (deps: CoreDependencies) => Promise, + runtime: EmbeddedNodeRuntimeOptions, + dependencyOverrides: Partial = {} +): Promise { + const recorder = createOutputRecorder(runtime); + const ready = deferred(); + const releaseHold = deferred(); + const deps = createEmbeddedDependencies( + runtime, + recorder, + new Map(), + ready, + releaseHold, + dependencyOverrides + ); + try { + await command(deps); + return recorder.resultFrom(); + } catch (error) { + return recorder.resultFrom(error); + } +} + +export function downEmbeddedNode( + options: EmbeddedNodeDownOptions = {}, + runtime: EmbeddedNodeRuntimeOptions = {} +): Promise { + return runEmbeddedCommand((deps) => runDownCommand(options, deps), runtime); +} + +export function statusEmbeddedNode( + options: EmbeddedNodeStatusOptions = {}, + runtime: EmbeddedNodeRuntimeOptions = {} +): Promise { + return runEmbeddedCommand((deps) => runStatusCommand(deps, options), runtime); +} diff --git a/packages/cli/src/node-embedded-stderr.test.ts b/packages/cli/src/node-embedded-stderr.test.ts new file mode 100644 index 000000000..98c338b8b --- /dev/null +++ b/packages/cli/src/node-embedded-stderr.test.ts @@ -0,0 +1,106 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const createRuntimeClientMock = vi.hoisted(() => vi.fn()); +const shutdownMock = vi.hoisted(() => vi.fn(async () => undefined)); + +vi.mock('./cli/lib/client-factory.js', () => ({ + createRuntimeClient: createRuntimeClientMock, + spawnAgentWithClient: vi.fn(async () => undefined), +})); + +vi.mock('./cli/lib/reflex-capture.js', () => ({ + startReflexCapture: () => ({ stop: vi.fn(async () => undefined) }), +})); + +import { startEmbeddedNode } from './node-embedded.js'; + +const tempRoots: string[] = []; + +beforeEach(() => { + shutdownMock.mockClear(); + createRuntimeClientMock.mockReset(); + createRuntimeClientMock.mockImplementation(async (options) => { + options.onStderr?.('[agent-relay][startup +1ms] resolving broker identity'); + return { + brokerPid: process.pid, + workspaceKey: 'rk_test', + getStatus: vi.fn(async () => ({})), + getSession: vi.fn(async () => ({ workspace_key: 'rk_test' })), + shutdown: shutdownMock, + }; + }); +}); + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe('embedded node broker stderr', () => { + it('reports a clean real-default-relay lifecycle as success despite startup diagnostics', async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-node-stderr-')); + tempRoots.push(stateDir); + const env = { + ...process.env, + AGENT_RELAY_DISABLE_IMPLICIT_FLEET_NODE: '1', + AGENT_RELAY_STATE_DIR: stateDir, + }; + + const started = await startEmbeddedNode({ stateDir, verbose: true }, { env }); + + expect(started.ok).toBe(true); + if (!started.ok) throw new Error(started.message); + expect(createRuntimeClientMock).toHaveBeenCalledTimes(1); + expect(started.output).toContainEqual({ + level: 'info', + message: '[broker] [agent-relay][startup +1ms] resolving broker identity', + }); + + await expect(started.handle.stop()).resolves.toMatchObject({ ok: true, code: 0 }); + await expect(started.handle.completion).resolves.toMatchObject({ ok: true, code: 0 }); + expect(shutdownMock).toHaveBeenCalledTimes(1); + }); + + it('bounds retained output while continuing to stream every broker diagnostic', async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-node-stderr-')); + tempRoots.push(stateDir); + const streamed: string[] = []; + createRuntimeClientMock.mockImplementation(async (options) => { + for (let index = 0; index < 1_500; index += 1) { + options.onStderr?.(`startup diagnostic ${index}`); + } + return { + brokerPid: process.pid, + workspaceKey: 'rk_test', + getStatus: vi.fn(async () => ({})), + getSession: vi.fn(async () => ({ workspace_key: 'rk_test' })), + shutdown: shutdownMock, + }; + }); + + const started = await startEmbeddedNode( + { stateDir, verbose: true }, + { + env: { + AGENT_RELAY_DISABLE_IMPLICIT_FLEET_NODE: '1', + AGENT_RELAY_STATE_DIR: stateDir, + }, + onOutput: (entry) => streamed.push(entry.message), + } + ); + + expect(started.ok).toBe(true); + if (!started.ok) throw new Error(started.message); + expect(streamed.length).toBeGreaterThan(1_500); + expect(started.output.length).toBeLessThanOrEqual(1_000); + expect(started.output).toContainEqual({ + level: 'info', + message: '[broker] startup diagnostic 1499', + }); + await started.handle.stop(); + }); +}); diff --git a/packages/cli/src/node-embedded.test.ts b/packages/cli/src/node-embedded.test.ts new file mode 100644 index 000000000..91226c68c --- /dev/null +++ b/packages/cli/src/node-embedded.test.ts @@ -0,0 +1,420 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { CoreDependencies, CoreRelay } from './cli/commands/core.js'; +import { startEmbeddedNodeWithDependencies } from './cli/lib/node-embedded.js'; +import { downEmbeddedNode, startEmbeddedNode, statusEmbeddedNode } from './node-embedded.js'; + +vi.mock('./cli/lib/reflex-capture.js', () => ({ + startReflexCapture: () => ({ stop: vi.fn(async () => undefined) }), +})); + +const tempRoots: string[] = []; + +function makeTempRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-node-embedded-')); + tempRoots.push(root); + return root; +} + +function successfulLifecycleOverrides(projectRoot: string) { + const dataDir = path.join(projectRoot, '.agentworkforce', 'relay'); + fs.mkdirSync(dataDir, { recursive: true }); + const shutdown = vi.fn(async () => undefined); + const relay: CoreRelay = { + spawn: vi.fn(async () => undefined), + getStatus: vi.fn(async () => ({})), + shutdown, + workspaceKey: 'rk_test', + }; + const overrides: Partial = { + getProjectPaths: () => ({ projectRoot, dataDir, teamDir: projectRoot }), + loadTeamsConfig: () => null, + createRelay: vi.fn(async () => relay), + execCommand: vi.fn(async () => ({ stdout: '', stderr: '' })), + killProcess: vi.fn(() => { + throw new Error('not running'); + }), + fs: { + existsSync: fs.existsSync, + readFileSync: (filePath, encoding) => fs.readFileSync(filePath, encoding), + writeFileSync: (filePath, data, encoding) => fs.writeFileSync(filePath, data, encoding), + unlinkSync: fs.unlinkSync, + readdirSync: (directory) => fs.readdirSync(directory), + mkdirSync: (directory, options) => fs.mkdirSync(directory, options), + rmSync: (target, options) => fs.rmSync(target, options), + accessSync: fs.accessSync, + }, + env: { AGENT_RELAY_DISABLE_IMPLICIT_FLEET_NODE: '1' }, + argv: ['node', 'relay', 'node', 'up'], + execPath: process.execPath, + cliScript: 'relay.js', + pid: process.pid, + now: () => Date.now(), + sleep: async () => undefined, + isPortInUse: vi.fn(async () => false), + }; + return { overrides, relay, shutdown }; +} + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } + vi.restoreAllMocks(); +}); + +describe('embedded node lifecycle', () => { + it('survives an already-running path that previously exited the process', async () => { + const processPidBefore = process.pid; + const stateDir = makeTempRoot(); + fs.writeFileSync( + path.join(stateDir, 'connection.json'), + JSON.stringify({ + url: 'http://127.0.0.1:3889', + port: 3889, + api_key: 'test', + pid: process.pid, + }) + ); + + const result = await startEmbeddedNode({ stateDir }); + + // Reaching these assertions is the survival proof: the real public entry + // point traversed runUpCommand's deps.exit(1) path without terminating the + // Vitest process. + expect(process.pid).toBe(processPidBefore); + expect(result).toMatchObject({ + ok: false, + code: 1, + message: expect.stringContaining('Broker already running'), + }); + expect(result.output.some((entry) => entry.message.includes('Broker already running'))).toBe(true); + }); + + it('owns shutdown through its handle without installing host signal listeners or writing to console', async () => { + const projectRoot = makeTempRoot(); + const { overrides, shutdown } = successfulLifecycleOverrides(projectRoot); + const sigintListeners = process.listenerCount('SIGINT'); + const sigtermListeners = process.listenerCount('SIGTERM'); + const originalMcpCommand = process.env.AGENT_RELAY_MCP_COMMAND; + const log = vi.spyOn(console, 'log'); + const warn = vi.spyOn(console, 'warn'); + const error = vi.spyOn(console, 'error'); + const streamed: string[] = []; + + const started = await startEmbeddedNodeWithDependencies( + { verbose: true }, + { onOutput: (entry) => streamed.push(entry.message) }, + overrides + ); + + expect(started.ok).toBe(true); + if (!started.ok) throw new Error(started.message); + expect(process.listenerCount('SIGINT')).toBe(sigintListeners); + expect(process.listenerCount('SIGTERM')).toBe(sigtermListeners); + expect(process.env.AGENT_RELAY_MCP_COMMAND).toBe(originalMcpCommand); + expect(log).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + expect(streamed).toContain('Broker started.'); + + await expect(started.handle.stop()).resolves.toMatchObject({ ok: true, code: 0 }); + await expect(started.handle.completion).resolves.toMatchObject({ ok: true, code: 0 }); + expect(shutdown).toHaveBeenCalledTimes(1); + expect(process.listenerCount('SIGINT')).toBe(sigintListeners); + expect(process.listenerCount('SIGTERM')).toBe(sigtermListeners); + }); + + it.each([true, 'true', 1])( + 'rejects truthy detached background ownership (%j) before running the CLI lifecycle', + async (background) => { + const projectRoot = makeTempRoot(); + const { overrides } = successfulLifecycleOverrides(projectRoot); + const spawnProcess = vi.fn(() => { + throw new Error('detached spawn must not be reached'); + }); + const result = await startEmbeddedNodeWithDependencies( + { background } as never, + {}, + { ...overrides, spawnProcess } + ); + + expect(result).toEqual({ + ok: false, + code: 2, + message: + 'Embedded node startup does not support background mode; omit `background` and own the returned lifecycle handle.', + output: [ + { + level: 'error', + message: + 'Embedded node startup does not support background mode; omit `background` and own the returned lifecycle handle.', + }, + ], + }); + expect(spawnProcess).not.toHaveBeenCalled(); + } + ); + + it('keeps foreground ownership authoritative when a background getter changes value', async () => { + const projectRoot = makeTempRoot(); + const { overrides } = successfulLifecycleOverrides(projectRoot); + const spawnProcess = vi.fn(() => { + throw new Error('detached spawn must not be reached'); + }); + let backgroundReads = 0; + const options = {} as Record; + Object.defineProperty(options, 'background', { + enumerable: true, + get: () => { + backgroundReads += 1; + return backgroundReads > 1; + }, + }); + + const result = await startEmbeddedNodeWithDependencies( + options as never, + {}, + { ...overrides, spawnProcess } + ); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error(result.message); + expect(backgroundReads).toBeGreaterThanOrEqual(2); + expect(spawnProcess).not.toHaveBeenCalled(); + await expect(result.handle.stop()).resolves.toMatchObject({ ok: true, code: 0 }); + }); + + it('converts status-command exits into structured failures', async () => { + const processPidBefore = process.pid; + + const result = await statusEmbeddedNode({ waitFor: 'not-a-number' }); + + expect(process.pid).toBe(processPidBefore); + expect(result).toMatchObject({ + ok: false, + code: 1, + message: '--wait-for must be a non-negative number of seconds.', + }); + }); + + it('reports a non-throwing down-command shutdown error as failure', async () => { + const processPidBefore = process.pid; + const stateDir = makeTempRoot(); + const connectionPath = path.join(stateDir, 'connection.json'); + const fakeBrokerPid = 2_000_000_000; + fs.writeFileSync( + connectionPath, + JSON.stringify({ + url: 'http://127.0.0.1:3889', + port: 3889, + api_key: 'test', + pid: fakeBrokerPid, + }) + ); + const realKill = process.kill.bind(process); + vi.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (pid === fakeBrokerPid && signal === 0) return true; + if (pid === fakeBrokerPid && signal === 'SIGTERM') { + throw new Error('denied embedded shutdown'); + } + return realKill(pid, signal); + }); + + const result = await downEmbeddedNode({ stateDir }); + + expect(process.pid).toBe(processPidBefore); + expect(result).toMatchObject({ + ok: false, + code: 1, + message: 'Error stopping broker: denied embedded shutdown', + }); + expect(result.output).toContainEqual({ + level: 'error', + message: 'Error stopping broker: denied embedded shutdown', + }); + expect(fs.existsSync(connectionPath)).toBe(true); + }); + + it('reports graceful shutdown timeout as failure and retains live-broker state', async () => { + const stateDir = makeTempRoot(); + const connectionPath = path.join(stateDir, 'connection.json'); + const fakeBrokerPid = 2_000_000_001; + fs.writeFileSync( + connectionPath, + JSON.stringify({ + url: 'http://127.0.0.1:3889', + port: 3889, + api_key: 'test', + pid: fakeBrokerPid, + }) + ); + const realKill = process.kill.bind(process); + vi.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (pid === fakeBrokerPid && (signal === 0 || signal === 'SIGTERM')) return true; + return realKill(pid, signal); + }); + + const result = await downEmbeddedNode({ stateDir, timeout: '1' }); + + expect(result).toMatchObject({ + ok: false, + code: 1, + message: 'Graceful shutdown timed out after 1ms. Use --force to kill.', + }); + expect(fs.existsSync(connectionPath)).toBe(true); + }); + + it('reports failed forced shutdown and retains live-broker state', async () => { + const stateDir = makeTempRoot(); + const connectionPath = path.join(stateDir, 'connection.json'); + const fakeBrokerPid = 2_000_000_002; + fs.writeFileSync( + connectionPath, + JSON.stringify({ + url: 'http://127.0.0.1:3889', + port: 3889, + api_key: 'test', + pid: fakeBrokerPid, + }) + ); + const realKill = process.kill.bind(process); + vi.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (pid === fakeBrokerPid && (signal === 0 || signal === 'SIGTERM')) return true; + if (pid === fakeBrokerPid && signal === 'SIGKILL') { + throw new Error('denied forced embedded shutdown'); + } + return realKill(pid, signal); + }); + + const result = await downEmbeddedNode({ force: true, stateDir, timeout: '1' }); + + expect(result).toMatchObject({ + ok: false, + code: 1, + message: 'Error force stopping broker: denied forced embedded shutdown', + }); + expect(fs.existsSync(connectionPath)).toBe(true); + }); + + it('resolves project paths from the isolated env instead of the host env', async () => { + const hostProject = makeTempRoot(); + const embeddedProject = makeTempRoot(); + const hostStateDir = path.join(hostProject, '.agentworkforce', 'relay'); + fs.mkdirSync(hostStateDir, { recursive: true }); + fs.writeFileSync( + path.join(hostStateDir, 'connection.json'), + JSON.stringify({ + url: 'http://127.0.0.1:3889', + port: 3889, + api_key: 'test', + pid: process.pid, + }) + ); + const originalHostProject = process.env.AGENT_RELAY_PROJECT; + process.env.AGENT_RELAY_PROJECT = hostProject; + + try { + const result = await statusEmbeddedNode({}, { env: { AGENT_RELAY_PROJECT: embeddedProject } }); + + expect(result).toMatchObject({ ok: true, code: 0 }); + expect(result.output).toContainEqual({ level: 'info', message: 'Status: STOPPED' }); + } finally { + if (originalHostProject === undefined) delete process.env.AGENT_RELAY_PROJECT; + else process.env.AGENT_RELAY_PROJECT = originalHostProject; + } + }); + + it('retains upward project-marker discovery when the isolated env has no override', async () => { + const projectRoot = makeTempRoot(); + const nested = path.join(projectRoot, 'packages', 'nested'); + const stateDir = path.join(projectRoot, '.agentworkforce', 'relay'); + fs.mkdirSync(nested, { recursive: true }); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'package.json'), '{}'); + fs.writeFileSync( + path.join(stateDir, 'connection.json'), + JSON.stringify({ + url: 'http://127.0.0.1:65534', + port: 65534, + api_key: 'test', + pid: process.pid, + }) + ); + const originalCwd = process.cwd(); + const originalHostProject = process.env.AGENT_RELAY_PROJECT; + delete process.env.AGENT_RELAY_PROJECT; + process.chdir(nested); + const resolvedProjectRoot = path.resolve(process.cwd(), '..', '..'); + + try { + const result = await statusEmbeddedNode({ waitFor: '0.001' }, { env: {} }); + + expect(result.output).toContainEqual({ level: 'info', message: 'Status: STARTING' }); + expect(result.output).toContainEqual({ + level: 'info', + message: `Project: ${resolvedProjectRoot}`, + }); + expect(result).toMatchObject({ ok: false, code: 1 }); + } finally { + process.chdir(originalCwd); + if (originalHostProject === undefined) delete process.env.AGENT_RELAY_PROJECT; + else process.env.AGENT_RELAY_PROJECT = originalHostProject; + } + }); + + it('resolves the bundled MCP helper beside the package CLI instead of the host script', async () => { + const projectRoot = makeTempRoot(); + const { overrides } = successfulLifecycleOverrides(projectRoot); + const checkedPaths: string[] = []; + const realFs = overrides.fs!; + overrides.fs = { + ...realFs, + existsSync: (filePath) => { + checkedPaths.push(filePath); + return realFs.existsSync(filePath); + }, + }; + delete overrides.cliScript; + + const started = await startEmbeddedNodeWithDependencies({}, {}, overrides); + + expect(started.ok).toBe(true); + if (!started.ok) throw new Error(started.message); + expect(checkedPaths).toContain(fileURLToPath(new URL('./cli/agent-relay-mcp.js', import.meta.url))); + await started.handle.stop(); + }); + + it('keeps all 14 lifecycle exits behind the injected dependency boundary', () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, 'cli', 'lib', 'broker-lifecycle.ts'), + 'utf8' + ); + const up = source.slice( + source.indexOf('export async function runUpCommand'), + source.indexOf('export async function runDownCommand') + ); + const background = up.slice(up.indexOf('if (options.background)'), up.indexOf('const basePort')); + const foreground = up.slice(up.indexOf('const basePort')); + const down = source.slice( + source.indexOf('export async function runDownCommand'), + source.indexOf('export async function runStatusCommand') + ); + const status = source.slice(source.indexOf('export async function runStatusCommand')); + const exitCount = (text: string) => text.match(/\bdeps\.exit\(/g)?.length ?? 0; + + expect(exitCount(background)).toBe(5); + expect(exitCount(foreground)).toBe(6); + expect(exitCount(down)).toBe(0); + expect(exitCount(status)).toBe(3); + expect(exitCount(source)).toBe(14); + expect(source).not.toMatch(/\bprocess\.exit\s*\(/); + expect(source).not.toMatch(/\bprocess\.(?:on|once)\s*\(/); + expect(source).not.toMatch(/\bconsole\.(?:log|warn|error)\s*\(/); + expect(source).not.toContain("stdio: 'inherit'"); + }); +}); diff --git a/packages/cli/src/node-embedded.ts b/packages/cli/src/node-embedded.ts new file mode 100644 index 000000000..15492d2c4 --- /dev/null +++ b/packages/cli/src/node-embedded.ts @@ -0,0 +1,23 @@ +/** + * Process-safe broker lifecycle entry point for long-lived Node.js hosts. + * + * Unlike the CLI command, these functions never call process.exit, install + * global signal handlers, or write lifecycle output directly to the console. + */ +export { + BrokerLifecycleExitError, + downEmbeddedNode, + startEmbeddedNode, + statusEmbeddedNode, + type EmbeddedNodeCommandFailure, + type EmbeddedNodeCommandResult, + type EmbeddedNodeCommandSuccess, + type EmbeddedNodeDownOptions, + type EmbeddedNodeHandle, + type EmbeddedNodeOutput, + type EmbeddedNodeOutputLevel, + type EmbeddedNodeRuntimeOptions, + type EmbeddedNodeStartOptions, + type EmbeddedNodeStartResult, + type EmbeddedNodeStatusOptions, +} from './cli/lib/node-embedded.js'; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 28994ad7a..9df709fb3 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -21,6 +21,12 @@ // the compiled CLI inline the declaration files and lose every workspace // export at runtime (e.g. `local up` → "Fleet local node skipped"). }, - "include": ["src/index.ts", "src/cli/index.ts", "src/cli/bootstrap.ts", "src/cli/agent-relay-mcp.ts"], + "include": [ + "src/index.ts", + "src/node-embedded.ts", + "src/cli/index.ts", + "src/cli/bootstrap.ts", + "src/cli/agent-relay-mcp.ts" + ], "exclude": ["node_modules", "dist", "src/**/*.test.ts"] } diff --git a/packages/config/package.json b/packages/config/package.json index 1bd83f0a5..9ca91f2f2 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,6 +1,6 @@ { "name": "@agent-relay/config", - "version": "10.2.0", + "version": "10.3.0", "description": "Shared configuration schemas and loaders for Agent Relay", "type": "module", "main": "dist/index.js", diff --git a/packages/config/src/project-namespace.test.ts b/packages/config/src/project-namespace.test.ts new file mode 100644 index 000000000..903d8fceb --- /dev/null +++ b/packages/config/src/project-namespace.test.ts @@ -0,0 +1,34 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { findProjectRoot } from './project-namespace.js'; + +const tempRoots: string[] = []; + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe('findProjectRoot environment isolation', () => { + it('ignores the host override but retains upward marker discovery with an explicit env', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-project-root-')); + const hostRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-host-project-')); + tempRoots.push(root, hostRoot); + const nested = path.join(root, 'packages', 'nested'); + fs.mkdirSync(nested, { recursive: true }); + fs.writeFileSync(path.join(root, 'package.json'), '{}'); + const originalHostProject = process.env.AGENT_RELAY_PROJECT; + process.env.AGENT_RELAY_PROJECT = hostRoot; + + try { + expect(findProjectRoot(nested, {})).toBe(root); + } finally { + if (originalHostProject === undefined) delete process.env.AGENT_RELAY_PROJECT; + else process.env.AGENT_RELAY_PROJECT = originalHostProject; + } + }); +}); diff --git a/packages/config/src/project-namespace.ts b/packages/config/src/project-namespace.ts index 796b0b3c8..d0a64c5fe 100644 --- a/packages/config/src/project-namespace.ts +++ b/packages/config/src/project-namespace.ts @@ -30,10 +30,13 @@ function hashPath(projectPath: string): string { * 1. AGENT_RELAY_PROJECT environment variable (for worktrees/subprojects) * 2. Find project root by looking for markers (.git, package.json, etc.) */ -export function findProjectRoot(startDir: string = process.cwd()): string { +export function findProjectRoot( + startDir: string = process.cwd(), + env: NodeJS.ProcessEnv = process.env +): string { // Allow explicit override for worktrees and subprojects - if (process.env.AGENT_RELAY_PROJECT) { - return path.resolve(process.env.AGENT_RELAY_PROJECT); + if (env.AGENT_RELAY_PROJECT) { + return path.resolve(env.AGENT_RELAY_PROJECT); } let current = path.resolve(startDir); @@ -72,8 +75,8 @@ export interface ProjectPaths { projectId: string; } -export function getProjectPaths(projectRoot?: string): ProjectPaths { - const root = projectRoot ?? findProjectRoot(); +export function getProjectPaths(projectRoot?: string, env: NodeJS.ProcessEnv = process.env): ProjectPaths { + const root = projectRoot ?? findProjectRoot(process.cwd(), env); const projectId = hashPath(root); // Store data in project-local .agentworkforce/relay/ directory const dataDir = path.join(root, PROJECT_DATA_DIR); diff --git a/packages/harness-driver/package.json b/packages/harness-driver/package.json index 1f62a632b..2a5931c92 100644 --- a/packages/harness-driver/package.json +++ b/packages/harness-driver/package.json @@ -1,6 +1,6 @@ { "name": "@agent-relay/harness-driver", - "version": "10.2.0", + "version": "10.3.0", "description": "Managed harness driver for Agent Relay — attach, wrap, spawn, inject, and observe agent runtimes", "type": "module", "main": "dist/index.js", diff --git a/packages/harness-driver/src/broker-path.test.ts b/packages/harness-driver/src/broker-path.test.ts index e60bab56b..2be92f367 100644 --- a/packages/harness-driver/src/broker-path.test.ts +++ b/packages/harness-driver/src/broker-path.test.ts @@ -77,6 +77,16 @@ describe('broker binary path resolution', () => { expect(getBrokerBinaryPath()).toBe(path.resolve(agentRelayBin)); }); + it('uses an explicit isolated env instead of host binary overrides', async () => { + const hostBinary = makeExecutable('host-broker'); + const embeddedBinary = makeExecutable('embedded-broker'); + process.env.AGENT_RELAY_BIN = hostBinary; + + const { getBrokerBinaryPath } = await loadBrokerPathModule(); + + expect(getBrokerBinaryPath({ AGENT_RELAY_BIN: embeddedBinary })).toBe(path.resolve(embeddedBinary)); + }); + it('resolves the broker from the platform optional dependency package', async () => { const pkgName = `@agent-relay/broker-${process.platform}-${process.arch}`; const ext = process.platform === 'win32' ? '.exe' : ''; @@ -125,6 +135,7 @@ describe('broker binary path resolution', () => { { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], + env: process.env, } ); }); diff --git a/packages/harness-driver/src/broker-path.ts b/packages/harness-driver/src/broker-path.ts index 3defec6fd..acbd04329 100644 --- a/packages/harness-driver/src/broker-path.ts +++ b/packages/harness-driver/src/broker-path.ts @@ -186,9 +186,9 @@ function getSourceCheckoutBinaryPaths(ext: string): string[] { * * @returns Absolute path to the broker binary, or null if not found */ -export function getBrokerBinaryPath(): string | null { +export function getBrokerBinaryPath(env: NodeJS.ProcessEnv = process.env): string | null { const ext = process.platform === 'win32' ? '.exe' : ''; - const override = process.env.BROKER_BINARY_PATH ?? process.env.AGENT_RELAY_BIN; + const override = env.BROKER_BINARY_PATH ?? env.AGENT_RELAY_BIN; if (override) { const resolvedOverride = resolve(override); @@ -223,6 +223,7 @@ export function getBrokerBinaryPath(): string | null { const result = execFileSync(cmd, [BROKER_NAME], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], + env, }).trim(); if (result) { return result.split(/\r?\n/u)[0].trim(); diff --git a/packages/harness-driver/src/client-connect.test.ts b/packages/harness-driver/src/client-connect.test.ts new file mode 100644 index 000000000..2aa7d6f1c --- /dev/null +++ b/packages/harness-driver/src/client-connect.test.ts @@ -0,0 +1,42 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { HarnessDriverClient } from './client.js'; + +const tempDirs: string[] = []; +const originalStateDir = process.env.AGENT_RELAY_STATE_DIR; + +function writeConnection(stateDir: string, url: string): void { + mkdirSync(stateDir, { recursive: true }); + writeFileSync( + path.join(stateDir, 'connection.json'), + JSON.stringify({ url, api_key: 'br_test', pid: process.pid }) + ); +} + +afterEach(() => { + if (originalStateDir === undefined) delete process.env.AGENT_RELAY_STATE_DIR; + else process.env.AGENT_RELAY_STATE_DIR = originalStateDir; + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe('HarnessDriverClient.connect environment isolation', () => { + it('uses the supplied state dir instead of the host process state dir', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'agent-relay-connect-env-')); + tempDirs.push(root); + const hostStateDir = path.join(root, 'host'); + const embeddedStateDir = path.join(root, 'embedded'); + writeConnection(hostStateDir, 'http://127.0.0.1:4101'); + writeConnection(embeddedStateDir, 'http://127.0.0.1:4102'); + process.env.AGENT_RELAY_STATE_DIR = hostStateDir; + + const client = HarnessDriverClient.connect({ + cwd: root, + env: { AGENT_RELAY_STATE_DIR: embeddedStateDir }, + }); + + expect(client.baseUrl).toBe('http://127.0.0.1:4102'); + }); +}); diff --git a/packages/harness-driver/src/client.ts b/packages/harness-driver/src/client.ts index 80ac4a815..555d8c5c7 100644 --- a/packages/harness-driver/src/client.ts +++ b/packages/harness-driver/src/client.ts @@ -290,14 +290,16 @@ export class HarnessDriverClient { * * @param cwd — project directory (default: process.cwd()) * @param connectionPath — explicit path to connection.json (overrides cwd) + * @param env — environment used to resolve AGENT_RELAY_STATE_DIR (default: process.env) */ static connect(options?: { cwd?: string; connectionPath?: string; + env?: NodeJS.ProcessEnv; eventBus?: EventBus; }): HarnessDriverClient { const cwd = options?.cwd ?? process.cwd(); - const stateDir = process.env.AGENT_RELAY_STATE_DIR; + const stateDir = (options?.env ?? process.env).AGENT_RELAY_STATE_DIR; const connPath = options?.connectionPath ?? path.join(stateDir ?? path.join(cwd, '.agentworkforce/relay'), 'connection.json'); @@ -347,9 +349,11 @@ export class HarnessDriverClient { */ static async spawn(options?: RuntimeSpawnOptions): Promise { const onStep = options?.onStep; + const parentEnv = options?.parentEnv ?? process.env; + const resolutionEnv = { ...parentEnv, ...options?.env }; let binaryPath = options?.binaryPath; if (!binaryPath) { - const resolved = getBrokerBinaryPath(); + const resolved = getBrokerBinaryPath(resolutionEnv); if (!resolved) { throw new Error(formatBrokerNotFoundError()); } @@ -357,7 +361,7 @@ export class HarnessDriverClient { } onStep?.(`Resolved broker binary: ${binaryPath}`); const apiKey = `br_${randomBytes(16).toString('hex')}`; - const { cwd, timeoutMs, args, env } = buildBrokerSpawnConfig(options, apiKey); + const { cwd, timeoutMs, args, env } = buildBrokerSpawnConfig(options, apiKey, parentEnv); const stderrLines: string[] = []; const stdoutLines: string[] = []; diff --git a/packages/harness-driver/src/spawn-config.test.ts b/packages/harness-driver/src/spawn-config.test.ts index 8a02d76ec..aef5381cc 100644 --- a/packages/harness-driver/src/spawn-config.test.ts +++ b/packages/harness-driver/src/spawn-config.test.ts @@ -83,4 +83,28 @@ describe('buildBrokerSpawnConfig', () => { '/tmp/relay-state', ]); }); + + it('does not reintroduce host-only variables when an isolated parent env is supplied', () => { + const hostOnlyKey = 'AGENT_RELAY_TEST_HOST_ONLY'; + const originalHostOnly = process.env[hostOnlyKey]; + process.env[hostOnlyKey] = 'host-secret'; + const parentEnv = { EMBEDDED_ONLY: '1' } as NodeJS.ProcessEnv; + + try { + const config = buildBrokerSpawnConfig( + { + cwd: '/tmp/my-project', + env: parentEnv, + parentEnv, + }, + 'br_test' + ); + + expect(config.env.EMBEDDED_ONLY).toBe('1'); + expect(config.env[hostOnlyKey]).toBeUndefined(); + } finally { + if (originalHostOnly === undefined) delete process.env[hostOnlyKey]; + else process.env[hostOnlyKey] = originalHostOnly; + } + }); }); diff --git a/packages/harness-driver/src/spawn-config.ts b/packages/harness-driver/src/spawn-config.ts index aa65d76ff..b1ab2eebd 100644 --- a/packages/harness-driver/src/spawn-config.ts +++ b/packages/harness-driver/src/spawn-config.ts @@ -29,6 +29,11 @@ export interface RuntimeSpawnOptions { cwd?: string; /** Environment variables for the broker process. */ env?: NodeJS.ProcessEnv; + /** + * Base environment inherited by the broker process. Defaults to process.env. + * Embedders can supply an isolated base without changing CLI behavior. + */ + parentEnv?: NodeJS.ProcessEnv; /** Forward broker stderr to this callback. */ onStderr?: (line: string) => void; /** Forward human-readable startup step markers to this callback (e.g. for `--verbose`). */ @@ -84,7 +89,7 @@ function nonEmptyString(value: string | undefined): string | undefined { export function buildBrokerSpawnConfig( options: RuntimeSpawnOptions | undefined, apiKey: string, - parentEnv: NodeJS.ProcessEnv = process.env + parentEnv: NodeJS.ProcessEnv = options?.parentEnv ?? process.env ): BrokerSpawnConfig { const cwd = options?.cwd ?? process.cwd(); const brokerName =