Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Comment thread
khaliqgant marked this conversation as resolved.

### 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

Expand Down
193 changes: 193 additions & 0 deletions packages/harness-driver/src/broker-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('./broker-path.js')> {
Expand All @@ -32,6 +33,7 @@ beforeEach(() => {
delete process.env.BROKER_BINARY_PATH;
delete process.env.AGENT_RELAY_BIN;
process.argv = [...originalArgv];
process.execPath = originalExecPath;
});

afterEach(() => {
Expand Down Expand Up @@ -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<typeof import('node:fs')>('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<typeof import('node:module')>('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<typeof import('node:fs')>('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<typeof import('node:module')>('node:module')),
createRequire: vi.fn(() => ({
resolve: vi.fn(() => {
throw new Error('optional package unavailable');
}),
})),
}));
vi.doMock('node:child_process', async () => ({
...(await vi.importActual<typeof import('node:child_process')>('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<typeof import('node:fs')>('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<typeof import('node:module')>('node:module')),
createRequire: vi.fn(() => ({
resolve: vi.fn(() => {
throw new Error('optional package unavailable');
}),
})),
}));
vi.doMock('node:child_process', async () => ({
...(await vi.importActual<typeof import('node:child_process')>('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<typeof import('node:fs')>('node:fs')),
existsSync: vi.fn((candidate: string) => candidate === staleBinary),
}));
vi.doMock('node:module', async () => ({
...(await vi.importActual<typeof import('node:module')>('node:module')),
createRequire: vi.fn(() => ({
resolve: vi.fn(() => {
throw new Error('optional package unavailable');
}),
})),
}));
vi.doMock('node:child_process', async () => ({
...(await vi.importActual<typeof import('node:child_process')>('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<typeof import('node:fs')>('node:fs')),
existsSync: vi.fn((candidate: string) => candidate === expectedBinaryPath),
}));
vi.doMock('node:module', async () => ({
...(await vi.importActual<typeof import('node:module')>('node:module')),
createRequire: vi.fn(() => ({
resolve: vi.fn(() => {
throw new Error('optional package unavailable');
}),
})),
}));
vi.doMock('node:child_process', async () => ({
...(await vi.importActual<typeof import('node:child_process')>('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`);
Expand Down
73 changes: 67 additions & 6 deletions packages/harness-driver/src/broker-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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));
Comment thread
khaliqgant marked this conversation as resolved.
}

return binaryPaths;
}

/**
* Resolve the broker binary via the platform-specific optional-dependency
* package (`@agent-relay/broker-<platform>-<arch>`). Returns null when the
Expand Down Expand Up @@ -183,6 +234,7 @@ function getSourceCheckoutBinaryPaths(ext: string): string[] {
* (`@agent-relay/broker-<platform>-<arch>`) — 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
*/
Expand Down Expand Up @@ -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], {
Expand All @@ -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;
}

Expand Down
Loading