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..99c66e294 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), }; } @@ -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']; @@ -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'), @@ -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); @@ -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', @@ -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 () => { diff --git a/packages/cli/src/cli/commands/core.ts b/packages/cli/src/cli/commands/core.ts index 19aab00f3..a762d773b 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; @@ -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 = { @@ -91,6 +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[], + options?: { timeout?: number } + ) => Promise<{ stdout: string; stderr: string }>; killProcess: (pid: number, signal?: NodeJS.Signals | number) => void; fs: CoreFileSystem; generateAgentName: () => string; @@ -200,6 +207,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); @@ -218,6 +226,13 @@ export function withDefaults(overrides: Partial = {}): 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, diff --git a/packages/cli/src/cli/lib/core-maintenance.ts b/packages/cli/src/cli/lib/core-maintenance.ts index b38d1ece3..ef821e5fa 100644 --- a/packages/cli/src/cli/lib/core-maintenance.ts +++ b/packages/cli/src/cli/lib/core-maintenance.ts @@ -15,10 +15,9 @@ 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'; +const CLI_VERSION_TIMEOUT_MS = 30_000; /** * Remove agent-relay snippet blocks from a markdown file's content. @@ -154,6 +153,159 @@ 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(file: string, args: string[], deps: CoreDependencies): Promise { + 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.'); + } + 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(temporaryPath, ['--version'], deps); + assertUpdatedVersion(downloadedVersion, requestedVersion, currentVersion); + + await deps.execCommand(`mv -f ${shellQuote(temporaryPath)} ${shellQuote(targetPath)}`); + const installedVersion = await readCliVersion(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. + } + } + } +} + +async function updateNpmInstall( + requestedVersion: string, + currentVersion: string, + deps: CoreDependencies +): Promise { + // 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(deps.execPath, [deps.cliScript, '--version'], 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 +331,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); } }