Skip to content

feat(service): devstrap service install|uninstall|status (P4-PROD-04)#139

Merged
Reederey87 merged 1 commit into
mainfrom
feat/p4-prod-04-service-install
Jul 6, 2026
Merged

feat(service): devstrap service install|uninstall|status (P4-PROD-04)#139
Reederey87 merged 1 commit into
mainfrom
feat/p4-prod-04-service-install

Conversation

@Reederey87

@Reederey87 Reederey87 commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Ships audit finding P4-PROD-04: devstrap service install|uninstall|status — the run-loop wrapped in a per-user launchd LaunchAgent (macOS) or systemd --user service (Linux), so the workspace converges unattended without a bespoke daemon. Ledger row moved to Recently shipped; spec/14's two installer rows flipped.

Design

  • Platform layer: ServiceSpec enriched (Description, WorkingDir, launchd-only log paths, RestartOnFailure/RestartDelaySeconds); ServiceManager gains DefaultLabel() and Install → (notes, err) so OS-idiomatic advisories (the Linux linger note) originate in the adapter — the goos-guard keeps OS branching out of the CLI. Untagged, golden-tested render/argv/parse logic; tagged LaunchdManager (com.devstrap.run-loop, modern bootstrap/bootout/print, idempotent reinstall) and SystemdUserManager (devstrap-run-loop.service, availability probe → typed ErrUnsupported, linger advisory).
  • CLI: install refuses an unconfigured hub (a service failing every tick just manufactures a restart-throttle loop) and an ephemeral $TMPDIR/go-build exec path; bakes run-loop args (interval, --namespace-only, absolutized --hub-file/--home/--root/--config when explicitly set); Env stays nil — adapters add only PATH, no secret ever enters a unit file (test-pinned). status honors --json; doctor gains an optional run-loop service check.

Live dogfood (real launchd, this Mac) caught two real bugs pre-merge

  1. launchctl print emits the service's top-level state = running before nested per-endpoint state = active lines — last-match parsing misreported a live service as stopped. Parse now takes each key's first occurrence (regression-pinned with the real output).
  2. bootout tears down asynchronously; an immediate reinstall's bootstrap raced the dying job (Bootstrap failed: 5: Input/output error). Install now polls until the label leaves the domain (bounded ~3s) before bootstrapping.

Also live-verified: fresh install → real tick ("run-loop tick: scan + sync + materialize"), running status with pid, reinstall over a running service, idempotent uninstall. No residue left.

Dual-review fixes (coordinator + Codex)

  • Label validation (validateServiceLabel): --label ../../evil previously wrote/deleted files outside ~/Library/LaunchAgents / ~/.config/systemd/user via filepath.Join and corrupted launchctl domain targets. Now gated at every adapter entry point.
  • Fail-closed control-character gate in both renderers (Codex High): systemd units are line-oriented — a \n in an exec path/arg/env value injected arbitrary directives past systemdQuote; raw control bytes also make launchd reject a plist silently. Both renderers now refuse any control character.
  • Propagated root flags absolutized: a relative --home/--root/--config baked into a unit would resolve against the supervisor's cwd, not the install-time cwd.
  • Accepted as-is (Codex Low): atomicWrite guarantees no partial read, not crash-durability.

Docs

spec/00 (inventory + not-implemented truth-up), spec/05 (shipped installer replaces the deferred daemon install; PLAT-05 resolved; exit-78/127 troubleshooting), spec/06 (unit shape + linger + fail-closed keychain custody — no DEVSTRAP_NO_KEYCHAIN auto-bake, per P6-XP-04), spec/13 ### service, spec/14, spec/16, ledger, work log.

Validation

gofmt clean · go build darwin+linux · GOOS=linux go vet · golangci-lint run 0 issues · go test -race ./... ok · linux-tagged tests green in Docker · TestEveryCommandIsDocumented · spec-drift --base origin/main pass · live launchd dogfood (see above) · dual review (opus implementation + coordinator line-by-line + Codex).

Follow-up noted in the work log: a native-Linux golangci-lint pass before the next Linux-touching PR.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added devstrap service with install, uninstall, and status (macOS LaunchAgent and Linux systemd --user).
    • service status supports --json, and surfaces useful log/unit details.
    • doctor now reports whether the background service is installed and running (when supported).
  • Bug Fixes

    • Improved reliability and idempotency for install/uninstall, including safer handling of unsupported platforms.
    • Hardened service setup: rejects unsafe executable paths, prevents malformed service labels/characters, and writes units/plists atomically with restrictive permissions.
  • Documentation

    • Updated specs and audit records to reflect the shipped devstrap service workflow.

@Reederey87 Reederey87 enabled auto-merge (squash) July 6, 2026 12:01
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d88f2593-af4c-4ddc-ae23-5b49ba16d553

📥 Commits

Reviewing files that changed from the base of the PR and between ce08940 and 0b2ff32.

📒 Files selected for processing (28)
  • docs/audits/README.md
  • internal/cli/doctor.go
  • internal/cli/doctor_test.go
  • internal/cli/root.go
  • internal/cli/run_loop.go
  • internal/cli/service.go
  • internal/cli/service_test.go
  • internal/platform/detect_darwin.go
  • internal/platform/detect_linux.go
  • internal/platform/platform.go
  • internal/platform/platform_test.go
  • internal/platform/service_darwin.go
  • internal/platform/service_darwin_test.go
  • internal/platform/service_launchd.go
  • internal/platform/service_launchd_test.go
  • internal/platform/service_linux.go
  • internal/platform/service_linux_test.go
  • internal/platform/service_systemd.go
  • internal/platform/service_systemd_test.go
  • internal/platform/testdata/run_loop.plist.golden
  • internal/platform/testdata/run_loop.service.golden
  • spec/00_START_HERE.md
  • spec/05_MAC_FIRST_IMPLEMENTATION.md
  • spec/06_LINUX_COMPATIBILITY.md
  • spec/13_CLI_DAEMON_API.md
  • spec/14_MVP_ROADMAP_AND_BACKLOG.md
  • spec/16_TEST_PLAN.md
  • spec/18_WORK_LOG.md

📝 Walkthrough

Walkthrough

This PR adds devstrap service install|uninstall|status for macOS LaunchAgent and Linux systemd user services, expands the platform service contract, wires CLI and doctor support, and updates specs/audit docs to mark the feature shipped.

Changes

Background Service Management

Layer / File(s) Summary
ServiceManager contract and platform detection wiring
internal/platform/platform.go, internal/platform/platform_test.go, internal/platform/detect_darwin.go, internal/platform/detect_linux.go
ServiceSpec/ServiceStatus gain new fields, ServiceManager adds DefaultLabel() and Install returns (notes, error); Detect() now registers LaunchdManager{}/SystemdUserManager{} instead of unsupported placeholders.
macOS launchd adapter
internal/platform/service_launchd.go, internal/platform/service_darwin.go, internal/platform/service_launchd_test.go, internal/platform/service_darwin_test.go, internal/platform/testdata/run_loop.plist.golden
LaunchdManager renders/writes plists, runs idempotent launchctl bootout/bootstrap, and parses launchctl print for status; includes golden plist and argv/parsing tests.
Linux systemd-user adapter
internal/platform/service_systemd.go, internal/platform/service_linux.go, internal/platform/service_systemd_test.go, internal/platform/service_linux_test.go, internal/platform/testdata/run_loop.service.golden
SystemdUserManager renders/writes unit files, runs systemctl --user reload/enable/restart, checks linger status, and reports is-active status; includes golden unit and argv tests.
CLI service command and doctor integration
internal/cli/service.go, internal/cli/service_test.go, internal/cli/root.go, internal/cli/run_loop.go, internal/cli/doctor.go, internal/cli/doctor_test.go
New service install/uninstall/status command validates hub config, resolves exec path (rejecting ephemeral paths), bakes run-loop args, and installs via the platform manager; doctor gains checkService for OK/WARN reporting.
Spec and audit documentation
docs/audits/README.md, spec/00_START_HERE.md, spec/05_MAC_FIRST_IMPLEMENTATION.md, spec/06_LINUX_COMPATIBILITY.md, spec/13_CLI_DAEMON_API.md, spec/14_MVP_ROADMAP_AND_BACKLOG.md, spec/16_TEST_PLAN.md, spec/18_WORK_LOG.md
Marks P4-PROD-04 shipped and updates roadmap, CLI, platform, and test-plan docs to describe the new service feature.

Estimated code review effort: 4 (Complex) | ~75 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it does not follow the required template: the Tests and Safety Checklist sections are missing. Add the ## Tests section with the two checklist items and a ## Safety Checklist section with the four required safety items.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly names the shipped service command set and matches the main change.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/p4-prod-04-service-install

Comment @coderabbitai help to get the list of available commands.

Wrap run-loop in a per-user launchd LaunchAgent (com.devstrap.run-loop,
modern bootstrap/bootout/print) or systemd --user service
(devstrap-run-loop.service) so the workspace converges unattended.
Enriched ServiceSpec/ServiceManager behind internal/platform; untagged
golden-tested renderers; install refuses an unconfigured hub and
ephemeral exec paths, writes units atomically 0600, bakes no secrets;
doctor gains an optional service check. Live launchd dogfood caught and
fixed a status misparse (nested state lines) and a reinstall race
(asynchronous bootout). Dual-review fixes: label validation (path
traversal via --label), fail-closed control-character gates in both
renderers (systemd unit-line injection), absolutized root flags.
Ledger: P4-PROD-04 -> Recently shipped; spec/14 installer rows flipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Reederey87 Reederey87 force-pushed the feat/p4-prod-04-service-install branch from ce08940 to 0b2ff32 Compare July 6, 2026 12:04
@Reederey87 Reederey87 merged commit 48ff4c0 into main Jul 6, 2026
6 of 7 checks passed
@Reederey87 Reederey87 deleted the feat/p4-prod-04-service-install branch July 6, 2026 12:11

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
internal/cli/service_test.go (1)

67-101: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add coverage for --root/--config absolutizing.

TestServiceInstallBuildsRunLoopArgs only exercises --home. The commit message specifically calls out "absolutizing propagated root flags" as a dogfooding/review fix, so --root and --config deserve direct assertions too (e.g. a relative --root/--config resolving to an absolute path in the baked args), not just --home.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/service_test.go` around lines 67 - 101,
`TestServiceInstallBuildsRunLoopArgs` only verifies `--home`, but the install
path should also cover absolutizing propagated root flags. Extend this test (or
add a sibling in the same service test suite) to pass relative `--root` and
`--config` values through `executeForTest`, then assert the baked
`installedSpec.Args` from `fakeServiceManager` contain their absolute
equivalents; use the existing `withFakeService`, `executeForTest`, and
`installedSpec.Args` checks to locate the flow.
internal/cli/service.go (1)

82-85: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate ErrUnsupported wrapping + label-resolution logic across subcommands.

The errors.Is(err, platform.ErrUnsupported) wrap block is repeated verbatim in install/uninstall/status, and the resolvedLabel := label; if resolvedLabel == "" { resolvedLabel = mgr.DefaultLabel() } pattern is also repeated 3x (lines 62-65, 117-120, 160-163). Extracting small helpers would reduce drift risk if the message or fallback logic ever needs to change.

♻️ Suggested helpers
+func wrapServiceErr(err error) error {
+	if errors.Is(err, platform.ErrUnsupported) {
+		return appError{code: exitGeneric, err: fmt.Errorf("background service is not supported on this platform/session: %w", err)}
+	}
+	return err
+}
+
+func resolveServiceLabel(label string, mgr platform.ServiceManager) string {
+	if label == "" {
+		return mgr.DefaultLabel()
+	}
+	return label
+}

Also applies to: 125-129, 166-169

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/service.go` around lines 82 - 85, The same ErrUnsupported
wrapping and label fallback logic is duplicated across the service subcommands,
so extract small helpers in service.go to keep install, uninstall, and status
consistent. Create one helper for resolving the label from
label/mgr.DefaultLabel(), and another for converting platform.ErrUnsupported
into the appError with the shared message, then replace the repeated blocks in
the subcommand handlers with those helpers.
internal/cli/doctor.go (1)

256-256: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Unbounded mgr.Status call inside a health check.

checkService shells out to launchctl print / systemctl status via mgr.Status(ctx, label) with whatever context doctor is given, and this diff doesn't add a timeout around it. If the OS service manager ever hangs (documented as happening with launchd in some environments), devstrap doctor — which is expected to run many quick checks — could hang indefinitely on this one check.

Please confirm whether cmd.Context() upstream already carries a deadline for CLI commands; if not, consider wrapping this specific call with context.WithTimeout.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/doctor.go` at line 256, The checkService health check is calling
mgr.Status with an unbounded context, so a hanging service manager can stall
doctor indefinitely. Update checkService to use a bounded context for this
specific mgr.Status call, either by confirming cmd.Context() already has a
deadline or by wrapping it with context.WithTimeout before invoking mgr.Status,
then canceling it promptly afterward. Refer to checkService and mgr.Status to
place the fix in the right spot.
internal/platform/service_darwin.go (1)

45-63: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

spec.Env mutation leaks into the caller's map.

ServiceSpec is passed by value, but Env is a map[string]string; when the caller supplies a non-nil Env, writing spec.Env["PATH"] = ... here mutates the same underlying map the caller holds a reference to, since map headers are copied by value but point at shared backing data. If the CLI layer reuses, inspects, or logs the original spec after calling Install (e.g. for status --json or diagnostics), it will observe the injected PATH it never set.

Clone the map before mutating it so Install never has externally-visible side effects on caller-owned data.

♻️ Proposed fix to avoid mutating the caller's map
-	if spec.Env == nil {
-		spec.Env = map[string]string{}
-	}
+	env := make(map[string]string, len(spec.Env)+1)
+	for k, v := range spec.Env {
+		env[k] = v
+	}
+	spec.Env = env
 	if spec.Env["PATH"] == "" {
 		spec.Env["PATH"] = defaultDarwinPath(filepath.Dir(spec.ExecPath))
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/platform/service_darwin.go` around lines 45 - 63, The Install method
in LaunchdManager is mutating the caller-owned ServiceSpec.Env map when it seeds
PATH, which leaks side effects outside the function. In LaunchdManager.Install,
make a shallow copy of spec.Env before any writes, then apply the PATH default
on the copied map so the original spec passed by the caller is never modified.
Keep the existing validation and defaultDarwinPath logic unchanged, but ensure
all Env updates happen on the cloned map.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/cli/doctor.go`:
- Around line 253-256: `checkService` is always using `mgr.DefaultLabel()`, so
`doctor` can miss a service installed with a custom label. Update the
`checkService` flow in `doctor.go` to accept and use the same label passed at
install time (or read the persisted label) instead of hardcoding the default.
Make sure the label used by `checkService` matches the one used by the
install/status path so the status check and reinstall hint target the correct
unit name.

In `@internal/platform/service_linux.go`:
- Around line 47-50: The availability errors in service_linux.go are losing the
underlying failure because the fmt.Errorf calls in the systemd checks only wrap
ErrUnsupported and print err with %v. Update both error returns in the systemd
probing path (around the runSystemctl/systemd user manager checks) to wrap err
with %w as well so the original availability failure stays in the error chain.

---

Nitpick comments:
In `@internal/cli/doctor.go`:
- Line 256: The checkService health check is calling mgr.Status with an
unbounded context, so a hanging service manager can stall doctor indefinitely.
Update checkService to use a bounded context for this specific mgr.Status call,
either by confirming cmd.Context() already has a deadline or by wrapping it with
context.WithTimeout before invoking mgr.Status, then canceling it promptly
afterward. Refer to checkService and mgr.Status to place the fix in the right
spot.

In `@internal/cli/service_test.go`:
- Around line 67-101: `TestServiceInstallBuildsRunLoopArgs` only verifies
`--home`, but the install path should also cover absolutizing propagated root
flags. Extend this test (or add a sibling in the same service test suite) to
pass relative `--root` and `--config` values through `executeForTest`, then
assert the baked `installedSpec.Args` from `fakeServiceManager` contain their
absolute equivalents; use the existing `withFakeService`, `executeForTest`, and
`installedSpec.Args` checks to locate the flow.

In `@internal/cli/service.go`:
- Around line 82-85: The same ErrUnsupported wrapping and label fallback logic
is duplicated across the service subcommands, so extract small helpers in
service.go to keep install, uninstall, and status consistent. Create one helper
for resolving the label from label/mgr.DefaultLabel(), and another for
converting platform.ErrUnsupported into the appError with the shared message,
then replace the repeated blocks in the subcommand handlers with those helpers.

In `@internal/platform/service_darwin.go`:
- Around line 45-63: The Install method in LaunchdManager is mutating the
caller-owned ServiceSpec.Env map when it seeds PATH, which leaks side effects
outside the function. In LaunchdManager.Install, make a shallow copy of spec.Env
before any writes, then apply the PATH default on the copied map so the original
spec passed by the caller is never modified. Keep the existing validation and
defaultDarwinPath logic unchanged, but ensure all Env updates happen on the
cloned map.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f9183084-1284-4266-93ec-fa105d218e5b

📥 Commits

Reviewing files that changed from the base of the PR and between b326c56 and ce08940.

📒 Files selected for processing (28)
  • docs/audits/README.md
  • internal/cli/doctor.go
  • internal/cli/doctor_test.go
  • internal/cli/root.go
  • internal/cli/run_loop.go
  • internal/cli/service.go
  • internal/cli/service_test.go
  • internal/platform/detect_darwin.go
  • internal/platform/detect_linux.go
  • internal/platform/platform.go
  • internal/platform/platform_test.go
  • internal/platform/service_darwin.go
  • internal/platform/service_darwin_test.go
  • internal/platform/service_launchd.go
  • internal/platform/service_launchd_test.go
  • internal/platform/service_linux.go
  • internal/platform/service_linux_test.go
  • internal/platform/service_systemd.go
  • internal/platform/service_systemd_test.go
  • internal/platform/testdata/run_loop.plist.golden
  • internal/platform/testdata/run_loop.service.golden
  • spec/00_START_HERE.md
  • spec/05_MAC_FIRST_IMPLEMENTATION.md
  • spec/06_LINUX_COMPATIBILITY.md
  • spec/13_CLI_DAEMON_API.md
  • spec/14_MVP_ROADMAP_AND_BACKLOG.md
  • spec/16_TEST_PLAN.md
  • spec/18_WORK_LOG.md

Comment thread internal/cli/doctor.go
Comment on lines +253 to +256
func checkService(ctx context.Context, opts *options) []checkResult {
mgr := serviceBackend()
label := mgr.DefaultLabel()
status, err := mgr.Status(ctx, label)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if the chosen service label is persisted anywhere (config/state) for doctor to read back.
rg -nP '\bservice\.label\b|serviceLabel|SavedLabel' --type=go

Repository: Reederey87/DevStrap

Length of output: 157


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline internal/cli/doctor.go --view expanded
printf '\n---\n'
ast-grep outline internal/cli/service.go --view expanded
printf '\n---\n'
rg -n "DefaultLabel|label|install|doctor|Status\(" internal/cli internal -g '*.go'
printf '\n--- service backend files ---\n'
rg -n "type .*Backend|func .*DefaultLabel|func .*Status|label" internal -g '*.go'

Repository: Reederey87/DevStrap

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- internal/cli/doctor.go (240-275) ---'
sed -n '240,275p' internal/cli/doctor.go
echo
echo '--- internal/cli/service.go (36-200) ---'
sed -n '36,200p' internal/cli/service.go
echo
echo '--- service manager interface ---'
rg -n "type ServiceManager interface|DefaultLabel|Status\(" internal -g '*.go'

Repository: Reederey87/DevStrap

Length of output: 12833


doctor should accept the service label used at install time
checkService always probes mgr.DefaultLabel(), so a devstrap service install --label <custom> can still be reported as “not installed” and the reinstall hint points at a second default-labeled instance. Add a --label path here, or persist the chosen label, so doctor checks the same unit name as install and status.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/doctor.go` around lines 253 - 256, `checkService` is always
using `mgr.DefaultLabel()`, so `doctor` can miss a service installed with a
custom label. Update the `checkService` flow in `doctor.go` to accept and use
the same label passed at install time (or read the persisted label) instead of
hardcoding the default. Make sure the label used by `checkService` matches the
one used by the install/status path so the status check and reinstall hint
target the correct unit name.

Comment thread internal/platform/service_linux.go Outdated
Reederey87 added a commit that referenced this pull request Jul 6, 2026
spec/14 DIRECTION bullet marking the wave complete (draft-pending
quarantine #133, self-healing WCK rotation #134, squash-merge worktree
GC P4-GIT-04, devstrap service P4-PROD-04) with the post-wave backlog
and the Pass-7 audit checkpoint; ledger intro trail extended.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant