diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml index ba78b65dd..111025f0e 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/CHANGELOG.md b/CHANGELOG.md index cd3e44679..a5d19a2eb 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 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 7227cc559..1b4e2d8b7 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' @@ -105,17 +107,65 @@ 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 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 -P "$dir" && pwd -P) || return 1 + if [ "$physical" != "$dir" ]; then + 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_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 + 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")" - safe_remove_path "$temp" - cat > "$temp" - chmod +x "$temp" - mv -f "$temp" "$target" + 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" @@ -275,11 +325,15 @@ 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" 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 +464,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 +476,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 @@ -627,10 +689,19 @@ 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") || 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 + 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 @@ -666,7 +737,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 @@ -811,6 +886,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" @@ -821,7 +897,10 @@ abort_standalone_install() { undo_brokers undo_cli TXN_STATE="" - 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." + if [ -n "$STANDALONE_TEMP_FAILURE" ]; then + error "$STANDALONE_TEMP_FAILURE ${ROLLBACK_OUTCOME}." + fi + 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 @@ -1024,14 +1103,16 @@ 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 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..." else + [ -z "$STANDALONE_TEMP_FAILURE" ] || return 1 info "Compressed binary not available, trying uncompressed..." fi else @@ -1048,11 +1129,12 @@ 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 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}" else @@ -1061,7 +1143,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 } @@ -1322,6 +1408,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" @@ -1368,12 +1460,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 ]; then + 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}" @@ -1381,10 +1477,24 @@ 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 + 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 a81a3b8bb..061721f41 100644 --- a/packages/cli/src/cli/commands/core.test.ts +++ b/packages/cli/src/cli/commands/core.test.ts @@ -2812,6 +2812,129 @@ 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_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.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'), + 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}'`) + ); + }); + + 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.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({ + 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 85e1502da..6afb58a41 100644 --- a/packages/cli/src/cli/lib/core-maintenance.ts +++ b/packages/cli/src/cli/lib/core-maintenance.ts @@ -21,6 +21,49 @@ function toErrorMessage(err: unknown): string { return describeError(err); } +/** Locate the running standalone executable, including custom and legacy installs. */ +function standaloneInstallDir(deps: CoreDependencies): string | undefined { + let runningBinary = path.resolve(deps.execPath); + try { + runningBinary = deps.fs.realpathSync?.(runningBinary) ?? runningBinary; + } catch { + // 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)); +} + +/** 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, `'"'"'`)}'`; +} + /** * Remove agent-relay snippet blocks from a markdown file's content. * Returns the cleaned content, or null if no snippets were found. @@ -155,6 +198,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}`); @@ -181,8 +225,14 @@ 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)}${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('npm install -g agent-relay@latest'); + const { stdout, stderr } = await deps.execCommand(installCommand); if (stdout.trim().length > 0) { deps.log(stdout.trimEnd()); } @@ -204,7 +254,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 7f47440a7..c06df3f98 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 @@ -421,14 +437,16 @@ 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" } -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 @@ -444,14 +462,133 @@ 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; } +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" + + 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" + + 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 run_main; rc=$? 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 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)" = "$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 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=$? +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 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=$? +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 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 000000000..1e637eddd --- /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 000000000..e1244a6a2 --- /dev/null +++ b/tests/relayflows/cases/1963-standalone-update/run.mjs @@ -0,0 +1,95 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { existsSync, rmSync, 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.'); +} +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(); +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.'); + +/** Build the exact target checkout, treating setup failures as proof errors. */ +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'); +run('npm', ['run', 'build:core']); +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: {}, + 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_INSTALL_DIR='${installDir.replace(/'/g, `'"'"'`)}'`) && + 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' +);