From 40a7100f0bea51cad2f68b89307fdbac7ad386e3 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Tue, 4 Aug 2026 08:34:27 +0200 Subject: [PATCH 1/4] fix(harness-driver): resolve installed broker through launchers --- CHANGELOG.md | 6 +- .../harness-driver/src/broker-path.test.ts | 86 +++++++++++++++++++ packages/harness-driver/src/broker-path.ts | 71 +++++++++++++-- 3 files changed, 153 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e47305567..da433bd49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased - Patch] + +### Fixed + +- `agent-relay node up` resolves its installed broker through canonical package-manager links and Relay's user install directories, so mise-managed and minimal-`PATH` launches no longer fail when the broker binary is already installed. ## [11.4.1] - 2026-08-03 diff --git a/packages/harness-driver/src/broker-path.test.ts b/packages/harness-driver/src/broker-path.test.ts index e60bab56b..2a2d1f322 100644 --- a/packages/harness-driver/src/broker-path.test.ts +++ b/packages/harness-driver/src/broker-path.test.ts @@ -6,6 +6,7 @@ import path from 'node:path'; const originalEnv = { ...process.env }; const originalArgv = [...process.argv]; const originalCwd = process.cwd(); +const originalExecPath = process.execPath; const tempDirs: string[] = []; async function loadBrokerPathModule(): Promise { @@ -32,6 +33,7 @@ beforeEach(() => { delete process.env.BROKER_BINARY_PATH; delete process.env.AGENT_RELAY_BIN; process.argv = [...originalArgv]; + process.execPath = originalExecPath; }); afterEach(() => { @@ -103,6 +105,90 @@ describe('broker binary path resolution', () => { expect(resolve).toHaveBeenCalledWith(`${pkgName}/package.json`); }); + it('canonicalizes a symlinked package-manager entry before resolving the optional package', async () => { + const root = makeTempDir(); + const entryPath = path.join(root, 'bin', 'agent-relay'); + const canonicalEntryPath = path.join( + root, + 'lib', + 'node_modules', + 'agent-relay', + 'dist', + 'cli', + 'index.js' + ); + const pkgName = `@agent-relay/broker-${process.platform}-${process.arch}`; + const ext = process.platform === 'win32' ? '.exe' : ''; + const pkgJsonPath = path.join( + root, + 'lib', + 'node_modules', + 'agent-relay', + 'node_modules', + pkgName, + 'package.json' + ); + const expectedBinaryPath = path.join(path.dirname(pkgJsonPath), 'bin', `agent-relay-broker${ext}`); + const resolvePackage = vi.fn((specifier: string) => { + if (specifier === `${pkgName}/package.json`) return pkgJsonPath; + throw new Error(`unresolved ${specifier}`); + }); + + vi.doMock('node:fs', async () => ({ + ...(await vi.importActual('node:fs')), + existsSync: vi.fn((candidate: string) => candidate === expectedBinaryPath), + realpathSync: vi.fn((candidate: string) => (candidate === entryPath ? canonicalEntryPath : candidate)), + })); + vi.doMock('node:module', async () => ({ + ...(await vi.importActual('node:module')), + createRequire: vi.fn((reference: string) => ({ + resolve: + reference === canonicalEntryPath + ? resolvePackage + : vi.fn(() => { + throw new Error(`unresolved from ${reference}`); + }), + })), + })); + process.argv[1] = entryPath; + + const { getBrokerBinaryPath } = await loadBrokerPathModule(); + + expect(getBrokerBinaryPath()).toBe(expectedBinaryPath); + expect(resolvePackage).toHaveBeenCalledWith(`${pkgName}/package.json`); + }); + + it('falls back to a broker installed beside the package-manager launcher', async () => { + const root = makeTempDir(); + const entryPath = path.join(root, 'bin', 'agent-relay'); + const expectedBinaryPath = path.join( + root, + 'bin', + `agent-relay-broker${process.platform === 'win32' ? '.exe' : ''}` + ); + process.argv[1] = entryPath; + + vi.doMock('node:fs', async () => ({ + ...(await vi.importActual('node:fs')), + existsSync: vi.fn((candidate: string) => candidate === expectedBinaryPath), + realpathSync: vi.fn(() => { + throw new Error('no canonical package path'); + }), + })); + vi.doMock('node:module', async () => ({ + ...(await vi.importActual('node:module')), + createRequire: vi.fn(() => ({ + resolve: vi.fn(() => { + throw new Error('optional package unavailable'); + }), + })), + })); + + const { getBrokerBinaryPath } = await loadBrokerPathModule(); + + expect(getBrokerBinaryPath()).toBe(expectedBinaryPath); + }); + it('falls back to PATH lookup after env, optional package, and development paths miss', async () => { const pathBinary = '/usr/local/bin/agent-relay-broker'; const execFileSync = vi.fn(() => `${pathBinary}\n`); diff --git a/packages/harness-driver/src/broker-path.ts b/packages/harness-driver/src/broker-path.ts index 3defec6fd..64a8e2091 100644 --- a/packages/harness-driver/src/broker-path.ts +++ b/packages/harness-driver/src/broker-path.ts @@ -6,10 +6,11 @@ * const binPath = getBrokerBinaryPath(); */ -import { existsSync } from 'node:fs'; +import { existsSync, realpathSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; import { execFileSync } from 'node:child_process'; import { createRequire } from 'node:module'; +import { homedir } from 'node:os'; import { fileURLToPath } from 'node:url'; const BROKER_NAME = 'agent-relay-broker'; @@ -28,6 +29,22 @@ function addUniquePath(paths: string[], candidate: string | null | undefined): v paths.push(candidate); } +function addResolutionReference(refs: string[], candidate: string | null | undefined): void { + addUniquePath(refs, candidate); + if (!candidate) { + return; + } + + try { + const filePath = candidate.startsWith('file:') ? fileURLToPath(candidate) : candidate; + addUniquePath(refs, realpathSync(filePath)); + } catch { + // The cwd fallback intentionally need not exist, and a launcher symlink may + // disappear during an in-place package-manager upgrade. Keep the original + // reference and let require.resolve handle the miss. + } +} + function getImportMetaUrl(): string | null { try { return import.meta.url; @@ -69,13 +86,15 @@ function getCurrentModuleReference(): string | null { function getResolutionReferences(): string[] { const refs: string[] = []; - addUniquePath(refs, getCurrentModuleReference()); + addResolutionReference(refs, getCurrentModuleReference()); // Also try the entry script so CLI consumers and bundled installs // (where the SDK lives under the consuming package's node_modules) can - // still find the optional-dep package. + // still find the optional-dep package. Package-manager launchers such as + // mise expose this as a symlink; its canonical target sits inside the global + // package tree and is therefore a separate, necessary resolution anchor. if (process.argv[1]) { - addUniquePath(refs, process.argv[1]); + addResolutionReference(refs, process.argv[1]); } // Fall back to the cwd's package.json as a final resolution anchor. @@ -86,11 +105,36 @@ function getResolutionReferences(): string[] { // some vite/webpack bundling configurations, repl experimentation), but // the consumer's cwd is inside their own project. We only use this // reference to run require.resolve; no file I/O is triggered on misses. - addUniquePath(refs, join(process.cwd(), 'package.json')); + addResolutionReference(refs, join(process.cwd(), 'package.json')); return refs; } +/** + * Binary locations created by Relay's standalone/npm installer. These are + * deliberately checked without shelling out: a version manager can launch + * Node with a minimal PATH, which is exactly when `which` cannot see the + * broker that the Relay installer placed beside its launcher or under the + * canonical user install directory. + */ +function getInstalledBinaryPaths(ext: string): string[] { + const binaryFile = `${BROKER_NAME}${ext}`; + const binaryPaths: string[] = []; + + if (process.argv[1]) { + addUniquePath(binaryPaths, join(dirname(resolve(process.argv[1])), binaryFile)); + } + addUniquePath(binaryPaths, join(dirname(process.execPath), binaryFile)); + + const userHome = homedir(); + if (userHome) { + addUniquePath(binaryPaths, join(userHome, '.agentworkforce', 'relay', 'bin', binaryFile)); + addUniquePath(binaryPaths, join(userHome, '.local', 'bin', binaryFile)); + } + + return binaryPaths; +} + /** * Resolve the broker binary via the platform-specific optional-dependency * package (`@agent-relay/broker--`). Returns null when the @@ -181,8 +225,9 @@ function getSourceCheckoutBinaryPaths(ext: string): string[] { * checkout * 3. Platform-specific optional-dep package * (`@agent-relay/broker--`) — primary production path - * 4. Cargo development paths (target/release and target/debug) - * 5. PATH lookup via `which` / `where` + * 4. Relay installer locations (launcher/executable sibling and user bin) + * 5. Cargo development paths (target/release and target/debug) + * 6. PATH lookup via `which` / `where` * * @returns Absolute path to the broker binary, or null if not found */ @@ -210,14 +255,22 @@ export function getBrokerBinaryPath(): string | null { return optionalDepBinary; } - // 3. Common development paths for local Cargo builds. + // 3. Relay's npm/standalone installer locations. This also covers mise and + // similar version managers when their activation PATH omits the user bin. + for (const installedPath of getInstalledBinaryPaths(ext)) { + if (existsSync(installedPath)) { + return installedPath; + } + } + + // 4. Common development paths for local Cargo builds. for (const developmentPath of getDevelopmentBinaryPaths(ext)) { if (existsSync(developmentPath)) { return developmentPath; } } - // 4. PATH lookup. + // 5. PATH lookup. try { const cmd = process.platform === 'win32' ? 'where' : 'which'; const result = execFileSync(cmd, [BROKER_NAME], { From 177c4747f7f1040eb9f1c826811ffa9c1aeaee41 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Tue, 4 Aug 2026 09:07:07 +0200 Subject: [PATCH 2/4] fix(harness-driver): resolve canonical broker siblings --- .../harness-driver/src/broker-path.test.ts | 29 +++++++++++++++++++ packages/harness-driver/src/broker-path.ts | 14 ++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/harness-driver/src/broker-path.test.ts b/packages/harness-driver/src/broker-path.test.ts index 2a2d1f322..26e61d327 100644 --- a/packages/harness-driver/src/broker-path.test.ts +++ b/packages/harness-driver/src/broker-path.test.ts @@ -189,6 +189,35 @@ describe('broker binary path resolution', () => { expect(getBrokerBinaryPath()).toBe(expectedBinaryPath); }); + it('falls back beside the canonical target of a symlinked package-manager launcher', async () => { + const root = makeTempDir(); + const entryPath = path.join(root, 'shims', 'agent-relay'); + const canonicalEntryPath = path.join(root, 'installs', 'node', 'bin', 'agent-relay'); + const expectedBinaryPath = path.join( + path.dirname(canonicalEntryPath), + `agent-relay-broker${process.platform === 'win32' ? '.exe' : ''}` + ); + process.argv[1] = entryPath; + + vi.doMock('node:fs', async () => ({ + ...(await vi.importActual('node:fs')), + existsSync: vi.fn((candidate: string) => candidate === expectedBinaryPath), + realpathSync: vi.fn((candidate: string) => (candidate === entryPath ? canonicalEntryPath : candidate)), + })); + vi.doMock('node:module', async () => ({ + ...(await vi.importActual('node:module')), + createRequire: vi.fn(() => ({ + resolve: vi.fn(() => { + throw new Error('optional package unavailable'); + }), + })), + })); + + const { getBrokerBinaryPath } = await loadBrokerPathModule(); + + expect(getBrokerBinaryPath()).toBe(expectedBinaryPath); + }); + it('falls back to PATH lookup after env, optional package, and development paths miss', async () => { const pathBinary = '/usr/local/bin/agent-relay-broker'; const execFileSync = vi.fn(() => `${pathBinary}\n`); diff --git a/packages/harness-driver/src/broker-path.ts b/packages/harness-driver/src/broker-path.ts index 64a8e2091..e5aca8452 100644 --- a/packages/harness-driver/src/broker-path.ts +++ b/packages/harness-driver/src/broker-path.ts @@ -121,10 +121,16 @@ function getInstalledBinaryPaths(ext: string): string[] { const binaryFile = `${BROKER_NAME}${ext}`; const binaryPaths: string[] = []; - if (process.argv[1]) { - addUniquePath(binaryPaths, join(dirname(resolve(process.argv[1])), binaryFile)); - } - addUniquePath(binaryPaths, join(dirname(process.execPath), binaryFile)); + const addSiblingBinaryPaths = (candidate: string | null | undefined): void => { + const launcherPaths: string[] = []; + addResolutionReference(launcherPaths, candidate); + for (const launcherPath of launcherPaths) { + addUniquePath(binaryPaths, join(dirname(resolve(launcherPath)), binaryFile)); + } + }; + + addSiblingBinaryPaths(process.argv[1]); + addSiblingBinaryPaths(process.execPath); const userHome = homedir(); if (userHome) { From 912a74e99419a84264d00ae40150e9f4d3c1bfde Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Tue, 4 Aug 2026 09:08:22 +0200 Subject: [PATCH 3/4] fix(harness-driver): prefer active PATH broker --- .../harness-driver/src/broker-path.test.ts | 46 +++++++++++++++++++ packages/harness-driver/src/broker-path.ts | 27 +++++------ 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/harness-driver/src/broker-path.test.ts b/packages/harness-driver/src/broker-path.test.ts index 26e61d327..eae09c206 100644 --- a/packages/harness-driver/src/broker-path.test.ts +++ b/packages/harness-driver/src/broker-path.test.ts @@ -183,6 +183,12 @@ describe('broker binary path resolution', () => { }), })), })); + vi.doMock('node:child_process', async () => ({ + ...(await vi.importActual('node:child_process')), + execFileSync: vi.fn(() => { + throw new Error('not found on PATH'); + }), + })); const { getBrokerBinaryPath } = await loadBrokerPathModule(); @@ -212,12 +218,52 @@ describe('broker binary path resolution', () => { }), })), })); + vi.doMock('node:child_process', async () => ({ + ...(await vi.importActual('node:child_process')), + execFileSync: vi.fn(() => { + throw new Error('not found on PATH'); + }), + })); const { getBrokerBinaryPath } = await loadBrokerPathModule(); expect(getBrokerBinaryPath()).toBe(expectedBinaryPath); }); + it('prefers a broker on PATH over a stale user-install fallback', async () => { + const pathBinary = '/current/bin/agent-relay-broker'; + const staleBinary = path.join( + os.homedir(), + '.agentworkforce', + 'relay', + 'bin', + `agent-relay-broker${process.platform === 'win32' ? '.exe' : ''}` + ); + const execFileSync = vi.fn(() => `${pathBinary}\n`); + + vi.doMock('node:fs', async () => ({ + ...(await vi.importActual('node:fs')), + existsSync: vi.fn((candidate: string) => candidate === staleBinary), + })); + vi.doMock('node:module', async () => ({ + ...(await vi.importActual('node:module')), + createRequire: vi.fn(() => ({ + resolve: vi.fn(() => { + throw new Error('optional package unavailable'); + }), + })), + })); + vi.doMock('node:child_process', async () => ({ + ...(await vi.importActual('node:child_process')), + execFileSync, + })); + + const { getBrokerBinaryPath } = await loadBrokerPathModule(); + + expect(getBrokerBinaryPath()).toBe(pathBinary); + expect(execFileSync).toHaveBeenCalledOnce(); + }); + it('falls back to PATH lookup after env, optional package, and development paths miss', async () => { const pathBinary = '/usr/local/bin/agent-relay-broker'; const execFileSync = vi.fn(() => `${pathBinary}\n`); diff --git a/packages/harness-driver/src/broker-path.ts b/packages/harness-driver/src/broker-path.ts index e5aca8452..035d11bc4 100644 --- a/packages/harness-driver/src/broker-path.ts +++ b/packages/harness-driver/src/broker-path.ts @@ -231,9 +231,9 @@ function getSourceCheckoutBinaryPaths(ext: string): string[] { * checkout * 3. Platform-specific optional-dep package * (`@agent-relay/broker--`) — primary production path - * 4. Relay installer locations (launcher/executable sibling and user bin) - * 5. Cargo development paths (target/release and target/debug) - * 6. PATH lookup via `which` / `where` + * 4. Cargo development paths (target/release and target/debug) + * 5. PATH lookup via `which` / `where` + * 6. Relay installer locations (launcher/executable sibling and user bin) * * @returns Absolute path to the broker binary, or null if not found */ @@ -261,22 +261,15 @@ export function getBrokerBinaryPath(): string | null { return optionalDepBinary; } - // 3. Relay's npm/standalone installer locations. This also covers mise and - // similar version managers when their activation PATH omits the user bin. - for (const installedPath of getInstalledBinaryPaths(ext)) { - if (existsSync(installedPath)) { - return installedPath; - } - } - - // 4. Common development paths for local Cargo builds. + // 3. Common development paths for local Cargo builds. for (const developmentPath of getDevelopmentBinaryPaths(ext)) { if (existsSync(developmentPath)) { return developmentPath; } } - // 5. PATH lookup. + // 4. PATH lookup. Prefer the operator's active PATH over fixed user install + // directories, which can contain stale brokers from an older Relay install. try { const cmd = process.platform === 'win32' ? 'where' : 'which'; const result = execFileSync(cmd, [BROKER_NAME], { @@ -290,6 +283,14 @@ export function getBrokerBinaryPath(): string | null { // Not found on PATH } + // 5. Relay's npm/standalone installer locations. This also covers mise and + // similar version managers when their activation PATH omits the user bin. + for (const installedPath of getInstalledBinaryPaths(ext)) { + if (existsSync(installedPath)) { + return installedPath; + } + } + return null; } From 33e0d0b9b7770fe9a3c1ad76ef8ef9504a43b5d3 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Tue, 4 Aug 2026 09:08:53 +0200 Subject: [PATCH 4/4] fix(harness-driver): support legacy broker installs --- .../harness-driver/src/broker-path.test.ts | 32 +++++++++++++++++++ packages/harness-driver/src/broker-path.ts | 1 + 2 files changed, 33 insertions(+) diff --git a/packages/harness-driver/src/broker-path.test.ts b/packages/harness-driver/src/broker-path.test.ts index eae09c206..1de97269e 100644 --- a/packages/harness-driver/src/broker-path.test.ts +++ b/packages/harness-driver/src/broker-path.test.ts @@ -264,6 +264,38 @@ describe('broker binary path resolution', () => { expect(execFileSync).toHaveBeenCalledOnce(); }); + it('falls back to the legacy standalone broker install directory', async () => { + const expectedBinaryPath = path.join( + os.homedir(), + '.agent-relay', + 'bin', + `agent-relay-broker${process.platform === 'win32' ? '.exe' : ''}` + ); + + vi.doMock('node:fs', async () => ({ + ...(await vi.importActual('node:fs')), + existsSync: vi.fn((candidate: string) => candidate === expectedBinaryPath), + })); + vi.doMock('node:module', async () => ({ + ...(await vi.importActual('node:module')), + createRequire: vi.fn(() => ({ + resolve: vi.fn(() => { + throw new Error('optional package unavailable'); + }), + })), + })); + vi.doMock('node:child_process', async () => ({ + ...(await vi.importActual('node:child_process')), + execFileSync: vi.fn(() => { + throw new Error('not found on PATH'); + }), + })); + + const { getBrokerBinaryPath } = await loadBrokerPathModule(); + + expect(getBrokerBinaryPath()).toBe(expectedBinaryPath); + }); + it('falls back to PATH lookup after env, optional package, and development paths miss', async () => { const pathBinary = '/usr/local/bin/agent-relay-broker'; const execFileSync = vi.fn(() => `${pathBinary}\n`); diff --git a/packages/harness-driver/src/broker-path.ts b/packages/harness-driver/src/broker-path.ts index 035d11bc4..0e89bbc48 100644 --- a/packages/harness-driver/src/broker-path.ts +++ b/packages/harness-driver/src/broker-path.ts @@ -135,6 +135,7 @@ function getInstalledBinaryPaths(ext: string): string[] { const userHome = homedir(); if (userHome) { addUniquePath(binaryPaths, join(userHome, '.agentworkforce', 'relay', 'bin', binaryFile)); + addUniquePath(binaryPaths, join(userHome, '.agent-relay', 'bin', binaryFile)); addUniquePath(binaryPaths, join(userHome, '.local', 'bin', binaryFile)); }