Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" \
Expand All @@ -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"
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions cmd/forge/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions cmd/forge/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 79 additions & 6 deletions internal/linuxinstall/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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")
Expand All @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions internal/linuxinstall/host_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/http/httptest"
Expand All @@ -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 })
Expand Down
6 changes: 6 additions & 0 deletions internal/linuxinstall/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading