From 5dfb7d5105b6cd4fa8b0d49d522b79cfef8691b2 Mon Sep 17 00:00:00 2001 From: Akarshkushwaha Date: Sat, 17 Oct 2026 23:30:39 +0530 Subject: [PATCH 1/6] fix(cli): update standalone installs with verified installer --- CHANGELOG.md | 6 +- install.sh | 40 +++++++- packages/cli/src/cli/commands/core.test.ts | 34 +++++++ packages/cli/src/cli/lib/core-maintenance.ts | 25 ++++- scripts/test-install-atomic.sh | 35 +++++++ .../cases/1963-standalone-update/case.json | 21 +++++ .../cases/1963-standalone-update/run.mjs | 91 +++++++++++++++++++ 7 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 tests/relayflows/cases/1963-standalone-update/case.json create mode 100644 tests/relayflows/cases/1963-standalone-update/run.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index cd3e446790..7472a087f5 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` upgrades standalone CLI and broker installs through the verified installer, keeps npm updates on npm, and exits non-zero when installation fails. ## [14.0.0] - 2026-10-13 diff --git a/install.sh b/install.sh index 7227cc559a..500fb33c8c 100755 --- a/install.sh +++ b/install.sh @@ -9,6 +9,7 @@ set -e # AGENT_RELAY_INSTALL_DIR - Installation directory (default: ~/.agentworkforce/relay) # AGENT_RELAY_BIN_DIR - Binary directory (default: ~/.local/bin) # AGENT_RELAY_TELEMETRY_DISABLED - Disable anonymous install telemetry (default: false) +# AGENT_RELAY_INSTALL_STANDALONE_ONLY - Require a verified standalone install (used by `agent-relay update`) REPO_RELAY="AgentWorkforce/relay" VERSION="${AGENT_RELAY_VERSION:-latest}" @@ -16,6 +17,7 @@ INSTALL_DIR="${AGENT_RELAY_INSTALL_DIR:-$HOME/.agentworkforce/relay}" BIN_DIR="${AGENT_RELAY_BIN_DIR:-$HOME/.local/bin}" ORIGINAL_PATH="${PATH:-}" STANDALONE_FAILURE_REASON="" +STANDALONE_TEMP_FAILURE="" # Colors RED='\033[0;31m' @@ -279,7 +281,10 @@ make_temp_file() { local dir="$1" local label="$2" MADE_TEMP="" - MADE_TEMP=$(mktemp "$dir/.${label}.XXXXXX") || return 1 + MADE_TEMP=$(mktemp "$dir/.${label}.XXXXXX") || { + STANDALONE_TEMP_FAILURE="Cannot create a temporary file in $dir; check that the directory is writable." + return 1 + } register_temp "$MADE_TEMP" } @@ -410,6 +415,10 @@ verify_asset_digest() { load_release_metadata expected=$(fetch_asset_expected_sha256 "$asset") if [ -z "$expected" ]; then + if [ "${AGENT_RELAY_INSTALL_STANDALONE_ONLY:-}" = "1" ]; then + warn "A published SHA-256 digest is required for $asset; refusing the standalone update." + return 1 + fi if [ "$RELEASE_JSON_STATE" = "failed" ]; then warn "Could not fetch release metadata from the GitHub API (rate limit or network); cannot verify the SHA-256 of $asset. Continuing with the smoke test only." else @@ -418,6 +427,10 @@ verify_asset_digest() { return 2 fi if ! actual=$(sha256_of "$file"); then + if [ "${AGENT_RELAY_INSTALL_STANDALONE_ONLY:-}" = "1" ]; then + warn "No SHA-256 tool found; refusing the standalone update." + return 1 + fi warn "No SHA-256 tool found (shasum, sha256sum or openssl); cannot verify $asset. Continuing with the smoke test only." return 2 fi @@ -629,7 +642,10 @@ check_broker_binary() { check_cli_binary() { local log - log=$(mktemp "${TMPDIR:-/tmp}/agent-relay-verify.XXXXXX") || return 1 + log=$(mktemp "${TMPDIR:-/tmp}/agent-relay-verify.XXXXXX") || { + STANDALONE_TEMP_FAILURE="Cannot create a temporary file in ${TMPDIR:-/tmp}; check that the directory is writable." + return 1 + } if "$1" --version >"$log" 2>&1; then rm -f "$log" return 0 @@ -821,6 +837,9 @@ abort_standalone_install() { undo_brokers undo_cli TXN_STATE="" + if [ -n "$STANDALONE_TEMP_FAILURE" ]; then + error "$STANDALONE_TEMP_FAILURE. ${ROLLBACK_OUTCOME}." + fi error "The v${VERSION} broker was rejected (integrity or smoke test failed), so the v${VERSION} CLI was NOT kept: ${ROLLBACK_OUTCOME}. The existing broker (${old_broker}) is untouched. Nothing was upgraded; re-run the installer, or set AGENT_RELAY_VERSION to a known-good version." } @@ -1031,7 +1050,9 @@ download_standalone_binary() { fi verify_output="$CLI_CHECK_OUTPUT" record_standalone_failure "Standalone binary verification failed for $target_path${verify_output:+: $verify_output}. Trying uncompressed binary..." + [ -z "$STANDALONE_TEMP_FAILURE" ] || return 1 else + [ -z "$STANDALONE_TEMP_FAILURE" ] || return 1 info "Compressed binary not available, trying uncompressed..." fi else @@ -1055,6 +1076,7 @@ download_standalone_binary() { fi verify_output="$CLI_CHECK_OUTPUT" record_standalone_failure "Standalone binary verification failed for $target_path${verify_output:+: $verify_output}" + [ -z "$STANDALONE_TEMP_FAILURE" ] || return 1 else info "Uncompressed binary not available (file too small: ${file_size} bytes)" rm -f "$FETCHED_TMP" @@ -1371,7 +1393,7 @@ main() { # broker (rc 2) rolls the CLI back; an unavailable one (rc 1) is fine. local broker_rc=0 download_broker_binary || broker_rc=$? - if [ "$broker_rc" -eq 2 ]; then + if [ "$broker_rc" -eq 2 ] || { [ "${AGENT_RELAY_INSTALL_STANDALONE_ONLY:-}" = "1" ] && [ "$broker_rc" -ne 0 ]; }; then abort_standalone_install fi # COMMIT: one assignment; everything after this is cleanup of backups. @@ -1385,6 +1407,18 @@ main() { verify_installation && print_usage && track_event "install_completed" && exit 0 fi + if [ -n "$STANDALONE_TEMP_FAILURE" ]; then + undo_cli + TXN_STATE="" + error "$STANDALONE_TEMP_FAILURE" + fi + + if [ "${AGENT_RELAY_INSTALL_STANDALONE_ONLY:-}" = "1" ]; then + undo_cli + TXN_STATE="" + error "Standalone update failed; the previous CLI and broker were preserved. ${STANDALONE_FAILURE_REASON}" + fi + # Standalone path not taken: nothing was replaced, disarm and drop the backup TXN_STATE="" [ -n "$LAUNCHER_BACKUP" ] && rm -f "$LAUNCHER_BACKUP" diff --git a/packages/cli/src/cli/commands/core.test.ts b/packages/cli/src/cli/commands/core.test.ts index a81a3b8bba..14e9d60a51 100644 --- a/packages/cli/src/cli/commands/core.test.ts +++ b/packages/cli/src/cli/commands/core.test.ts @@ -2812,6 +2812,40 @@ describe('registerCoreCommands', () => { }); }); + it('updates a standalone binary with the verified installer and pinned version', async () => { + const home = os.homedir(); + const execCommand = vi.fn(async () => ({ stdout: 'updated\n', stderr: '' })); + const { deps } = createHarness({ + execPath: nodePath.join(home, '.agentworkforce', 'relay', 'bin', 'agent-relay'), + checkForUpdatesResult: { updateAvailable: true, latestVersion: '2.0.0' }, + execCommand, + }); + const program = new Command(); + registerCoreMaintenance(program, deps); + + expect(await runCommand(program, ['update'])).toBeUndefined(); + expect(execCommand).toHaveBeenCalledWith( + "AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 AGENT_RELAY_VERSION='2.0.0' bash -o pipefail -c 'curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/relay/main/install.sh | bash'" + ); + expect(deps.log).toHaveBeenCalledWith('Successfully updated to 2.0.0'); + }); + + it('returns a failure when a standalone update fails', async () => { + const home = os.homedir(); + const { deps } = createHarness({ + execPath: nodePath.join(home, '.agentworkforce', 'relay', 'bin', 'agent-relay'), + checkForUpdatesResult: { updateAvailable: true, latestVersion: '2.0.0' }, + execCommand: vi.fn(async () => { + throw new Error('installer failed'); + }), + }); + const program = new Command(); + registerCoreMaintenance(program, deps); + + expect(await runCommand(program, ['update'])).toBe(1); + expect(deps.error).toHaveBeenCalledWith('Failed to install update: installer failed'); + }); + it('update tracks failed install attempts without leaking messages', async () => { const { deps } = createHarness({ checkForUpdatesResult: { updateAvailable: true, latestVersion: '2.0.0' }, diff --git a/packages/cli/src/cli/lib/core-maintenance.ts b/packages/cli/src/cli/lib/core-maintenance.ts index 85e1502da2..981aed5c78 100644 --- a/packages/cli/src/cli/lib/core-maintenance.ts +++ b/packages/cli/src/cli/lib/core-maintenance.ts @@ -21,6 +21,23 @@ function toErrorMessage(err: unknown): string { return describeError(err); } +function isStandaloneInstall(deps: CoreDependencies): boolean { + const installDir = deps.env.AGENT_RELAY_INSTALL_DIR || path.join(os.homedir(), '.agentworkforce', 'relay'); + const installedBinary = path.resolve(installDir, 'bin', 'agent-relay'); + const runningBinary = path.resolve(deps.execPath); + const realpath = deps.fs.realpathSync; + if (!realpath) return runningBinary === installedBinary; + try { + return realpath(runningBinary) === realpath(installedBinary); + } catch { + return runningBinary === installedBinary; + } +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + /** * Remove agent-relay snippet blocks from a markdown file's content. * Returns the cleaned content, or null if no snippets were found. @@ -181,8 +198,12 @@ export async function runUpdateCommand(options: { check?: boolean }, deps: CoreD deps.log('Installing update...'); const toVersion = info.latestVersion ?? 'latest'; + const standalone = isStandaloneInstall(deps); + const installCommand = standalone + ? `AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 AGENT_RELAY_VERSION=${shellQuote(toVersion)} bash -o pipefail -c 'curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/relay/main/install.sh | bash'` + : 'npm install -g agent-relay@latest'; try { - const { stdout, stderr } = await deps.execCommand('npm install -g agent-relay@latest'); + const { stdout, stderr } = await deps.execCommand(installCommand); if (stdout.trim().length > 0) { deps.log(stdout.trimEnd()); } @@ -204,7 +225,7 @@ export async function runUpdateCommand(options: { check?: boolean }, deps: CoreD ...(errorClass ? { error_class: errorClass } : {}), }); deps.error(`Failed to install update: ${toErrorMessage(err)}`); - deps.log('Try running manually: npm install -g agent-relay@latest'); + deps.log(`Try running manually: ${installCommand}`); deps.exit(1); } } diff --git a/scripts/test-install-atomic.sh b/scripts/test-install-atomic.sh index 7f47440a7d..e917fb3b48 100755 --- a/scripts/test-install-atomic.sh +++ b/scripts/test-install-atomic.sh @@ -363,6 +363,22 @@ exec 8<&- kill "$RUNNING" 2>/dev/null; { wait "$RUNNING"; } 2>/dev/null # --------------------------------------------------------------------------- +echo "== strict standalone update requires a digest and a writable temp directory ==" +reset_release +D="$(newdir)" +make_fake_broker "$FIX/asset/agent-relay-broker-test-test" ok new +make_release_json "$FIX/release.json" "agent-relay-broker-test-test" "" +AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 fetch_release_asset agent-relay-broker-test-test "$D" raw; rc=$? +check "strict update rejects a missing published digest" test "$rc" -eq 2 +check "strict update explains the missing digest" contains "$(cat "$LOG")" "published SHA-256 digest is required" +check "strict update leaves no new binary" test -z "$(leftovers "$D")" + +STANDALONE_TEMP_FAILURE="" +TMPDIR="$WORK/missing-directory" check_cli_binary "$WORK/hello"; rc=$? +check "unwritable temp directory rejects CLI verification" test "$rc" -ne 0 +check "temp failure names the directory" contains "$STANDALONE_TEMP_FAILURE" "$WORK/missing-directory" +STANDALONE_TEMP_FAILURE="" + echo "== (d) missing digest warns instead of silently passing ==" reset_release @@ -452,6 +468,25 @@ check "all good: installer exits 0" test "$rc" -eq 0 check "all good: CLI upgraded" test "$("$INSTALL_DIR/bin/agent-relay" --version)" = "9.9.9" check "all good: no .prev left" test ! -e "$INSTALL_DIR/bin/agent-relay.prev" +e2e_setup ok yes +AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 run_main; rc=$? +check "strict update: verified CLI and broker install" test "$rc" -eq 0 +check "strict update: CLI upgraded" test "$("$INSTALL_DIR/bin/agent-relay" --version)" = "9.9.9" + +e2e_setup ok no +AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 run_main; rc=$? +check "strict update: missing digest fails" test "$rc" -ne 0 +check "strict update: previous CLI preserved" test "$("$INSTALL_DIR/bin/agent-relay" --version)" = "1.0.0" +check "strict update: previous broker preserved" test "$(sha256_of "$INSTALL_DIR/bin/agent-relay-broker")" = "$OLD_BROKER_HASH" +check "strict update: no npm fallback" bash -c '! grep -q "Installing via npm" "$1"' _ "$WORK/main.out" + +e2e_setup ok yes +TMPDIR="$WORK/missing-directory" run_main; rc=$? +check "bad temp directory: installer exits non-zero" test "$rc" -ne 0 +check "bad temp directory: previous CLI preserved" test "$("$INSTALL_DIR/bin/agent-relay" --version)" = "1.0.0" +check "bad temp directory: reports temp failure" contains "$(cat "$WORK/main.out")" "Cannot create a temporary file" +check "bad temp directory: no npm fallback" bash -c '! grep -q "Installing via npm" "$1"' _ "$WORK/main.out" + e2e_setup crash no run_main; rc=$? check "broker rejected: installer exits NON-zero" test "$rc" -ne 0 diff --git a/tests/relayflows/cases/1963-standalone-update/case.json b/tests/relayflows/cases/1963-standalone-update/case.json new file mode 100644 index 0000000000..1e637eddde --- /dev/null +++ b/tests/relayflows/cases/1963-standalone-update/case.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "id": "1963-standalone-update", + "kind": "bugfix", + "title": "Update a standalone install through the verified binary installer", + "runner": { + "command": ["node", "tests/relayflows/cases/1963-standalone-update/run.mjs"] + }, + "requirements": [], + "timeoutSeconds": 900, + "expected": { + "base": { + "outcome": "bug", + "signature": "standalone_update_runs_npm" + }, + "head": { + "outcome": "fixed", + "signature": "standalone_update_uses_verified_installer" + } + } +} diff --git a/tests/relayflows/cases/1963-standalone-update/run.mjs b/tests/relayflows/cases/1963-standalone-update/run.mjs new file mode 100644 index 0000000000..fbc3e9d905 --- /dev/null +++ b/tests/relayflows/cases/1963-standalone-update/run.mjs @@ -0,0 +1,91 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { existsSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const caseId = '1963-standalone-update'; +const arm = process.env.RELAY_PR_PROOF_ARM; +const targetDir = process.env.RELAY_PR_PROOF_TARGET_DIR; +const harnessDir = process.env.RELAY_PR_PROOF_HARNESS_DIR; +const resultPath = process.env.RELAY_PR_PROOF_RESULT_PATH; +if (!['base', 'head'].includes(arm) || !targetDir || !harnessDir || !resultPath) { + throw new Error('Missing RelayFlow proof environment.'); +} + +const expectedSha = process.env[arm === 'base' ? 'RELAY_PR_PROOF_BASE_SHA' : 'RELAY_PR_PROOF_HEAD_SHA']; +const actualSha = execFileSync('git', ['-C', targetDir, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); +if (!expectedSha || actualSha !== expectedSha) throw new Error('Target SHA mismatch.'); +const runnerPath = fileURLToPath(import.meta.url); +if (path.relative(harnessDir, runnerPath).startsWith('..')) + throw new Error('Runner is outside the head checkout.'); + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: targetDir, + encoding: 'utf8', + timeout: 8 * 60_000, + env: { ...process.env, AGENT_RELAY_TELEMETRY_DISABLED: '1' }, + }); + if (result.error || result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed: ${result.error?.message ?? result.stderr}`); + } +} + +if (!existsSync(path.join(targetDir, 'node_modules', 'vitest'))) run('npm', ['ci', '--ignore-scripts']); +const modulePath = path.join(targetDir, 'packages', 'cli', 'dist', 'cli', 'lib', 'core-maintenance.js'); +if (!existsSync(modulePath)) run('npm', ['run', 'build:core']); +const { runUpdateCommand } = await import(pathToFileURL(modulePath).href); + +const installDir = path.join(targetDir, '.tmp', 'proof-1963-standalone'); +let command = ''; +const makeDeps = (fail) => ({ + getVersion: () => '1.0.0', + checkForUpdates: async () => ({ updateAvailable: true, latestVersion: '2.0.0' }), + log: () => {}, + error: () => {}, + env: { AGENT_RELAY_INSTALL_DIR: installDir }, + execPath: path.join(installDir, 'bin', 'agent-relay'), + fs: { realpathSync: (value) => value }, + execCommand: async (value) => { + command = value; + if (fail) throw new Error('simulated installer failure'); + return { stdout: '', stderr: '' }; + }, + exit: (code) => { + throw Object.assign(new Error('CLI exit'), { code }); + }, +}); + +await runUpdateCommand({}, makeDeps(false)); +const selectedCommand = command; +command = ''; +let failureExit; +try { + await runUpdateCommand({}, makeDeps(true)); +} catch (error) { + failureExit = error.code; +} +if (command !== selectedCommand || failureExit !== 1) { + throw new Error('Update did not preserve the chosen installer or exit 1 on failure.'); +} + +const standalone = + selectedCommand.includes('AGENT_RELAY_INSTALL_STANDALONE_ONLY=1') && + selectedCommand.includes("AGENT_RELAY_VERSION='2.0.0'") && + selectedCommand.includes('install.sh') && + !selectedCommand.includes('npm install'); +const npm = selectedCommand === 'npm install -g agent-relay@latest'; +if (!standalone && !npm) throw new Error(`Unrecognized update command: ${selectedCommand}`); +const outcome = standalone ? 'fixed' : 'bug'; +const signature = standalone ? 'standalone_update_uses_verified_installer' : 'standalone_update_runs_npm'; +writeFileSync( + resultPath, + JSON.stringify({ + version: 1, + caseId, + arm, + outcome, + signature, + details: `Running from a standalone binary selected ${standalone ? 'the pinned verified installer' : 'npm'}; an installer failure exited 1.`, + }) + '\n' +); From 92fe195621ceffb0c26d6c59862231e060922132 Mon Sep 17 00:00:00 2001 From: Akarshkushwaha Date: Sat, 17 Oct 2026 23:39:46 +0530 Subject: [PATCH 2/6] fix(cli): validate standalone update before committing binaries --- CHANGELOG.md | 2 +- install.sh | 25 +++++++++---- packages/cli/src/cli/commands/core.test.ts | 36 ++++++++++++++++++- packages/cli/src/cli/lib/core-maintenance.ts | 2 +- scripts/test-install-atomic.sh | 12 +++++++ .../cases/1963-standalone-update/run.mjs | 5 +-- 6 files changed, 70 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7472a087f5..a5d19a2ebe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- `agent-relay update` upgrades standalone CLI and broker installs through the verified installer, keeps npm updates on npm, and exits non-zero when installation fails. +- `agent-relay update` upgrades standalone CLI and broker installs through the verified installer instead of installing a separate npm copy. Installer temp-directory failures now report the cause without falling back to npm. ## [14.0.0] - 2026-10-13 diff --git a/install.sh b/install.sh index 500fb33c8c..328d1dd218 100755 --- a/install.sh +++ b/install.sh @@ -682,7 +682,11 @@ install_binary_atomic() { fi if ! "$check_fn" "$tmp"; then - warn "Verification of the new binary failed; $dest left untouched" + if [ -n "$STANDALONE_TEMP_FAILURE" ]; then + warn "$STANDALONE_TEMP_FAILURE" + else + warn "Verification of the new binary failed; $dest left untouched" + fi rm -f "$tmp" return 1 fi @@ -827,6 +831,7 @@ download_broker_binary() { # rejected after the CLI was replaced, undo the CLI step (transactional # install) and fail loudly instead of leaving a CLI/broker version skew. abort_standalone_install() { + local reason="${1:-The v${VERSION} broker was rejected (integrity or smoke test failed)}" local broker="$INSTALL_DIR/bin/agent-relay-broker" local old_broker="none installed" @@ -838,9 +843,9 @@ abort_standalone_install() { undo_cli TXN_STATE="" if [ -n "$STANDALONE_TEMP_FAILURE" ]; then - error "$STANDALONE_TEMP_FAILURE. ${ROLLBACK_OUTCOME}." + error "$STANDALONE_TEMP_FAILURE ${ROLLBACK_OUTCOME}." fi - error "The v${VERSION} broker was rejected (integrity or smoke test failed), so the v${VERSION} CLI was NOT kept: ${ROLLBACK_OUTCOME}. The existing broker (${old_broker}) is untouched. Nothing was upgraded; re-run the installer, or set AGENT_RELAY_VERSION to a known-good version." + error "${reason}, so the v${VERSION} CLI was NOT kept: ${ROLLBACK_OUTCOME}. The existing broker (${old_broker}) is untouched. Nothing was upgraded; re-run the installer, or set AGENT_RELAY_VERSION to a known-good version." } # Idempotent undo of one destination: put this run's backup back; a destination @@ -1048,9 +1053,9 @@ download_standalone_binary() { success "Downloaded standalone agent-relay binary" return 0 fi + [ -z "$STANDALONE_TEMP_FAILURE" ] || return 1 verify_output="$CLI_CHECK_OUTPUT" record_standalone_failure "Standalone binary verification failed for $target_path${verify_output:+: $verify_output}. Trying uncompressed binary..." - [ -z "$STANDALONE_TEMP_FAILURE" ] || return 1 else [ -z "$STANDALONE_TEMP_FAILURE" ] || return 1 info "Compressed binary not available, trying uncompressed..." @@ -1074,9 +1079,9 @@ download_standalone_binary() { success "Downloaded standalone agent-relay binary (no Node.js required!)" return 0 fi + [ -z "$STANDALONE_TEMP_FAILURE" ] || return 1 verify_output="$CLI_CHECK_OUTPUT" record_standalone_failure "Standalone binary verification failed for $target_path${verify_output:+: $verify_output}" - [ -z "$STANDALONE_TEMP_FAILURE" ] || return 1 else info "Uncompressed binary not available (file too small: ${file_size} bytes)" rm -f "$FETCHED_TMP" @@ -1390,12 +1395,16 @@ main() { if [ "$standalone_ok" -eq 1 ]; then INSTALL_METHOD="binary" # Download broker binary for workflow/SDK agent spawning. A rejected - # broker (rc 2) rolls the CLI back; an unavailable one (rc 1) is fine. + # broker (rc 2) rolls the CLI back. Strict updates also require an + # available broker and a working launcher before dropping backups. local broker_rc=0 download_broker_binary || broker_rc=$? if [ "$broker_rc" -eq 2 ] || { [ "${AGENT_RELAY_INSTALL_STANDALONE_ONLY:-}" = "1" ] && [ "$broker_rc" -ne 0 ]; }; then abort_standalone_install fi + if [ "${AGENT_RELAY_INSTALL_STANDALONE_ONLY:-}" = "1" ] && ! check_cli_binary "$BIN_DIR/agent-relay"; then + abort_standalone_install "The v${VERSION} launcher failed verification" + fi # COMMIT: one assignment; everything after this is cleanup of backups. TXN_STATE="" rm -f "$INSTALL_DIR/bin/agent-relay${TXN_SUFFIX}" @@ -1403,7 +1412,9 @@ main() { [ -n "$LAUNCHER_BACKUP" ] && rm -f "$LAUNCHER_BACKUP" LAUNCHER_BACKUP="" # Install ACP bridge for Zed editor (requires Node.js) - install_acp_bridge || true + if [ "${AGENT_RELAY_INSTALL_STANDALONE_ONLY:-}" != "1" ]; then + install_acp_bridge || true + fi verify_installation && print_usage && track_event "install_completed" && exit 0 fi diff --git a/packages/cli/src/cli/commands/core.test.ts b/packages/cli/src/cli/commands/core.test.ts index 14e9d60a51..83e55dc7c2 100644 --- a/packages/cli/src/cli/commands/core.test.ts +++ b/packages/cli/src/cli/commands/core.test.ts @@ -2825,11 +2825,45 @@ describe('registerCoreCommands', () => { expect(await runCommand(program, ['update'])).toBeUndefined(); expect(execCommand).toHaveBeenCalledWith( - "AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 AGENT_RELAY_VERSION='2.0.0' bash -o pipefail -c 'curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/relay/main/install.sh | bash'" + `AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 AGENT_RELAY_VERSION='2.0.0' bash -c 'installer=$(curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/relay/main/install.sh) && bash -c "$installer"'` ); expect(deps.log).toHaveBeenCalledWith('Successfully updated to 2.0.0'); }); + it('updates a standalone binary in a custom install directory', async () => { + const installDir = nodePath.resolve('/custom directory/relay'); + const { deps } = createHarness({ + execPath: nodePath.join(installDir, 'bin', 'agent-relay'), + env: { AGENT_RELAY_INSTALL_DIR: installDir }, + checkForUpdatesResult: { updateAvailable: true, latestVersion: '2.0.0' }, + }); + const program = new Command(); + registerCoreMaintenance(program, deps); + + expect(await runCommand(program, ['update'])).toBeUndefined(); + expect(deps.execCommand).toHaveBeenCalledWith( + expect.stringContaining('AGENT_RELAY_INSTALL_STANDALONE_ONLY=1') + ); + }); + + it('recognizes a symlink to a standalone binary', async () => { + const installedBinary = nodePath.join(os.homedir(), '.agentworkforce', 'relay', 'bin', 'agent-relay'); + const { deps } = createHarness({ + execPath: nodePath.resolve('/linked/agent-relay'), + checkForUpdatesResult: { updateAvailable: true, latestVersion: '2.0.0' }, + configureDependencies: (deps) => { + deps.fs.realpathSync = () => installedBinary; + }, + }); + const program = new Command(); + registerCoreMaintenance(program, deps); + + expect(await runCommand(program, ['update'])).toBeUndefined(); + expect(deps.execCommand).toHaveBeenCalledWith( + expect.stringContaining('AGENT_RELAY_INSTALL_STANDALONE_ONLY=1') + ); + }); + it('returns a failure when a standalone update fails', async () => { const home = os.homedir(); const { deps } = createHarness({ diff --git a/packages/cli/src/cli/lib/core-maintenance.ts b/packages/cli/src/cli/lib/core-maintenance.ts index 981aed5c78..efc7cf9418 100644 --- a/packages/cli/src/cli/lib/core-maintenance.ts +++ b/packages/cli/src/cli/lib/core-maintenance.ts @@ -200,7 +200,7 @@ export async function runUpdateCommand(options: { check?: boolean }, deps: CoreD const toVersion = info.latestVersion ?? 'latest'; const standalone = isStandaloneInstall(deps); const installCommand = standalone - ? `AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 AGENT_RELAY_VERSION=${shellQuote(toVersion)} bash -o pipefail -c 'curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/relay/main/install.sh | bash'` + ? `AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 AGENT_RELAY_VERSION=${shellQuote(toVersion)} bash -c 'installer=$(curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/relay/main/install.sh) && bash -c "$installer"'` : 'npm install -g agent-relay@latest'; try { const { stdout, stderr } = await deps.execCommand(installCommand); diff --git a/scripts/test-install-atomic.sh b/scripts/test-install-atomic.sh index e917fb3b48..f6c2def35f 100755 --- a/scripts/test-install-atomic.sh +++ b/scripts/test-install-atomic.sh @@ -472,6 +472,17 @@ e2e_setup ok yes AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 run_main; rc=$? check "strict update: verified CLI and broker install" test "$rc" -eq 0 check "strict update: CLI upgraded" test "$("$INSTALL_DIR/bin/agent-relay" --version)" = "9.9.9" +check "strict update: optional ACP install is skipped" bash -c '! grep -q "relay-acp\|ACP bridge" "$1"' _ "$WORK/main.out" + +e2e_setup ok yes +saved_launcher="$(declare -f install_binary_launcher)" +install_binary_launcher() { printf '#!/bin/bash\nexit 1\n' > "$BIN_DIR/agent-relay"; chmod +x "$BIN_DIR/agent-relay"; } +AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 run_main; rc=$? +eval "$saved_launcher" +check "strict update: broken launcher fails" test "$rc" -ne 0 +check "strict update: broken launcher restores CLI" test "$("$INSTALL_DIR/bin/agent-relay" --version)" = "1.0.0" +check "strict update: broken launcher restores broker" test "$(sha256_of "$INSTALL_DIR/bin/agent-relay-broker")" = "$OLD_BROKER_HASH" +check "strict update: broken launcher is removed" test ! -e "$BIN_DIR/agent-relay" e2e_setup ok no AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 run_main; rc=$? @@ -485,6 +496,7 @@ TMPDIR="$WORK/missing-directory" run_main; rc=$? check "bad temp directory: installer exits non-zero" test "$rc" -ne 0 check "bad temp directory: previous CLI preserved" test "$("$INSTALL_DIR/bin/agent-relay" --version)" = "1.0.0" check "bad temp directory: reports temp failure" contains "$(cat "$WORK/main.out")" "Cannot create a temporary file" +check "bad temp directory: no misleading verification failure" bash -c '! grep -qi "verification.*failed" "$1"' _ "$WORK/main.out" check "bad temp directory: no npm fallback" bash -c '! grep -q "Installing via npm" "$1"' _ "$WORK/main.out" e2e_setup crash no diff --git a/tests/relayflows/cases/1963-standalone-update/run.mjs b/tests/relayflows/cases/1963-standalone-update/run.mjs index fbc3e9d905..c817977ef7 100644 --- a/tests/relayflows/cases/1963-standalone-update/run.mjs +++ b/tests/relayflows/cases/1963-standalone-update/run.mjs @@ -1,5 +1,5 @@ import { execFileSync, spawnSync } from 'node:child_process'; -import { existsSync, writeFileSync } from 'node:fs'; +import { existsSync, rmSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -11,6 +11,7 @@ const resultPath = process.env.RELAY_PR_PROOF_RESULT_PATH; if (!['base', 'head'].includes(arm) || !targetDir || !harnessDir || !resultPath) { throw new Error('Missing RelayFlow proof environment.'); } +rmSync(resultPath, { force: true }); const expectedSha = process.env[arm === 'base' ? 'RELAY_PR_PROOF_BASE_SHA' : 'RELAY_PR_PROOF_HEAD_SHA']; const actualSha = execFileSync('git', ['-C', targetDir, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); @@ -33,7 +34,7 @@ function run(command, args) { if (!existsSync(path.join(targetDir, 'node_modules', 'vitest'))) run('npm', ['ci', '--ignore-scripts']); const modulePath = path.join(targetDir, 'packages', 'cli', 'dist', 'cli', 'lib', 'core-maintenance.js'); -if (!existsSync(modulePath)) run('npm', ['run', 'build:core']); +run('npm', ['run', 'build:core']); const { runUpdateCommand } = await import(pathToFileURL(modulePath).href); const installDir = path.join(targetDir, '.tmp', 'proof-1963-standalone'); From 848674ff34c9923053729cca4e8d66aa15022789 Mon Sep 17 00:00:00 2001 From: Akarshkushwaha Date: Sun, 18 Oct 2026 00:10:39 +0530 Subject: [PATCH 3/6] fix(cli): address standalone update review feedback --- install.sh | 27 +++++++++++----- packages/cli/src/cli/commands/core.test.ts | 29 +++++++++++++---- packages/cli/src/cli/lib/core-maintenance.ts | 25 ++++++++------- scripts/test-install-atomic.sh | 32 ++++++++++++++++--- .../cases/1963-standalone-update/run.mjs | 5 ++- 5 files changed, 87 insertions(+), 31 deletions(-) diff --git a/install.sh b/install.sh index 328d1dd218..3fcbc9873b 100755 --- a/install.sh +++ b/install.sh @@ -111,11 +111,12 @@ write_executable_file() { local target="$1" local temp="${target}.tmp.$$" - mkdir -p "$(dirname "$target")" - safe_remove_path "$temp" - cat > "$temp" - chmod +x "$temp" - mv -f "$temp" "$target" + mkdir -p "$(dirname "$target")" || return 1 + safe_remove_path "$temp" || return 1 + if ! cat > "$temp" || ! chmod +x "$temp" || ! mv -f "$temp" "$target"; then + rm -f "$temp" + return 1 + fi } install_binary_launcher() { @@ -640,6 +641,7 @@ check_broker_binary() { smoke_test_broker "$1" } +# Smoke-test the CLI; strict updates also require the selected release version. check_cli_binary() { local log log=$(mktemp "${TMPDIR:-/tmp}/agent-relay-verify.XXXXXX") || { @@ -647,6 +649,11 @@ check_cli_binary() { return 1 } if "$1" --version >"$log" 2>&1; then + if [ "${AGENT_RELAY_INSTALL_STANDALONE_ONLY:-}" = "1" ] && [ "$(tr -d '\r' < "$log")" != "$VERSION" ]; then + CLI_CHECK_OUTPUT="CLI version does not match the requested version $VERSION" + rm -f "$log" + return 1 + fi rm -f "$log" return 0 fi @@ -1048,7 +1055,7 @@ download_standalone_binary() { if fetch_release_asset "${binary_name}.gz" "$INSTALL_DIR/bin" gz; then CLI_CHECK_OUTPUT="" if install_binary_atomic "$FETCHED_TMP" "$target_path" check_cli_binary; then - install_binary_launcher "$target_path" + install_binary_launcher "$target_path" || abort_standalone_install "Could not install the CLI launcher" prepend_bin_dir_to_path success "Downloaded standalone agent-relay binary" return 0 @@ -1074,7 +1081,7 @@ download_standalone_binary() { if [ "$file_size" -gt 1000000 ]; then CLI_CHECK_OUTPUT="" if install_binary_atomic "$FETCHED_TMP" "$target_path" check_cli_binary; then - install_binary_launcher "$target_path" + install_binary_launcher "$target_path" || abort_standalone_install "Could not install the CLI launcher" prepend_bin_dir_to_path success "Downloaded standalone agent-relay binary (no Node.js required!)" return 0 @@ -1088,7 +1095,11 @@ download_standalone_binary() { fi fi - info "No standalone binary available for $PLATFORM, falling back to npm" + if [ "${AGENT_RELAY_INSTALL_STANDALONE_ONLY:-}" = "1" ]; then + info "No verified standalone binary available for $PLATFORM" + else + info "No standalone binary available for $PLATFORM, falling back to npm" + fi return 1 } diff --git a/packages/cli/src/cli/commands/core.test.ts b/packages/cli/src/cli/commands/core.test.ts index 83e55dc7c2..05fbfde342 100644 --- a/packages/cli/src/cli/commands/core.test.ts +++ b/packages/cli/src/cli/commands/core.test.ts @@ -2825,24 +2825,41 @@ describe('registerCoreCommands', () => { expect(await runCommand(program, ['update'])).toBeUndefined(); expect(execCommand).toHaveBeenCalledWith( - `AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 AGENT_RELAY_VERSION='2.0.0' bash -c 'installer=$(curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/relay/main/install.sh) && bash -c "$installer"'` + `AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 AGENT_RELAY_INSTALL_DIR='${nodePath.join(home, '.agentworkforce', 'relay')}' AGENT_RELAY_VERSION='2.0.0' bash -c 'installer=$(curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/relay/main/install.sh) && bash -c "$installer"'` ); expect(deps.log).toHaveBeenCalledWith('Successfully updated to 2.0.0'); }); - it('updates a standalone binary in a custom install directory', async () => { - const installDir = nodePath.resolve('/custom directory/relay'); + it.each([{}, { AGENT_RELAY_INSTALL_DIR: '/another-install' }])( + 'updates a standalone binary in its actual custom directory with env %j', + async (env) => { + const installDir = nodePath.resolve("/custom directory/relay's install"); + const { deps } = createHarness({ + execPath: nodePath.join(installDir, 'bin', 'agent-relay'), + env, + checkForUpdatesResult: { updateAvailable: true, latestVersion: '2.0.0' }, + }); + const program = new Command(); + registerCoreMaintenance(program, deps); + + expect(await runCommand(program, ['update'])).toBeUndefined(); + expect(deps.execCommand).toHaveBeenCalledWith( + expect.stringContaining(`AGENT_RELAY_INSTALL_DIR='${installDir.replace(/'/g, `'"'"'`)}'`) + ); + } + ); + + it('updates a standalone binary in the legacy install directory', async () => { + const installDir = nodePath.join(os.homedir(), '.agent-relay'); const { deps } = createHarness({ execPath: nodePath.join(installDir, 'bin', 'agent-relay'), - env: { AGENT_RELAY_INSTALL_DIR: installDir }, checkForUpdatesResult: { updateAvailable: true, latestVersion: '2.0.0' }, }); const program = new Command(); registerCoreMaintenance(program, deps); - expect(await runCommand(program, ['update'])).toBeUndefined(); expect(deps.execCommand).toHaveBeenCalledWith( - expect.stringContaining('AGENT_RELAY_INSTALL_STANDALONE_ONLY=1') + expect.stringContaining(`AGENT_RELAY_INSTALL_DIR='${installDir}'`) ); }); diff --git a/packages/cli/src/cli/lib/core-maintenance.ts b/packages/cli/src/cli/lib/core-maintenance.ts index efc7cf9418..aab16c73b4 100644 --- a/packages/cli/src/cli/lib/core-maintenance.ts +++ b/packages/cli/src/cli/lib/core-maintenance.ts @@ -21,19 +21,21 @@ function toErrorMessage(err: unknown): string { return describeError(err); } -function isStandaloneInstall(deps: CoreDependencies): boolean { - const installDir = deps.env.AGENT_RELAY_INSTALL_DIR || path.join(os.homedir(), '.agentworkforce', 'relay'); - const installedBinary = path.resolve(installDir, 'bin', 'agent-relay'); - const runningBinary = path.resolve(deps.execPath); - const realpath = deps.fs.realpathSync; - if (!realpath) return runningBinary === installedBinary; +/** Locate the running standalone executable, including custom and legacy installs. */ +function standaloneInstallDir(deps: CoreDependencies): string | undefined { + let runningBinary = path.resolve(deps.execPath); try { - return realpath(runningBinary) === realpath(installedBinary); + runningBinary = deps.fs.realpathSync?.(runningBinary) ?? runningBinary; } catch { - return runningBinary === installedBinary; + // Keep the executable path when realpath is unavailable. } + // npm runs under node (or bun); execPath is the runtime, not the CLI script. + if (path.basename(runningBinary) !== 'agent-relay' || path.basename(path.dirname(runningBinary)) !== 'bin') + return undefined; + return path.dirname(path.dirname(runningBinary)); } +/** Quote one environment value for the POSIX installer shell. */ function shellQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'`; } @@ -172,6 +174,7 @@ function removeZedConfig( } } +/** Update the running installation and report installer failures with a nonzero exit. */ export async function runUpdateCommand(options: { check?: boolean }, deps: CoreDependencies): Promise { const currentVersion = deps.getVersion(); deps.log(`Current version: ${currentVersion}`); @@ -198,9 +201,9 @@ export async function runUpdateCommand(options: { check?: boolean }, deps: CoreD deps.log('Installing update...'); const toVersion = info.latestVersion ?? 'latest'; - const standalone = isStandaloneInstall(deps); - const installCommand = standalone - ? `AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 AGENT_RELAY_VERSION=${shellQuote(toVersion)} bash -c 'installer=$(curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/relay/main/install.sh) && bash -c "$installer"'` + const installDir = standaloneInstallDir(deps); + const installCommand = installDir + ? `AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 AGENT_RELAY_INSTALL_DIR=${shellQuote(installDir)} AGENT_RELAY_VERSION=${shellQuote(toVersion)} bash -c 'installer=$(curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/relay/main/install.sh) && bash -c "$installer"'` : 'npm install -g agent-relay@latest'; try { const { stdout, stderr } = await deps.execCommand(installCommand); diff --git a/scripts/test-install-atomic.sh b/scripts/test-install-atomic.sh index f6c2def35f..64eef18c9c 100755 --- a/scripts/test-install-atomic.sh +++ b/scripts/test-install-atomic.sh @@ -440,11 +440,12 @@ REAL_PLATFORM="$PLATFORM" make_fake_cli() { # make_fake_cli printf '#!/bin/bash\n[ "$1" = "--version" ] && { echo "%s"; exit 0; }\nexit 0\n' "$2" > "$1"; chmod +x "$1" } -e2e_setup() { # e2e_setup [old-cli: yes|no] +# Prepare an isolated release and installation, optionally overriding the CLI version. +e2e_setup() { # e2e_setup [old-cli: yes|no] [new-version] reset_release INSTALL_DIR="$(newdir)"; BIN_DIR="$(newdir)"; mkdir -p "$INSTALL_DIR/bin" make_fake_broker "$INSTALL_DIR/bin/agent-relay-broker" ok oldbroker - make_fake_cli "$FIX/cli-new" "9.9.9" + make_fake_cli "$FIX/cli-new" "${4:-9.9.9}" gzip -c "$FIX/cli-new" > "$FIX/asset/agent-relay-$REAL_PLATFORM.gz" make_fake_broker "$FIX/asset/agent-relay-broker-$REAL_PLATFORM" "$1" newbroker if [ "${3:-yes}" = yes ]; then make_fake_cli "$INSTALL_DIR/bin/agent-relay" "1.0.0"; fi @@ -460,6 +461,7 @@ e2e_setup() { # e2e_setup [old-cli: yes|no] } > "$FIX/release.json" OLD_BROKER_HASH="$(sha256_of "$INSTALL_DIR/bin/agent-relay-broker")" } +# Run the installer in a subshell so exit and transaction state cannot leak between cases. run_main() { ( export AGENT_RELAY_TELEMETRY_DISABLED=1; main ) > "$WORK/main.out" 2>&1; } e2e_setup ok yes @@ -468,13 +470,13 @@ check "all good: installer exits 0" test "$rc" -eq 0 check "all good: CLI upgraded" test "$("$INSTALL_DIR/bin/agent-relay" --version)" = "9.9.9" check "all good: no .prev left" test ! -e "$INSTALL_DIR/bin/agent-relay.prev" -e2e_setup ok yes +e2e_setup ok yes yes "$VERSION" AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 run_main; rc=$? check "strict update: verified CLI and broker install" test "$rc" -eq 0 -check "strict update: CLI upgraded" test "$("$INSTALL_DIR/bin/agent-relay" --version)" = "9.9.9" +check "strict update: CLI upgraded" test "$("$INSTALL_DIR/bin/agent-relay" --version)" = "$VERSION" check "strict update: optional ACP install is skipped" bash -c '! grep -q "relay-acp\|ACP bridge" "$1"' _ "$WORK/main.out" -e2e_setup ok yes +e2e_setup ok yes yes "$VERSION" saved_launcher="$(declare -f install_binary_launcher)" install_binary_launcher() { printf '#!/bin/bash\nexit 1\n' > "$BIN_DIR/agent-relay"; chmod +x "$BIN_DIR/agent-relay"; } AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 run_main; rc=$? @@ -484,12 +486,32 @@ check "strict update: broken launcher restores CLI" test "$("$INSTALL_DIR/bin/ag check "strict update: broken launcher restores broker" test "$(sha256_of "$INSTALL_DIR/bin/agent-relay-broker")" = "$OLD_BROKER_HASH" check "strict update: broken launcher is removed" test ! -e "$BIN_DIR/agent-relay" +e2e_setup ok yes +AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 run_main; rc=$? +check "strict update: wrong release version fails" test "$rc" -ne 0 +check "strict update: wrong version restores CLI" test "$("$INSTALL_DIR/bin/agent-relay" --version)" = "1.0.0" +check "strict update: wrong version preserves broker" test "$(sha256_of "$INSTALL_DIR/bin/agent-relay-broker")" = "$OLD_BROKER_HASH" + +for launcher_mode in failed-write stale-target; do + e2e_setup ok yes yes "$VERSION" + make_fake_cli "$BIN_DIR/agent-relay" "0.9.0" + cp -p "$BIN_DIR/agent-relay" "$WORK/launcher.orig" + install_binary_launcher() { [ "$launcher_mode" = stale-target ]; } + AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 run_main; rc=$? + eval "$saved_launcher" + check "strict update: $launcher_mode fails" test "$rc" -ne 0 + check "strict update: $launcher_mode restores CLI" test "$("$INSTALL_DIR/bin/agent-relay" --version)" = "1.0.0" + check "strict update: $launcher_mode restores broker" test "$(sha256_of "$INSTALL_DIR/bin/agent-relay-broker")" = "$OLD_BROKER_HASH" + check "strict update: $launcher_mode preserves launcher" cmp -s "$BIN_DIR/agent-relay" "$WORK/launcher.orig" +done + e2e_setup ok no AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 run_main; rc=$? check "strict update: missing digest fails" test "$rc" -ne 0 check "strict update: previous CLI preserved" test "$("$INSTALL_DIR/bin/agent-relay" --version)" = "1.0.0" check "strict update: previous broker preserved" test "$(sha256_of "$INSTALL_DIR/bin/agent-relay-broker")" = "$OLD_BROKER_HASH" check "strict update: no npm fallback" bash -c '! grep -q "Installing via npm" "$1"' _ "$WORK/main.out" +check "strict update: no misleading fallback message" bash -c '! grep -qi "falling back to npm" "$1"' _ "$WORK/main.out" e2e_setup ok yes TMPDIR="$WORK/missing-directory" run_main; rc=$? diff --git a/tests/relayflows/cases/1963-standalone-update/run.mjs b/tests/relayflows/cases/1963-standalone-update/run.mjs index c817977ef7..e1244a6a21 100644 --- a/tests/relayflows/cases/1963-standalone-update/run.mjs +++ b/tests/relayflows/cases/1963-standalone-update/run.mjs @@ -20,6 +20,7 @@ const runnerPath = fileURLToPath(import.meta.url); if (path.relative(harnessDir, runnerPath).startsWith('..')) throw new Error('Runner is outside the head checkout.'); +/** Build the exact target checkout, treating setup failures as proof errors. */ function run(command, args) { const result = spawnSync(command, args, { cwd: targetDir, @@ -39,12 +40,13 @@ const { runUpdateCommand } = await import(pathToFileURL(modulePath).href); const installDir = path.join(targetDir, '.tmp', 'proof-1963-standalone'); let command = ''; +/** Model a custom standalone install in a fresh shell with no install override. */ const makeDeps = (fail) => ({ getVersion: () => '1.0.0', checkForUpdates: async () => ({ updateAvailable: true, latestVersion: '2.0.0' }), log: () => {}, error: () => {}, - env: { AGENT_RELAY_INSTALL_DIR: installDir }, + env: {}, execPath: path.join(installDir, 'bin', 'agent-relay'), fs: { realpathSync: (value) => value }, execCommand: async (value) => { @@ -72,6 +74,7 @@ if (command !== selectedCommand || failureExit !== 1) { const standalone = selectedCommand.includes('AGENT_RELAY_INSTALL_STANDALONE_ONLY=1') && + selectedCommand.includes(`AGENT_RELAY_INSTALL_DIR='${installDir.replace(/'/g, `'"'"'`)}'`) && selectedCommand.includes("AGENT_RELAY_VERSION='2.0.0'") && selectedCommand.includes('install.sh') && !selectedCommand.includes('npm install'); From dc903b4243f4f21069c99a9b0f80e96f4ee76967 Mon Sep 17 00:00:00 2001 From: Akarshkushwaha Date: Sun, 18 Oct 2026 00:31:45 +0530 Subject: [PATCH 4/6] fix(installer): protect launcher paths and temporary files --- install.sh | 46 ++++++++++++++++++++++++++++++---- scripts/test-install-atomic.sh | 38 ++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/install.sh b/install.sh index 3fcbc9873b..a2a6707e54 100755 --- a/install.sh +++ b/install.sh @@ -107,18 +107,47 @@ safe_remove_path() { fi } +# Privileged installs must not write through directories another user can replace. +check_privileged_directory() { + [ "$EUID" -eq 0 ] || return 0 + local dir="$1" metadata owner mode physical destination + case "$dir" in /*) ;; *) dir="$PWD/$dir" ;; esac + destination="$dir" + while [ ! -d "$dir" ]; do dir=$(dirname "$dir"); done + physical=$(cd "$dir" && pwd -P) || return 1 + if [ "$physical" != "$dir" ]; then + check_privileged_directory "$physical" || return 1 + fi + while :; do + metadata=$(stat -L -c '%u %a' "$dir" 2>/dev/null) || metadata=$(stat -L -f '%u %Lp' "$dir") || return 1 + read -r owner mode <<< "$metadata" + # A root-owned sticky ancestor (e.g. /tmp) cannot replace root-owned children. + if [ "$owner" != 0 ] || { [ $((8#$mode & 0022)) -ne 0 ] && { [ "$dir" = "$destination" ] || [ $((8#$mode & 01000)) -eq 0 ]; }; }; then + warn "Unsafe directory for a privileged install: $dir" + return 1 + fi + [ "$dir" != / ] || break + dir=$(dirname "$dir") + done +} + +# Publish a launcher from a registered, unpredictable temp file in its destination directory. write_executable_file() { local target="$1" - local temp="${target}.tmp.$$" - - mkdir -p "$(dirname "$target")" || return 1 - safe_remove_path "$temp" || return 1 - if ! cat > "$temp" || ! chmod +x "$temp" || ! mv -f "$temp" "$target"; then + local temp dir + dir=$(dirname "$target") + + check_privileged_directory "$dir" || return 1 + mkdir -p "$dir" || return 1 + make_temp_file "$dir" "launcher-write" || return 1 + temp="$MADE_TEMP" + if ! cat > "$temp" || ! chmod 755 "$temp" || ! mv -f "$temp" "$target"; then rm -f "$temp" return 1 fi } +# Write the shell entry point that forwards arguments to the standalone executable. install_binary_launcher() { local target_binary="$1" @@ -278,6 +307,7 @@ EOT # run in the caller's shell so that register_temp updates the TEMP_FILES that # the EXIT/INT/TERM cleanup reads (a $(...) subshell would lose the entry). MADE_TEMP="" +# Allocate and register a destination-local temp file, reporting failures to the caller. make_temp_file() { local dir="$1" local label="$2" @@ -1360,6 +1390,12 @@ main() { detect_platform get_latest_version + check_privileged_directory "$BIN_DIR" && check_privileged_directory "$INSTALL_DIR/bin" || error "Install directories must be trusted by the installing user." + mkdir -p "$BIN_DIR" "$INSTALL_DIR/bin" || error "Could not create install directories." + if [ "$BIN_DIR" -ef "$INSTALL_DIR/bin" ]; then + error "The launcher directory and standalone binary directory must differ; set AGENT_RELAY_BIN_DIR to a separate directory. Nothing was upgraded." + fi + # Track install started track_event "install_started" diff --git a/scripts/test-install-atomic.sh b/scripts/test-install-atomic.sh index 64eef18c9c..acb060d1e8 100755 --- a/scripts/test-install-atomic.sh +++ b/scripts/test-install-atomic.sh @@ -437,6 +437,7 @@ echo "== transactional standalone install (CLI + broker) ==" detect_platform >/dev/null 2>&1 REAL_PLATFORM="$PLATFORM" +# Create a runnable fixture reporting a chosen CLI version. make_fake_cli() { # make_fake_cli printf '#!/bin/bash\n[ "$1" = "--version" ] && { echo "%s"; exit 0; }\nexit 0\n' "$2" > "$1"; chmod +x "$1" } @@ -464,6 +465,43 @@ e2e_setup() { # e2e_setup [old-cli: yes|no] [new # Run the installer in a subshell so exit and transaction state cannot leak between cases. run_main() { ( export AGENT_RELAY_TELEMETRY_DISABLED=1; main ) > "$WORK/main.out" 2>&1; } +for layout in same-directory symlink-directory; do + e2e_setup ok yes yes "$VERSION" + if [ "$layout" = same-directory ]; then + BIN_DIR="$INSTALL_DIR/bin" + else + BIN_DIR="$WORK/linked-bin" + ln -s "$INSTALL_DIR/bin" "$BIN_DIR" + fi + AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 run_main; rc=$? + check "overlapping $layout: exits non-zero" test "$rc" -ne 0 + check "overlapping $layout: CLI remains executable" test "$("$INSTALL_DIR/bin/agent-relay" --version)" = "1.0.0" + check "overlapping $layout: broker preserved" test "$(sha256_of "$INSTALL_DIR/bin/agent-relay-broker")" = "$OLD_BROKER_HASH" + check "overlapping $layout: explains directory conflict" contains "$(cat "$WORK/main.out")" "must differ" +done + +D="$(newdir)" +printf 'untouched\n' > "$D/victim" +ln -s "$D/victim" "$D/agent-relay.tmp.$$" +write_executable_file "$D/agent-relay" <<< '#!/bin/bash'; rc=$? +check "launcher: secure temporary write succeeds" test "$rc" -eq 0 +check "launcher: published script is readable and executable" test "$(stat -c %a "$D/agent-relay" 2>/dev/null || stat -f %Lp "$D/agent-relay")" = 755 +check "launcher: predictable symlink target untouched" test "$(cat "$D/victim")" = untouched + +if [ "$EUID" -eq 0 ]; then + D="$(newdir)" + chmod 777 "$D" + check_privileged_directory "$D"; rc=$? + check "privileged launcher: rejects shared directory" test "$rc" -ne 0 + chmod 700 "$D" + check_privileged_directory "$D"; rc=$? + check "privileged launcher: accepts private root-owned directory" test "$rc" -eq 0 + chown 65534 "$D" + check_privileged_directory "$D"; rc=$? + check "privileged launcher: rejects another owner's directory" test "$rc" -ne 0 + chown 0 "$D" +fi + e2e_setup ok yes run_main; rc=$? check "all good: installer exits 0" test "$rc" -eq 0 From 201ac373ca020b138b1509fc94e8a3263eb8760f Mon Sep 17 00:00:00 2001 From: Akarshkushwaha Date: Sun, 18 Oct 2026 18:48:17 +0530 Subject: [PATCH 5/6] fix(installer): reject untrusted symlinks during privileged writes --- install.sh | 14 +++++++++++++- scripts/test-install-atomic.sh | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index a2a6707e54..3c30c3cb98 100755 --- a/install.sh +++ b/install.sh @@ -110,8 +110,9 @@ safe_remove_path() { # Privileged installs must not write through directories another user can replace. check_privileged_directory() { [ "$EUID" -eq 0 ] || return 0 - local dir="$1" metadata owner mode physical destination + local dir="$1" metadata owner mode physical destination link_target case "$dir" in /*) ;; *) dir="$PWD/$dir" ;; esac + while [ "$dir" != / ] && [ "${dir%/}" != "$dir" ]; do dir="${dir%/}"; done destination="$dir" while [ ! -d "$dir" ]; do dir=$(dirname "$dir"); done physical=$(cd "$dir" && pwd -P) || return 1 @@ -119,6 +120,17 @@ check_privileged_directory() { check_privileged_directory "$physical" || return 1 fi while :; do + if [ -L "$dir" ]; then + owner=$(stat -c '%u' "$dir" 2>/dev/null) || owner=$(stat -f '%u' "$dir") || return 1 + if [ "$owner" != 0 ]; then + warn "Unsafe symlink for a privileged install: $dir" + return 1 + fi + # Validate each link in a chain, not just its fully resolved destination. + link_target=$(readlink "$dir") || return 1 + case "$link_target" in /*) ;; *) link_target="$(dirname "$dir")/$link_target" ;; esac + check_privileged_directory "$link_target" || return 1 + fi metadata=$(stat -L -c '%u %a' "$dir" 2>/dev/null) || metadata=$(stat -L -f '%u %Lp' "$dir") || return 1 read -r owner mode <<< "$metadata" # A root-owned sticky ancestor (e.g. /tmp) cannot replace root-owned children. diff --git a/scripts/test-install-atomic.sh b/scripts/test-install-atomic.sh index acb060d1e8..02339a7605 100755 --- a/scripts/test-install-atomic.sh +++ b/scripts/test-install-atomic.sh @@ -500,6 +500,22 @@ if [ "$EUID" -eq 0 ]; then check_privileged_directory "$D"; rc=$? check "privileged launcher: rejects another owner's directory" test "$rc" -ne 0 chown 0 "$D" + + mkdir "$D/target" + ln -s "$D/target" "$D/link" + check_privileged_directory "$D/link"; rc=$? + check "privileged launcher: accepts trusted symlink" test "$rc" -eq 0 + chown -h 65534 "$D/link" + for candidate in "$D/link" "$D/link/" "$D/link/new-child"; do + check_privileged_directory "$candidate"; rc=$? + check "privileged launcher: rejects untrusted symlink at $candidate" test "$rc" -ne 0 + done + ln -s link "$D/trusted-link" + check_privileged_directory "$D/trusted-link"; rc=$? + check "privileged launcher: rejects untrusted link inside a chain" test "$rc" -ne 0 + write_executable_file "$D/link/launcher" <<< '#!/bin/bash'; rc=$? + check "privileged launcher: refuses write through untrusted symlink" test "$rc" -ne 0 + check "privileged launcher: target remains untouched" test ! -e "$D/target/launcher" fi e2e_setup ok yes From b360e00898299ddc7d217c16e3106942a765924c Mon Sep 17 00:00:00 2001 From: Akarshkushwaha Date: Mon, 19 Oct 2026 14:12:00 +0530 Subject: [PATCH 6/6] fix(update): preserve launcher paths and validate physical symlinks --- .github/workflows/test-install.yml | 9 +++++ install.sh | 12 +++++-- packages/cli/src/cli/commands/core.test.ts | 38 ++++++++++++++++++++ packages/cli/src/cli/lib/core-maintenance.ts | 28 ++++++++++++++- scripts/test-install-atomic.sh | 14 ++++++++ 5 files changed, 97 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml index ba78b65dde..111025f0e8 100644 --- a/.github/workflows/test-install.yml +++ b/.github/workflows/test-install.yml @@ -111,6 +111,15 @@ jobs: bash scripts/test-install-atomic.sh echo "✓ Atomic install tests passed" + - name: Run privileged atomic install tests in isolation + if: matrix.os == 'ubuntu-latest' && matrix.node == 'with-node' + run: | + docker run --rm --network none --cap-drop ALL --cap-add CHOWN \ + --security-opt no-new-privileges \ + --mount type=bind,source="$PWD",target=/workspace,readonly \ + --tmpfs /tmp:rw,exec,mode=1777 --workdir /workspace \ + ubuntu:24.04 bash scripts/test-install-atomic.sh + - name: Test install script with Node.js if: matrix.node == 'with-node' env: diff --git a/install.sh b/install.sh index 3c30c3cb98..1b4e2d8b77 100755 --- a/install.sh +++ b/install.sh @@ -110,12 +110,12 @@ safe_remove_path() { # Privileged installs must not write through directories another user can replace. check_privileged_directory() { [ "$EUID" -eq 0 ] || return 0 - local dir="$1" metadata owner mode physical destination link_target + local dir="$1" metadata owner mode physical destination link_target link_parent case "$dir" in /*) ;; *) dir="$PWD/$dir" ;; esac while [ "$dir" != / ] && [ "${dir%/}" != "$dir" ]; do dir="${dir%/}"; done destination="$dir" while [ ! -d "$dir" ]; do dir=$(dirname "$dir"); done - physical=$(cd "$dir" && pwd -P) || return 1 + physical=$(cd -P "$dir" && pwd -P) || return 1 if [ "$physical" != "$dir" ]; then check_privileged_directory "$physical" || return 1 fi @@ -128,7 +128,13 @@ check_privileged_directory() { fi # Validate each link in a chain, not just its fully resolved destination. link_target=$(readlink "$dir") || return 1 - case "$link_target" in /*) ;; *) link_target="$(dirname "$dir")/$link_target" ;; esac + case "$link_target" in + /*) ;; + *) + link_parent=$(cd -P "$(dirname "$dir")" && pwd -P) || return 1 + link_target="$link_parent/$link_target" + ;; + esac check_privileged_directory "$link_target" || return 1 fi metadata=$(stat -L -c '%u %a' "$dir" 2>/dev/null) || metadata=$(stat -L -f '%u %Lp' "$dir") || return 1 diff --git a/packages/cli/src/cli/commands/core.test.ts b/packages/cli/src/cli/commands/core.test.ts index 05fbfde342..061721f410 100644 --- a/packages/cli/src/cli/commands/core.test.ts +++ b/packages/cli/src/cli/commands/core.test.ts @@ -2881,6 +2881,44 @@ describe('registerCoreCommands', () => { ); }); + it.each(['matching', 'symlink-target', 'other-install', 'not-a-launcher', 'not-executable'])( + 'preserves a custom launcher directory only for this installation (%s)', + async (mode) => { + const installDir = nodePath.resolve('/opt/custom-relay'); + const binary = nodePath.join(installDir, 'bin', 'agent-relay'); + const launcherDir = nodePath.resolve('/opt/custom-launchers'); + const launcher = nodePath.join(launcherDir, 'agent-relay'); + const laterDir = nodePath.resolve('/opt/later-launchers'); + const fs = createFsMock({ + [launcher]: + mode === 'not-a-launcher' + ? '#!/bin/sh\nexit 0\n' + : `#!/usr/bin/env bash\nexec "${mode === 'other-install' ? nodePath.resolve('/other/bin/agent-relay') : mode === 'symlink-target' ? nodePath.resolve('/linked/agent-relay') : binary}" "$@"\n`, + [nodePath.join(laterDir, 'agent-relay')]: `#!/usr/bin/env bash\nexec "${binary}" "$@"\n`, + }); + fs.accessSync = vi.fn((candidate) => { + if (!fs.existsSync(candidate) || mode === 'not-executable') throw new Error('not executable'); + }); + fs.realpathSync = (candidate) => + candidate === nodePath.resolve('/linked/agent-relay') ? binary : candidate; + const { deps } = createHarness({ + fs, + env: { PATH: [launcherDir, laterDir].join(nodePath.delimiter) }, + execPath: binary, + checkForUpdatesResult: { updateAvailable: true, latestVersion: '2.0.0' }, + }); + const program = new Command(); + registerCoreMaintenance(program, deps); + expect(await runCommand(program, ['update'])).toBeUndefined(); + const command = vi.mocked(deps.execCommand).mock.calls[0][0]; + if (mode === 'matching' || mode === 'symlink-target') { + expect(command).toContain(`AGENT_RELAY_BIN_DIR='${launcherDir}'`); + } else { + expect(command).not.toContain('AGENT_RELAY_BIN_DIR='); + } + } + ); + it('returns a failure when a standalone update fails', async () => { const home = os.homedir(); const { deps } = createHarness({ diff --git a/packages/cli/src/cli/lib/core-maintenance.ts b/packages/cli/src/cli/lib/core-maintenance.ts index aab16c73b4..6afb58a414 100644 --- a/packages/cli/src/cli/lib/core-maintenance.ts +++ b/packages/cli/src/cli/lib/core-maintenance.ts @@ -35,6 +35,30 @@ function standaloneInstallDir(deps: CoreDependencies): string | undefined { return path.dirname(path.dirname(runningBinary)); } +/** Preserve a custom launcher directory only when PATH selects this installation. */ +function standaloneLauncherDir(deps: CoreDependencies, installDir: string): string | undefined { + if (deps.env.PATH === undefined) return undefined; + for (const directory of deps.env.PATH.split(path.delimiter)) { + const launcher = path.resolve(directory || '.', 'agent-relay'); + try { + deps.fs.accessSync(launcher, 1); + } catch { + continue; + } + // Inspect only the first executable entry; another installation must stay untouched. + try { + const target = deps.fs.readFileSync(launcher, 'utf8').match(/^exec "([^"\r\n]+)" "\$@"$/m)?.[1]; + if (!target || !path.isAbsolute(target)) return undefined; + const current = path.join(installDir, 'bin', 'agent-relay'); + const resolve = (value: string) => deps.fs.realpathSync?.(value) ?? path.resolve(value); + return resolve(target) === resolve(current) ? path.dirname(launcher) : undefined; + } catch { + return undefined; + } + } + return undefined; +} + /** Quote one environment value for the POSIX installer shell. */ function shellQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'`; @@ -202,8 +226,10 @@ export async function runUpdateCommand(options: { check?: boolean }, deps: CoreD deps.log('Installing update...'); const toVersion = info.latestVersion ?? 'latest'; const installDir = standaloneInstallDir(deps); + const launcherDir = installDir ? standaloneLauncherDir(deps, installDir) : undefined; + const launcherEnv = launcherDir ? ` AGENT_RELAY_BIN_DIR=${shellQuote(launcherDir)}` : ''; const installCommand = installDir - ? `AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 AGENT_RELAY_INSTALL_DIR=${shellQuote(installDir)} AGENT_RELAY_VERSION=${shellQuote(toVersion)} bash -c 'installer=$(curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/relay/main/install.sh) && bash -c "$installer"'` + ? `AGENT_RELAY_INSTALL_STANDALONE_ONLY=1 AGENT_RELAY_INSTALL_DIR=${shellQuote(installDir)}${launcherEnv} AGENT_RELAY_VERSION=${shellQuote(toVersion)} bash -c 'installer=$(curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/relay/main/install.sh) && bash -c "$installer"'` : 'npm install -g agent-relay@latest'; try { const { stdout, stderr } = await deps.execCommand(installCommand); diff --git a/scripts/test-install-atomic.sh b/scripts/test-install-atomic.sh index 02339a7605..c06df3f984 100755 --- a/scripts/test-install-atomic.sh +++ b/scripts/test-install-atomic.sh @@ -516,6 +516,20 @@ if [ "$EUID" -eq 0 ]; then write_executable_file "$D/link/launcher" <<< '#!/bin/bash'; rc=$? check "privileged launcher: refuses write through untrusted symlink" test "$rc" -ne 0 check "privileged launcher: target remains untouched" test ! -e "$D/target/launcher" + + D="$(newdir)" + mkdir -p "$D/real/parent" "$D/final" "$D/destination" + ln -s real/parent "$D/alias" + ln -s ../destination "$D/real/parent/link" + ln -s ../final "$D/real/destination" + # Logical `cd` would inspect this decoy instead of the kernel's real path. + chown 65534 "$D/destination" + check_privileged_directory "$D/alias/link"; rc=$? + check "privileged launcher: resolves relative hops from the physical parent" test "$rc" -eq 0 + chown 0 "$D/destination" + chown -h 65534 "$D/real/destination" + check_privileged_directory "$D/alias/link"; rc=$? + check "privileged launcher: rejects an untrusted hop on the physical chain" test "$rc" -ne 0 fi e2e_setup ok yes