Skip to content
Open
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]

### Fixed

- `agent-relay update` now replaces the active standalone binary and verifies the installed CLI version before reporting success, instead of silently updating an unused npm copy.

## [10.6.4] - 2026-07-18

Expand Down
209 changes: 208 additions & 1 deletion packages/cli/src/cli/commands/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ function createFsMock(initialFiles: Record<string, string> = {}): CoreFileSystem
files.delete(filePath);
}),
accessSync: vi.fn(() => undefined),
realpathSync: vi.fn((filePath: string) => filePath),
};
}

Expand All @@ -136,6 +137,7 @@ function createHarness(options?: {
spawnedProcess?: SpawnedProcess;
spawnImpl?: CoreDependencies['spawnProcess'];
execCommand?: CoreDependencies['execCommand'];
execFileCommand?: CoreDependencies['execFileCommand'];
killImpl?: CoreDependencies['killProcess'];
nowImpl?: CoreDependencies['now'];
sleepImpl?: CoreDependencies['sleep'];
Expand Down Expand Up @@ -174,6 +176,7 @@ function createHarness(options?: {
spawnProcess:
options?.spawnImpl ?? (vi.fn(() => spawnedProcess) as unknown as CoreDependencies['spawnProcess']),
execCommand: options?.execCommand ?? vi.fn(async () => ({ stdout: '', stderr: '' })),
execFileCommand: options?.execFileCommand ?? vi.fn(async () => ({ stdout: '', stderr: '' })),
killProcess: options?.killImpl ?? vi.fn(() => undefined),
fs,
generateAgentName: vi.fn(() => 'AutoAgent'),
Expand Down Expand Up @@ -1341,9 +1344,12 @@ describe('registerCoreCommands', () => {
});

it('update tracks successful install attempts', async () => {
const execCommand = vi.fn(async () => ({ stdout: 'updated\n', stderr: '' }));
const execFileCommand = vi.fn(async () => ({ stdout: '2.0.0\n', stderr: '' }));
const { deps } = createHarness({
checkForUpdatesResult: { updateAvailable: true, latestVersion: '2.0.0' },
execCommand: vi.fn(async () => ({ stdout: 'updated\n', stderr: '' })),
execCommand,
execFileCommand,
});
const program = new Command();
registerCoreMaintenance(program, deps);
Expand All @@ -1352,6 +1358,10 @@ describe('registerCoreCommands', () => {

expect(exitCode).toBeUndefined();
expect(deps.execCommand).toHaveBeenCalledWith('npm install -g agent-relay@latest');
expect(deps.execFileCommand).toHaveBeenCalledWith('/usr/bin/node', ['/tmp/agent-relay.js', '--version'], {
timeout: 30_000,
});
expect(deps.log).toHaveBeenCalledWith('Successfully updated to 2.0.0');
expect(telemetryMocks.track).toHaveBeenCalledWith('cli_update', {
from_version: '1.2.3',
to_version: '2.0.0',
Expand All @@ -1378,6 +1388,203 @@ describe('registerCoreCommands', () => {
success: false,
error_class: 'Error',
});
const output = [
...vi.mocked(deps.log).mock.calls.flat(),
...vi.mocked(deps.error).mock.calls.flat(),
...vi.mocked(deps.warn).mock.calls.flat(),
].join('\n');
expect(output).not.toContain('registry token');
expect(output).not.toContain('/tmp/private');
});

it('update fails instead of reporting success when the npm-installed CLI version is unchanged', async () => {
const execCommand = vi.fn(async () => ({ stdout: 'updated\n', stderr: '' }));
const execFileCommand = vi.fn(async () => ({
stdout: 'agent-relay v1.2.3\n',
stderr: '',
}));
const { deps } = createHarness({
checkForUpdatesResult: { updateAvailable: true, latestVersion: '2.0.0' },
execCommand,
execFileCommand,
});
const program = new Command();
registerCoreMaintenance(program, deps);

const exitCode = await runCommand(program, ['update']);

expect(exitCode).toBe(1);
expect(deps.log).not.toHaveBeenCalledWith(expect.stringContaining('Successfully updated'));
expect(deps.warn).toHaveBeenCalledWith('The update could not be installed and verified.');
expect(deps.log).toHaveBeenCalledWith('Try running manually: npm install -g agent-relay@latest');
expect(telemetryMocks.track).toHaveBeenCalledWith('cli_update', {
from_version: '1.2.3',
to_version: '2.0.0',
success: false,
error_class: 'Error',
});
});

it('update verifies npm installs with argv-safe Windows paths', async () => {
const execPath = String.raw`C:\Program Files\nodejs\node.exe`;
const cliScript = String.raw`C:\Users\relay user\AppData\Roaming\npm\node_modules\agent-relay\dist\cli.js`;
const execFileCommand = vi.fn(async () => ({ stdout: 'agent-relay v2.0.0\n', stderr: '' }));
const { deps } = createHarness({
execPath,
cliScript,
execCommand: vi.fn(async () => ({ stdout: '', stderr: '' })),
execFileCommand,
checkForUpdatesResult: { updateAvailable: true, latestVersion: '2.0.0' },
});
const program = new Command();
registerCoreMaintenance(program, deps);

const exitCode = await runCommand(program, ['update']);

expect(exitCode).toBeUndefined();
expect(execFileCommand).toHaveBeenCalledWith(execPath, [cliScript, '--version'], {
timeout: 30_000,
});
expect(deps.log).toHaveBeenCalledWith('Successfully updated to 2.0.0');
});

it('update never replays captured npm output when verification fails', async () => {
const execFileCommand = vi.fn(async () => ({
stdout: 'agent-relay v1.2.3\n',
stderr: '',
}));
const { deps } = createHarness({
execCommand: vi.fn(async () => ({
stdout: 'registry token secret-output\n',
stderr: 'private path /tmp/installer-secret\n',
})),
execFileCommand,
checkForUpdatesResult: { updateAvailable: true, latestVersion: '2.0.0' },
});
const program = new Command();
registerCoreMaintenance(program, deps);

const exitCode = await runCommand(program, ['update']);
const output = [
...vi.mocked(deps.log).mock.calls.flat(),
...vi.mocked(deps.error).mock.calls.flat(),
...vi.mocked(deps.warn).mock.calls.flat(),
].join('\n');

expect(exitCode).toBe(1);
expect(output).not.toContain('secret-output');
expect(output).not.toContain('/tmp/installer-secret');
});

it.runIf(
(process.platform === 'darwin' || process.platform === 'linux') &&
(process.arch === 'x64' || process.arch === 'arm64')
)('update resolves the standalone target behind ~/.local/bin and atomically replaces it', async () => {
const home = os.homedir();
const launcher = nodePath.join(home, '.local', 'bin', 'agent-relay');
const target = nodePath.join(home, '.agentworkforce', 'relay', 'bin', 'agent-relay');
const temporary = nodePath.join(nodePath.dirname(target), '.agent-relay.update-4242');
const fs = createFsMock({ [launcher]: 'shim', [target]: 'old-binary' });
fs.realpathSync = vi.fn((filePath: string) => (filePath === launcher ? target : filePath));
const execCommand = vi.fn(async () => ({ stdout: '', stderr: '' }));
const execFileCommand = vi.fn(async () => ({ stdout: 'agent-relay v2.0.0\n', stderr: '' }));
const { deps } = createHarness({
fs,
execPath: launcher,
cliScript: '/$bunfs/root/agent-relay',
execCommand,
execFileCommand,
checkForUpdatesResult: { updateAvailable: true, latestVersion: '2.0.0' },
});
const program = new Command();
registerCoreMaintenance(program, deps);

const exitCode = await runCommand(program, ['update']);

expect(exitCode).toBeUndefined();
expect(execCommand).not.toHaveBeenCalledWith('npm install -g agent-relay@latest');
expect(execCommand).toHaveBeenCalledWith(
expect.stringContaining(
`https://github.com/AgentWorkforce/relay/releases/download/v2.0.0/agent-relay-${process.platform}-${process.arch}`
)
);
expect(execCommand).toHaveBeenCalledWith(`chmod +x '${temporary}'`);
expect(execFileCommand).toHaveBeenCalledWith(temporary, ['--version'], { timeout: 30_000 });
expect(execCommand).toHaveBeenCalledWith(`mv -f '${temporary}' '${target}'`);
expect(execFileCommand).toHaveBeenCalledWith(target, ['--version'], { timeout: 30_000 });
expect(deps.log).toHaveBeenCalledWith('Successfully updated to 2.0.0');
});

it.runIf(
(process.platform === 'darwin' || process.platform === 'linux') &&
(process.arch === 'x64' || process.arch === 'arm64')
)('update does not replace a standalone binary whose download reports the old version', async () => {
const home = os.homedir();
const target = nodePath.join(home, '.agentworkforce', 'relay', 'bin', 'agent-relay');
const fs = createFsMock({ [target]: 'old-binary' });
const execCommand = vi.fn(async () => ({ stdout: '', stderr: '' }));
const execFileCommand = vi.fn(async () => ({
stdout: 'agent-relay v1.2.3\n',
stderr: '',
}));
const { deps } = createHarness({
fs,
execPath: target,
cliScript: '/$bunfs/root/agent-relay',
execCommand,
execFileCommand,
checkForUpdatesResult: { updateAvailable: true, latestVersion: '2.0.0' },
});
const program = new Command();
registerCoreMaintenance(program, deps);

const exitCode = await runCommand(program, ['update']);

expect(exitCode).toBe(1);
expect(execCommand).not.toHaveBeenCalledWith(expect.stringMatching(/^mv -f /));
expect(execCommand).not.toHaveBeenCalledWith('npm install -g agent-relay@latest');
expect(deps.log).not.toHaveBeenCalledWith(expect.stringContaining('Successfully updated'));
expect(deps.log).toHaveBeenCalledWith(
expect.stringContaining('Reinstall the standalone CLI manually: curl -fsSL')
);
});

it.runIf(
(process.platform === 'darwin' || process.platform === 'linux') &&
(process.arch === 'x64' || process.arch === 'arm64')
)('update reports failure when the resolved standalone target still has the old version', async () => {
const home = os.homedir();
const target = nodePath.join(home, '.agentworkforce', 'relay', 'bin', 'agent-relay');
const temporary = nodePath.join(nodePath.dirname(target), '.agent-relay.update-4242');
const fs = createFsMock({ [target]: 'old-binary' });
const execCommand = vi.fn(async () => ({ stdout: '', stderr: '' }));
const execFileCommand = vi.fn(async (file: string) => {
if (file === temporary) {
return { stdout: 'agent-relay v2.0.0\n', stderr: '' };
}
if (file === target) {
return { stdout: 'agent-relay v1.2.3\n', stderr: '' };
}
return { stdout: '', stderr: '' };
});
const { deps } = createHarness({
fs,
execPath: target,
cliScript: '/$bunfs/root/agent-relay',
execCommand,
execFileCommand,
checkForUpdatesResult: { updateAvailable: true, latestVersion: '2.0.0' },
});
const program = new Command();
registerCoreMaintenance(program, deps);

const exitCode = await runCommand(program, ['update']);

expect(exitCode).toBe(1);
expect(execCommand).toHaveBeenCalledWith(`mv -f '${temporary}' '${target}'`);
expect(execFileCommand).toHaveBeenCalledWith(target, ['--version'], { timeout: 30_000 });
expect(deps.log).not.toHaveBeenCalledWith(expect.stringContaining('Successfully updated'));
expect(deps.warn).toHaveBeenCalledWith('The update could not be installed and verified.');
});

it('uninstall dry-run covers renamed and legacy installer bin directories', async () => {
Expand Down
17 changes: 16 additions & 1 deletion packages/cli/src/cli/commands/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { exec, spawn as spawnProcess } from 'node:child_process';
import { exec, execFile, spawn as spawnProcess } from 'node:child_process';
import { promisify } from 'node:util';
import { Command, InvalidArgumentError } from 'commander';

Expand All @@ -16,6 +16,7 @@ import { createRuntimeClient, spawnAgentWithClient } from '../lib/client-factory
import { defaultExit, runSignalHandler } from '../lib/exit.js';

const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);

type ExitFn = (code: number) => never;

Expand Down Expand Up @@ -72,6 +73,7 @@ export interface CoreFileSystem {
mkdirSync: (path: string, options?: { recursive?: boolean }) => void;
rmSync: (path: string, options?: { recursive?: boolean; force?: boolean }) => void;
accessSync: (path: string, mode?: number) => void;
realpathSync: (path: string) => string;
}

type UpdateInfo = {
Expand All @@ -91,6 +93,11 @@ export interface CoreDependencies {
) => CoreRelay | Promise<CoreRelay>;
spawnProcess: (command: string, args: string[], options?: Record<string, unknown>) => SpawnedProcess;
execCommand: (command: string) => Promise<{ stdout: string; stderr: string }>;
execFileCommand: (
file: string,
args: string[],
options?: { timeout?: number }
) => Promise<{ stdout: string; stderr: string }>;
killProcess: (pid: number, signal?: NodeJS.Signals | number) => void;
fs: CoreFileSystem;
generateAgentName: () => string;
Expand Down Expand Up @@ -200,6 +207,7 @@ export function withDefaults(overrides: Partial<CoreDependencies> = {}): CoreDep
mkdirSync: (dirPath, options) => fs.mkdirSync(dirPath, options),
rmSync: (targetPath, options) => fs.rmSync(targetPath, options),
accessSync: fs.accessSync,
realpathSync: fs.realpathSync,
};

const defaultVersion = resolveCliVersion(fileSystem);
Expand All @@ -218,6 +226,13 @@ export function withDefaults(overrides: Partial<CoreDependencies> = {}): CoreDep
stderr: result.stderr ?? '',
};
},
execFileCommand: async (file: string, args: string[], options?: { timeout?: number }) => {
const result = await execFileAsync(file, args, { timeout: options?.timeout ?? 30_000 });
return {
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
};
},
killProcess: process.kill,
fs: fileSystem,
generateAgentName,
Expand Down
Loading
Loading