From 659773ec9244307f6a7d537e35884829c3af8d52 Mon Sep 17 00:00:00 2001 From: kricha <4619899+kricha@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:00:39 +0000 Subject: [PATCH] feat(installer): add conservative Linux uninstall --- .github/workflows/ci.yml | 52 ++ README.md | 4 +- cmd/forge/main.go | 19 + cmd/forge/main_test.go | 40 ++ internal/linuxinstall/host.go | 85 ++- internal/linuxinstall/host_test.go | 71 ++ internal/linuxinstall/install.go | 6 + internal/linuxinstall/install_test.go | 40 ++ internal/linuxinstall/uninstall.go | 315 +++++++++ internal/linuxinstall/uninstall_test.go | 770 ++++++++++++++++++++++ internal/linuxinstall/upgrade.go | 2 +- scripts/linux-installer-privileged-e2e.sh | 76 +++ 12 files changed, 1472 insertions(+), 8 deletions(-) create mode 100644 internal/linuxinstall/uninstall.go create mode 100644 internal/linuxinstall/uninstall_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16278e7..61fe35e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,7 @@ jobs: - name: Privileged Linux installer E2E env: AGENT_FORGE_PRIVILEGED_E2E: "1" + AGENT_FORGE_UNINSTALL_ACCEPTANCE: "" run: | linux_assets="$RUNNER_TEMP/agent-forge-linux-release" old_linux_assets="$RUNNER_TEMP/agent-forge-old-linux-release" @@ -74,6 +75,7 @@ jobs: RUNNER_ENVIRONMENT="$RUNNER_ENVIRONMENT" \ RUNNER_TEMP="$RUNNER_TEMP" \ AGENT_FORGE_PRIVILEGED_E2E="$AGENT_FORGE_PRIVILEGED_E2E" \ + AGENT_FORGE_UNINSTALL_ACCEPTANCE="$AGENT_FORGE_UNINSTALL_ACCEPTANCE" \ AGENT_FORGE_UPGRADE_FROM_VERSION=v0.1.3 \ AGENT_FORGE_UPGRADE_FROM_COMMIT=dfa09f5fd82a01b79c977cae20299db79ede9bdc \ AGENT_FORGE_UPGRADE_FROM_ASSET_DIR="$old_linux_assets" \ @@ -87,3 +89,53 @@ jobs: path: dist/* if-no-files-found: error retention-days: 7 + + uninstall-acceptance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: go.mod + cache: true + - name: Build deterministic candidate release assets + run: scripts/release-artifacts-e2e.sh "$GITHUB_SHA" v999.0.0 + - name: Privileged uninstall acceptance from v0.1.5 + env: + AGENT_FORGE_PRIVILEGED_E2E: "1" + AGENT_FORGE_UNINSTALL_ACCEPTANCE: "1" + run: | + linux_assets="$RUNNER_TEMP/agent-forge-linux-release" + old_linux_assets="$RUNNER_TEMP/agent-forge-v0.1.5-linux-release" + scripts/prepare-linux-release.sh v999.0.0 "$PWD/dist" "$linux_assets" + mkdir "$old_linux_assets" + for asset in \ + SHA256SUMS \ + agent-forge-cli_v0.1.5_linux_amd64.tar.gz \ + agent-forge-gate_v0.1.5_linux_amd64.tar.gz \ + agent-forge-worker_v0.1.5_linux_amd64.tar.gz; do + curl --proto '=https' --tlsv1.2 --fail --location --silent --show-error --retry 3 --retry-all-errors --retry-delay 1 \ + --output "$old_linux_assets/$asset" \ + "https://github.com/0k-lab/agent-forge/releases/download/v0.1.5/$asset" + done + printf '%s %s\n' e983a4f637cbbdb623d8366d6abf41d84cf9fa43bf297edefc6b3aeca7a8dbee "$old_linux_assets/SHA256SUMS" | sha256sum --check --strict + for archive in \ + agent-forge-cli_v0.1.5_linux_amd64.tar.gz \ + agent-forge-gate_v0.1.5_linux_amd64.tar.gz \ + agent-forge-worker_v0.1.5_linux_amd64.tar.gz; do + awk -v name="$archive" '$2 == name && NF == 2' "$old_linux_assets/SHA256SUMS" >"$RUNNER_TEMP/agent-forge-v0.1.5-check" + [ "$(wc -l <"$RUNNER_TEMP/agent-forge-v0.1.5-check")" -eq 1 ] + (cd "$old_linux_assets" && sha256sum --check --strict "$RUNNER_TEMP/agent-forge-v0.1.5-check") + done + sudo /usr/bin/env \ + CI="$CI" \ + GITHUB_ACTIONS="$GITHUB_ACTIONS" \ + RUNNER_ENVIRONMENT="$RUNNER_ENVIRONMENT" \ + RUNNER_TEMP="$RUNNER_TEMP" \ + AGENT_FORGE_PRIVILEGED_E2E="$AGENT_FORGE_PRIVILEGED_E2E" \ + AGENT_FORGE_UNINSTALL_ACCEPTANCE="$AGENT_FORGE_UNINSTALL_ACCEPTANCE" \ + AGENT_FORGE_UPGRADE_FROM_VERSION=v0.1.5 \ + AGENT_FORGE_UPGRADE_FROM_COMMIT=8fb034dc678793bbc1ecbe1cb9c9083e622c08fe \ + AGENT_FORGE_UPGRADE_FROM_ASSET_DIR="$old_linux_assets" \ + "$PWD/scripts/linux-installer-privileged-e2e.sh" \ + v999.0.0 "$GITHUB_SHA" "$linux_assets" diff --git a/README.md b/README.md index 630d49f..ca4bda7 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,14 @@ sudo ./forge install \ The default installation is staged under `/opt/agent-forge` for the dedicated `agent-forge` system account but is not started. Add `--enable-now` to reload systemd, enable and start Gate, wait for `/readyz`, start Worker, and verify its authenticated connection. `--run-as-root` is an explicit alternative service identity; it is never inferred from the caller's effective UID. -The installer verifies the trust anchor and exact six-entry Linux manifest before destination mutation, copies and hashes selected archives into private staging, applies a strict tar allowlist, generates isolated owner/Worker tokens, and publishes without replacement. Re-running the exact version is validation-only and does not rotate tokens or repair drift. Supplying a strictly newer exact SemVer with both `--upgrade` and `--enable-now` performs a serialized offline transactional upgrade: configuration, secrets, SQLite, repositories, and Worker state remain in place while binaries, units, and the receipt are replaced. Gate and Worker readiness are re-proved; any handled publication or readiness failure restores the exact previous files and re-proves the previous services. After readiness, the immediately previous binaries, units, and receipt are retained in one hardened sibling slot. The adjacent root-owned installer lock rejects concurrent upgrades. Downgrades, implicit version replacement, upgrade without readiness activation, and releases with a different Gate SQLite schema fail closed. Schema-changing migrations, crash recovery, repair, uninstall, and online download remain out of scope. +The installer verifies the trust anchor and exact six-entry Linux manifest before destination mutation, copies and hashes selected archives into private staging, applies a strict tar allowlist, generates isolated owner/Worker tokens, and publishes without replacement. Re-running the exact version is validation-only and does not rotate tokens or repair drift. Supplying a strictly newer exact SemVer with both `--upgrade` and `--enable-now` performs a serialized offline transactional upgrade: configuration, secrets, SQLite, repositories, and Worker state remain in place while binaries, units, and the receipt are replaced. Gate and Worker readiness are re-proved; any handled publication or readiness failure restores the exact previous files and re-proves the previous services. After readiness, the immediately previous binaries, units, and receipt are retained in one hardened sibling slot. The adjacent root-owned installer lock rejects concurrent lifecycle operations. Downgrades, implicit version replacement, upgrade without readiness activation, and releases with a different Gate SQLite schema fail closed. Schema-changing migrations, crash recovery, repair, and online download remain out of scope. `store_schema_version` is the persisted-storage compatibility epoch and MUST be bumped for any incompatible schema or persisted semantic change; upgrades and rollbacks across epochs are rejected. Run `sudo /opt/agent-forge/bin/forge rollback` to activate that one exact older slot without downloading assets or changing configuration, secrets, SQLite, account state, or systemd links. Rollback validates both releases first, restores and re-proves the current release on handled failure, and rejects a second rollback because its slot is newer. +Run `sudo /opt/agent-forge/bin/forge uninstall` only from the exact currently installed release. Uninstall preserves the prior enabled/active state on handled failure, conditionally stops and disables Worker before Gate, removes only the five release binaries, two unit files, receipt, direct systemd links, and optional previous-release slot, then reloads systemd. Configuration, secrets, SQLite, repositories, worktrees, runtime state, and the dedicated `agent-forge` account remain unchanged under `/opt/agent-forge`; there is no purge mode. Retained state is not automatically adoptable by a later install. Handled failures restore the exact prior service state and re-prove only services that were active; the transaction does not promise crash atomicity. After commit, immutable quarantine cleanup is best-effort: any remaining adjacent `.agent-forge.uninstall-*` residue fails closed and an operator must validate and remove it before another lifecycle operation or reinstall. + ### Linux installation diagnostics Run `sudo /opt/agent-forge/bin/forge doctor` to inspect the installation at `/opt/agent-forge`. Root is required because the authenticated readiness checks must read the mode-`0600` owner token; the token and all config, secret, HMAC, repository credential, and database contents are omitted from output. diff --git a/cmd/forge/main.go b/cmd/forge/main.go index 160777c..af2c4ac 100644 --- a/cmd/forge/main.go +++ b/cmd/forge/main.go @@ -88,6 +88,7 @@ type commandOptions struct { var installLinux = linuxinstall.Install var rollbackLinux = linuxinstall.Rollback +var uninstallLinux = linuxinstall.Uninstall var doctorLinux = linuxinstall.Doctor var effectiveUID = os.Geteuid @@ -108,6 +109,9 @@ func run(args []string, getenv func(string) string, stdin io.Reader, stdout, std if name == "rollback" { return runRollback(args[1:]) } + if name == "uninstall" { + return runUninstall(args[1:]) + } if name != "submit" && name != "wait" && name != "status" && name != "events" && name != "result" { return fail(CodeInvalidUsage) } @@ -176,6 +180,21 @@ func run(args []string, getenv func(string) string, stdin io.Reader, stdout, std } } +func runUninstall(args []string) error { + if len(args) != 0 || effectiveUID() != 0 { + return fail(CodeInvalidUsage) + } + err := uninstallLinux(linuxinstall.UninstallOptions{ + Version: buildinfo.Version, Commit: buildinfo.Commit, Arch: runtime.GOARCH, + Account: linuxinstall.HostAccountManager{}, Ownership: linuxinstall.HostOwnershipManager{}, Services: linuxinstall.HostServiceManager{}, + }) + if err != nil { + stage, _ := linuxinstall.FailureStage(err) + return &CLIError{Code: CodeInvalidInput, InstallStage: stage} + } + return nil +} + func runRollback(args []string) error { if len(args) != 0 || effectiveUID() != 0 { return fail(CodeInvalidUsage) diff --git a/cmd/forge/main_test.go b/cmd/forge/main_test.go index 863d707..bdf9274 100644 --- a/cmd/forge/main_test.go +++ b/cmd/forge/main_test.go @@ -49,6 +49,46 @@ func TestRollbackCommandRequiresRootNoArgsAndPassesCLIIdentity(t *testing.T) { } } +func TestUninstallCommandRequiresRootNoArgsAndPassesCLIIdentity(t *testing.T) { + previousUninstall, previousUID := uninstallLinux, effectiveUID + previousVersion, previousCommit := buildinfo.Version, buildinfo.Commit + t.Cleanup(func() { + uninstallLinux, effectiveUID = previousUninstall, previousUID + buildinfo.Version, buildinfo.Commit = previousVersion, previousCommit + }) + buildinfo.Version = "v1.2.4" + buildinfo.Commit = "89abcdef0123456789abcdef0123456789abcdef" + effectiveUID = func() int { return 0 } + var got linuxinstall.UninstallOptions + uninstallLinux = func(o linuxinstall.UninstallOptions) error { got = o; return nil } + var stdout, stderr bytes.Buffer + if err := run([]string{"uninstall"}, env(nil), nil, &stdout, &stderr); err != nil { + t.Fatal(err) + } + if stdout.Len() != 0 || stderr.Len() != 0 { + t.Fatalf("stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + if got.Version != buildinfo.Version || got.Commit != buildinfo.Commit || got.Arch == "" || got.Account == nil || got.Ownership == nil || got.Services == nil { + t.Fatalf("uninstall options = %#v", got) + } + if err := run([]string{"uninstall", "extra"}, env(nil), nil, io.Discard, io.Discard); err == nil { + t.Fatal("accepted uninstall arguments") + } + effectiveUID = func() int { return 1000 } + if err := run([]string{"uninstall"}, env(nil), nil, io.Discard, io.Discard); err == nil { + t.Fatal("accepted non-root uninstall") + } + effectiveUID = func() int { return 0 } + uninstallLinux = func(linuxinstall.UninstallOptions) error { + return linuxinstall.Uninstall(linuxinstall.UninstallOptions{}) + } + err := run([]string{"uninstall"}, env(nil), nil, io.Discard, io.Discard) + var cliErr *CLIError + if !errors.As(err, &cliErr) || cliErr.Code != CodeInvalidInput || cliErr.InstallStage != "validate" { + t.Fatalf("uninstall error = %#v", err) + } +} + func TestInstallCommandRequiresOfflineTrustInputsAndExplicitRootMode(t *testing.T) { previous := installLinux previousUID := effectiveUID diff --git a/internal/linuxinstall/host.go b/internal/linuxinstall/host.go index a0ca765..5d6872a 100644 --- a/internal/linuxinstall/host.go +++ b/internal/linuxinstall/host.go @@ -45,12 +45,13 @@ func (HostOwnershipManager) Owner(name string) (int, int, error) { type HostAccountManager struct{} const ( - groupaddPath = "/usr/sbin/groupadd" - groupdelPath = "/usr/sbin/groupdel" - useraddPath = "/usr/sbin/useradd" - userdelPath = "/usr/sbin/userdel" - getentPath = "/usr/bin/getent" - systemctlPath = "/usr/bin/systemctl" + groupaddPath = "/usr/sbin/groupadd" + groupdelPath = "/usr/sbin/groupdel" + useraddPath = "/usr/sbin/useradd" + userdelPath = "/usr/sbin/userdel" + getentPath = "/usr/bin/getent" + systemctlPath = "/usr/bin/systemctl" + serviceStateOutputLimit = 1024 ) var ( @@ -172,6 +173,32 @@ func validateServiceAccountRecord(body []byte, name, home, gid string) error { // HostServiceManager invokes systemctl with an argument vector and no shell. type HostServiceManager struct{} +type boundedOutput struct { + bytes.Buffer + overflow bool +} + +func (b *boundedOutput) Write(p []byte) (int, error) { + written := len(p) + remaining := serviceStateOutputLimit - b.Len() + if len(p) > remaining { + p = p[:max(remaining, 0)] + b.overflow = true + } + _, err := b.Buffer.Write(p) + return written, err +} + +var systemctlStateOutput = func(argv ...string) ([]byte, error) { + cmd := privilegedCommand(systemctlPath, argv...) + var output boundedOutput + cmd.Stdout, cmd.Stderr = &output, io.Discard + if err := cmd.Run(); err != nil || output.overflow { + return nil, errors.New("systemctl state query failed") + } + return output.Bytes(), nil +} + func (HostServiceManager) Run(argv ...string) error { if len(argv) == 0 { return errors.New("empty systemctl invocation") @@ -182,6 +209,52 @@ func (HostServiceManager) Run(argv ...string) error { return nil } +func (HostServiceManager) State(unit string) (ServiceState, error) { + if unit != "agent-forge-gate.service" && unit != "agent-forge-worker.service" { + return ServiceState{}, errors.New("unsupported service state query") + } + body, err := systemctlStateOutput("show", "--no-pager", "--property=LoadState", "--property=ActiveState", "--property=UnitFileState", unit) + if err != nil || len(body) == 0 || len(body) > serviceStateOutputLimit { + return ServiceState{}, errors.New("systemctl state query failed") + } + values := map[string]string{} + for _, line := range strings.Split(strings.TrimSuffix(string(body), "\n"), "\n") { + key, value, ok := strings.Cut(line, "=") + if !ok || key != "LoadState" && key != "ActiveState" && key != "UnitFileState" { + return ServiceState{}, errors.New("invalid systemctl state") + } + if _, duplicate := values[key]; duplicate { + return ServiceState{}, errors.New("invalid systemctl state") + } + values[key] = value + } + if len(values) != 3 { + return ServiceState{}, errors.New("invalid systemctl state") + } + if values["LoadState"] == "not-found" && values["ActiveState"] == "inactive" && values["UnitFileState"] == "" { + return ServiceState{}, nil + } + if values["LoadState"] != "loaded" { + return ServiceState{}, errors.New("ambiguous systemctl state") + } + state := ServiceState{} + switch values["ActiveState"] { + case "active": + state.Active = true + case "inactive": + default: + return ServiceState{}, errors.New("ambiguous systemctl state") + } + switch values["UnitFileState"] { + case "enabled": + state.Enabled = true + case "disabled", "linked": + default: + return ServiceState{}, errors.New("ambiguous systemctl state") + } + return state, nil +} + func readinessClient() *http.Client { return &http.Client{ Timeout: 2 * time.Second, diff --git a/internal/linuxinstall/host_test.go b/internal/linuxinstall/host_test.go index 4d072eb..b34313f 100644 --- a/internal/linuxinstall/host_test.go +++ b/internal/linuxinstall/host_test.go @@ -6,6 +6,7 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/base64" + "errors" "fmt" "net/http" "net/http/httptest" @@ -15,6 +16,76 @@ import ( "time" ) +func TestHostServiceStateUsesFixedShowQueryAndExactParsing(t *testing.T) { + previous := systemctlStateOutput + t.Cleanup(func() { systemctlStateOutput = previous }) + for _, tc := range []struct { + name string + body string + want ServiceState + }{ + {"enabled active", "LoadState=loaded\nActiveState=active\nUnitFileState=enabled\n", ServiceState{Enabled: true, Active: true}}, + {"disabled inactive", "LoadState=loaded\nActiveState=inactive\nUnitFileState=disabled\n", ServiceState{}}, + {"disabled active", "LoadState=loaded\nActiveState=active\nUnitFileState=disabled\n", ServiceState{Active: true}}, + {"enabled inactive", "LoadState=loaded\nActiveState=inactive\nUnitFileState=enabled\n", ServiceState{Enabled: true}}, + {"linked inactive", "LoadState=loaded\nActiveState=inactive\nUnitFileState=linked\n", ServiceState{}}, + {"linked active", "LoadState=loaded\nActiveState=active\nUnitFileState=linked\n", ServiceState{Active: true}}, + {"not found", "LoadState=not-found\nActiveState=inactive\nUnitFileState=\n", ServiceState{}}, + } { + t.Run(tc.name, func(t *testing.T) { + var argv []string + systemctlStateOutput = func(args ...string) ([]byte, error) { + argv = append([]string(nil), args...) + return []byte(tc.body), nil + } + got, err := (HostServiceManager{}).State("agent-forge-gate.service") + if err != nil || got != tc.want { + t.Fatalf("state=%+v err=%v", got, err) + } + wantArgv := "show --no-pager --property=LoadState --property=ActiveState --property=UnitFileState agent-forge-gate.service" + if strings.Join(argv, " ") != wantArgv { + t.Fatalf("argv=%q", argv) + } + }) + } +} + +func TestHostServiceStateRejectsAmbiguousOutputAndCommandFailure(t *testing.T) { + previous := systemctlStateOutput + t.Cleanup(func() { systemctlStateOutput = previous }) + for _, tc := range []struct { + name string + body string + err error + }{ + {"malformed", "LoadState loaded\nActiveState=active\nUnitFileState=enabled\n", nil}, + {"missing", "LoadState=loaded\nActiveState=active\n", nil}, + {"duplicate", "LoadState=loaded\nActiveState=active\nUnitFileState=enabled\nActiveState=active\n", nil}, + {"unknown key", "LoadState=loaded\nActiveState=active\nUnitFileState=enabled\nDescription=secret\n", nil}, + {"failed", "LoadState=loaded\nActiveState=failed\nUnitFileState=enabled\n", nil}, + {"activating", "LoadState=loaded\nActiveState=activating\nUnitFileState=enabled\n", nil}, + {"masked", "LoadState=loaded\nActiveState=inactive\nUnitFileState=masked\n", nil}, + {"static", "LoadState=loaded\nActiveState=inactive\nUnitFileState=static\n", nil}, + {"linked runtime", "LoadState=loaded\nActiveState=inactive\nUnitFileState=linked-runtime\n", nil}, + {"alias", "LoadState=loaded\nActiveState=inactive\nUnitFileState=alias\n", nil}, + {"unknown load", "LoadState=error\nActiveState=inactive\nUnitFileState=disabled\n", nil}, + {"inconsistent not found", "LoadState=not-found\nActiveState=active\nUnitFileState=\n", nil}, + {"oversized", strings.Repeat("x", serviceStateOutputLimit+1), nil}, + {"nonzero", "raw secret output", errors.New("raw secret command error")}, + } { + t.Run(tc.name, func(t *testing.T) { + systemctlStateOutput = func(...string) ([]byte, error) { return []byte(tc.body), tc.err } + _, err := (HostServiceManager{}).State("agent-forge-worker.service") + if err == nil { + t.Fatal("accepted ambiguous service state") + } + if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), tc.body) { + t.Fatalf("exposed raw systemctl output: %v", err) + } + }) + } +} + func TestGateReadyRequiresStrictOwnerBoundProof(t *testing.T) { oldURL, oldWindow := gateBaseURL, readinessWindow t.Cleanup(func() { gateBaseURL, readinessWindow = oldURL, oldWindow }) diff --git a/internal/linuxinstall/install.go b/internal/linuxinstall/install.go index 5535ef4..fce39a5 100644 --- a/internal/linuxinstall/install.go +++ b/internal/linuxinstall/install.go @@ -61,6 +61,12 @@ type ServiceManager interface { GateReady(ownerToken, version, commit string) error WorkerReady(ownerToken string) error } +type ServiceState struct { + Enabled, Active bool +} +type ServiceStateManager interface { + State(unit string) (ServiceState, error) +} type OwnershipManager interface { Chown(name string, uid, gid int) error Owner(name string) (uid, gid int, err error) diff --git a/internal/linuxinstall/install_test.go b/internal/linuxinstall/install_test.go index c2f2cdb..18be249 100644 --- a/internal/linuxinstall/install_test.go +++ b/internal/linuxinstall/install_test.go @@ -231,12 +231,38 @@ type fakeServices struct { ready int failAt string failOnce bool + states map[string]ServiceState + stateCalls []string + stateFailures map[int]error + stateResults map[int]ServiceState workerReadyCalls int blockWorkerReadyCall int workerReadyEntered chan struct{} workerReadyRelease chan struct{} } +func (f *fakeServices) serviceState(unit string) ServiceState { + if f.states == nil { + f.states = map[string]ServiceState{ + "agent-forge-gate.service": {Enabled: true, Active: true}, + "agent-forge-worker.service": {Enabled: true, Active: true}, + } + } + return f.states[unit] +} + +func (f *fakeServices) State(unit string) (ServiceState, error) { + f.stateCalls = append(f.stateCalls, unit) + call := len(f.stateCalls) + if err := f.stateFailures[call]; err != nil { + return ServiceState{}, err + } + if state, ok := f.stateResults[call]; ok { + return state, nil + } + return f.serviceState(unit), nil +} + func (f *fakeServices) fails(name string) bool { if f.failAt != name { return false @@ -249,6 +275,20 @@ func (f *fakeServices) fails(name string) bool { func (f *fakeServices) Run(argv ...string) error { f.calls = append(f.calls, append([]string(nil), argv...)) + if len(argv) == 2 { + state := f.serviceState(argv[1]) + switch argv[0] { + case "enable": + state.Enabled = true + case "disable": + state.Enabled = false + case "start": + state.Active = true + case "stop": + state.Active = false + } + f.states[argv[1]] = state + } if f.fails(strings.Join(argv, " ")) { return errors.New("injected service failure") } diff --git a/internal/linuxinstall/uninstall.go b/internal/linuxinstall/uninstall.go new file mode 100644 index 0000000..9afee0d --- /dev/null +++ b/internal/linuxinstall/uninstall.go @@ -0,0 +1,315 @@ +//go:build linux + +package linuxinstall + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "agent-forge/internal/configjson" +) + +type UninstallOptions struct { + Version, Commit, Root, Arch string + Account AccountManager + Services ServiceManager + Ownership OwnershipManager +} + +var renameUninstall = os.Rename +var unlinkUninstall = os.Remove +var removeUninstallCleanup = os.Remove + +func cleanupUninstallQuarantine(quarantine string, previousExists bool) { + for _, name := range upgradeObjects { + if removeUninstallCleanup(filepath.Join(quarantine, name)) != nil { + return + } + } + if previousExists { + previous := filepath.Join(quarantine, previousSlotName) + for _, name := range upgradeObjects { + if removeUninstallCleanup(filepath.Join(previous, name)) != nil { + return + } + } + for _, name := range []string{"bin", "systemd", ""} { + if removeUninstallCleanup(filepath.Join(previous, name)) != nil { + return + } + } + } + for _, name := range []string{"bin", "systemd", ""} { + if removeUninstallCleanup(filepath.Join(quarantine, name)) != nil { + return + } + } +} + +func Uninstall(o UninstallOptions) (retErr error) { + stage := "validate" + defer func() { + if retErr != nil { + if _, ok := FailureStage(retErr); !ok { + retErr = &installStageError{stage: stage, err: retErr} + } + } + }() + if !versionRE.MatchString(o.Version) || !commitRE.MatchString(o.Commit) || o.Arch == "" || o.Services == nil || o.Ownership == nil { + return errors.New("uninstall unavailable") + } + if o.Root == "" { + if requireTrustedAncestor("/opt") != nil || requireTrustedAncestor("/etc/systemd/system") != nil { + return errors.New("uninstall unavailable") + } + } + + stage = "host" + lock, err := acquireUpgradeLock(o.Root) + if err != nil { + return err + } + defer lock.Close() + + stage = "existing" + installPath := rooted(o.Root, prefix) + var header receipt + headerBody, err := readNoFollow(filepath.Join(installPath, "install-receipt.json"), 1<<20) + if err != nil || configjson.Decode(headerBody, &header) != nil { + return errors.New("uninstall unavailable") + } + inspectOptions := Options{Arch: o.Arch, RunAsRoot: header.RunAsRoot, Account: o.Account, Ownership: o.Ownership} + current, err := inspectExistingInstall(installPath, inspectOptions) + if err != nil || current == nil || current.Version != o.Version || current.Commit != o.Commit { + return errors.New("uninstall unavailable") + } + if !current.RunAsRoot { + validator, ok := o.Account.(accountValidator) + if !ok { + return errors.New("uninstall unavailable") + } + uid, gid, validateErr := validator.Validate("agent-forge", prefix) + if validateErr != nil || uid != current.AccountUID || gid != current.AccountGID { + return errors.New("uninstall unavailable") + } + } + if validateLinks(o.Root) != nil { + return errors.New("uninstall unavailable") + } + parent := filepath.Dir(installPath) + if rejectTransactionMaterial(parent) != nil { + return errors.New("uninstall unavailable") + } + previousPath := filepath.Join(parent, previousSlotName) + previousExists := false + if _, statErr := os.Lstat(previousPath); statErr == nil { + if _, inspectErr := inspectPreviousSlot(previousPath, inspectOptions, current); inspectErr != nil { + return errors.New("uninstall unavailable") + } + previousExists = true + } else if !os.IsNotExist(statErr) { + return errors.New("uninstall unavailable") + } + ownerToken, err := existingOwnerToken(installPath) + if err != nil { + return errors.New("uninstall unavailable") + } + stateManager, ok := o.Services.(ServiceStateManager) + if !ok { + return errors.New("uninstall unavailable") + } + gateState, err := stateManager.State("agent-forge-gate.service") + if err != nil { + return errors.New("uninstall unavailable") + } + workerState, err := stateManager.State("agent-forge-worker.service") + if err != nil || workerState.Active && !gateState.Active { + return errors.New("uninstall unavailable") + } + + stage = "staging" + quarantine, err := os.MkdirTemp(parent, ".agent-forge.uninstall-") + if err != nil { + return err + } + preserve := false + defer func() { + if !preserve { + for _, name := range []string{"bin", "systemd", ""} { + _ = os.Remove(filepath.Join(quarantine, name)) + } + } + }() + if err := os.Chmod(quarantine, 0o700); err != nil { + return err + } + for _, dir := range []string{"bin", "systemd"} { + if err := os.Mkdir(filepath.Join(quarantine, dir), 0o700); err != nil { + return err + } + } + for _, name := range upgradeObjects { + if !sameFilesystem(filepath.Join(installPath, name), filepath.Dir(filepath.Join(quarantine, name))) { + return errors.New("uninstall rename topology crosses filesystems") + } + } + if previousExists && !sameFilesystem(previousPath, quarantine) { + return errors.New("uninstall rename topology crosses filesystems") + } + + stage = "activation" + moved := make([]string, 0, len(upgradeObjects)) + previousMoved := false + recover := func(primary error) error { + var recovery []error + for i := len(moved) - 1; i >= 0; i-- { + name := moved[i] + if err := renameUninstall(filepath.Join(quarantine, name), filepath.Join(installPath, name)); err != nil { + recovery = append(recovery, fmt.Errorf("uninstall recovery failed: %s", name)) + } + } + if previousMoved { + if err := renameUninstall(filepath.Join(quarantine, previousSlotName), previousPath); err != nil { + recovery = append(recovery, errors.New("uninstall recovery failed: previous slot")) + } + } + for _, name := range []string{"agent-forge-gate.service", "agent-forge-worker.service"} { + link := rooted(o.Root, "/etc/systemd/system/"+name) + target := prefix + "/systemd/" + name + if o.Root != "" { + target = rooted(o.Root, target) + } + got, readErr := os.Readlink(link) + if readErr == nil && got == target { + continue + } + info, statErr := os.Lstat(link) + if statErr == nil && info.Mode()&os.ModeSymlink != 0 && singlyLinked(info) { + statErr = os.Remove(link) + } + if os.IsNotExist(statErr) || statErr == nil { + statErr = os.Symlink(target, link) + } + if statErr != nil { + recovery = append(recovery, errors.New("uninstall recovery failed: unit link")) + } + } + if err := o.Services.Run("daemon-reload"); err != nil { + recovery = append(recovery, errors.New("uninstall recovery failed: daemon reload")) + } + restoreService := func(unit string, want ServiceState, ready func() error) { + got, probeErr := stateManager.State(unit) + if probeErr != nil || got.Enabled != want.Enabled { + operation := "disable" + if want.Enabled { + operation = "enable" + } + if err := o.Services.Run(operation, unit); err != nil { + recovery = append(recovery, errors.New("uninstall recovery failed: service enablement")) + } + } + if probeErr != nil || got.Active != want.Active { + operation := "stop" + if want.Active { + operation = "start" + } + if err := o.Services.Run(operation, unit); err != nil { + recovery = append(recovery, errors.New("uninstall recovery failed: service activity")) + } + } + if want.Active { + if err := ready(); err != nil { + recovery = append(recovery, errors.New("uninstall recovery failed: readiness")) + } + } + if got, err := stateManager.State(unit); err != nil || got != want { + recovery = append(recovery, errors.New("uninstall recovery failed: service state")) + } + } + restoreService("agent-forge-gate.service", gateState, func() error { + return o.Services.GateReady(ownerToken, current.Version, current.Commit) + }) + restoreService("agent-forge-worker.service", workerState, func() error { + return o.Services.WorkerReady(ownerToken) + }) + if len(recovery) != 0 { + preserve = true + record := filepath.Join(parent, ".agent-forge.uninstall-recovery") + if err := writeExclusive(record, []byte("manual recovery required\n"), 0o600); err != nil && !os.IsExist(err) { + recovery = append(recovery, errors.New("uninstall recovery material failed")) + } + } + return errors.Join(append([]error{primary}, recovery...)...) + } + var shutdown [][]string + if workerState.Active { + shutdown = append(shutdown, []string{"stop", "agent-forge-worker.service"}) + } + if workerState.Enabled { + shutdown = append(shutdown, []string{"disable", "agent-forge-worker.service"}) + } + if gateState.Active { + shutdown = append(shutdown, []string{"stop", "agent-forge-gate.service"}) + } + if gateState.Enabled { + shutdown = append(shutdown, []string{"disable", "agent-forge-gate.service"}) + } + successfullyDisabled := map[string]bool{} + for _, call := range shutdown { + if err := o.Services.Run(call...); err != nil { + return recover(err) + } + if call[0] == "disable" { + successfullyDisabled[call[1]] = true + } + } + + stage = "publication" + for _, name := range upgradeObjects { + if err := renameUninstall(filepath.Join(installPath, name), filepath.Join(quarantine, name)); err != nil { + return recover(err) + } + moved = append(moved, name) + } + if previousExists { + if err := renameUninstall(previousPath, filepath.Join(quarantine, previousSlotName)); err != nil { + return recover(err) + } + previousMoved = true + } + for _, name := range []string{"agent-forge-gate.service", "agent-forge-worker.service"} { + link := rooted(o.Root, "/etc/systemd/system/"+name) + target := prefix + "/systemd/" + name + if o.Root != "" { + target = rooted(o.Root, target) + } + info, statErr := os.Lstat(link) + if os.IsNotExist(statErr) && successfullyDisabled[name] { + continue + } + if statErr != nil || info.Mode()&os.ModeSymlink == 0 || !singlyLinked(info) { + return recover(errors.New("uninstall unit link changed")) + } + got, readErr := os.Readlink(link) + if readErr != nil || got != target { + return recover(errors.New("uninstall unit link changed")) + } + if err := unlinkUninstall(link); err != nil { + return recover(err) + } + } + if err := o.Services.Run("daemon-reload"); err != nil { + return recover(err) + } + for _, unit := range []string{"agent-forge-gate.service", "agent-forge-worker.service"} { + state, err := stateManager.State(unit) + if err != nil || state != (ServiceState{}) { + return recover(errors.New("uninstall service shutdown not verified")) + } + } + preserve = true + cleanupUninstallQuarantine(quarantine, previousExists) + return nil +} diff --git a/internal/linuxinstall/uninstall_test.go b/internal/linuxinstall/uninstall_test.go new file mode 100644 index 0000000..43bb1d7 --- /dev/null +++ b/internal/linuxinstall/uninstall_test.go @@ -0,0 +1,770 @@ +//go:build linux + +package linuxinstall + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + "testing" +) + +type tamperingServices struct { + *fakeServices + link string +} + +func (f *tamperingServices) Run(argv ...string) error { + if strings.Join(argv, " ") == "disable agent-forge-gate.service" { + if err := os.Remove(f.link); err != nil { + return err + } + if err := os.Symlink("/tmp/not-agent-forge", f.link); err != nil { + return err + } + } + return f.fakeServices.Run(argv...) +} + +type disableLinkRemovingServices struct { + *fakeServices + root string + linkForDisable map[string]string +} + +func (f *disableLinkRemovingServices) Run(argv ...string) error { + if err := f.fakeServices.Run(argv...); err != nil { + return err + } + if len(argv) == 2 && argv[0] == "disable" { + unit := argv[1] + if f.linkForDisable != nil { + unit = f.linkForDisable[unit] + } + return os.Remove(rooted(f.root, "/etc/systemd/system/"+unit)) + } + return nil +} + +func TestUninstallRejectsMissingLinkWithoutSuccessfulDisableForSameUnit(t *testing.T) { + upgrade, installPath, _ := preparedUpgrade(t, &fakeServices{}) + if err := Install(upgrade); err != nil { + t.Fatal(err) + } + fake := &fakeServices{states: map[string]ServiceState{ + "agent-forge-gate.service": {Active: true}, + "agent-forge-worker.service": {Enabled: true, Active: true}, + }} + services := &disableLinkRemovingServices{ + fakeServices: fake, + root: upgrade.Root, + linkForDisable: map[string]string{ + "agent-forge-worker.service": "agent-forge-gate.service", + }, + } + if err := Uninstall(UninstallOptions{Version: upgrade.Version, Commit: upgrade.Commit, Root: upgrade.Root, Arch: upgrade.Arch, Account: upgrade.Account, Ownership: upgrade.Ownership, Services: services}); err == nil { + t.Fatal("accepted link disappearance after another unit's disable") + } + for _, name := range []string{"agent-forge-gate.service", "agent-forge-worker.service"} { + link := rooted(upgrade.Root, "/etc/systemd/system/"+name) + want := rooted(upgrade.Root, prefix+"/systemd/"+name) + if got, readErr := os.Readlink(link); readErr != nil || got != want { + t.Fatalf("link %s=%q err=%v", name, got, readErr) + } + } + if !pathExists(filepath.Join(installPath, "install-receipt.json")) { + t.Fatal("release was not recovered") + } +} + +func TestUninstallAcceptsDirectLinksRemovedBySuccessfulDisable(t *testing.T) { + upgrade, installPath, _ := preparedUpgrade(t, &fakeServices{}) + if err := Install(upgrade); err != nil { + t.Fatal(err) + } + services := &disableLinkRemovingServices{fakeServices: &fakeServices{}, root: upgrade.Root} + if err := Uninstall(UninstallOptions{Version: upgrade.Version, Commit: upgrade.Commit, Root: upgrade.Root, Arch: upgrade.Arch, Account: upgrade.Account, Ownership: upgrade.Ownership, Services: services}); err != nil { + t.Fatal(err) + } + for _, name := range []string{"agent-forge-gate.service", "agent-forge-worker.service"} { + if pathExists(rooted(upgrade.Root, "/etc/systemd/system/"+name)) { + t.Fatalf("canonical unit link remains: %s", name) + } + } + for _, name := range upgradeObjects { + if pathExists(filepath.Join(installPath, name)) { + t.Fatalf("release object remains: %s", name) + } + } +} + +func TestUninstallFailureRestoresLinksSlotsBytesAndStateAfterDisableRemovedLinks(t *testing.T) { + for _, tc := range []struct { + name string + failAt string + failRename bool + }{ + {name: "one link", failAt: "stop agent-forge-gate.service"}, + {name: "both links", failRename: true}, + } { + t.Run(tc.name, func(t *testing.T) { + upgrade, installPath, _ := preparedUpgrade(t, &fakeServices{}) + if err := Install(upgrade); err != nil { + t.Fatal(err) + } + previous := filepath.Join(filepath.Dir(installPath), previousSlotName) + currentBefore, previousBefore := map[string][]byte{}, map[string][]byte{} + for _, name := range upgradeObjects { + currentBefore[name] = []byte(read(t, filepath.Join(installPath, name))) + previousBefore[name] = []byte(read(t, filepath.Join(previous, name))) + } + fake := &fakeServices{failAt: tc.failAt, failOnce: true} + services := &disableLinkRemovingServices{fakeServices: fake, root: upgrade.Root} + previousRename := renameUninstall + t.Cleanup(func() { renameUninstall = previousRename }) + if tc.failRename { + renameUninstall = func(string, string) error { return errors.New("injected rename failure") } + } + + err := Uninstall(UninstallOptions{Version: upgrade.Version, Commit: upgrade.Commit, Root: upgrade.Root, Arch: upgrade.Arch, Account: upgrade.Account, Ownership: upgrade.Ownership, Services: services}) + if err == nil { + t.Fatal("injected failure succeeded") + } + assertInstalledBytes(t, installPath, currentBefore) + assertInstalledBytes(t, previous, previousBefore) + for _, name := range []string{"agent-forge-gate.service", "agent-forge-worker.service"} { + link := rooted(upgrade.Root, "/etc/systemd/system/"+name) + want := rooted(upgrade.Root, prefix+"/systemd/"+name) + if got, readErr := os.Readlink(link); readErr != nil || got != want { + t.Fatalf("link %s=%q err=%v", name, got, readErr) + } + if got := fake.serviceState(name); got != (ServiceState{Enabled: true, Active: true}) { + t.Fatalf("%s state=%+v", name, got) + } + } + }) + } +} + +func TestUninstallRemovesOnlyReleaseObjectsAndLinks(t *testing.T) { + root := t.TempDir() + account := &fakeAccount{} + ownership := newFakeOwnership() + assets, anchor := releaseFixture(t, nil) + if err := Install(Options{Version: testVersion, Commit: testCommit, AssetDir: assets, SHA256SUMSSHA256: anchor, Root: root, Arch: "amd64", Account: account, Ownership: ownership, Random: strings.NewReader(strings.Repeat("a", 32) + strings.Repeat("b", 32))}); err != nil { + t.Fatal(err) + } + installPath := rooted(root, prefix) + for name, body := range map[string]string{ + "var/gate/state/forge.db": "sqlite-marker", + "var/repositories/repository": "repository", + "var/worker/worktrees/worktree": "worktree", + "var/worker/runtime/runtime": "runtime", + } { + if err := os.WriteFile(filepath.Join(installPath, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + mutable := []string{"etc", "secrets", "var"} + type state struct { + inode uint64 + mode os.FileMode + uid, gid uint32 + body []byte + } + before := map[string]state{} + for _, name := range mutable { + path := filepath.Join(installPath, name) + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + stat := info.Sys().(*syscall.Stat_t) + before[name] = state{inode: stat.Ino, mode: info.Mode(), uid: stat.Uid, gid: stat.Gid} + } + for _, name := range []string{"etc/gate.json", "etc/worker.json", "secrets/gate.env", "secrets/worker.env", "var/gate/state/forge.db", "var/repositories/repository", "var/worker/worktrees/worktree", "var/worker/runtime/runtime"} { + path := filepath.Join(installPath, name) + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + stat := info.Sys().(*syscall.Stat_t) + before[name] = state{inode: stat.Ino, mode: info.Mode(), uid: stat.Uid, gid: stat.Gid, body: []byte(read(t, path))} + } + services := &fakeServices{} + if err := Uninstall(UninstallOptions{Version: testVersion, Commit: testCommit, Root: root, Arch: "amd64", Account: account, Ownership: ownership, Services: services}); err != nil { + t.Fatal(err) + } + gotCalls := make([]string, len(services.calls)) + for i := range services.calls { + gotCalls[i] = strings.Join(services.calls[i], " ") + } + wantCalls := []string{"stop agent-forge-worker.service", "disable agent-forge-worker.service", "stop agent-forge-gate.service", "disable agent-forge-gate.service", "daemon-reload"} + if strings.Join(gotCalls, "|") != strings.Join(wantCalls, "|") { + t.Fatalf("service order=%v", gotCalls) + } + for _, name := range upgradeObjects { + if pathExists(filepath.Join(installPath, name)) { + t.Fatalf("release object remains: %s", name) + } + } + for _, name := range []string{"agent-forge-gate.service", "agent-forge-worker.service"} { + if pathExists(rooted(root, "/etc/systemd/system/"+name)) { + t.Fatalf("direct unit link remains: %s", name) + } + } + for name, want := range before { + path := filepath.Join(installPath, name) + info, err := os.Lstat(path) + if err != nil { + t.Fatalf("mutable path missing: %s", name) + } + stat := info.Sys().(*syscall.Stat_t) + if stat.Ino != want.inode || info.Mode() != want.mode || stat.Uid != want.uid || stat.Gid != want.gid { + t.Fatalf("mutable metadata changed: %s", name) + } + if want.body != nil { + got, err := os.ReadFile(path) + if err != nil || !bytes.Equal(got, want.body) { + t.Fatalf("mutable bytes changed: %s", name) + } + } + } + if account.calls != 1 || account.validations != 1 || account.rollbacks != 0 { + t.Fatalf("account calls=%d validations=%d rollbacks=%d", account.calls, account.validations, account.rollbacks) + } + entries, err := os.ReadDir(filepath.Dir(installPath)) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".agent-forge.uninstall-") { + t.Fatalf("uninstall material remains: %s", entry.Name()) + } + } +} + +func TestUninstallRechecksExactLinkBeforeUnlinkAndRecovers(t *testing.T) { + root := t.TempDir() + account := &fakeAccount{} + ownership := newFakeOwnership() + assets, anchor := releaseFixture(t, nil) + if err := Install(Options{Version: testVersion, Commit: testCommit, AssetDir: assets, SHA256SUMSSHA256: anchor, Root: root, Arch: "amd64", Account: account, Ownership: ownership, Random: strings.NewReader(strings.Repeat("a", 32) + strings.Repeat("b", 32))}); err != nil { + t.Fatal(err) + } + installPath := rooted(root, prefix) + before := map[string][]byte{} + for _, name := range upgradeObjects { + before[name] = []byte(read(t, filepath.Join(installPath, name))) + } + gateLink := rooted(root, "/etc/systemd/system/agent-forge-gate.service") + services := &tamperingServices{fakeServices: &fakeServices{}, link: gateLink} + err := Uninstall(UninstallOptions{Version: testVersion, Commit: testCommit, Root: root, Arch: "amd64", Account: account, Ownership: ownership, Services: services}) + if err == nil { + t.Fatal("unlinked a changed direct unit link") + } + assertInstalledBytes(t, installPath, before) + want := rooted(root, prefix+"/systemd/agent-forge-gate.service") + if got, readErr := os.Readlink(gateLink); readErr != nil || got != want { + t.Fatalf("gate link=%q err=%v", got, readErr) + } + if services.ready != 2 { + t.Fatalf("recovery readiness proofs=%d", services.ready) + } +} + +func TestUninstallRemovesStrictPreviousSlot(t *testing.T) { + upgrade, installPath, _ := preparedUpgrade(t, &fakeServices{}) + if err := Install(upgrade); err != nil { + t.Fatal(err) + } + previous := filepath.Join(filepath.Dir(installPath), previousSlotName) + if !pathExists(previous) { + t.Fatal("fixture has no previous slot") + } + services := &fakeServices{} + if err := Uninstall(UninstallOptions{Version: upgrade.Version, Commit: upgrade.Commit, Root: upgrade.Root, Arch: upgrade.Arch, Account: upgrade.Account, Ownership: upgrade.Ownership, Services: services}); err != nil { + t.Fatal(err) + } + if pathExists(previous) { + t.Fatal("previous slot remains") + } + for _, name := range []string{"etc", "secrets", "var"} { + if !pathExists(filepath.Join(installPath, name)) { + t.Fatalf("mutable directory removed: %s", name) + } + } +} + +func TestUninstallRejectsInvalidStateBeforeServiceMutation(t *testing.T) { + for _, name := range []string{"missing-current", "cli-identity", "malformed-slot", "missing-link", "unsafe-link", "transaction-material"} { + t.Run(name, func(t *testing.T) { + services := &fakeServices{} + upgrade, installPath, _ := preparedUpgrade(t, &fakeServices{}) + if err := Install(upgrade); err != nil { + t.Fatal(err) + } + o := UninstallOptions{Version: upgrade.Version, Commit: upgrade.Commit, Root: upgrade.Root, Arch: upgrade.Arch, Account: upgrade.Account, Ownership: upgrade.Ownership, Services: services} + switch name { + case "missing-current": + if err := os.Remove(filepath.Join(installPath, "install-receipt.json")); err != nil { + t.Fatal(err) + } + case "cli-identity": + o.Commit = strings.Repeat("f", 40) + case "malformed-slot": + if err := os.Chmod(filepath.Join(filepath.Dir(installPath), previousSlotName, "install-receipt.json"), 0o600); err != nil { + t.Fatal(err) + } + case "missing-link": + if err := os.Remove(rooted(upgrade.Root, "/etc/systemd/system/agent-forge-gate.service")); err != nil { + t.Fatal(err) + } + case "unsafe-link": + link := rooted(upgrade.Root, "/etc/systemd/system/agent-forge-gate.service") + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + if err := os.Symlink("/tmp/wrong", link); err != nil { + t.Fatal(err) + } + case "transaction-material": + if err := os.WriteFile(filepath.Join(filepath.Dir(installPath), ".agent-forge.uninstall-recovery"), []byte("stale"), 0o600); err != nil { + t.Fatal(err) + } + } + if err := Uninstall(o); err == nil { + t.Fatal("accepted invalid uninstall state") + } + if len(services.calls) != 0 { + t.Fatalf("services called before rejection: %v", services.calls) + } + }) + } +} + +func TestUninstallRejectsUncertainOrImpossibleServiceStateBeforeMutation(t *testing.T) { + for _, tc := range []struct { + name string + services *fakeServices + }{ + {"probe failure", &fakeServices{stateFailures: map[int]error{1: errors.New("injected probe failure")}}}, + {"worker active without gate", &fakeServices{states: map[string]ServiceState{ + "agent-forge-gate.service": {}, + "agent-forge-worker.service": {Active: true}, + }}}, + } { + t.Run(tc.name, func(t *testing.T) { + upgrade, installPath, _ := preparedUpgrade(t, &fakeServices{}) + if err := Install(upgrade); err != nil { + t.Fatal(err) + } + err := Uninstall(UninstallOptions{Version: upgrade.Version, Commit: upgrade.Commit, Root: upgrade.Root, Arch: upgrade.Arch, Account: upgrade.Account, Ownership: upgrade.Ownership, Services: tc.services}) + if err == nil { + t.Fatal("accepted uncertain service state") + } + if len(tc.services.calls) != 0 { + t.Fatalf("service mutation before rejection: %v", tc.services.calls) + } + entries, readErr := os.ReadDir(filepath.Dir(installPath)) + if readErr != nil { + t.Fatal(readErr) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".agent-forge.uninstall-") { + t.Fatalf("staged before state rejection: %s", entry.Name()) + } + } + }) + } +} + +func TestUninstallPreservesServiceStateByCallingOnlyNecessaryShutdownOperations(t *testing.T) { + for _, tc := range []struct { + name string + state ServiceState + want []string + }{ + {"enabled active", ServiceState{Enabled: true, Active: true}, []string{"stop agent-forge-worker.service", "disable agent-forge-worker.service", "stop agent-forge-gate.service", "disable agent-forge-gate.service", "daemon-reload"}}, + {"disabled inactive", ServiceState{}, []string{"daemon-reload"}}, + {"disabled active", ServiceState{Active: true}, []string{"stop agent-forge-worker.service", "stop agent-forge-gate.service", "daemon-reload"}}, + {"enabled inactive", ServiceState{Enabled: true}, []string{"disable agent-forge-worker.service", "disable agent-forge-gate.service", "daemon-reload"}}, + } { + t.Run(tc.name, func(t *testing.T) { + upgrade, _, _ := preparedUpgrade(t, &fakeServices{}) + if err := Install(upgrade); err != nil { + t.Fatal(err) + } + services := &fakeServices{states: map[string]ServiceState{ + "agent-forge-gate.service": tc.state, "agent-forge-worker.service": tc.state, + }} + if err := Uninstall(UninstallOptions{Version: upgrade.Version, Commit: upgrade.Commit, Root: upgrade.Root, Arch: upgrade.Arch, Account: upgrade.Account, Ownership: upgrade.Ownership, Services: services}); err != nil { + t.Fatal(err) + } + var got []string + for _, call := range services.calls { + got = append(got, strings.Join(call, " ")) + } + if strings.Join(got, "|") != strings.Join(tc.want, "|") { + t.Fatalf("calls=%v want=%v", got, tc.want) + } + if services.ready != 0 { + t.Fatalf("success readiness=%d", services.ready) + } + }) + } +} + +func TestUninstallFailureRestoresExactPriorServiceState(t *testing.T) { + for _, tc := range []struct { + name string + state ServiceState + }{ + {"enabled active", ServiceState{Enabled: true, Active: true}}, + {"disabled inactive", ServiceState{}}, + {"disabled active", ServiceState{Active: true}}, + {"enabled inactive", ServiceState{Enabled: true}}, + } { + t.Run(tc.name, func(t *testing.T) { + upgrade, _, _ := preparedUpgrade(t, &fakeServices{}) + if err := Install(upgrade); err != nil { + t.Fatal(err) + } + services := &fakeServices{ + states: map[string]ServiceState{"agent-forge-gate.service": tc.state, "agent-forge-worker.service": tc.state}, + failAt: "daemon-reload", failOnce: true, + } + if err := Uninstall(UninstallOptions{Version: upgrade.Version, Commit: upgrade.Commit, Root: upgrade.Root, Arch: upgrade.Arch, Account: upgrade.Account, Ownership: upgrade.Ownership, Services: services}); err == nil { + t.Fatal("injected failure succeeded") + } + for _, unit := range []string{"agent-forge-gate.service", "agent-forge-worker.service"} { + if got := services.serviceState(unit); got != tc.state { + t.Fatalf("%s state=%+v want=%+v", unit, got, tc.state) + } + } + wantReady := 0 + if tc.state.Active { + wantReady = 2 + } + if services.ready != wantReady { + t.Fatalf("readiness=%d want=%d", services.ready, wantReady) + } + }) + } +} + +func TestUninstallPostReloadStateVerificationFailureRecovers(t *testing.T) { + upgrade, _, _ := preparedUpgrade(t, &fakeServices{}) + if err := Install(upgrade); err != nil { + t.Fatal(err) + } + services := &fakeServices{stateFailures: map[int]error{3: errors.New("injected post-reload probe failure")}} + err := Uninstall(UninstallOptions{Version: upgrade.Version, Commit: upgrade.Commit, Root: upgrade.Root, Arch: upgrade.Arch, Account: upgrade.Account, Ownership: upgrade.Ownership, Services: services}) + if err == nil { + t.Fatal("committed without verified stopped services") + } + for _, unit := range []string{"agent-forge-gate.service", "agent-forge-worker.service"} { + if got := services.serviceState(unit); got != (ServiceState{Enabled: true, Active: true}) { + t.Fatalf("%s state=%+v", unit, got) + } + } + if services.ready != 2 { + t.Fatalf("recovery readiness=%d", services.ready) + } +} + +func TestUninstallHandledFailuresRestoreCurrentSlotLinksAndReadiness(t *testing.T) { + type failure struct { + name string + service string + renameAt int + unlinkAt int + } + failures := []failure{ + {name: "stop-worker", service: "stop agent-forge-worker.service"}, + {name: "disable-worker", service: "disable agent-forge-worker.service"}, + {name: "stop-gate", service: "stop agent-forge-gate.service"}, + {name: "disable-gate", service: "disable agent-forge-gate.service"}, + {name: "daemon-reload", service: "daemon-reload"}, + {name: "unlink-gate", unlinkAt: 1}, + {name: "unlink-worker", unlinkAt: 2}, + } + for i := 1; i <= len(upgradeObjects)+1; i++ { + failures = append(failures, failure{name: fmt.Sprintf("rename-%d", i), renameAt: i}) + } + for _, failure := range failures { + t.Run(failure.name, func(t *testing.T) { + services := &fakeServices{failAt: failure.service, failOnce: true} + upgrade, installPath, _ := preparedUpgrade(t, &fakeServices{}) + if err := Install(upgrade); err != nil { + t.Fatal(err) + } + previous := filepath.Join(filepath.Dir(installPath), previousSlotName) + currentBefore, slotBefore := map[string][]byte{}, map[string][]byte{} + for _, name := range upgradeObjects { + currentBefore[name] = []byte(read(t, filepath.Join(installPath, name))) + slotBefore[name] = []byte(read(t, filepath.Join(previous, name))) + } + + previousRename, previousUnlink := renameUninstall, unlinkUninstall + t.Cleanup(func() { renameUninstall, unlinkUninstall = previousRename, previousUnlink }) + renameCalls, unlinkCalls := 0, 0 + renameUninstall = func(a, b string) error { + renameCalls++ + if renameCalls == failure.renameAt { + return errors.New("injected rename failure") + } + return previousRename(a, b) + } + unlinkUninstall = func(name string) error { + unlinkCalls++ + if unlinkCalls == failure.unlinkAt { + return errors.New("injected unlink failure") + } + return previousUnlink(name) + } + + err := Uninstall(UninstallOptions{Version: upgrade.Version, Commit: upgrade.Commit, Root: upgrade.Root, Arch: upgrade.Arch, Account: upgrade.Account, Ownership: upgrade.Ownership, Services: services}) + if err == nil { + t.Fatal("injected failure succeeded") + } + assertInstalledBytes(t, installPath, currentBefore) + assertInstalledBytes(t, previous, slotBefore) + for _, name := range []string{"agent-forge-gate.service", "agent-forge-worker.service"} { + link := rooted(upgrade.Root, "/etc/systemd/system/"+name) + want := rooted(upgrade.Root, prefix+"/systemd/"+name) + if got, readErr := os.Readlink(link); readErr != nil || got != want { + t.Fatalf("link %s=%q err=%v", name, got, readErr) + } + } + if services.ready != 2 { + t.Fatalf("recovery readiness proofs=%d", services.ready) + } + entries, readErr := os.ReadDir(filepath.Dir(installPath)) + if readErr != nil { + t.Fatal(readErr) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".agent-forge.uninstall-") { + t.Fatalf("recovered transaction retained %s", entry.Name()) + } + } + }) + } +} + +func TestUninstallRejectsCrossMountRenameTopologyBeforeServices(t *testing.T) { + root := t.TempDir() + account := &fakeAccount{} + ownership := newFakeOwnership() + assets, anchor := releaseFixture(t, nil) + if err := Install(Options{Version: testVersion, Commit: testCommit, AssetDir: assets, SHA256SUMSSHA256: anchor, Root: root, Arch: "amd64", Account: account, Ownership: ownership, Random: strings.NewReader(strings.Repeat("a", 32) + strings.Repeat("b", 32))}); err != nil { + t.Fatal(err) + } + installPath := rooted(root, prefix) + before := map[string][]byte{} + for _, name := range upgradeObjects { + before[name] = []byte(read(t, filepath.Join(installPath, name))) + } + previousSameFilesystem := sameFilesystem + t.Cleanup(func() { sameFilesystem = previousSameFilesystem }) + sameFilesystem = func(a, _ string) bool { return a != filepath.Join(installPath, "bin/forge") } + services := &fakeServices{} + if err := Uninstall(UninstallOptions{Version: testVersion, Commit: testCommit, Root: root, Arch: "amd64", Account: account, Ownership: ownership, Services: services}); err == nil { + t.Fatal("accepted cross-mount rename topology") + } + assertInstalledBytes(t, installPath, before) + if len(services.calls) != 0 { + t.Fatalf("services called before topology rejection: %v", services.calls) + } +} + +func TestUninstallRecoveryFailureRetainsPrivateEvidence(t *testing.T) { + root := t.TempDir() + account := &fakeAccount{} + ownership := newFakeOwnership() + assets, anchor := releaseFixture(t, nil) + if err := Install(Options{Version: testVersion, Commit: testCommit, AssetDir: assets, SHA256SUMSSHA256: anchor, Root: root, Arch: "amd64", Account: account, Ownership: ownership, Random: strings.NewReader(strings.Repeat("a", 32) + strings.Repeat("b", 32))}); err != nil { + t.Fatal(err) + } + previousRename := renameUninstall + t.Cleanup(func() { renameUninstall = previousRename }) + calls := 0 + renameUninstall = func(a, b string) error { + calls++ + if calls == 2 || calls == 3 { + return errors.New("injected rename failure") + } + return previousRename(a, b) + } + services := &fakeServices{} + err := Uninstall(UninstallOptions{Version: testVersion, Commit: testCommit, Root: root, Arch: "amd64", Account: account, Ownership: ownership, Services: services}) + if err == nil || strings.Contains(err.Error(), "FORGE_") { + t.Fatalf("recovery error=%v", err) + } + parent := filepath.Dir(rooted(root, prefix)) + marker := filepath.Join(parent, ".agent-forge.uninstall-recovery") + info, statErr := os.Lstat(marker) + if statErr != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { + t.Fatalf("recovery marker info=%v err=%v", info, statErr) + } + foundPrivate := false + entries, readErr := os.ReadDir(parent) + if readErr != nil { + t.Fatal(readErr) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".agent-forge.uninstall-") && entry.IsDir() { + info, statErr := os.Lstat(filepath.Join(parent, entry.Name())) + foundPrivate = statErr == nil && info.Mode().Perm() == 0o700 + } + } + if !foundPrivate { + t.Fatal("private recovery quarantine missing") + } +} + +func TestUninstallValidationNeverExecutesInstalledBinaries(t *testing.T) { + root := t.TempDir() + account := &fakeAccount{} + ownership := newFakeOwnership() + assets, anchor := releaseFixture(t, nil) + if err := Install(Options{Version: testVersion, Commit: testCommit, AssetDir: assets, SHA256SUMSSHA256: anchor, Root: root, Arch: "amd64", Account: account, Ownership: ownership, Random: strings.NewReader(strings.Repeat("a", 32) + strings.Repeat("b", 32))}); err != nil { + t.Fatal(err) + } + installPath := rooted(root, prefix) + sentinel := filepath.Join(root, "executed") + binary := filepath.Join(installPath, "bin/forge") + body := []byte("#!/bin/sh\ntouch '" + sentinel + "'\n") + if err := os.WriteFile(binary, body, 0o755); err != nil { + t.Fatal(err) + } + receiptPath := filepath.Join(installPath, "install-receipt.json") + var current receipt + if err := json.Unmarshal([]byte(read(t, receiptPath)), ¤t); err != nil { + t.Fatal(err) + } + current.Files["bin/forge"] = hashBytes(body) + receiptBody, err := json.MarshalIndent(current, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.Chmod(receiptPath, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(receiptPath, append(receiptBody, '\n'), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(receiptPath, 0o400); err != nil { + t.Fatal(err) + } + if err := Uninstall(UninstallOptions{Version: testVersion, Commit: testCommit, Root: root, Arch: "amd64", Account: account, Ownership: ownership, Services: &fakeServices{}}); err != nil { + t.Fatal(err) + } + if pathExists(sentinel) { + t.Fatal("uninstall executed an installed binary") + } +} + +func TestUninstallHoldsInstallerLockThroughRecovery(t *testing.T) { + root := t.TempDir() + account := &fakeAccount{} + ownership := newFakeOwnership() + assets, anchor := releaseFixture(t, nil) + if err := Install(Options{Version: testVersion, Commit: testCommit, AssetDir: assets, SHA256SUMSSHA256: anchor, Root: root, Arch: "amd64", Account: account, Ownership: ownership, Random: strings.NewReader(strings.Repeat("a", 32) + strings.Repeat("b", 32))}); err != nil { + t.Fatal(err) + } + entered, release := make(chan struct{}), make(chan struct{}) + services := &fakeServices{failAt: "stop agent-forge-worker.service", failOnce: true, blockWorkerReadyCall: 1, workerReadyEntered: entered, workerReadyRelease: release} + done := make(chan error, 1) + go func() { + done <- Uninstall(UninstallOptions{Version: testVersion, Commit: testCommit, Root: root, Arch: "amd64", Account: account, Ownership: ownership, Services: services}) + }() + <-entered + if lock, err := acquireUpgradeLock(root); err == nil { + _ = lock.Close() + t.Fatal("concurrent lifecycle operation acquired lock during recovery") + } + close(release) + if err := <-done; err == nil { + t.Fatal("injected uninstall failure succeeded") + } +} + +func TestUninstallPartialCleanupFailureLeavesCommittedUninstall(t *testing.T) { + upgrade, installPath, _ := preparedUpgrade(t, &fakeServices{}) + if err := Install(upgrade); err != nil { + t.Fatal(err) + } + previousPath := filepath.Join(filepath.Dir(installPath), previousSlotName) + mutablePath := filepath.Join(installPath, "var/gate/state/forge.db") + if err := os.WriteFile(mutablePath, []byte("mutable-marker"), 0o600); err != nil { + t.Fatal(err) + } + mutableBefore, err := os.Lstat(mutablePath) + if err != nil { + t.Fatal(err) + } + previousRemove := removeUninstallCleanup + t.Cleanup(func() { removeUninstallCleanup = previousRemove }) + cleanupCalls := 0 + removeUninstallCleanup = func(name string) error { + cleanupCalls++ + if cleanupCalls == 2 { + return errors.New("injected partial cleanup failure") + } + return previousRemove(name) + } + services := &fakeServices{} + if err := Uninstall(UninstallOptions{Version: upgrade.Version, Commit: upgrade.Commit, Root: upgrade.Root, Arch: upgrade.Arch, Account: upgrade.Account, Ownership: upgrade.Ownership, Services: services}); err != nil { + t.Fatalf("committed uninstall returned cleanup error: %v", err) + } + if cleanupCalls != 2 { + t.Fatalf("cleanup continued after failure; calls=%d", cleanupCalls) + } + for _, name := range upgradeObjects { + if pathExists(filepath.Join(installPath, name)) { + t.Fatalf("canonical release object restored: %s", name) + } + } + if pathExists(previousPath) { + t.Fatal("canonical previous slot restored") + } + for _, name := range []string{"agent-forge-gate.service", "agent-forge-worker.service"} { + if pathExists(rooted(upgrade.Root, "/etc/systemd/system/"+name)) { + t.Fatalf("canonical unit link restored: %s", name) + } + } + if services.ready != 0 { + t.Fatalf("post-commit cleanup reactivated services; readiness proofs=%d", services.ready) + } + mutableAfter, err := os.Lstat(mutablePath) + if err != nil { + t.Fatal(err) + } + beforeStat, afterStat := mutableBefore.Sys().(*syscall.Stat_t), mutableAfter.Sys().(*syscall.Stat_t) + if beforeStat.Ino != afterStat.Ino || beforeStat.Uid != afterStat.Uid || beforeStat.Gid != afterStat.Gid || mutableBefore.Mode() != mutableAfter.Mode() || read(t, mutablePath) != "mutable-marker" { + t.Fatal("mutable state changed") + } + entries, err := os.ReadDir(filepath.Dir(installPath)) + if err != nil { + t.Fatal(err) + } + var residue string + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".agent-forge.uninstall-") && entry.IsDir() { + residue = filepath.Join(filepath.Dir(installPath), entry.Name()) + } + } + if residue == "" || pathExists(filepath.Join(residue, "bin/forge")) || !pathExists(filepath.Join(residue, "bin/forge-gate")) { + t.Fatal("partial immutable cleanup residue not preserved") + } + if rejectTransactionMaterial(filepath.Dir(installPath)) == nil { + t.Fatal("cleanup residue did not block later lifecycle operations") + } +} diff --git a/internal/linuxinstall/upgrade.go b/internal/linuxinstall/upgrade.go index 89d0017..54519f2 100644 --- a/internal/linuxinstall/upgrade.go +++ b/internal/linuxinstall/upgrade.go @@ -41,7 +41,7 @@ func rejectTransactionMaterial(parent string) error { return err } for _, entry := range entries { - if strings.HasPrefix(entry.Name(), ".agent-forge.upgrade-") || strings.HasPrefix(entry.Name(), ".agent-forge.rollback-") { + if strings.HasPrefix(entry.Name(), ".agent-forge.upgrade-") || strings.HasPrefix(entry.Name(), ".agent-forge.rollback-") || strings.HasPrefix(entry.Name(), ".agent-forge.uninstall-") { return errors.New("ambiguous installer transaction material") } } diff --git a/scripts/linux-installer-privileged-e2e.sh b/scripts/linux-installer-privileged-e2e.sh index 0e23ff9..64af11f 100755 --- a/scripts/linux-installer-privileged-e2e.sh +++ b/scripts/linux-installer-privileged-e2e.sh @@ -72,6 +72,11 @@ case "$ASSET_DIR" in /*) ;; *) usage ;; esac # run on a persistent or self-hosted machine. STAGE=runner-preflight [ "${AGENT_FORGE_PRIVILEGED_E2E:-}" = 1 ] || { echo "privileged E2E opt-in is required" >&2; exit 1; } +UNINSTALL_ACCEPTANCE=${AGENT_FORGE_UNINSTALL_ACCEPTANCE:-} +case "$UNINSTALL_ACCEPTANCE" in + ''|1) ;; + *) echo "invalid uninstall acceptance opt-in" >&2; exit 1 ;; +esac [ "${CI:-}" = true ] && [ "${GITHUB_ACTIONS:-}" = true ] && [ "${RUNNER_ENVIRONMENT:-}" = github-hosted ] || { echo "privileged E2E requires a disposable GitHub-hosted runner" >&2 exit 1 @@ -95,6 +100,10 @@ if [ -n "$OLD_VERSION$OLD_COMMIT$OLD_ASSET_DIR" ]; then exit 1 } fi +[ "$UNINSTALL_ACCEPTANCE" != 1 ] || [ -n "$OLD_VERSION" ] || { + echo "uninstall acceptance requires an upgrade baseline" >&2 + exit 1 +} for tool in chmod cmp getent grep id journalctl mkfifo mktemp mount readlink rm runuser sha256sum stat systemctl tar tr umount uname wc python3; do command -v "$tool" >/dev/null 2>&1 || { echo "missing required tool: $tool" >&2; exit 1; } @@ -445,6 +454,66 @@ PY } >"$output" } +run_candidate_uninstall_acceptance() { + UNINSTALL_BEFORE="$TMP/uninstall-before" + UNINSTALL_AFTER="$TMP/uninstall-after" + ACCOUNT_BEFORE="$TMP/account-before" + ACCOUNT_AFTER="$TMP/account-after" + snapshot_upgrade_preserved_state "$UNINSTALL_BEFORE" + { + getent passwd agent-forge + getent group agent-forge + } >"$ACCOUNT_BEFORE" + STAGE=candidate-uninstall + uninstall_log="$TMP/uninstall.log" + uninstall_pipe="$TMP/uninstall.pipe" + /usr/bin/mkfifo "$uninstall_pipe" + /usr/bin/python3 "$DIAGNOSTICS" capture "$uninstall_pipe" "$uninstall_log" & + capture_pid=$! + if /opt/agent-forge/bin/forge uninstall >"$uninstall_pipe" 2>&1; then + uninstall_rc=0 + else + uninstall_rc=$? + fi + wait "$capture_pid" + /usr/bin/rm -f "$uninstall_pipe" + if [ "$uninstall_rc" -ne 0 ]; then + uninstall_stage=$(/usr/bin/python3 "$DIAGNOSTICS" parse "$uninstall_log") + [ -z "$uninstall_stage" ] || printf '::error title=Privileged Linux uninstall stage::install_stage=%s\n' "$uninstall_stage" >&2 + exit 1 + fi + [ ! -s "$uninstall_log" ] || { echo "uninstall emitted unexpected output" >&2; exit 1; } + for unit in agent-forge-gate.service agent-forge-worker.service; do + [ "$(systemctl is-active "$unit" 2>/dev/null || true)" != active ] + ! systemctl is-enabled "$unit" >/dev/null 2>&1 + [ "$(systemctl show --property=LoadState --value "$unit")" = not-found ] + done + for path in \ + /opt/agent-forge/bin/forge \ + /opt/agent-forge/bin/forge-gate \ + /opt/agent-forge/bin/forge-worker \ + /opt/agent-forge/bin/forge-codex-plugin \ + /opt/agent-forge/bin/forge-ref-plugin \ + /opt/agent-forge/systemd/agent-forge-gate.service \ + /opt/agent-forge/systemd/agent-forge-worker.service \ + /opt/agent-forge/install-receipt.json \ + /opt/.agent-forge.previous \ + /etc/systemd/system/agent-forge-gate.service \ + /etc/systemd/system/agent-forge-worker.service; do + [ ! -e "$path" ] && [ ! -L "$path" ] || { echo "uninstall retained release path: $path" >&2; exit 1; } + done + for path in /opt/.agent-forge.uninstall-*; do + [ ! -e "$path" ] && [ ! -L "$path" ] || { echo "uninstall retained quarantine: $path" >&2; exit 1; } + done + snapshot_upgrade_preserved_state "$UNINSTALL_AFTER" + cmp "$UNINSTALL_BEFORE" "$UNINSTALL_AFTER" || { echo "uninstall changed preserved state" >&2; exit 1; } + { + getent passwd agent-forge + getent group agent-forge + } >"$ACCOUNT_AFTER" + cmp "$ACCOUNT_BEFORE" "$ACCOUNT_AFTER" || { echo "uninstall changed dedicated account" >&2; exit 1; } +} + STAGE=initial-install if [ -n "$OLD_VERSION" ]; then install_release "$OLD_BOOTSTRAP" "$OLD_VERSION" "$OLD_COMMIT" "$OLD_ASSET_DIR" "$OLD_ANCHOR" clean @@ -619,6 +688,13 @@ EOF snapshot_upgrade_preserved_state "$UPGRADE_AFTER" cmp "$UPGRADE_BEFORE" "$UPGRADE_AFTER" || { echo "upgrade changed preserved state" >&2; exit 1; } + if [ "$UNINSTALL_ACCEPTANCE" = 1 ]; then + run_candidate_uninstall_acceptance + STAGE=complete + echo "privileged Linux installer e2e: PASS" + exit 0 + fi + STAGE=explicit-rollback /opt/agent-forge/bin/forge rollback >"$TMP/rollback-output" 2>&1 [ ! -s "$TMP/rollback-output" ] || { echo "rollback emitted unexpected output" >&2; exit 1; }