diff --git a/docs/audits/README.md b/docs/audits/README.md index 4e915ac..93b17d9 100644 --- a/docs/audits/README.md +++ b/docs/audits/README.md @@ -43,6 +43,7 @@ Currently-actionable findings, pass-scoped. Earlier passes (1–3) are largely i | P4-SEC-04 | P1 | PRs #54/#57 (2026-07-03) | Pre-enrollment bootstrap window: local pairing plane fully closed. Part 1 (PR #54): full 256-bit device fingerprint (`internal/devicekeys/fingerprint.go`, golden-vector-pinned) binding signing key + age recipient, compare-and-confirm gate on `devices approve`/`enroll --approve` before any DB write, `SECU-05` keyless-placeholder refusal. Part 2 (PR #57): one-paste `devstrap-pair1:` pairing codes (`devices pairing-code`, `init --join --code [--fingerprint]`, `devices enroll --code --approve --fingerprint`), unauthenticated-by-design blob with integrity from the derived-fingerprint ceremony; control-char rejection; carried fields surfaced at confirm time. Proven live on R2 (dogfood run 3). Remaining (tracked separately): authenticated snapshot exchange, remote trust propagation. | | P4-SEC-07 (rotation) | P2 | PR #56 (2026-07-03) | Periodic (non-revoke) WCK rotation — the last open remainder: `keys rotate` (pure Rotate, wrap-first atomic, no revoke side effects) + age-triggered auto-rotation in `sync` (`keys.rotate_max_age` default 90d, `0` disables; post-pull/pre-push with same-cycle grant push; storm suppression via pulled-grant age reset), doctor `workspace key age`. Forward exposure only; documented-not-built: old-epoch containment, keychain-slot growth. Proven live on R2 (dogfood run 3: epoch 1→2 converged in one sync per device). With the foundation (PR #25) and pairing (PRs #48–#50) rows above, P4-SEC-07 is fully shipped. | | P4-GIT-03 | P1 | slices 1–6 (2026-07-05, PRs #107/#121–#124/#125/#127 + read-confine) | OS-enforced agent sandbox, fully shipped. macOS Seatbelt + Linux bubblewrap + Linux Landlock fallback confine writes to the worktree/tmp, deny credential reads (full-fidelity backends), and deny policy-scoped network; a pure-Go seccomp syscall denylist (mount/kexec/ptrace/keyring/io_uring/legacy-escape → EPERM) rides both Linux backends; unsigned local `sandbox.violation` telemetry records backend/mode/limitations per run and persists tagged macOS Seatbelt denials, surfaced by `agent show`/`doctor`; and tighter read confinement (`--read-confine auto\|on\|off`, default-on for the readonly policy, `--read-allow` extras) restricts reads to the worktree/tmp, OS toolchain roots, and $HOME build caches on all three backends — which also gives the Landlock fallback a credential-read boundary it otherwise lacks. `--sandbox auto\|off\|require` gates the whole thing; `require` refuses to launch when a requested confinement cannot be enforced. Documented-not-built follow-ups (not `P4-GIT-03` remainders): Linux runtime denial detection, macOS `mach-lookup` tightening, and containerization (spec/14 roadmap). | +| P4-PROD-04 | P2 | `feat/p4-prod-04-service-install` | `devstrap service install\|uninstall\|status` wraps `run-loop` in a per-user launchd LaunchAgent (macOS, `com.devstrap.run-loop`, modern `bootstrap`/`bootout`/`print` verbs) or systemd `--user` service (Linux, `devstrap-run-loop.service`) so the workspace converges unattended. The OS branch lives entirely behind `internal/platform` (`ServiceSpec`/`ServiceManager` enriched; `service_launchd.go`/`service_systemd.go` untagged + golden-tested, `service_darwin.go`/`service_linux.go` PATH-shim-tested). Install refuses an unconfigured hub and an ephemeral `$TMPDIR`/`go-build` exec path, writes the plist/unit atomically at `0600`, restarts on failure throttled to 30s, and bakes no secret (adapters add only `PATH`); the systemd path advises `loginctl enable-linger` when lingering is off and never auto-downgrades keychain custody (`P6-XP-04`). `doctor` folds in an optional service check. Docs: `spec/13` §service, `spec/05`/`spec/06` shipped installers, `spec/16` test rows. (Pass-4 finding — does not count toward the Pass-6 total.) | | P6-SYNC-01 | P1 | PR #30 | Per-event `event_verification_failure` quarantine + approve-time replay; one bad signed event no longer wedges the batch. Open residual: synced `device.revoked` trust propagation (revoke is still local-only). | | P6-SEC-01 | P1 | PRs #31/#33/#34 | (a) grant carriers verified before WCK ingestion; (b/c) `(epoch, kid)`-addressed custody, overwrite refusal, grant-preferred `PushKey`, replay-time grant ingestion. Fully shipped. | | P6-SEC-02 | P2 | PRs #32/#33 | Founder/join split (`init --join`, pull-before-push, founder gate) + `(epoch, kid)` keying; a joiner never self-mints or loses pre-approval events. Open residuals: `P6-SEC-03` truncate wedge, `P4-SEC-04` part 2 (one-paste pairing code; fingerprint confirmation shipped). | @@ -167,7 +168,7 @@ Currently-actionable findings, pass-scoped. Earlier passes (1–3) are largely i | P4-QUAL-04 | P2 | Enforce coverage in CI + add a Windows build _(narrowed 2026-07-05: the coverage half shipped — ci.yml generates `coverage.out` and gates a 50% `go tool cover -func` floor, closing `P5-QUAL-04`; remaining open scope is the Windows build only — `internal/platform` build tags are already windows-clean, known runtime risk: the `syscall.Signal(0)` liveness probe in `internal/cli/repo_lock.go`)_ | | P4-QUAL-05 | — | **Shipped v0.1.1 (2026-07-05)** — moved to _Recently shipped_ above. | | P4-QUAL-07 | P3 | Enable resource/context-leak linters (`bodyclose`, `sqlclosecheck`, `contextcheck`, …) | -| P4-PROD-04 | P2 | `devstrap service install` (LaunchAgent/systemd unit wrapping `run-loop`) | +| P4-PROD-04 | — | **Shipped 2026-07-06** (`feat/p4-prod-04-service-install`) — `devstrap service install\|uninstall\|status` (LaunchAgent/systemd unit wrapping `run-loop`) — moved to _Recently shipped_ above. | | P4-PROD-05 | — | **Shipped v0.1.0 (2026-07-05)** — moved to _Recently shipped_ above. | ### Pass 3 (2026-06-28 cloud-sync) — residual diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 29b291f..6928ffb 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -3,6 +3,7 @@ package cli import ( "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -240,9 +241,39 @@ func runDoctorChecks(ctx context.Context, opts *options) []checkResult { results = append(results, checkResult{Name: "state database", Status: checkError, Detail: err.Error()}) } results = append(results, checkRepoLocks(paths.Home)...) + results = append(results, checkService(ctx, opts)...) return results } +// checkService reports the background run-loop service's health (P4-PROD-04). +// It is entirely optional: an unsupported platform/session omits the check, a +// not-installed service reports ok (with the install hint), a running service +// reports ok, and an installed-but-stopped service warns with an inspection +// remedy. +func checkService(ctx context.Context, opts *options) []checkResult { + mgr := serviceBackend() + label := mgr.DefaultLabel() + status, err := mgr.Status(ctx, label) + if err != nil { + if errors.Is(err, platform.ErrUnsupported) { + return nil + } + return []checkResult{{Name: "run-loop service", Status: checkWarn, Detail: err.Error()}} + } + if !status.Installed { + return []checkResult{{Name: "run-loop service", Status: checkOK, Detail: "not installed (optional; `devstrap service install` for unattended sync)"}} + } + if status.Running { + return []checkResult{{Name: "run-loop service", Status: checkOK, Detail: fmt.Sprintf("installed and running (%s)", status.Detail)}} + } + return []checkResult{{ + Name: "run-loop service", + Status: checkWarn, + Detail: fmt.Sprintf("installed but not running (%s)", status.Detail), + Remedy: fmt.Sprintf("inspect launchctl print / journalctl --user -u %s; reinstall with devstrap service install", label), + }} +} + func checkAgentRunSweep(ctx context.Context, opts *options, store *state.Store) []checkResult { _ = opts reconciled, stillRunning, err := sweepStaleAgentRuns(ctx, store) diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index 0bc6b11..6808c48 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -3,8 +3,10 @@ package cli import ( "context" "path/filepath" + "strings" "testing" + "github.com/Reederey87/DevStrap/internal/platform" "github.com/Reederey87/DevStrap/internal/state" "github.com/spf13/viper" ) @@ -159,3 +161,27 @@ func TestCheckHubHealthWorkspaceIDRowFileHub(t *testing.T) { t.Fatalf("checkHubHealth results = %+v, want workspace id row", results) } } + +func TestDoctorWarnsWhenServiceInstalledButStopped(t *testing.T) { + f := &fakeServiceManager{ + labelVal: "fake.run-loop", + statusVal: platform.ServiceStatus{Installed: true, Running: false, Detail: "not loaded", UnitPath: "/x/fake.plist"}, + } + withFakeService(t, f) + + v := viper.New() + v.Set("home", t.TempDir()) + opts := &options{v: v} + + results := checkService(context.Background(), opts) + if len(results) != 1 { + t.Fatalf("checkService results = %+v, want exactly one", results) + } + got := results[0] + if got.Name != "run-loop service" || got.Status != checkWarn { + t.Fatalf("service check = %+v, want a warning row", got) + } + if !strings.Contains(got.Remedy, "journalctl --user -u fake.run-loop") { + t.Errorf("remedy = %q, want the inspection hint", got.Remedy) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 87457ec..c3aa39d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -113,6 +113,7 @@ func NewRootCommand(stdout, stderr io.Writer) *cobra.Command { cmd.AddCommand(newEnvCommand(stdout, opts)) cmd.AddCommand(newRunCommand(stdout, opts)) cmd.AddCommand(newRunLoopCommand(stdout, opts)) + cmd.AddCommand(newServiceCommand(stdout, opts)) cmd.AddCommand(newAgentCommand(stdout, opts)) cmd.AddCommand(newDevicesCommand(stdout, opts)) cmd.AddCommand(newConflictsCommand(stdout, opts)) diff --git a/internal/cli/run_loop.go b/internal/cli/run_loop.go index 8651c2e..d97a6a3 100644 --- a/internal/cli/run_loop.go +++ b/internal/cli/run_loop.go @@ -16,6 +16,13 @@ import ( // or log consumer must be able to notice a persistently failing loop; swallowing // every error forever hides outages. A single transient failure resets on the // next success. +// +// When run under `devstrap service` (P4-PROD-04), a non-zero exit here trips +// the supervisor's restart, throttled by the 30s ThrottleInterval (launchd) / +// RestartSec (systemd) baked into the service spec — so a persistently failing +// loop backs off rather than hot-looping. Keep that delay comfortably shorter +// than a typical --interval so a genuinely transient failure still recovers +// promptly. const runLoopMaxConsecutiveFailures = 5 // newRunLoopCommand implements `devstrap run-loop` (XP-02): a portable diff --git a/internal/cli/service.go b/internal/cli/service.go new file mode 100644 index 0000000..b8d6963 --- /dev/null +++ b/internal/cli/service.go @@ -0,0 +1,293 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Reederey87/DevStrap/internal/platform" + "github.com/spf13/cobra" +) + +// serviceBackend resolves the platform service manager; a test seam like +// agent.go's sandboxBackend. +var serviceBackend = func() platform.ServiceManager { return platform.Detect().Service } + +// newServiceCommand implements `devstrap service install|uninstall|status` +// (P4-PROD-04): it wraps the existing `run-loop` in a per-user launchd +// LaunchAgent (macOS) or systemd user service (Linux) so the workspace +// converges unattended, without a bespoke daemon. +func newServiceCommand(stdout io.Writer, opts *options) *cobra.Command { + cmd := &cobra.Command{ + Use: "service", + Short: "Install the run-loop as a background OS service", + } + cmd.AddCommand(newServiceInstallCommand(stdout, opts)) + cmd.AddCommand(newServiceUninstallCommand(stdout, opts)) + cmd.AddCommand(newServiceStatusCommand(stdout, opts)) + return cmd +} + +func newServiceInstallCommand(stdout io.Writer, opts *options) *cobra.Command { + var interval time.Duration + var namespaceOnly bool + var hubFile string + var label string + var execPath string + cmd := &cobra.Command{ + Use: "install", + Short: "Install and start the run-loop background service", + Args: usageArgs(cobra.NoArgs), + RunE: func(cmd *cobra.Command, args []string) error { + stderr := cmd.ErrOrStderr() + // A service that cannot resolve a hub would relaunch and fail on + // every tick; refuse up front with the same remedy run-loop uses. + if err := hubConfigured(opts, hubFile); err != nil { + return appError{code: exitInvalidConfig, err: err} + } + resolvedExec, err := resolveServiceExecPath(execPath) + if err != nil { + return err + } + bakedArgs, err := serviceRunLoopArgs(cmd, opts, interval, namespaceOnly, hubFile) + if err != nil { + return err + } + mgr := serviceBackend() + resolvedLabel := label + if resolvedLabel == "" { + resolvedLabel = mgr.DefaultLabel() + } + logDir := opts.paths().LogDir() + spec := platform.ServiceSpec{ + Label: resolvedLabel, + Description: "DevStrap run-loop (scan + sync + materialize)", + ExecPath: resolvedExec, + Args: bakedArgs, + StdoutPath: filepath.Join(logDir, "run-loop.out.log"), + StderrPath: filepath.Join(logDir, "run-loop.err.log"), + // Coupled to run-loop's own consecutive-failure ceiling — see the + // note by runLoopMaxConsecutiveFailures. Env stays nil: the + // adapters add only PATH, and no secret ever enters a service file. + RestartOnFailure: true, + RestartDelaySeconds: 30, + } + notes, err := mgr.Install(cmd.Context(), spec) + if err != nil { + 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 + } + opts.progressf(stderr, "installed %s service %q\n", mgr.Name(), resolvedLabel) + if status, serr := mgr.Status(cmd.Context(), resolvedLabel); serr == nil && status.UnitPath != "" { + opts.progressf(stderr, "unit: %s\n", status.UnitPath) + } + opts.progressf(stderr, "logs: %s, %s\n", spec.StdoutPath, spec.StderrPath) + // Notes are operator advisories (e.g. the Linux linger caveat), not + // mere progress — print them verbatim even under --quiet. + for _, note := range notes { + _, _ = fmt.Fprintln(stderr, note) + } + return nil + }, + } + cmd.Flags().DurationVar(&interval, "interval", 5*time.Minute, "run-loop sync interval") + cmd.Flags().BoolVar(&namespaceOnly, "namespace-only", false, "sync namespace metadata only; skip materialization") + cmd.Flags().StringVar(&hubFile, "hub-file", "", "file-backed test hub path") + cmd.Flags().StringVar(&label, "label", "", "service label (defaults to the OS-idiomatic label)") + cmd.Flags().StringVar(&execPath, "exec-path", "", "absolute path to the devstrap binary the service runs (defaults to this binary)") + return cmd +} + +func newServiceUninstallCommand(stdout io.Writer, opts *options) *cobra.Command { + var label string + cmd := &cobra.Command{ + Use: "uninstall", + Short: "Stop and remove the run-loop background service", + Args: usageArgs(cobra.NoArgs), + RunE: func(cmd *cobra.Command, args []string) error { + stderr := cmd.ErrOrStderr() + mgr := serviceBackend() + resolvedLabel := label + if resolvedLabel == "" { + resolvedLabel = mgr.DefaultLabel() + } + // Best-effort pre-check so we can report the idempotent "not + // installed" case; a Status error here never blocks uninstall. + status, _ := mgr.Status(cmd.Context(), resolvedLabel) + if err := mgr.Uninstall(cmd.Context(), resolvedLabel); err != nil { + 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 + } + if status.Installed { + opts.progressf(stderr, "uninstalled %s service %q\n", mgr.Name(), resolvedLabel) + } else { + opts.progressf(stderr, "%s service %q not installed; nothing to do\n", mgr.Name(), resolvedLabel) + } + return nil + }, + } + cmd.Flags().StringVar(&label, "label", "", "service label (defaults to the OS-idiomatic label)") + return cmd +} + +// serviceStatusJSON is the --json shape for `service status`. +type serviceStatusJSON struct { + Manager string `json:"manager"` + Label string `json:"label"` + Installed bool `json:"installed"` + Running bool `json:"running"` + Detail string `json:"detail"` + UnitPath string `json:"unit_path"` +} + +func newServiceStatusCommand(stdout io.Writer, opts *options) *cobra.Command { + var label string + cmd := &cobra.Command{ + Use: "status", + Short: "Show the run-loop service status", + Args: usageArgs(cobra.NoArgs), + RunE: func(cmd *cobra.Command, args []string) error { + mgr := serviceBackend() + resolvedLabel := label + if resolvedLabel == "" { + resolvedLabel = mgr.DefaultLabel() + } + status, err := mgr.Status(cmd.Context(), resolvedLabel) + if err != nil { + 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 + } + if opts.v.GetBool("json") { + enc := json.NewEncoder(stdout) + enc.SetIndent("", " ") + return enc.Encode(serviceStatusJSON{ + Manager: mgr.Name(), + Label: resolvedLabel, + Installed: status.Installed, + Running: status.Running, + Detail: status.Detail, + UnitPath: status.UnitPath, + }) + } + _, _ = fmt.Fprintf(stdout, "manager: %s\n", mgr.Name()) + _, _ = fmt.Fprintf(stdout, "label: %s\n", resolvedLabel) + _, _ = fmt.Fprintf(stdout, "installed: %t\n", status.Installed) + _, _ = fmt.Fprintf(stdout, "running: %t\n", status.Running) + if status.Detail != "" { + _, _ = fmt.Fprintf(stdout, "detail: %s\n", status.Detail) + } + if status.UnitPath != "" { + _, _ = fmt.Fprintf(stdout, "unit: %s\n", status.UnitPath) + } + return nil + }, + } + cmd.Flags().StringVar(&label, "label", "", "service label (defaults to the OS-idiomatic label)") + return cmd +} + +// resolveServiceExecPath resolves the devstrap binary the service will run. An +// explicit --exec-path is honored verbatim but must be absolute. Otherwise the +// path comes from os.Executable() with symlinks resolved, and is REFUSED when +// it points at an ephemeral location (the OS temp dir or a `go build`/`go run` +// cache): baking such a path into a launchd/systemd unit would wire the service +// to a binary that disappears. +func resolveServiceExecPath(execPath string) (string, error) { + if execPath != "" { + if !filepath.IsAbs(execPath) { + return "", appError{code: exitInvalidConfig, err: fmt.Errorf("--exec-path must be absolute, got %q", execPath)} + } + return execPath, nil + } + exe, err := os.Executable() + if err != nil { + return "", appError{code: exitInvalidConfig, err: fmt.Errorf("resolve this binary's path: %w", err)} + } + resolved, err := filepath.EvalSymlinks(exe) + if err != nil { + return "", appError{code: exitInvalidConfig, err: fmt.Errorf("resolve this binary's path: %w", err)} + } + if isEphemeralExecPath(resolved) { + return "", appError{code: exitInvalidConfig, err: fmt.Errorf( + "this devstrap binary lives at an ephemeral path (%s); install devstrap to a stable location (e.g. /usr/local/bin) and re-run, or pass --exec-path ", resolved)} + } + return resolved, nil +} + +// isEphemeralExecPath reports whether p is under the OS temp dir or a Go build +// cache — the two ways os.Executable() resolves to a binary that will not +// survive (a `go run`/`go test` binary, or one unpacked to $TMPDIR). +func isEphemeralExecPath(p string) bool { + if tmp := os.TempDir(); tmp != "" { + // Resolve the temp dir's own symlinks (/var → /private/var on macOS) so + // the prefix test compares real path against real path. + if rt, err := filepath.EvalSymlinks(tmp); err == nil { + tmp = rt + } + // Segment-aware prefix: filepath.Rel keeps "/tmpfoo" from matching "/tmp". + if rel, err := filepath.Rel(tmp, p); err == nil && + rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return true + } + } + return strings.Contains(p, "go-build") +} + +// serviceRunLoopArgs builds the run-loop argv the service executes. It bakes +// the interval and optional --namespace-only/--hub-file (absolute), and +// propagates the root-level --home/--root/--config ONLY when the operator set +// them explicitly, so the service inherits the same non-default state locations +// the operator is using now. +func serviceRunLoopArgs(cmd *cobra.Command, opts *options, interval time.Duration, namespaceOnly bool, hubFile string) ([]string, error) { + args := []string{"run-loop", "--interval", interval.String()} + if namespaceOnly { + args = append(args, "--namespace-only") + } + if hubFile != "" { + abs, err := filepath.Abs(hubFile) + if err != nil { + return nil, appError{code: exitInvalidConfig, err: fmt.Errorf("resolve --hub-file: %w", err)} + } + args = append(args, "--hub-file", abs) + } + // Absolutize each propagated path (Codex review): the service resolves + // relative paths against launchd/systemd's working directory, not the + // install-time cwd, so a relative --home/--root/--config would point the + // long-lived service at the wrong state. + root := cmd.Root().PersistentFlags() + appendAbs := func(flag, value string) error { + abs, err := filepath.Abs(value) + if err != nil { + return appError{code: exitInvalidConfig, err: fmt.Errorf("resolve --%s: %w", flag, err)} + } + args = append(args, "--"+flag, abs) + return nil + } + if root.Changed("home") { + if err := appendAbs("home", opts.home); err != nil { + return nil, err + } + } + if root.Changed("root") { + if err := appendAbs("root", opts.root); err != nil { + return nil, err + } + } + if root.Changed("config") { + if err := appendAbs("config", opts.cfgFile); err != nil { + return nil, err + } + } + return args, nil +} diff --git a/internal/cli/service_test.go b/internal/cli/service_test.go new file mode 100644 index 0000000..04c1891 --- /dev/null +++ b/internal/cli/service_test.go @@ -0,0 +1,226 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "path/filepath" + "strings" + "testing" + + "github.com/Reederey87/DevStrap/internal/platform" +) + +// fakeServiceManager is an in-memory platform.ServiceManager injected via the +// serviceBackend seam so the CLI tests never touch launchctl/systemctl. +type fakeServiceManager struct { + nameVal string + labelVal string + installNotes []string + installErr error + uninstallErr error + statusVal platform.ServiceStatus + statusErr error + + installedSpec *platform.ServiceSpec + uninstalled bool +} + +func (f *fakeServiceManager) Name() string { + if f.nameVal == "" { + return "fake" + } + return f.nameVal +} + +func (f *fakeServiceManager) DefaultLabel() string { + if f.labelVal == "" { + return "fake.run-loop" + } + return f.labelVal +} + +func (f *fakeServiceManager) Install(_ context.Context, spec platform.ServiceSpec) ([]string, error) { + s := spec + f.installedSpec = &s + return f.installNotes, f.installErr +} + +func (f *fakeServiceManager) Uninstall(_ context.Context, _ string) error { + f.uninstalled = true + return f.uninstallErr +} + +func (f *fakeServiceManager) Status(_ context.Context, _ string) (platform.ServiceStatus, error) { + return f.statusVal, f.statusErr +} + +// withFakeService swaps the serviceBackend seam for the duration of the test. +func withFakeService(t *testing.T, f *fakeServiceManager) { + t.Helper() + prev := serviceBackend + serviceBackend = func() platform.ServiceManager { return f } + t.Cleanup(func() { serviceBackend = prev }) +} + +func TestServiceInstallBuildsRunLoopArgs(t *testing.T) { + f := &fakeServiceManager{} + withFakeService(t, f) + home := t.TempDir() + hub := filepath.Join(t.TempDir(), "hub.json") + _, _, err := executeForTest( + "--home", home, + "service", "install", + "--hub-file", hub, + "--exec-path", "/usr/local/bin/devstrap", + "--interval", "10m", + "--namespace-only", + ) + if err != nil { + t.Fatalf("install: %v", err) + } + if f.installedSpec == nil { + t.Fatal("Install was not called") + } + want := []string{"run-loop", "--interval", "10m0s", "--namespace-only", "--hub-file", hub, "--home", home} + if strings.Join(f.installedSpec.Args, " ") != strings.Join(want, " ") { + t.Errorf("baked args = %v, want %v", f.installedSpec.Args, want) + } + if f.installedSpec.ExecPath != "/usr/local/bin/devstrap" { + t.Errorf("exec path = %q", f.installedSpec.ExecPath) + } + if !f.installedSpec.RestartOnFailure || f.installedSpec.RestartDelaySeconds != 30 { + t.Errorf("restart policy = (%v, %d), want (true, 30)", f.installedSpec.RestartOnFailure, f.installedSpec.RestartDelaySeconds) + } + // Log paths flow from --home through opts.paths().LogDir(). + wantOut := filepath.Join(home, "logs", "run-loop.out.log") + if f.installedSpec.StdoutPath != wantOut { + t.Errorf("stdout path = %q, want %q", f.installedSpec.StdoutPath, wantOut) + } +} + +func TestServiceInstallRefusesTempExecPath(t *testing.T) { + f := &fakeServiceManager{} + withFakeService(t, f) + // No --exec-path: os.Executable() resolves to the go-test binary under the + // build cache / temp dir, which must be refused. + _, stderr, err := executeForTest("--home", t.TempDir(), "service", "install", "--hub-file", filepath.Join(t.TempDir(), "hub.json")) + if err == nil { + t.Fatal("expected refusal of an ephemeral exec path") + } + if got := ExitCodeWithWriter(err, io.Discard); got != exitInvalidConfig { + t.Errorf("exit code = %d, want %d", got, exitInvalidConfig) + } + if !strings.Contains(stderr, "ephemeral path") && !strings.Contains(stderr, "stable") { + t.Errorf("stderr = %q, want an ephemeral-path remedy", stderr) + } + if f.installedSpec != nil { + t.Error("Install must not run when the exec path is refused") + } +} + +func TestServiceInstallRequiresHubConfig(t *testing.T) { + f := &fakeServiceManager{} + withFakeService(t, f) + // --home to an empty temp dir so no real config supplies a hub. + _, stderr, err := executeForTest("--home", t.TempDir(), "service", "install", "--exec-path", "/usr/local/bin/devstrap") + if err == nil { + t.Fatal("expected hub-config refusal") + } + if got := ExitCodeWithWriter(err, io.Discard); got != exitInvalidConfig { + t.Errorf("exit code = %d, want %d", got, exitInvalidConfig) + } + if !strings.Contains(stderr, "no hub configured") { + t.Errorf("stderr = %q, want 'no hub configured'", stderr) + } + if f.installedSpec != nil { + t.Error("Install must not run without a configured hub") + } +} + +func TestServiceInstallPrintsAdapterNotes(t *testing.T) { + f := &fakeServiceManager{installNotes: []string{"systemd lingering is off; run: loginctl enable-linger $USER"}} + withFakeService(t, f) + _, stderr, err := executeForTest("--home", t.TempDir(), "service", "install", "--hub-file", filepath.Join(t.TempDir(), "hub.json"), "--exec-path", "/usr/local/bin/devstrap") + if err != nil { + t.Fatalf("install: %v", err) + } + if !strings.Contains(stderr, "enable-linger") { + t.Errorf("stderr = %q, want the adapter note printed verbatim", stderr) + } + if !strings.Contains(stderr, "installed fake service") { + t.Errorf("stderr = %q, want the install progress line", stderr) + } +} + +func TestServiceInstallEnvContainsNoSecrets(t *testing.T) { + f := &fakeServiceManager{} + withFakeService(t, f) + _, _, err := executeForTest("--home", t.TempDir(), "service", "install", "--hub-file", filepath.Join(t.TempDir(), "hub.json"), "--exec-path", "/usr/local/bin/devstrap") + if err != nil { + t.Fatalf("install: %v", err) + } + if f.installedSpec.Env != nil { + t.Errorf("spec.Env = %v, want nil (the CLI bakes no env; adapters add only PATH)", f.installedSpec.Env) + } +} + +func TestServiceUninstallIdempotent(t *testing.T) { + // Not installed: uninstall is a success no-op with a note. + f := &fakeServiceManager{statusVal: platform.ServiceStatus{Installed: false}} + withFakeService(t, f) + _, stderr, err := executeForTest("--home", t.TempDir(), "service", "uninstall") + if err != nil { + t.Fatalf("uninstall: %v", err) + } + if !f.uninstalled { + t.Error("Uninstall was not called") + } + if !strings.Contains(stderr, "not installed") { + t.Errorf("stderr = %q, want a not-installed note", stderr) + } + + // Installed: uninstall reports removal. + f2 := &fakeServiceManager{statusVal: platform.ServiceStatus{Installed: true}} + withFakeService(t, f2) + _, stderr2, err := executeForTest("--home", t.TempDir(), "service", "uninstall") + if err != nil { + t.Fatalf("uninstall (installed): %v", err) + } + if !strings.Contains(stderr2, "uninstalled fake service") { + t.Errorf("stderr = %q, want an uninstalled line", stderr2) + } +} + +func TestServiceStatusJSON(t *testing.T) { + f := &fakeServiceManager{statusVal: platform.ServiceStatus{Installed: true, Running: false, Detail: "not loaded", UnitPath: "/x/fake.plist"}} + withFakeService(t, f) + stdout, _, err := executeForTest("--home", t.TempDir(), "--json", "service", "status") + if err != nil { + t.Fatalf("status: %v", err) + } + var got serviceStatusJSON + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("unmarshal %q: %v", stdout, err) + } + want := serviceStatusJSON{Manager: "fake", Label: "fake.run-loop", Installed: true, Running: false, Detail: "not loaded", UnitPath: "/x/fake.plist"} + if got != want { + t.Errorf("status json = %+v, want %+v", got, want) + } +} + +func TestServiceUnsupportedExitsNonzero(t *testing.T) { + f := &fakeServiceManager{installErr: fmt.Errorf("%w: no launchd domain", platform.ErrUnsupported)} + withFakeService(t, f) + _, stderr, err := executeForTest("--home", t.TempDir(), "service", "install", "--hub-file", filepath.Join(t.TempDir(), "hub.json"), "--exec-path", "/usr/local/bin/devstrap") + if err == nil { + t.Fatal("expected an error for an unsupported platform") + } + if got := ExitCodeWithWriter(err, io.Discard); got != exitGeneric { + t.Errorf("exit code = %d, want %d", got, exitGeneric) + } + if !strings.Contains(stderr, "not supported on this platform") { + t.Errorf("stderr = %q, want an unsupported-platform message", stderr) + } +} diff --git a/internal/platform/detect_darwin.go b/internal/platform/detect_darwin.go index 713210c..b607311 100644 --- a/internal/platform/detect_darwin.go +++ b/internal/platform/detect_darwin.go @@ -6,7 +6,7 @@ func Detect() Set { return newSet( "darwin", NativeWatcher{}, - UnsupportedServiceManager{Platform: "darwin", Target: "launchd"}, + LaunchdManager{}, SystemKeychain{Platform: "darwin", Target: "keychain"}, SeatbeltSandbox{}, ) diff --git a/internal/platform/detect_linux.go b/internal/platform/detect_linux.go index 8a2fe99..f79eaea 100644 --- a/internal/platform/detect_linux.go +++ b/internal/platform/detect_linux.go @@ -6,7 +6,7 @@ func Detect() Set { return newSet( "linux", NativeWatcher{}, - UnsupportedServiceManager{Platform: "linux", Target: "systemd-user"}, + SystemdUserManager{}, SystemKeychain{Platform: "linux", Target: "secret-service"}, // bwrap-then-landlock chooser (P4-GIT-03 slice 3); probes stay lazy so // Detect itself never launches a subprocess. diff --git a/internal/platform/platform.go b/internal/platform/platform.go index cd5a2da..7030766 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -41,21 +41,40 @@ type Watcher interface { } type ServiceSpec struct { - Label string - ExecPath string - Args []string - Env map[string]string + Label string + Description string + ExecPath string + Args []string + Env map[string]string + // WorkingDir, when set, is the service's working directory. + WorkingDir string + // StdoutPath/StderrPath are launchd-only log destinations; systemd routes to + // journald and ignores them. + StdoutPath string + StderrPath string + // RestartOnFailure asks the supervisor to relaunch the service when it exits + // with a failure; RestartDelaySeconds throttles the relaunch (0 → adapter + // default of 30s). + RestartOnFailure bool + RestartDelaySeconds int } type ServiceStatus struct { Installed bool Running bool Detail string + // UnitPath is the on-disk plist/unit file backing the service. + UnitPath string } type ServiceManager interface { Name() string - Install(ctx context.Context, spec ServiceSpec) error + // DefaultLabel is the OS-idiomatic label used when the caller does not pass + // one (a reverse-DNS launchd label, a bare systemd unit name). + DefaultLabel() string + // Install returns OS-idiomatic advisory notes (e.g. the Linux linger note) + // alongside any error, so the CLI never has to branch on the OS. + Install(ctx context.Context, spec ServiceSpec) (notes []string, err error) Uninstall(ctx context.Context, label string) error Status(ctx context.Context, label string) (ServiceStatus, error) } @@ -135,8 +154,10 @@ func (m UnsupportedServiceManager) Name() string { return "unsupported-service" } -func (m UnsupportedServiceManager) Install(context.Context, ServiceSpec) error { - return fmt.Errorf("%w: %s service manager is not implemented", ErrUnsupported, m.Name()) +func (m UnsupportedServiceManager) DefaultLabel() string { return "devstrap-run-loop" } + +func (m UnsupportedServiceManager) Install(context.Context, ServiceSpec) ([]string, error) { + return nil, fmt.Errorf("%w: %s service manager is not implemented", ErrUnsupported, m.Name()) } func (m UnsupportedServiceManager) Uninstall(context.Context, string) error { diff --git a/internal/platform/platform_test.go b/internal/platform/platform_test.go index 586e808..ec27716 100644 --- a/internal/platform/platform_test.go +++ b/internal/platform/platform_test.go @@ -52,7 +52,7 @@ func TestPollWatcherEmitsScanAndStopsOnContext(t *testing.T) { func TestUnsupportedAdaptersReturnSentinel(t *testing.T) { service := UnsupportedServiceManager{Target: "launchd"} - if err := service.Install(t.Context(), ServiceSpec{}); !errors.Is(err, ErrUnsupported) { + if _, err := service.Install(t.Context(), ServiceSpec{}); !errors.Is(err, ErrUnsupported) { t.Fatalf("service Install err = %v, want ErrUnsupported", err) } keychain := UnsupportedKeychain{Target: "keychain"} diff --git a/internal/platform/service_darwin.go b/internal/platform/service_darwin.go new file mode 100644 index 0000000..a9ef6fe --- /dev/null +++ b/internal/platform/service_darwin.go @@ -0,0 +1,184 @@ +//go:build darwin + +package platform + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" +) + +// LaunchdManager installs the run-loop as a per-user launchd LaunchAgent via +// the modern `launchctl bootstrap`/`bootout`/`print` verbs. Zero-value fields +// resolve to ~/Library/LaunchAgents and the current uid. +type LaunchdManager struct { + AgentsDir string + UID int +} + +func (m LaunchdManager) Name() string { return "launchd" } + +func (m LaunchdManager) DefaultLabel() string { return "com.devstrap.run-loop" } + +func (m LaunchdManager) agentsDir() (string, error) { + if m.AgentsDir != "" { + return m.AgentsDir, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Library", "LaunchAgents"), nil +} + +func (m LaunchdManager) uid() int { + if m.UID != 0 { + return m.UID + } + return os.Getuid() +} + +func (m LaunchdManager) Install(ctx context.Context, spec ServiceSpec) ([]string, error) { + if err := validateServiceLabel(spec.Label); err != nil { + return nil, fmt.Errorf("launchd: %w", err) + } + if !filepath.IsAbs(spec.ExecPath) { + return nil, fmt.Errorf("launchd: exec path must be absolute, got %q", spec.ExecPath) + } + agentsDir, err := m.agentsDir() + if err != nil { + return nil, err + } + // Seed a PATH so a Homebrew/user install of the binary and its sibling + // tools (git, gh) resolve when launchd runs the service with a bare env. + if spec.Env == nil { + spec.Env = map[string]string{} + } + if spec.Env["PATH"] == "" { + spec.Env["PATH"] = defaultDarwinPath(filepath.Dir(spec.ExecPath)) + } + plist, err := renderLaunchdPlist(spec) + if err != nil { + return nil, err + } + //nolint:gosec // ~/Library/LaunchAgents is a standard user dir launchd must traverse; it holds only the 0600 plist, no secret material. + if err := os.MkdirAll(agentsDir, 0o755); err != nil { + return nil, fmt.Errorf("create LaunchAgents dir: %w", err) + } + for _, logPath := range []string{spec.StdoutPath, spec.StderrPath} { + if logPath == "" { + continue + } + //nolint:gosec // Standard log dir under DevStrap home; the log files themselves are 0600. + if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { + return nil, fmt.Errorf("create log dir: %w", err) + } + } + plistPath := launchdPlistPath(agentsDir, spec.Label) + if err := atomicWrite(plistPath, plist, 0o600); err != nil { + return nil, err + } + // Best-effort bootout first so a reinstall is idempotent: booting out an + // already-loaded service lets the following bootstrap succeed. A not-loaded + // service makes bootout fail, which is expected and ignored. + uid := m.uid() + _, _ = runLaunchctl(ctx, launchdBootoutArgs(uid, spec.Label)) + // bootout is asynchronous: tearing down a *running* service's process lags, + // and bootstrapping into a domain that still holds the old job fails with + // EIO ("Bootstrap failed: 5: Input/output error" — caught in live dogfood). + // Wait until the label is gone from the domain before bootstrapping. + m.waitUntilBootedOut(ctx, uid, spec.Label) + if stderr, err := runLaunchctl(ctx, launchdBootstrapArgs(uid, plistPath)); err != nil { + return nil, fmt.Errorf("launchctl bootstrap: %w: %s", err, stderr) + } + return nil, nil +} + +// waitUntilBootedOut polls `launchctl print` until the label is no longer in +// the domain (print exits non-zero), so a reinstall's bootstrap does not race +// the previous job's asynchronous teardown. Bounded and best-effort: on a +// not-loaded service it returns on the first poll, and on timeout it returns so +// the caller bootstraps anyway (surfacing any real error there). +func (m LaunchdManager) waitUntilBootedOut(ctx context.Context, uid int, label string) { + for i := 0; i < 30; i++ { + if _, err := runLaunchctlOut(ctx, launchdPrintArgs(uid, label)); err != nil { + return + } + select { + case <-ctx.Done(): + return + case <-time.After(100 * time.Millisecond): + } + } +} + +func (m LaunchdManager) Uninstall(ctx context.Context, label string) error { + if err := validateServiceLabel(label); err != nil { + return fmt.Errorf("launchd: %w", err) + } + agentsDir, err := m.agentsDir() + if err != nil { + return err + } + // bootout failure means the service was not loaded — idempotent uninstall. + _, _ = runLaunchctl(ctx, launchdBootoutArgs(m.uid(), label)) + if err := os.Remove(launchdPlistPath(agentsDir, label)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove plist: %w", err) + } + return nil +} + +func (m LaunchdManager) Status(ctx context.Context, label string) (ServiceStatus, error) { + if err := validateServiceLabel(label); err != nil { + return ServiceStatus{}, fmt.Errorf("launchd: %w", err) + } + agentsDir, err := m.agentsDir() + if err != nil { + return ServiceStatus{}, err + } + plistPath := launchdPlistPath(agentsDir, label) + status := ServiceStatus{UnitPath: plistPath} + if _, err := os.Stat(plistPath); err != nil { + if os.IsNotExist(err) { + status.Detail = "not installed" + return status, nil + } + return status, fmt.Errorf("stat plist: %w", err) + } + status.Installed = true + out, err := runLaunchctlOut(ctx, launchdPrintArgs(m.uid(), label)) + if err != nil { + // A non-zero print exit means the service is not loaded in the domain + // (installed on disk but not bootstrapped). + status.Running = false + status.Detail = "not loaded" + return status, nil + } + status.Running, status.Detail = parseLaunchctlPrint(out) + return status, nil +} + +// runLaunchctl runs a launchctl argv (built by the argv builders) and returns +// its captured stderr. launchctl is resolved from PATH so tests can shim it. +func runLaunchctl(ctx context.Context, argv []string) (string, error) { + var stderr bytes.Buffer + //nolint:gosec // argv comes from the fixed launchctl argv builders (verb + domain/label), not user input. + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + cmd.Stderr = &stderr + err := cmd.Run() + return stderr.String(), err +} + +// runLaunchctlOut runs a launchctl argv and returns its captured stdout. +func runLaunchctlOut(ctx context.Context, argv []string) (string, error) { + var stdout bytes.Buffer + //nolint:gosec // argv comes from the fixed launchctl argv builders (verb + domain/label), not user input. + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + cmd.Stdout = &stdout + err := cmd.Run() + return stdout.String(), err +} diff --git a/internal/platform/service_darwin_test.go b/internal/platform/service_darwin_test.go new file mode 100644 index 0000000..b158136 --- /dev/null +++ b/internal/platform/service_darwin_test.go @@ -0,0 +1,132 @@ +//go:build darwin + +package platform + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// stubExec installs a fake `name` binary on PATH whose behavior is the given +// shell script body (the P6-QUAL-04 PATH-shim pattern, cloned from +// internal/cli/hub_test.go's stubOp). Returns the temp dir. +func stubExec(t *testing.T, name, script string) string { + t.Helper() + dir := t.TempDir() + shim := filepath.Join(dir, name) + if err := os.WriteFile(shim, []byte("#!/bin/sh\n"+script+"\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return dir +} + +func TestLaunchdManagerInstallBootsOutThenBootstraps(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "calls.log") + t.Setenv("SHIM_LOG", logPath) + // print exits non-zero (service not loaded) so the post-bootout drain poll + // returns on its first probe. The bootstrap branch records whether the plist + // ($3) already exists, proving the plist was written before bootstrap ran. + stubExec(t, "launchctl", `echo "$@" >> "$SHIM_LOG" +case "$1" in + print) exit 1 ;; + bootstrap) + if [ -f "$3" ]; then echo "plist-present" >> "$SHIM_LOG"; else echo "plist-absent" >> "$SHIM_LOG"; fi ;; +esac +exit 0`) + + agentsDir := t.TempDir() + mgr := LaunchdManager{AgentsDir: agentsDir, UID: 501} + spec := ServiceSpec{ + Label: "com.devstrap.run-loop", + ExecPath: "/usr/local/bin/devstrap", + Args: []string{"run-loop"}, + } + notes, err := mgr.Install(t.Context(), spec) + if err != nil { + t.Fatalf("Install: %v", err) + } + if notes != nil { + t.Errorf("notes = %v, want nil", notes) + } + plistPath := launchdPlistPath(agentsDir, spec.Label) + if _, err := os.Stat(plistPath); err != nil { + t.Fatalf("plist not written: %v", err) + } + raw, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read shim log: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(raw)), "\n") + bootoutIdx := indexWithPrefix(lines, "bootout ") + bootstrapIdx := indexWithPrefix(lines, "bootstrap ") + if bootoutIdx < 0 || bootstrapIdx < 0 { + t.Fatalf("expected both bootout and bootstrap calls, got %v", lines) + } + if bootoutIdx > bootstrapIdx { + t.Errorf("bootout must precede bootstrap (reinstall idempotency); calls = %v", lines) + } + if !strings.Contains(string(raw), "plist-present") { + t.Errorf("plist was not present when bootstrap ran:\n%s", raw) + } +} + +func indexWithPrefix(lines []string, prefix string) int { + for i, l := range lines { + if strings.HasPrefix(l, prefix) { + return i + } + } + return -1 +} + +func TestLaunchdManagerUninstallIdempotent(t *testing.T) { + // bootout exits 3 (service not loaded) — Uninstall must ignore it. + stubExec(t, "launchctl", "exit 3") + agentsDir := t.TempDir() + mgr := LaunchdManager{AgentsDir: agentsDir, UID: 501} + plistPath := launchdPlistPath(agentsDir, "com.devstrap.run-loop") + if err := os.WriteFile(plistPath, []byte(""), 0o600); err != nil { + t.Fatal(err) + } + if err := mgr.Uninstall(t.Context(), "com.devstrap.run-loop"); err != nil { + t.Fatalf("Uninstall: %v", err) + } + if _, err := os.Stat(plistPath); !errors.Is(err, os.ErrNotExist) { + t.Errorf("plist still present after uninstall: %v", err) + } + // A second uninstall (plist already gone) is still a no-error no-op. + if err := mgr.Uninstall(t.Context(), "com.devstrap.run-loop"); err != nil { + t.Fatalf("second Uninstall: %v", err) + } +} + +func TestLaunchdManagerStatusParsesPrint(t *testing.T) { + stubExec(t, "launchctl", `if [ "$1" = "print" ]; then + printf 'com.devstrap.run-loop = {\n\tstate = running\n\tpid = 4242\n}\n' + exit 0 +fi +exit 0`) + agentsDir := t.TempDir() + mgr := LaunchdManager{AgentsDir: agentsDir, UID: 501} + plistPath := launchdPlistPath(agentsDir, "com.devstrap.run-loop") + if err := os.WriteFile(plistPath, []byte(""), 0o600); err != nil { + t.Fatal(err) + } + status, err := mgr.Status(t.Context(), "com.devstrap.run-loop") + if err != nil { + t.Fatalf("Status: %v", err) + } + if !status.Installed || !status.Running { + t.Errorf("status = %+v, want installed+running", status) + } + if status.UnitPath != plistPath { + t.Errorf("UnitPath = %q, want %q", status.UnitPath, plistPath) + } + if !strings.Contains(status.Detail, "pid 4242") { + t.Errorf("Detail = %q, want it to mention pid 4242", status.Detail) + } +} diff --git a/internal/platform/service_launchd.go b/internal/platform/service_launchd.go new file mode 100644 index 0000000..67b04e2 --- /dev/null +++ b/internal/platform/service_launchd.go @@ -0,0 +1,261 @@ +package platform + +import ( + "bytes" + "encoding/xml" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "text/template" +) + +// defaultRestartDelaySeconds is the throttle both adapters fall back to when a +// spec leaves RestartDelaySeconds at 0. It is coupled to run-loop's own +// consecutive-failure ceiling — see the note by runLoopMaxConsecutiveFailures. +const defaultRestartDelaySeconds = 30 + +// launchdKV is one sorted environment pair; the plist template renders the +// EnvironmentVariables dict from a stable slice, never a map, so the output is +// byte-deterministic (golden-testable). +type launchdKV struct { + Key string + Value string +} + +type launchdPlistView struct { + Label string + ProgramArguments []string + RestartOnFailure bool + ThrottleInterval int + StandardOutPath string + StandardErrorPath string + WorkingDirectory string + Env []launchdKV +} + +// launchdPlistTemplate renders a launchd LaunchAgent plist. Every interpolated +// value passes through the "xml" FuncMap helper (encoding/xml.EscapeText) so a +// label, path, or env value containing & < > " can never break the XML. +var launchdPlistTemplate = template.Must(template.New("plist").Funcs(template.FuncMap{ + "xml": xmlEscape, +}).Parse(` + + + + Label + {{xml .Label}} + ProgramArguments + +{{- range .ProgramArguments}} + {{xml .}} +{{- end}} + + RunAtLoad + +{{- if .RestartOnFailure}} + KeepAlive + + SuccessfulExit + + +{{- end}} + ThrottleInterval + {{.ThrottleInterval}} +{{- if .StandardOutPath}} + StandardOutPath + {{xml .StandardOutPath}} +{{- end}} +{{- if .StandardErrorPath}} + StandardErrorPath + {{xml .StandardErrorPath}} +{{- end}} +{{- if .WorkingDirectory}} + WorkingDirectory + {{xml .WorkingDirectory}} +{{- end}} +{{- if .Env}} + EnvironmentVariables + +{{- range .Env}} + {{xml .Key}} + {{xml .Value}} +{{- end}} + +{{- end}} + + +`)) + +// xmlEscape returns s with XML metacharacters escaped, for interpolation into +// the plist template. +func xmlEscape(s string) string { + var buf bytes.Buffer + // EscapeText only errors if the underlying writer errors; a bytes.Buffer + // never does, so the error is structurally impossible here. + _ = xml.EscapeText(&buf, []byte(s)) + return buf.String() +} + +func renderLaunchdPlist(spec ServiceSpec) ([]byte, error) { + if spec.Label == "" { + return nil, fmt.Errorf("launchd: empty label") + } + if spec.ExecPath == "" { + return nil, fmt.Errorf("launchd: empty exec path") + } + // XML 1.0 forbids most control characters even escaped; launchd would + // reject the plist at load, silently (exit 78 class). Fail closed here, + // matching the systemd renderer's injection gate. + if err := rejectServiceControlChars(spec); err != nil { + return nil, fmt.Errorf("launchd: %w", err) + } + throttle := spec.RestartDelaySeconds + if throttle <= 0 { + throttle = defaultRestartDelaySeconds + } + view := launchdPlistView{ + Label: spec.Label, + ProgramArguments: append([]string{spec.ExecPath}, spec.Args...), + RestartOnFailure: spec.RestartOnFailure, + ThrottleInterval: throttle, + StandardOutPath: spec.StdoutPath, + StandardErrorPath: spec.StderrPath, + WorkingDirectory: spec.WorkingDir, + } + for _, k := range sortedKeys(spec.Env) { + view.Env = append(view.Env, launchdKV{Key: k, Value: spec.Env[k]}) + } + var buf bytes.Buffer + if err := launchdPlistTemplate.Execute(&buf, view); err != nil { + return nil, fmt.Errorf("render launchd plist: %w", err) + } + return buf.Bytes(), nil +} + +// serviceLabelPattern constrains service labels to filename- and argv-safe +// characters. A label reaches filepath.Join (the plist/unit path), a launchctl +// domain target, and a systemd unit name — a "/", "..", or whitespace in any +// of those would escape the unit directory or corrupt the argv (review +// finding: `--label ../../evil` wrote and deleted files outside +// ~/Library/LaunchAgents). +var serviceLabelPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +// validateServiceLabel gates every adapter entry point that derives a path or +// argv from the label. +func validateServiceLabel(label string) error { + if !serviceLabelPattern.MatchString(label) { + return fmt.Errorf("invalid service label %q: use only letters, digits, '.', '-', '_' (must start with a letter or digit)", label) + } + return nil +} + +// launchdPlistPath is the on-disk plist path; launchd requires the filename to +// equal