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
12 changes: 10 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ EMBED_CONFIG := config.json
EMBED_MODELS := models.json

.PHONY: all help build build-check build-summary config embed-ready check run once dry-run no-mutate \
install uninstall print-service migrate-config \
install uninstall purge print-service migrate-config \
test coverage vet fmt fmt-check lint staticcheck vulcheck ci tidy clean \
ssh-add

Expand Down Expand Up @@ -93,7 +93,7 @@ no-mutate: build
install: build
sudo $(BINARY) --install --config $(CONFIG)

## uninstall: stop, disable, and remove everything `make install` created (requires root)
## uninstall: stop, disable, and remove the service; leaves data in place (requires root)
# Deliberately does not depend on `build`: uninstalling must work even without
# a config.json/models.json in place, since removing a broken install is
# exactly when those might be missing or invalid. If a binary from a previous
Expand All @@ -107,6 +107,14 @@ uninstall:
fi
sudo $(BINARY) --uninstall

## purge: destructive — stop, disable, remove the service, and delete its workspace/logs/state data (requires root)
purge:
@if [ ! -x "$(BINARY)" ]; then \
echo "no existing $(BINARY); building one (requires $(EMBED_CONFIG) and $(EMBED_MODELS) at the repo root, since go:embed compiles them in)"; \
go build -ldflags="-w -s" -o $(BINARY) ./cmd; \
fi
sudo $(BINARY) --uninstall --purge

## print-service: print the embedded systemd unit without installing anything
print-service: build
$(BINARY) --print-service
Expand Down
33 changes: 23 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,8 @@ All flags on the built binary (`bin/coding-agent-loop`, or via `make run` / `mak
| `--no-server` | off | do not start the control API |
| `--check` | off | run start-up checks (binaries, auth, config) and exit |
| `--install` | off | install + enable + start the systemd unit; **must run as root** |
| `--uninstall` | off | stop, disable, and remove everything `--install` created; **must run as root** |
| `--uninstall` | off | stop, disable, and remove the systemd unit and `/opt/coding-agent-loop`; **leaves all data in place**; **must run as root** |
| `--purge` | off | only with `--uninstall`: also delete the configured workspace/logs/state paths and the dedicated service account's home |
| `--print-service` | off | print the embedded systemd unit to stdout and exit; no privileges needed |
| `--migrate-config` | off | rewrite `--config` to the current `config.json` schema, in place; see [Migrating config.json](#migrating-configjson) |

Expand Down Expand Up @@ -863,31 +864,43 @@ sudo systemctl restart coding-agent-loop
curl localhost:8787/status # gate/run state (from the host, not the service user)
```

To remove everything `--install` created:
To remove the service `--install` created, leaving all data in place:

```sh
sudo bin/coding-agent-loop --uninstall # or: make uninstall
```

`--uninstall` (root required) reverses every step of `--install`:
To remove the service *and* its data:

```sh
sudo bin/coding-agent-loop --uninstall --purge # or: make purge
```

`--uninstall` (root required) always does this much:

1. stops and disables `coding-agent-loop.service` (fine if it wasn't running);
2. reads `workspace.root`, `workspace.repos_root`, `workspace.logs_root`, `store.path`, and
2. removes `/etc/systemd/system/coding-agent-loop.service` and runs `systemctl daemon-reload`;
3. removes `/opt/coding-agent-loop` entirely (binary and config).

Only with `--purge` does it also:

4. read `workspace.root`, `workspace.repos_root`, `workspace.logs_root`, `store.path`, and
`claude.usage_cache_path` from `/opt/coding-agent-loop/config.json` (the copy that actually drove
the running service — falling back to whatever `--config` points at, default `config.json`, if
that copy is already gone, then to the compiled defaults under `~/.agent-loop` if neither is
found) and removes exactly those directories/files, resolved against the account the service ran
found) and remove exactly those directories/files, resolved against the account the service ran
as — **not** `claude.credentials_path`, which is Claude Code's own login and predates this app's
install;
3. removes `/etc/systemd/system/coding-agent-loop.service` and runs `systemctl daemon-reload`;
4. removes `/opt/coding-agent-loop` entirely (binary and config);
5. if `--install` ever fell back to creating the dedicated `coding-agent-loop` system user (no
`$SUDO_USER` at install time), removes that account and its entire home (`userdel -r`) — safe
because that account and home exist solely for this service, and a superset of step 2 for
`$SUDO_USER` at install time), remove that account and its entire home (`userdel -r`) — safe
because that account and home exist solely for this service, and a superset of step 4 for
anything under it.

Without `--purge`, the configured state paths and (if applicable) the dedicated account/home are
left untouched, and `--uninstall` logs each path it left behind.

If you moved `workspace.root`/`workspace.repos_root`/`workspace.logs_root`/`store.path`/
`claude.usage_cache_path` to non-default locations, step 2 follows your config there too — it does
`claude.usage_cache_path` to non-default locations, step 4 follows your config there too — it does
not assume `~/.agent-loop`. Run `--uninstall` the same way you ran `--install` (`sudo` from your own
account, or with the same `--config`) so it resolves the same account and config `--install` used.

Expand Down
22 changes: 20 additions & 2 deletions cmd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type flags struct {
checkOnly bool
install bool
uninstall bool
purge bool
printUnit bool
migrateConfig bool
}
Expand All @@ -61,7 +62,8 @@ Usage:
coding-agent-loop --print-service print the embedded systemd unit
coding-agent-loop --migrate-config bring config.json up to the current schema, in place
sudo coding-agent-loop --install install, enable, and start the systemd unit
sudo coding-agent-loop --uninstall stop, disable, and remove everything --install created
sudo coding-agent-loop --uninstall stop, disable, and remove the service only
sudo coding-agent-loop --uninstall --purge also delete workspace/logs/state data

See README.md for configuration (config.json, models.json) and the control API.

Expand All @@ -82,11 +84,17 @@ func main() {
flag.BoolVar(&f.noServer, "no-server", false, "do not start the control API")
flag.BoolVar(&f.checkOnly, "check", false, "run start-up checks and exit")
flag.BoolVar(&f.install, "install", false, "install the systemd unit (embedded in this binary), enable it, and start it; must run as root")
flag.BoolVar(&f.uninstall, "uninstall", false, "stop, disable, and remove the systemd unit, /opt/coding-agent-loop, and any ~/.agent-loop or dedicated service user --install created; must run as root")
flag.BoolVar(&f.uninstall, "uninstall", false, "stop, disable, and remove the systemd unit and /opt/coding-agent-loop; leaves data in place unless --purge is also given; must run as root")
flag.BoolVar(&f.purge, "purge", false, "with --uninstall: also delete the service's data — workspace.root, workspace.repos_root, workspace.logs_root, store.path, claude.usage_cache_path, and the dedicated service account's home. Without it, --uninstall removes only the service and leaves all data in place")
flag.BoolVar(&f.printUnit, "print-service", false, "print the systemd unit --install would write and exit")
flag.BoolVar(&f.migrateConfig, "migrate-config", false, "rewrite -config to the current config.json schema: keep every value already set, add new fields at their default, drop and report fields the schema no longer has; the original is saved as -config.bak first. Combine with -dry-run to preview on stdout instead of writing anything")
flag.Parse()

if err := validateFlags(f); err != nil {
fmt.Fprintf(os.Stderr, "coding-agent-loop: %v\n", err)
os.Exit(1)
}

if f.printUnit {
unit, err := install.PreviewUnit()
if err != nil {
Expand All @@ -103,6 +111,15 @@ func main() {
}
}

// validateFlags rejects flag combinations that parse individually but make
// no sense together.
func validateFlags(f flags) error {
if f.purge && !f.uninstall {
return fmt.Errorf("--purge only applies to --uninstall (try: sudo coding-agent-loop --uninstall --purge)")
}
return nil
}

func run(f flags) error {
log := newLogger(f.logLevel)

Expand All @@ -115,6 +132,7 @@ func run(f flags) error {
if f.uninstall {
return install.Uninstall(install.UninstallOptions{
ConfigPath: f.configPath,
Purge: f.purge,
Log: func(format string, args ...any) { log.Info(format, args...) },
})
}
Expand Down
96 changes: 73 additions & 23 deletions internal/install/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,18 +277,27 @@ type UninstallOptions struct {
// ConfigPath is consulted for workspace/store paths only when
// installedConfigPath does not exist. Optional.
ConfigPath string
Log func(format string, args ...any)
// Purge, when true, also deletes the service's data: the configured
// state paths (workspace.root, workspace.repos_root, workspace.logs_root,
// store.path, claude.usage_cache_path) and, if Run ever created it, the
// dedicated service account and its home. When false, --uninstall removes
// only the service and leaves all of that data in place.
Purge bool
Log func(format string, args ...any)
}

// Uninstall reverses Run: stops and disables the unit, removes the unit file
// and /opt/coding-agent-loop, and removes exactly the state directories and
// files the operator's config.json told the service to use — workspace.root,
// workspace.repos_root, workspace.logs_root, store.path, and
// claude.usage_cache_path — resolved against the account the service ran as,
// the same way Run resolves it. It never touches claude.credentials_path:
// that file is Claude Code's own login, not something this app created, and
// other tools may depend on it surviving. It must run as root, since it
// touches /etc, /opt, and the service account's files.
// Uninstall reverses the service-installing part of Run unconditionally:
// stops and disables the unit, removes the unit file, and removes
// /opt/coding-agent-loop. When opts.Purge is set, it additionally removes the
// state directories and files the operator's config.json told the service to
// use — workspace.root, workspace.repos_root, workspace.logs_root,
// store.path, and claude.usage_cache_path — resolved against the account the
// service ran as, the same way Run resolves it, and (if Run ever created it)
// the dedicated service account and its home. It never touches
// claude.credentials_path under either flag: that file is Claude Code's own
// login, not something this app created, and other tools may depend on it
// surviving. It must run as root, since it touches /etc, /opt, and the
// service account's files.
func Uninstall(opts UninstallOptions) error {
if os.Geteuid() != 0 {
return fmt.Errorf("--uninstall must run as root (try: sudo %s --uninstall)", os.Args[0])
Expand All @@ -309,7 +318,7 @@ func Uninstall(opts UninstallOptions) error {
if err != nil {
log("could not resolve the service account; skipping its state directories", "error", err.Error())
} else {
removeConfiguredStatePaths(t.home, opts.ConfigPath, log)
applyStatePaths(t.home, opts.ConfigPath, opts.Purge, log)
}

if _, err := os.Stat(unitPath); err == nil {
Expand All @@ -331,11 +340,16 @@ func Uninstall(opts UninstallOptions) error {

// The dedicated fallback user, if Run ever created one: its entire home
// exists solely for this service, so the account and home go together.
// This is a superset of removeConfiguredStatePaths above when state paths
// live under that home (the common case), and also mops up anything else
// under it.
// This is a superset of applyStatePaths above when state paths live under
// that home (the common case), and also mops up anything else under it.
// Only removed when purging: userdel without -r would orphan the home to
// a dangling uid, and a later --install would recreate the account with a
// possibly different uid, leaving any retained data unreadable.
if _, err := user.Lookup(dedicatedUser); err == nil {
if _, lookErr := exec.LookPath("userdel"); lookErr != nil {
if !opts.Purge {
log("leaving service account and home in place, re-run with --uninstall --purge to remove them",
"user", dedicatedUser, "home", dedicatedHome)
} else if _, lookErr := exec.LookPath("userdel"); lookErr != nil {
log("userdel not available; remove the service user and its home manually",
"user", dedicatedUser, "home", dedicatedHome)
} else {
Expand Down Expand Up @@ -426,15 +440,51 @@ func expandHome(p, home string) string {
return p
}

// removeConfiguredStatePaths removes exactly the directories/file the
// service's own config told it to use, resolved against home.
func removeConfiguredStatePaths(home, fallbackConfigPath string, log func(string, ...any)) {
// resolvedStatePaths returns the directories and files the service was
// configured to write, resolved against home.
func resolvedStatePaths(home, fallbackConfigPath string, log func(string, ...any)) (dirs, files []string) {
paths := loadStatePaths(fallbackConfigPath, log)
removeDir(expandHome(paths.Workspace.Root, home), log)
removeDir(expandHome(paths.Workspace.ReposRoot, home), log)
removeDir(expandHome(paths.Workspace.LogsRoot, home), log)
removeFile(expandHome(paths.Store.Path, home), log)
removeFile(expandHome(paths.Claude.UsageCachePath, home), log)
dirs = []string{
expandHome(paths.Workspace.Root, home),
expandHome(paths.Workspace.ReposRoot, home),
expandHome(paths.Workspace.LogsRoot, home),
}
files = []string{
expandHome(paths.Store.Path, home),
expandHome(paths.Claude.UsageCachePath, home),
}
return dirs, files
}

// applyStatePaths removes the configured state directories/files when purge
// is set. Otherwise it leaves them in place and logs exactly what was
// retained and how to remove it later.
func applyStatePaths(home, fallbackConfigPath string, purge bool, log func(string, ...any)) {
dirs, files := resolvedStatePaths(home, fallbackConfigPath, log)
if !purge {
for _, d := range dirs {
logRetainedPath(d, log)
}
for _, f := range files {
logRetainedPath(f, log)
}
return
}
for _, d := range dirs {
removeDir(d, log)
}
for _, f := range files {
removeFile(f, log)
}
}

// logRetainedPath logs a state path left in place by a non-purge uninstall,
// if it actually exists.
func logRetainedPath(path string, log func(string, ...any)) {
if _, err := os.Stat(path); err != nil {
return
}
log("data retained, re-run with --uninstall --purge to delete this data", "path", path)
}

func removeDir(dir string, log func(string, ...any)) {
Expand Down
91 changes: 91 additions & 0 deletions internal/install/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,97 @@ func TestLoadStatePathsFallsBackToDefaultsWhenNeitherConfigExists(t *testing.T)
}
}

func TestApplyStatePathsRemovesWhenPurging(t *testing.T) {
home := t.TempDir()
work := filepath.Join(home, ".agent-loop", "work")
logs := filepath.Join(home, ".agent-loop", "logs")
state := filepath.Join(home, ".agent-loop", "state.db")
if err := os.MkdirAll(work, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(logs, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(state, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
installed := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(installed, []byte(`{"workspace":{"root":"`+work+`","logs_root":"`+logs+`"},"store":{"path":"`+state+`"}}`), 0o644); err != nil {
t.Fatal(err)
}
restore := setInstalledConfigPathForTest(installed)
defer restore()

applyStatePaths(home, "", true, func(string, ...any) {})

for _, p := range []string{work, logs, state} {
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Errorf("expected %s to be removed after purge, stat err = %v", p, err)
}
}
}

func TestApplyStatePathsKeepsDataWhenNotPurging(t *testing.T) {
home := t.TempDir()
work := filepath.Join(home, ".agent-loop", "work")
logs := filepath.Join(home, ".agent-loop", "logs")
state := filepath.Join(home, ".agent-loop", "state.db")
if err := os.MkdirAll(work, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(logs, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(state, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
installed := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(installed, []byte(`{"workspace":{"root":"`+work+`","logs_root":"`+logs+`"},"store":{"path":"`+state+`"}}`), 0o644); err != nil {
t.Fatal(err)
}
restore := setInstalledConfigPathForTest(installed)
defer restore()

logged := false
applyStatePaths(home, "", false, func(string, ...any) { logged = true })

for _, p := range []string{work, logs, state} {
if _, err := os.Stat(p); err != nil {
t.Errorf("expected %s to survive a non-purge uninstall, stat err = %v", p, err)
}
}
if !logged {
t.Error("expected applyStatePaths to log the retained paths")
}
}

func TestApplyStatePathsHonoursConfiguredPaths(t *testing.T) {
dataRoot := t.TempDir()
customWork := filepath.Join(dataRoot, "custom-work")
customState := filepath.Join(dataRoot, "custom-state.db")
if err := os.MkdirAll(customWork, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(customState, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
installed := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(installed, []byte(`{"workspace":{"root":"`+customWork+`"},"store":{"path":"`+customState+`"}}`), 0o644); err != nil {
t.Fatal(err)
}
restore := setInstalledConfigPathForTest(installed)
defer restore()

applyStatePaths(t.TempDir(), "", true, func(string, ...any) {})

if _, err := os.Stat(customWork); !os.IsNotExist(err) {
t.Errorf("expected configured workspace root %s to be removed, stat err = %v", customWork, err)
}
if _, err := os.Stat(customState); !os.IsNotExist(err) {
t.Errorf("expected configured store path %s to be removed, stat err = %v", customState, err)
}
}

// setInstalledConfigPathForTest overrides the package-level installedConfigPath
// for the duration of a test and returns a func to restore it.
func setInstalledConfigPathForTest(path string) func() {
Expand Down
Loading