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..1de97269e 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,197 @@ 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'); + }), + })), + })); + 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 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'); + }), + })), + })); + 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 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 3defec6fd..0e89bbc48 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,43 @@ 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[] = []; + + 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) { + addUniquePath(binaryPaths, join(userHome, '.agentworkforce', 'relay', 'bin', binaryFile)); + addUniquePath(binaryPaths, join(userHome, '.agent-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 @@ -183,6 +234,7 @@ function getSourceCheckoutBinaryPaths(ext: string): string[] { * (`@agent-relay/broker--`) — primary production path * 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 */ @@ -217,7 +269,8 @@ export function getBrokerBinaryPath(): string | null { } } - // 4. 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], { @@ -231,6 +284,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; }