From 3a7333e4c8375764bf0056089212d682f99f7039 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sat, 18 Jul 2026 22:42:07 +0200 Subject: [PATCH 1/3] fix(cli): update active standalone binary --- CHANGELOG.md | 6 +- packages/cli/src/cli/commands/core.test.ts | 156 ++++++++++++++- packages/cli/src/cli/commands/core.ts | 2 + packages/cli/src/cli/lib/core-maintenance.ts | 195 +++++++++++++++++-- 4 files changed, 340 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 147f129fc..c8f83064d 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 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 diff --git a/packages/cli/src/cli/commands/core.test.ts b/packages/cli/src/cli/commands/core.test.ts index d5871388b..0d03a3350 100644 --- a/packages/cli/src/cli/commands/core.test.ts +++ b/packages/cli/src/cli/commands/core.test.ts @@ -123,6 +123,7 @@ function createFsMock(initialFiles: Record = {}): CoreFileSystem files.delete(filePath); }), accessSync: vi.fn(() => undefined), + realpathSync: vi.fn((filePath: string) => filePath), }; } @@ -1341,9 +1342,14 @@ describe('registerCoreCommands', () => { }); it('update tracks successful install attempts', async () => { + const execCommand = vi.fn(async (command: string) => + command === 'npm install -g agent-relay@latest' + ? { stdout: 'updated\n', stderr: '' } + : { 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, }); const program = new Command(); registerCoreMaintenance(program, deps); @@ -1352,6 +1358,8 @@ describe('registerCoreCommands', () => { expect(exitCode).toBeUndefined(); expect(deps.execCommand).toHaveBeenCalledWith('npm install -g agent-relay@latest'); + expect(deps.execCommand).toHaveBeenCalledWith("'/usr/bin/node' '/tmp/agent-relay.js' --version"); + 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', @@ -1378,6 +1386,152 @@ 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 (command: string) => + command === 'npm install -g agent-relay@latest' + ? { stdout: 'updated\n', stderr: '' } + : { stdout: 'agent-relay v1.2.3\n', stderr: '' } + ); + const { deps } = createHarness({ + checkForUpdatesResult: { updateAvailable: true, latestVersion: '2.0.0' }, + execCommand, + }); + 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.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 (command: string) => { + if (command === `'${temporary}' --version` || command === `'${target}' --version`) { + return { stdout: 'agent-relay v2.0.0\n', stderr: '' }; + } + return { stdout: '', stderr: '' }; + }); + const { deps } = createHarness({ + fs, + execPath: launcher, + cliScript: '/$bunfs/root/agent-relay', + execCommand, + 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(execCommand).toHaveBeenCalledWith(`'${temporary}' --version`); + expect(execCommand).toHaveBeenCalledWith(`mv -f '${temporary}' '${target}'`); + expect(execCommand).toHaveBeenCalledWith(`'${target}' --version`); + 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 temporary = nodePath.join(nodePath.dirname(target), '.agent-relay.update-4242'); + const fs = createFsMock({ [target]: 'old-binary' }); + const execCommand = vi.fn(async (command: string) => + command === `'${temporary}' --version` + ? { stdout: 'agent-relay v1.2.3\n', stderr: '' } + : { stdout: '', stderr: '' } + ); + const { deps } = createHarness({ + fs, + execPath: target, + cliScript: '/$bunfs/root/agent-relay', + execCommand, + 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 (command: string) => { + if (command === `'${temporary}' --version`) { + return { stdout: 'agent-relay v2.0.0\n', stderr: '' }; + } + if (command === `'${target}' --version`) { + return { stdout: 'agent-relay v1.2.3\n', stderr: '' }; + } + return { stdout: '', stderr: '' }; + }); + const { deps } = createHarness({ + fs, + execPath: target, + cliScript: '/$bunfs/root/agent-relay', + execCommand, + 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(execCommand).toHaveBeenCalledWith(`'${target}' --version`); + 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 () => { diff --git a/packages/cli/src/cli/commands/core.ts b/packages/cli/src/cli/commands/core.ts index 19aab00f3..75fc84599 100644 --- a/packages/cli/src/cli/commands/core.ts +++ b/packages/cli/src/cli/commands/core.ts @@ -72,6 +72,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 = { @@ -200,6 +201,7 @@ export function withDefaults(overrides: Partial = {}): 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); diff --git a/packages/cli/src/cli/lib/core-maintenance.ts b/packages/cli/src/cli/lib/core-maintenance.ts index b38d1ece3..d28aee154 100644 --- a/packages/cli/src/cli/lib/core-maintenance.ts +++ b/packages/cli/src/cli/lib/core-maintenance.ts @@ -15,10 +15,8 @@ const LEGACY_MCP_SERVER_KEYS = ['relaycast'] as const; const ZED_SETTINGS_PATH = path.join('.config', 'zed', 'settings.json'); const DEFAULT_ZED_SERVER_NAME = 'Agent Relay'; const INSTALL_DIR_NAMES = ['.agentworkforce/relay', '.agent-relay'] as const; - -function toErrorMessage(err: unknown): string { - return err instanceof Error ? err.message : String(err); -} +const STANDALONE_INSTALL_SCRIPT = + 'curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/relay/main/install.sh | bash'; /** * Remove agent-relay snippet blocks from a markdown file's content. @@ -154,6 +152,165 @@ function removeZedConfig( } } +/** Single-quote a value for safe interpolation into a POSIX shell command. */ +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function safeRealpath(filePath: string, fileSystem: CoreFileSystem): string { + try { + return path.resolve(fileSystem.realpathSync(filePath)); + } catch { + return path.resolve(filePath); + } +} + +/** + * Resolve the active Bun standalone executable, including installs reached + * through the ~/.local/bin launcher. npm installs execute node/bun with a real + * JS cliScript, while compiled Bun binaries expose the embedded /$bunfs path. + */ +function resolveActiveStandaloneBinary(deps: CoreDependencies): string | null { + const homeDir = os.homedir(); + const knownPaths = [ + ...INSTALL_DIR_NAMES.map((installDir) => path.join(homeDir, installDir, 'bin', 'agent-relay')), + path.join(homeDir, '.local', 'bin', 'agent-relay'), + ]; + const resolvedExecPath = safeRealpath(deps.execPath, deps.fs); + const hasBunMarker = deps.cliScript.includes('/$bunfs/') || deps.cliScript.includes('\\$bunfs\\'); + const execName = path.basename(resolvedExecPath); + const looksLikeStandaloneExecutable = + hasBunMarker || execName === 'agent-relay' || execName.startsWith('agent-relay-'); + + if (!looksLikeStandaloneExecutable) { + return null; + } + + for (const candidate of knownPaths) { + if (deps.fs.existsSync(candidate) && safeRealpath(candidate, deps.fs) === resolvedExecPath) { + return resolvedExecPath; + } + } + + // Support directly-invoked standalone binaries outside the installer paths. + return hasBunMarker && deps.fs.existsSync(deps.execPath) ? resolvedExecPath : null; +} + +function normalizeVersion(value: string): string { + return value + .trim() + .replace(/^openclaw-v/, '') + .replace(/^v/, ''); +} + +function parseCliVersion(output: string): string | null { + const match = output.match(/(?:^|[^0-9])v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/m); + return match?.[1] ?? null; +} + +async function readCliVersion(command: string, deps: CoreDependencies): Promise { + const { stdout, stderr } = await deps.execCommand(command); + const version = parseCliVersion(`${stdout}\n${stderr}`); + if (!version) { + throw new Error('The updated CLI did not report a valid version.'); + } + return version; +} + +function assertUpdatedVersion( + installedVersion: string, + requestedVersion: string, + currentVersion: string +): void { + const expectedVersion = requestedVersion === 'latest' ? null : normalizeVersion(requestedVersion); + if (expectedVersion && installedVersion !== expectedVersion) { + throw new Error(`The updated CLI reported ${installedVersion}, expected ${expectedVersion}.`); + } + if (installedVersion === normalizeVersion(currentVersion)) { + throw new Error(`The active CLI still reports ${installedVersion} after the update.`); + } +} + +function standaloneAssetName(): string { + if (process.platform !== 'darwin' && process.platform !== 'linux') { + throw new Error(`Standalone updates are not supported on ${process.platform}.`); + } + if (process.arch !== 'x64' && process.arch !== 'arm64') { + throw new Error(`Standalone updates are not supported on ${process.arch}.`); + } + return `agent-relay-${process.platform}-${process.arch}`; +} + +function standaloneDownloadUrl(requestedVersion: string): string { + const releasePath = + requestedVersion === 'latest' + ? 'latest/download' + : `download/v${encodeURIComponent(normalizeVersion(requestedVersion))}`; + return `https://github.com/AgentWorkforce/relay/releases/${releasePath}/${standaloneAssetName()}`; +} + +async function updateStandaloneBinary( + targetPath: string, + requestedVersion: string, + currentVersion: string, + deps: CoreDependencies +): Promise { + // Keep the temporary download beside the target so mv(1) uses an atomic + // same-filesystem rename. The launcher/symlink remains pointed at this path. + const temporaryPath = path.join( + path.dirname(targetPath), + `.${path.basename(targetPath)}.update-${deps.pid}` + ); + try { + if (deps.fs.existsSync(temporaryPath)) { + deps.fs.unlinkSync(temporaryPath); + } + const downloadUrl = standaloneDownloadUrl(requestedVersion); + await deps.execCommand( + `curl -fsSL --retry 3 --retry-delay 1 ${shellQuote(downloadUrl)} -o ${shellQuote(temporaryPath)}` + ); + await deps.execCommand(`chmod +x ${shellQuote(temporaryPath)}`); + + const downloadedVersion = await readCliVersion(`${shellQuote(temporaryPath)} --version`, deps); + assertUpdatedVersion(downloadedVersion, requestedVersion, currentVersion); + + await deps.execCommand(`mv -f ${shellQuote(temporaryPath)} ${shellQuote(targetPath)}`); + const installedVersion = await readCliVersion(`${shellQuote(targetPath)} --version`, deps); + assertUpdatedVersion(installedVersion, requestedVersion, currentVersion); + return installedVersion; + } finally { + if (deps.fs.existsSync(temporaryPath)) { + try { + deps.fs.unlinkSync(temporaryPath); + } catch { + // Best-effort cleanup; never mask the install or verification result. + } + } + } +} + +function npmCliVersionCommand(deps: CoreDependencies): string { + return `${shellQuote(deps.execPath)} ${shellQuote(deps.cliScript)} --version`; +} + +async function updateNpmInstall( + requestedVersion: string, + currentVersion: string, + deps: CoreDependencies +): Promise { + const { stdout, stderr } = await deps.execCommand('npm install -g agent-relay@latest'); + if (stdout.trim().length > 0) { + deps.log(stdout.trimEnd()); + } + if (stderr.trim().length > 0) { + deps.error(stderr.trimEnd()); + } + + const installedVersion = await readCliVersion(npmCliVersionCommand(deps), deps); + assertUpdatedVersion(installedVersion, requestedVersion, currentVersion); + return installedVersion; +} + export async function runUpdateCommand(options: { check?: boolean }, deps: CoreDependencies): Promise { const currentVersion = deps.getVersion(); deps.log(`Current version: ${currentVersion}`); @@ -179,31 +336,35 @@ export async function runUpdateCommand(options: { check?: boolean }, deps: CoreD } deps.log('Installing update...'); - const toVersion = info.latestVersion ?? 'latest'; + const requestedVersion = info.latestVersion ?? 'latest'; + const standaloneTarget = resolveActiveStandaloneBinary(deps); try { - const { stdout, stderr } = await deps.execCommand('npm install -g agent-relay@latest'); - if (stdout.trim().length > 0) { - deps.log(stdout.trimEnd()); - } - if (stderr.trim().length > 0) { - deps.error(stderr.trimEnd()); - } - deps.log(`Successfully updated to ${toVersion}`); + const installedVersion = standaloneTarget + ? await updateStandaloneBinary(standaloneTarget, requestedVersion, currentVersion, deps) + : await updateNpmInstall(requestedVersion, currentVersion, deps); + deps.log(`Successfully updated to ${installedVersion}`); track('cli_update', { from_version: currentVersion, - to_version: toVersion, + to_version: installedVersion, success: true, }); } catch (err: unknown) { const errorClass = errorClassName(err); track('cli_update', { from_version: currentVersion, - to_version: toVersion, + to_version: requestedVersion, success: false, ...(errorClass ? { error_class: errorClass } : {}), }); - deps.error(`Failed to install update: ${toErrorMessage(err)}`); - deps.log('Try running manually: npm install -g agent-relay@latest'); + // Command errors can include environment-specific registry details. Keep + // output actionable without reflecting raw command output or credentials. + deps.warn('The update could not be installed and verified.'); + deps.log( + standaloneTarget + ? `Reinstall the standalone CLI manually: ${STANDALONE_INSTALL_SCRIPT}` + : 'Try running manually: npm install -g agent-relay@latest' + ); + deps.log('Then confirm with: agent-relay --version'); deps.exit(1); } } From b2e604e75003ab89a656f3ca1f17fc5b8220f66c Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sat, 18 Jul 2026 22:57:29 +0200 Subject: [PATCH 2/3] fix(cli): harden update verification --- packages/cli/src/cli/commands/core.test.ts | 107 ++++++++++++++----- packages/cli/src/cli/commands/core.ts | 11 +- packages/cli/src/cli/lib/core-maintenance.ts | 24 ++--- 3 files changed, 96 insertions(+), 46 deletions(-) diff --git a/packages/cli/src/cli/commands/core.test.ts b/packages/cli/src/cli/commands/core.test.ts index 0d03a3350..47905a7b1 100644 --- a/packages/cli/src/cli/commands/core.test.ts +++ b/packages/cli/src/cli/commands/core.test.ts @@ -137,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']; @@ -175,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'), @@ -1342,14 +1344,12 @@ describe('registerCoreCommands', () => { }); it('update tracks successful install attempts', async () => { - const execCommand = vi.fn(async (command: string) => - command === 'npm install -g agent-relay@latest' - ? { stdout: 'updated\n', stderr: '' } - : { stdout: '2.0.0\n', stderr: '' } - ); + 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, + execFileCommand, }); const program = new Command(); registerCoreMaintenance(program, deps); @@ -1358,7 +1358,7 @@ describe('registerCoreCommands', () => { expect(exitCode).toBeUndefined(); expect(deps.execCommand).toHaveBeenCalledWith('npm install -g agent-relay@latest'); - expect(deps.execCommand).toHaveBeenCalledWith("'/usr/bin/node' '/tmp/agent-relay.js' --version"); + expect(deps.execFileCommand).toHaveBeenCalledWith('/usr/bin/node', ['/tmp/agent-relay.js', '--version']); expect(deps.log).toHaveBeenCalledWith('Successfully updated to 2.0.0'); expect(telemetryMocks.track).toHaveBeenCalledWith('cli_update', { from_version: '1.2.3', @@ -1396,14 +1396,15 @@ describe('registerCoreCommands', () => { }); it('update fails instead of reporting success when the npm-installed CLI version is unchanged', async () => { - const execCommand = vi.fn(async (command: string) => - command === 'npm install -g agent-relay@latest' - ? { stdout: 'updated\n', stderr: '' } - : { stdout: 'agent-relay v1.2.3\n', stderr: '' } - ); + 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); @@ -1422,6 +1423,55 @@ describe('registerCoreCommands', () => { }); }); + 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']); + 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') @@ -1432,17 +1482,14 @@ describe('registerCoreCommands', () => { 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 (command: string) => { - if (command === `'${temporary}' --version` || command === `'${target}' --version`) { - return { stdout: 'agent-relay v2.0.0\n', stderr: '' }; - } - return { stdout: '', stderr: '' }; - }); + 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(); @@ -1458,9 +1505,9 @@ describe('registerCoreCommands', () => { ) ); expect(execCommand).toHaveBeenCalledWith(`chmod +x '${temporary}'`); - expect(execCommand).toHaveBeenCalledWith(`'${temporary}' --version`); + expect(execFileCommand).toHaveBeenCalledWith(temporary, ['--version']); expect(execCommand).toHaveBeenCalledWith(`mv -f '${temporary}' '${target}'`); - expect(execCommand).toHaveBeenCalledWith(`'${target}' --version`); + expect(execFileCommand).toHaveBeenCalledWith(target, ['--version']); expect(deps.log).toHaveBeenCalledWith('Successfully updated to 2.0.0'); }); @@ -1470,18 +1517,18 @@ describe('registerCoreCommands', () => { )('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 temporary = nodePath.join(nodePath.dirname(target), '.agent-relay.update-4242'); const fs = createFsMock({ [target]: 'old-binary' }); - const execCommand = vi.fn(async (command: string) => - command === `'${temporary}' --version` - ? { stdout: 'agent-relay v1.2.3\n', stderr: '' } - : { stdout: '', stderr: '' } - ); + 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(); @@ -1506,11 +1553,12 @@ describe('registerCoreCommands', () => { 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 (command: string) => { - if (command === `'${temporary}' --version`) { + 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 (command === `'${target}' --version`) { + if (file === target) { return { stdout: 'agent-relay v1.2.3\n', stderr: '' }; } return { stdout: '', stderr: '' }; @@ -1520,6 +1568,7 @@ describe('registerCoreCommands', () => { execPath: target, cliScript: '/$bunfs/root/agent-relay', execCommand, + execFileCommand, checkForUpdatesResult: { updateAvailable: true, latestVersion: '2.0.0' }, }); const program = new Command(); @@ -1529,7 +1578,7 @@ describe('registerCoreCommands', () => { expect(exitCode).toBe(1); expect(execCommand).toHaveBeenCalledWith(`mv -f '${temporary}' '${target}'`); - expect(execCommand).toHaveBeenCalledWith(`'${target}' --version`); + expect(execFileCommand).toHaveBeenCalledWith(target, ['--version']); expect(deps.log).not.toHaveBeenCalledWith(expect.stringContaining('Successfully updated')); expect(deps.warn).toHaveBeenCalledWith('The update could not be installed and verified.'); }); diff --git a/packages/cli/src/cli/commands/core.ts b/packages/cli/src/cli/commands/core.ts index 75fc84599..e6fa7e201 100644 --- a/packages/cli/src/cli/commands/core.ts +++ b/packages/cli/src/cli/commands/core.ts @@ -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'; @@ -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; @@ -92,6 +93,7 @@ export interface CoreDependencies { ) => CoreRelay | Promise; spawnProcess: (command: string, args: string[], options?: Record) => SpawnedProcess; execCommand: (command: string) => Promise<{ stdout: string; stderr: string }>; + execFileCommand: (file: string, args: string[]) => Promise<{ stdout: string; stderr: string }>; killProcess: (pid: number, signal?: NodeJS.Signals | number) => void; fs: CoreFileSystem; generateAgentName: () => string; @@ -220,6 +222,13 @@ export function withDefaults(overrides: Partial = {}): CoreDep stderr: result.stderr ?? '', }; }, + execFileCommand: async (file: string, args: string[]) => { + const result = await execFileAsync(file, args); + return { + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + }; + }, killProcess: process.kill, fs: fileSystem, generateAgentName, diff --git a/packages/cli/src/cli/lib/core-maintenance.ts b/packages/cli/src/cli/lib/core-maintenance.ts index d28aee154..ddb7d2cdc 100644 --- a/packages/cli/src/cli/lib/core-maintenance.ts +++ b/packages/cli/src/cli/lib/core-maintenance.ts @@ -208,8 +208,8 @@ function parseCliVersion(output: string): string | null { return match?.[1] ?? null; } -async function readCliVersion(command: string, deps: CoreDependencies): Promise { - const { stdout, stderr } = await deps.execCommand(command); +async function readCliVersion(file: string, args: string[], deps: CoreDependencies): Promise { + const { stdout, stderr } = await deps.execFileCommand(file, args); const version = parseCliVersion(`${stdout}\n${stderr}`); if (!version) { throw new Error('The updated CLI did not report a valid version.'); @@ -271,11 +271,11 @@ async function updateStandaloneBinary( ); await deps.execCommand(`chmod +x ${shellQuote(temporaryPath)}`); - const downloadedVersion = await readCliVersion(`${shellQuote(temporaryPath)} --version`, deps); + const downloadedVersion = await readCliVersion(temporaryPath, ['--version'], deps); assertUpdatedVersion(downloadedVersion, requestedVersion, currentVersion); await deps.execCommand(`mv -f ${shellQuote(temporaryPath)} ${shellQuote(targetPath)}`); - const installedVersion = await readCliVersion(`${shellQuote(targetPath)} --version`, deps); + const installedVersion = await readCliVersion(targetPath, ['--version'], deps); assertUpdatedVersion(installedVersion, requestedVersion, currentVersion); return installedVersion; } finally { @@ -289,24 +289,16 @@ async function updateStandaloneBinary( } } -function npmCliVersionCommand(deps: CoreDependencies): string { - return `${shellQuote(deps.execPath)} ${shellQuote(deps.cliScript)} --version`; -} - async function updateNpmInstall( requestedVersion: string, currentVersion: string, deps: CoreDependencies ): Promise { - const { stdout, stderr } = await deps.execCommand('npm install -g agent-relay@latest'); - if (stdout.trim().length > 0) { - deps.log(stdout.trimEnd()); - } - if (stderr.trim().length > 0) { - deps.error(stderr.trimEnd()); - } + // Installer output may contain registry credentials or private paths. Keep + // it captured and only report the credential-safe remediation below. + await deps.execCommand('npm install -g agent-relay@latest'); - const installedVersion = await readCliVersion(npmCliVersionCommand(deps), deps); + const installedVersion = await readCliVersion(deps.execPath, [deps.cliScript, '--version'], deps); assertUpdatedVersion(installedVersion, requestedVersion, currentVersion); return installedVersion; } From 6a020164cb41abedf708dc4e905901e74cf4fb0a Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sat, 18 Jul 2026 23:04:02 +0200 Subject: [PATCH 3/3] fix(cli): bound update version probes --- packages/cli/src/cli/commands/core.test.ts | 14 +++++++++----- packages/cli/src/cli/commands/core.ts | 10 +++++++--- packages/cli/src/cli/lib/core-maintenance.ts | 5 ++++- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/cli/commands/core.test.ts b/packages/cli/src/cli/commands/core.test.ts index 47905a7b1..99c66e294 100644 --- a/packages/cli/src/cli/commands/core.test.ts +++ b/packages/cli/src/cli/commands/core.test.ts @@ -1358,7 +1358,9 @@ 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']); + 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', @@ -1440,7 +1442,9 @@ describe('registerCoreCommands', () => { const exitCode = await runCommand(program, ['update']); expect(exitCode).toBeUndefined(); - expect(execFileCommand).toHaveBeenCalledWith(execPath, [cliScript, '--version']); + expect(execFileCommand).toHaveBeenCalledWith(execPath, [cliScript, '--version'], { + timeout: 30_000, + }); expect(deps.log).toHaveBeenCalledWith('Successfully updated to 2.0.0'); }); @@ -1505,9 +1509,9 @@ describe('registerCoreCommands', () => { ) ); expect(execCommand).toHaveBeenCalledWith(`chmod +x '${temporary}'`); - expect(execFileCommand).toHaveBeenCalledWith(temporary, ['--version']); + expect(execFileCommand).toHaveBeenCalledWith(temporary, ['--version'], { timeout: 30_000 }); expect(execCommand).toHaveBeenCalledWith(`mv -f '${temporary}' '${target}'`); - expect(execFileCommand).toHaveBeenCalledWith(target, ['--version']); + expect(execFileCommand).toHaveBeenCalledWith(target, ['--version'], { timeout: 30_000 }); expect(deps.log).toHaveBeenCalledWith('Successfully updated to 2.0.0'); }); @@ -1578,7 +1582,7 @@ describe('registerCoreCommands', () => { expect(exitCode).toBe(1); expect(execCommand).toHaveBeenCalledWith(`mv -f '${temporary}' '${target}'`); - expect(execFileCommand).toHaveBeenCalledWith(target, ['--version']); + 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.'); }); diff --git a/packages/cli/src/cli/commands/core.ts b/packages/cli/src/cli/commands/core.ts index e6fa7e201..a762d773b 100644 --- a/packages/cli/src/cli/commands/core.ts +++ b/packages/cli/src/cli/commands/core.ts @@ -93,7 +93,11 @@ export interface CoreDependencies { ) => CoreRelay | Promise; spawnProcess: (command: string, args: string[], options?: Record) => SpawnedProcess; execCommand: (command: string) => Promise<{ stdout: string; stderr: string }>; - execFileCommand: (file: string, args: 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; @@ -222,8 +226,8 @@ export function withDefaults(overrides: Partial = {}): CoreDep stderr: result.stderr ?? '', }; }, - execFileCommand: async (file: string, args: string[]) => { - const result = await execFileAsync(file, args); + 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 ?? '', diff --git a/packages/cli/src/cli/lib/core-maintenance.ts b/packages/cli/src/cli/lib/core-maintenance.ts index ddb7d2cdc..ef821e5fa 100644 --- a/packages/cli/src/cli/lib/core-maintenance.ts +++ b/packages/cli/src/cli/lib/core-maintenance.ts @@ -17,6 +17,7 @@ const DEFAULT_ZED_SERVER_NAME = 'Agent Relay'; const INSTALL_DIR_NAMES = ['.agentworkforce/relay', '.agent-relay'] as const; const STANDALONE_INSTALL_SCRIPT = 'curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/relay/main/install.sh | bash'; +const CLI_VERSION_TIMEOUT_MS = 30_000; /** * Remove agent-relay snippet blocks from a markdown file's content. @@ -209,7 +210,9 @@ function parseCliVersion(output: string): string | null { } async function readCliVersion(file: string, args: string[], deps: CoreDependencies): Promise { - const { stdout, stderr } = await deps.execFileCommand(file, args); + const { stdout, stderr } = await deps.execFileCommand(file, args, { + timeout: CLI_VERSION_TIMEOUT_MS, + }); const version = parseCliVersion(`${stdout}\n${stderr}`); if (!version) { throw new Error('The updated CLI did not report a valid version.');