diff --git a/.env.example b/.env.example index 6bed4031..199ae7ac 100644 --- a/.env.example +++ b/.env.example @@ -140,10 +140,10 @@ # DRIGA_CONFIG: path; default=$XDG_CONFIG_HOME/driga/config.toml or ~/.config/driga/config.toml; optional. Configuration file override. # DRIGA_CONFIG= -# DRIGA_DATA_DIR: path; default=Linux: $XDG_DATA_HOME/driga or ~/.local/share/driga; macOS: ~/Library/Application Support/driga; optional. Database and fleet data directory override. +# DRIGA_DATA_DIR: path; default=Linux: $XDG_DATA_HOME/driga or ~/.local/share/driga; macOS: ~/Library/Application Support/driga; development builds use driga-dev; optional. Database and fleet data directory override. # DRIGA_DATA_DIR= -# DRIGA_SPOOL_DIR: path; default=Linux: $XDG_STATE_HOME/driga/events or ~/.local/state/driga/events; macOS: ~/Library/Application Support/driga/events; optional. Hook/ack spool directory override. +# DRIGA_SPOOL_DIR: path; default=Linux: $XDG_STATE_HOME/driga/events or ~/.local/state/driga/events; macOS: ~/Library/Application Support/driga/events; development builds use driga-dev; optional. Hook/ack spool directory override. # DRIGA_SPOOL_DIR= # DRIGA_CHAT_SOCKET: path; default=unset; optional. Chat broker socket for the MCP child of one Chat turn. diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 9d48e8a2..be61839b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -28,12 +28,15 @@ builds: - arm64 flags: - -trimpath + # Only a tagged release is a release build; a snapshot is a development + # build, with its own service label and data directory. ldflags: - >- -s -w -X main.version={{ .Version }} -X main.commit={{ .FullCommit }} -X main.date={{ .CommitDate }} + -X github.com/labstack/driga/internal/variant.release={{ if not .IsSnapshot }}true{{ end }} hooks: post: @@ -63,6 +66,7 @@ builds: -X main.version={{ .Version }} -X main.commit={{ .FullCommit }} -X main.date={{ .CommitDate }} + -X github.com/labstack/driga/internal/variant.release={{ if not .IsSnapshot }}true{{ end }} # Driga.exe, the Windows desktop app: a GUI-subsystem binary that installs # the Driga service and shows its data. @@ -84,6 +88,7 @@ builds: -X main.version={{ .Version }} -X main.commit={{ .FullCommit }} -X main.date={{ .CommitDate }} + -X github.com/labstack/driga/internal/variant.release={{ if not .IsSnapshot }}true{{ end }} archives: - id: driga @@ -229,29 +234,68 @@ homebrew_casks: # `app` (DMG) integration. custom_block: | app "Driga.app" - require "json" + require "fileutils" + # Uninstalling Driga.app removes the Driga service's launch agent + # with it. An upgrade (or reinstall) runs the old version's uninstall + # in the same brew process as the new version's postflight, so the + # preflight keeps a copy of the plist named for this process; only + # that process's postflight restores it. The copy is deleted when brew + # exits, so an ordinary uninstall leaves none behind, however long a + # slow upgrade takes. + uninstall_preflight do + plist = File.expand_path("~/Library/LaunchAgents/run.driga.service.plist") + if File.exist?(plist) + kept = File.expand_path("~/Library/Caches/run.driga/run.driga.service.#{Process.pid}.plist") + FileUtils.mkdir_p(File.dirname(kept), mode: 0o700) + FileUtils.rm_f(kept) + FileUtils.cp(plist, kept) + File.chmod(0o600, kept) + at_exit { FileUtils.rm_f(kept) } + end + end + # Homebrew skips signal on upgrade, where the app restarts itself. + uninstall launchctl: "run.driga.service", + signal: [["TERM", "run.driga"]] postflight do # Migrate before open -a can activate an old same-bundle drigad. # Fresh installs still wait for the app's first launch. - if File.exist?(File.expand_path("~/Library/LaunchAgents/run.driga.service.plist")) - previous = system_command "/usr/bin/plutil", - args: ["-convert", "json", "-o", "-", File.expand_path("~/Library/LaunchAgents/run.driga.service.plist")], - must_succeed: true - # system_command shell-escapes env: values yet runs no shell, so a - # value with a space would gain a literal backslash. /usr/bin/env - # takes these assignments verbatim. - environment = JSON.parse(previous.stdout).fetch("EnvironmentVariables", {}).map { |name, value| "#{name}=#{value}" } - system_command "/usr/bin/env", - args: [*environment, "#{appdir}/Driga.app/Contents/MacOS/driga", "service", "install"], + plist = File.expand_path("~/Library/LaunchAgents/run.driga.service.plist") + kept = File.expand_path("~/Library/Caches/run.driga/run.driga.service.#{Process.pid}.plist") + if !File.exist?(plist) && File.exist?(kept) + FileUtils.mkdir_p(File.dirname(plist)) + FileUtils.cp(kept, plist) + File.chmod(0o600, plist) + end + # Copies another brew process left behind (one that did not exit + # cleanly) are never restored. + Dir.glob(File.expand_path("~/Library/Caches/run.driga/run.driga.service.*.plist")).each { |path| FileUtils.rm_f(path) } + # The plist keeps the environment it records (the app's login + # shell); install points it at this version and starts it. + if File.exist?(plist) + system_command "#{appdir}/Driga.app/Contents/MacOS/driga", + args: ["service", "install"], must_succeed: true end end + zap trash: [ + "~/.config/driga", + "~/Library/Application Support/driga", + "~/Library/Caches/run.driga", + "~/Library/HTTPStorages/run.driga", + "~/Library/LaunchAgents/run.driga.collector.plist", + "~/Library/Logs/driga", + "~/Library/Preferences/run.driga.plist", + "~/Library/Saved Application State/run.driga.savedState", + "~/Library/WebKit/run.driga", + ] caveats: | Driga.app is in /Applications, and driga links to the CLI inside it. Open Driga from Finder, Spotlight or with driga app. Its first launch installs the Driga service (drigad), which collects at login whether or - not the app is open. Upgrades migrate an existing service automatically. - Open Driga once to finish first-launch setup. + not the app is open. Upgrades migrate an existing service automatically, + and brew uninstall removes it. Open Driga once to finish first-launch + setup. Before deleting Driga.app without Homebrew, run + driga service uninstall. url: template: "https://github.com/labstack/driga/releases/download/{{ .Tag }}/{{ .ArtifactName }}" commit_msg_template: "chore: update driga to {{ .Tag }}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b7d332e7..3cd7a52d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,6 +43,14 @@ just build # builds ./bin/driga, which is not on PATH just install # deliberately copies it to ~/.local/bin ``` +A checkout build is a development build: its Driga service installs as +`run.driga.service.dev` (Linux `driga-dev`, Windows `Driga service (dev)`) and +keeps its database and spool in a `driga-dev` data directory (on macOS +`~/Library/Application Support/driga-dev`), so it never replaces a release's +service or shares its data. The configuration file is shared. Only tagged +GoReleaser releases carry the `internal/variant.release=true` link-time marker; +a `just snapshot` build is a development build too. + ### The app's UI The app's page and the panels MCP App are built from `ui/` with [Bun](https://bun.sh): diff --git a/README.md b/README.md index e1e006aa..0ef10719 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Pre-release databases must be reset; `driga doctor` prints the reset command. | Leftovers | Orphan helper process candidates and local disk cleanup candidates, with evidence and confirmation before removal. | | Machine health | Memory, CPU, pressure, disk, and agent process attribution. | | Multiple machines | Optional fleet views over pinned mutual TLS, with explicit pairing and sharing controls. | -| Other interfaces | Versioned JSON, read-only MCP tools, the terminal UI, and opt-in summaries and Chat through Claude. | +| Other interfaces | Versioned JSON, MCP tools (read-only views plus edits to Driga's own dashboards), the terminal UI, and opt-in summaries and Chat through Claude. | ## Install @@ -48,8 +48,12 @@ sends notifications under its own name. Hooks keep using the stable Homebrew `driga` link. After `brew upgrade`, a running app restarts itself on the new version. -If you installed the Driga service with an earlier release, the app -removes it on its first launch, says so once, and runs the Driga service itself. +The app installs the Driga service as a per-user launch agent on its first +launch and repairs it on every launch, for example after Driga.app moves. +`brew uninstall --cask driga` removes the service with the app, and +`brew uninstall --zap --cask driga` also removes Driga's data, logs and +configuration. Before deleting Driga.app any other way, run +`driga service uninstall`. On Windows (experimental), use Scoop: @@ -71,12 +75,19 @@ checksums, packages, and archive installation. To build from a source checkout, use the Go version in [go.mod](go.mod) or newer: ```sh -CGO_ENABLED=0 go build ./cmd/driga -./driga version +CGO_ENABLED=0 go build -o bin/driga ./cmd/driga +ln -f bin/driga bin/drigad # the Driga service is the same program as drigad +./bin/driga version ``` -Keep Git on PATH and the installed binary at a stable absolute path before -setting up hooks or a service. On a machine without a desktop, install the CLI +`just build` does both. The service runs `drigad` from beside `driga`, so keep +the two together (`driga service install` refuses a `driga` without its +`drigad`), Git on PATH and the installed binaries at a stable absolute path +before setting up hooks or a service. A source build is a development build: its +service installs as `run.driga.service.dev` on macOS, `driga-dev` on Linux and +`Driga service (dev)` on Windows, beside a release's, and keeps its data in a +`driga-dev` data directory. To run a source build as the account's service, build it with +`-ldflags "-X github.com/labstack/driga/internal/variant.release=true"`. On a machine without a desktop, install the CLI alone and run the Driga service as a service with `driga service install`. CalVer tags use the year as their major number; Go module major-version rules prevent `go install ...@` with this module path. Use a package, archive, or source checkout. @@ -147,7 +158,8 @@ driga inbox ack driga cleanup list --sizes ``` -`driga mcp` serves the same reads as MCP tools. Use `driga --help` +`driga mcp` serves the same reads as read-only MCP tools, plus tools that +create, edit and delete Driga's own dashboards. Use `driga --help` for command-specific flags. ## Privacy and what it reads diff --git a/cmd/driga-docgen/mcp.go b/cmd/driga-docgen/mcp.go index eeb75e03..50a2a3cd 100644 --- a/cmd/driga-docgen/mcp.go +++ b/cmd/driga-docgen/mcp.go @@ -142,14 +142,22 @@ func renderMCP() (string, error) { return strings.TrimRight(b.String(), "\n") + "\n", nil } +// toolIndex lists tools with their annotations. MCP's defaults apply to a +// hint a tool leaves out: a tool that is not read-only may be destructive, +// and is not idempotent. func toolIndex(b *strings.Builder, tools []*sdk.Tool) { - b.WriteString("| Tool | Read-only |\n|---|---|\n") + b.WriteString("| Tool | Read-only | Destructive | Idempotent |\n|---|---|---|---|\n") + yes := map[bool]string{true: "yes", false: "no"} for _, tool := range tools { - ro := "no" - if tool.Annotations != nil && tool.Annotations.ReadOnlyHint { - ro = "yes" + h := tool.Annotations + if h == nil { + h = &sdk.ToolAnnotations{} } - fmt.Fprintf(b, "| [%s](#%s) | %s |\n", code(tool.Name), tool.Name, ro) + destructive, idempotent := !h.ReadOnlyHint, h.ReadOnlyHint || h.IdempotentHint + if !h.ReadOnlyHint && h.DestructiveHint != nil { + destructive = *h.DestructiveHint + } + fmt.Fprintf(b, "| [%s](#%s) | %s | %s | %s |\n", code(tool.Name), tool.Name, yes[h.ReadOnlyHint], yes[destructive], yes[idempotent]) } b.WriteString("\n") } diff --git a/docs/release.md b/docs/release.md index 3a8a10b9..7bbd0039 100644 --- a/docs/release.md +++ b/docs/release.md @@ -171,13 +171,33 @@ explains the organization-name fallback and `AssociatedBundleIdentifiers`. `service install`, which the app runs on launch, points the launch agent at `Contents/Helpers/Driga Service.app/Contents/MacOS/drigad`, associates `run.driga.service`, registers both bundles with Launch Services, and atomically -repairs an existing stale plist. It unloads the running service before replacing -its stale definition, then starts it. This migrates the former -`Contents/MacOS/drigad` path even when the version has not changed. Reinstalling -a current definition is harmless. It also removes the `run.driga.collector` -agent earlier releases installed. Development binaries outside the app keep -their ordinary path and have no association; a `driga-dev` build installs as -`run.driga.service.dev`, so it never takes over the release's service. +replaces an existing plist that differs from the one it would write: another +executable, a missing association, an older definition or a refreshed +environment (the app installs with `--refresh-env`; a terminal install keeps +the recorded one). It renders and checks the new plist and writes it beside the +old one first. launchd keeps the definition it bootstrapped, so install then +runs `launchctl bootout gui//run.driga.service` whether or not the job's +process runs (a job loaded but not running, as after Driga.app moved, would +otherwise keep its old definition) and polls `launchctl print` until launchd has +let the job go (at most 10 seconds), renames the new plist into place and runs +`launchctl bootstrap gui/` on it, retrying with backoff while launchd +answers 5, 36 or 37. When launchd refuses the new definition, the previous plist +is restored and bootstrapped again. Start bootstraps an unloaded job, and +`launchctl kickstart` only a loaded job that stays loaded with its process +exited. Without a `gui/` domain (exit 112), uninstall still deletes the +plist and start and status ask for a desktop login. Driga never uses the deprecated `launchctl load` and +`unload`. This migrates the former `Contents/MacOS/drigad` path even when the +version has not changed. Reinstalling a current definition is harmless. It also +removes the `run.driga.collector` agent earlier releases installed. + +The plist keeps the job alive only while its executable exists (`KeepAlive` +`PathState`), with `ProcessType` `Background` and a 30-second +`ThrottleInterval`; the service also exits by itself within a minute of its +executable's deletion. Development binaries outside the app keep their ordinary +path and have no association. Only tagged GoReleaser releases, not snapshots, carry the +`internal/variant.release=true` link-time marker; every other build installs as +`run.driga.service.dev` and collects into a `driga-dev` data directory, so it +never takes over the release's service or shares its data. The service has its own nested app, `Contents/Helpers/Driga Service.app`, with bundle identifier `run.driga.service`, `LSBackgroundOnly=true`, matching version @@ -185,10 +205,20 @@ metadata, and Driga's icon and display name. `Contents/Helpers` is for this launchd-managed helper; `Contents/Library/LoginItems` would imply a Service Management login item, which this service does not use. The GUI retains `run.driga`, its existing executable, and the Homebrew `bin/driga` symlink. -The cask postflight runs `service install` when a service plist already exists, -so an upgrade stops the old same-bundle process before the first `open -a`. -It passes the saved launch agent environment through to preserve custom data -paths, configuration, and PATH during that repair. +The cask's `uninstall launchctl: "run.driga.service"` removes the launch agent +and its plist when Driga is uninstalled, and `signal` stops a running app +(Homebrew skips it on upgrade, where the app restarts itself). `zap` also +trashes Driga's data, logs, configuration and caches. Homebrew runs the old +version's uninstall during an upgrade too, in the same brew process as the new +version's postflight, so the cask's `uninstall_preflight` first copies the plist +to `~/Library/Caches/run.driga/run.driga.service..plist` and deletes +the copy when that process exits. The postflight restores the plist only from +its own process's copy, however long the upgrade took, and runs +`service install` when a service plist exists, so an upgrade (or reinstall) +restores the service and stops the old same-bundle process before the first +`open -a`. The restored plist keeps its recorded environment (custom data +paths, configuration and PATH). An ordinary `brew uninstall` leaves no copy, +and a copy another brew process left behind is deleted, never restored. Fresh cask installs still wait for the first GUI launch to install the service. The helper can never register as the GUI's running instance. It also keeps Prohibited activation policy on the main thread as defense in depth. @@ -330,10 +360,15 @@ Retain the archive's license notices. Use a source checkout and the Go version in `go.mod` or newer: ```sh -CGO_ENABLED=0 go build ./cmd/driga -./driga version +CGO_ENABLED=0 go build -o bin/driga ./cmd/driga +ln -f bin/driga bin/drigad # the Driga service runs drigad, beside driga +./bin/driga version ``` +`just build` does both. Keep `drigad` beside `driga`: `driga service install` +refuses to install without it. A source build is a development build, with its +own service label and `driga-dev` data directory. + Check out a release tag first to build that source. Ordinary builds still report `dev` without linker stamps. CalVer's year is the major number, so Go module major-version rules prevent `go install ...@` with this unsuffixed module diff --git a/docs/usage.md b/docs/usage.md index d5c7f5de..d1baaabd 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -359,16 +359,37 @@ database, hosts Chat and runs actions. It starts at login and keeps running whether or not the app or the terminal UI is open; both are its clients, as is `driga mcp`. It runs as a per-user LaunchAgent on macOS (`run.driga.service`), a systemd user unit on Linux (`driga`) and a Task Scheduler task at logon on -Windows. A `driga-dev` build installs its own (`run.driga.service.dev`, -`driga-dev`, and Windows task `Driga service (dev)`), so it never takes over the release's. +Windows. Only published releases install under those names. Any other build +(`just build`, `just install`, `go build`, `go run`, a GoReleaser snapshot) is a +development build and +installs its own service (`run.driga.service.dev`, `driga-dev`, and Windows task +`Driga service (dev)`, logging to `driga-dev`) and keeps its own database and +spool in a `driga-dev` data directory, so it never takes over the release's +service or reads its data; so does an executable named `driga-`, under +``. The configuration file is shared. `DRIGA_DATA_DIR` and +`DRIGA_SPOOL_DIR` still override the directories. Installing a release +replaces whatever service holds the release's label, including one an older +development build installed. The app's first launch installs the service, and every launch makes sure it runs and restarts it when it runs another version, as after an upgrade. It installs the service with the PATH and other variables of your login shell, so agent discovery and terminal jumps work when Driga is opened from Finder or at -login. Quitting the app leaves the service running. On a machine without the -app, or to install it yourself, run `driga service install`; `driga init` -offers it too. Installing removes the `run.driga.collector` agent, systemd unit +login. On macOS that login-shell environment is the one the service records: +the app installs with `--refresh-env`, so a changed login-shell PATH reaches the +service at the next app launch, and the service restarts only when the +definition changes (its log names the changed variables, never their values). +`driga service install` from a terminal keeps the recorded environment; pass +`--refresh-env` to record the terminal's instead. When the app cannot read the +login shell, it keeps the environment the service already has. Replacing a +definition writes the new plist first, boots the old job out and waits until +launchd has let it go, then loads the new one; when launchd refuses it, the +previous plist is restored and loaded again. Without a desktop session (over +SSH before anyone logs in), `driga service uninstall` still removes the plist, +and start and status say to log in to the desktop session. On Linux and Windows an existing service keeps its environment until +`driga service uninstall` and `driga service install`. Quitting the app leaves +the service running. On a machine without the app, or to install it yourself, +run `driga service install`; `driga init` offers it too. Installing removes the `run.driga.collector` agent, systemd unit or task an earlier release installed. Only one service runs per data directory; `driga doctor` names it. @@ -381,6 +402,15 @@ driga service restart driga service uninstall ``` +On macOS, launchd keeps the service alive only while its executable exists, as +a background process, and the service exits when its executable is deleted, so +deleting Driga.app does not leave launchd restarting a missing helper. The +launch agent stays registered until it is removed, though: `brew uninstall +--cask driga` removes it, and before deleting Driga.app any other way, run +`driga service uninstall`. Driga finds its app by bundle identifier +(`run.driga`) and layout, not by name, so a renamed or moved Driga.app keeps its +service; open it once afterwards so the launch agent points at the new path. + ### Service identity - **macOS:** release binaries are signed by **LabStack LLC**. The Background diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 3b3ea906..cd8fb3d4 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -425,7 +425,7 @@ func Doctor(ctx context.Context, d DoctorDeps) []Check { if err != nil { add("service_definition", "warn", err.Error(), "check the service plist; driga service install") } else if stale { - add("service_definition", "warn", "the service plist runs another binary or collects into another data directory", "driga service install") + add("service_definition", "warn", "the service plist runs another binary or collects into another data directory", "driga service install --refresh-env") } } status := d.NotificationStatus diff --git a/internal/cli/doctor_service_drift_test.go b/internal/cli/doctor_service_drift_test.go index 7065145a..98f4bd14 100644 --- a/internal/cli/doctor_service_drift_test.go +++ b/internal/cli/doctor_service_drift_test.go @@ -9,5 +9,5 @@ func TestDoctorWarnsAboutStaleMacOSService(t *testing.T) { d, _ := doctorFixture(t) d.OS = "darwin" d.ServicePlistStale = func() (bool, error) { return true, nil } - requireCheck(t, Doctor(context.Background(), d), "service_definition", "warn", "driga service install") + requireCheck(t, Doctor(context.Background(), d), "service_definition", "warn", "driga service install --refresh-env") } diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index c8d7588e..a47e3fa7 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -15,6 +15,7 @@ import ( "github.com/labstack/driga/internal/collector" "github.com/labstack/driga/internal/config" "github.com/labstack/driga/internal/model" + "github.com/labstack/driga/internal/service" "github.com/labstack/driga/internal/store" ) @@ -385,7 +386,8 @@ func TestDoctorLinuxChecks(t *testing.T) { func TestServiceLogsLinux(t *testing.T) { name, args := serviceLogCommand("linux", true) - if name != "journalctl" || strings.Join(args, " ") != "--user -u driga -n 50 -f" { + // A test binary is a development build, so its unit is driga-dev. + if name != "journalctl" || strings.Join(args, " ") != "--user -u "+service.Label("linux")+" -n 50 -f" || service.Label("linux") != "driga-dev" { t.Fatal(name, args) } name, args = serviceLogCommand("linux", false) diff --git a/internal/cli/hook_test.go b/internal/cli/hook_test.go index d3835d18..fbeb22f4 100644 --- a/internal/cli/hook_test.go +++ b/internal/cli/hook_test.go @@ -51,7 +51,8 @@ func TestHookUsesRuntimeSpoolAndStructuredRotatedErrors(t *testing.T) { if d.SpoolDir != config.SpoolDir() { t.Fatal("hook and collector spool differ", d.SpoolDir, config.SpoolDir()) } - if runtime.GOOS == "darwin" && d.SpoolDir != filepath.Join(home, "Library", "Application Support", "driga", "events") { + // A test binary is a development build, which keeps its own spool. + if runtime.GOOS == "darwin" && d.SpoolDir != filepath.Join(home, "Library", "Application Support", "driga-dev", "events") { t.Fatal("macOS hook used XDG state", d.SpoolDir) } events, _, err := spool.Drain(d.SpoolDir, nil) diff --git a/internal/cli/service.go b/internal/cli/service.go index 660e2fb7..a2b57717 100644 --- a/internal/cli/service.go +++ b/internal/cli/service.go @@ -28,9 +28,17 @@ type ServiceCmd struct { Logs ServiceLogsCmd `cmd:"" help:"Print the Driga service's log."` Uninstall ServiceUninstallCmd `cmd:"" help:"Uninstall the Driga service."` } -type ServiceInstallCmd struct{} -func (ServiceInstallCmd) Run(g *Globals) error { return (serviceControl{Action: "install"}).Run(g) } +// ServiceInstallCmd installs the service. On macOS an installed service +// keeps the environment it records (the app's login shell) unless +// --refresh-env records this shell's instead. +type ServiceInstallCmd struct { + RefreshEnv bool `name:"refresh-env" help:"macOS: record this shell's PATH and Driga variables in the service instead of keeping the ones it records."` +} + +func (c ServiceInstallCmd) Run(g *Globals) error { + return (serviceControl{Action: "install", RefreshEnv: c.RefreshEnv}).Run(g) +} type ServiceStartCmd struct{} @@ -61,8 +69,9 @@ type ServiceUninstallCmd struct{} func (ServiceUninstallCmd) Run(g *Globals) error { return (serviceControl{Action: "uninstall"}).Run(g) } type serviceControl struct { - Action string - Follow bool + Action string + Follow bool + RefreshEnv bool } // Run controls the login service or prints the last 50 lines of its log. @@ -93,7 +102,12 @@ func (c serviceControl) Run(g *Globals) error { if runtime.GOOS == "darwin" { serviceBin = service.ExecutablePath(serviceBin) } - lifecycle := service.NewLifecycle(service.LifecycleOptions{Executable: serviceBin, DataDir: config.DataDir(), Output: commandOutput(g)}) + if c.Action == "install" { + if err := service.CheckDaemon(serviceBin); err != nil { + return err + } + } + lifecycle := service.NewLifecycle(service.LifecycleOptions{Executable: serviceBin, DataDir: config.DataDir(), Output: commandOutput(g), RefreshEnvironment: c.RefreshEnv}) if c.Action == "status" { st := lifecycle.Observe() if st.Err != nil { diff --git a/internal/cli/service_run.go b/internal/cli/service_run.go index d3d42503..978a4457 100644 --- a/internal/cli/service_run.go +++ b/internal/cli/service_run.go @@ -106,6 +106,16 @@ func (c ServiceRunCmd) Run(g *Globals) error { return err } defer restore() + if runtime.GOOS == "darwin" && c.Owner == "service" { + // Driga.app deleted without driga service uninstall: exit + // cleanly, and launchd's PathState keeps it from respawning. + if exe, err := os.Executable(); err == nil { + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + defer cancel() + go service.WatchExecutable(ctx, exe, service.ExecutableCheck, cancel) + } + } return collector.Run(ctx, d, dir) } lock, err := collector.Lock(dir, d.Owner) diff --git a/internal/cli/service_run_test.go b/internal/cli/service_run_test.go index 01973d97..50d90084 100644 --- a/internal/cli/service_run_test.go +++ b/internal/cli/service_run_test.go @@ -31,3 +31,16 @@ func TestServiceRunOwnerFlag(t *testing.T) { t.Fatal("owner flag appears in help") } } + +func TestServiceInstallRefreshEnvFlag(t *testing.T) { + for args, want := range map[string]bool{"": false, "--refresh-env": true} { + argv := []string{"service", "install"} + if args != "" { + argv = append(argv, args) + } + root, _, err := parseCommand(t, argv...) + if err != nil || root.Service.Install.RefreshEnv != want { + t.Fatalf("%v: RefreshEnv=%t, err=%v", argv, root.Service.Install.RefreshEnv, err) + } + } +} diff --git a/internal/collector/daemon.go b/internal/collector/daemon.go index 5b8e2f36..7295b94e 100644 --- a/internal/collector/daemon.go +++ b/internal/collector/daemon.go @@ -323,6 +323,11 @@ func run(ctx context.Context, d Deps, dataDir string) error { pullers.server = server defer pullers.close() var timers schedule + // A spool an older release left readable to others becomes private now, + // not at the first hourly prune. + if err := spool.Protect(d.SpoolDir); err != nil { + slog.Warn("could not make the spool private", "event", "spool_protect_failed", "err", scrub.Scrub(err.Error())) + } lastPrune := clock.Now() var spoolStamp time.Time var lastPoll time.Time diff --git a/internal/command/policy.go b/internal/command/policy.go index 66351a3d..04b1ba2c 100644 --- a/internal/command/policy.go +++ b/internal/command/policy.go @@ -2,6 +2,7 @@ package command import ( "crypto/sha256" + "errors" "os" "path/filepath" "runtime" @@ -11,33 +12,157 @@ import ( "github.com/labstack/driga/internal/config" ) -// canonical resolves existing ancestors too, so a missing leaf cannot hide a -// symlink escape. Preparation never creates a requested path. +// canonical resolves p the way the kernel does: component by component from +// the root, splicing each symlink's target in relative to the already +// resolved prefix, including a dangling symlink and one whose target does +// not exist yet. A missing component keeps the rest of the path literal, so +// a missing leaf cannot hide a symlink escape. Preparation never creates a +// requested path. func canonical(p string) (string, error) { - p, err := filepath.Abs(p) - if err != nil { - return "", err + // filepath.Abs would clean "link/.." lexically; the kernel does not. + if runtime.GOOS == "windows" { + abs, err := filepath.Abs(p) + if err != nil { + return "", err + } + p = abs + } else if !filepath.IsAbs(p) { + cwd, err := os.Getwd() + if err != nil { + return "", err + } + p = cwd + string(filepath.Separator) + p + } + // An existing path is the OS's to resolve; the walk below covers a + // missing tail and dangling links. + if r, err := filepath.EvalSymlinks(p); err == nil { + return r, nil + } + const maxHops = 40 + windows := runtime.GOOS == "windows" + sep := string(filepath.Separator) + volume := filepath.VolumeName(p) + resolved := volume + sep + rest := splitPath(p[len(volume):]) + for hops := 0; len(rest) > 0; { + name := rest[0] + rest = rest[1:] + switch name { + case ".": + continue + case "..": + resolved = filepath.Dir(resolved) + continue + } + next := filepath.Join(resolved, name) + info, err := os.Lstat(next) + if os.IsNotExist(err) { + resolved = filepath.Join(append([]string{next}, rest...)...) + break + } + if err != nil { + return "", err + } + if info.Mode()&os.ModeSymlink == 0 { + resolved = next + continue + } + if hops++; hops > maxHops { + return "", &os.PathError{Op: "canonical", Path: p, Err: errors.New("too many levels of symbolic links")} + } + target, err := os.Readlink(next) + if err != nil { + return "", err + } + // A relative target is relative to resolved, the link's own + // resolved directory. + base, parts, err := linkTarget(windows, resolved, target) + if err != nil { + return "", &os.PathError{Op: "canonical", Path: next, Err: err} + } + resolved, rest = base, append(parts, rest...) } + // What exists is resolved once more by the OS, for Windows junctions and + // short names the walk above leaves alone. var tail []string - for { - q, e := filepath.EvalSymlinks(p) - if e == nil { + for q := resolved; ; { + if r, err := filepath.EvalSymlinks(q); err == nil { for i := len(tail) - 1; i >= 0; i-- { - q = filepath.Join(q, tail[i]) + r = filepath.Join(r, tail[i]) } - return q, nil - } - if !os.IsNotExist(e) { - return "", e + return r, nil + } else if !os.IsNotExist(err) { + return "", err } - parent := filepath.Dir(p) - if parent == p { - return "", e + parent := filepath.Dir(q) + if parent == q { + return resolved, nil } - tail = append(tail, filepath.Base(p)) - p = parent + tail = append(tail, filepath.Base(q)) + q = parent + } +} + +// errUnresolvableLink is a symlink whose target canonical does not resolve +// by its own rules: on Windows a device, volume-GUID, \\?\, \\.\, UNC or +// drive-relative target. A path through one is refused, never allowed. +var errUnresolvableLink = errors.New("symlink target cannot be resolved safely") + +// linkTarget is where a symlink in the resolved directory dir points: the +// directory its target starts from and the target's components, by the +// rules of Unix or Windows whatever the host. On Windows it accepts only a +// relative target, one rooted without a volume (\x, at dir's volume) and a +// drive-absolute one (C:\x); everything else fails closed. +func linkTarget(windows bool, dir, target string) (string, []string, error) { + if !windows { + if strings.HasPrefix(target, "/") { + return "/", splitPath(target), nil + } + return dir, splitPath(target), nil + } + sep := func(c byte) bool { return c == '\\' || c == '/' } + split := func(p string) []string { + return strings.FieldsFunc(p, func(r rune) bool { return r == '\\' || r == '/' }) + } + switch { + case len(target) >= 2 && sep(target[0]) && sep(target[1]): + // UNC, \\?\ and \\.\ name devices and volumes by rules this walk + // does not model. + return "", nil, errUnresolvableLink + case target != "" && sep(target[0]): + volume := windowsDrive(dir) + if volume == "" { + return "", nil, errUnresolvableLink + } + return volume + `\`, split(target), nil + case len(target) >= 2 && target[1] == ':': + if windowsDrive(target) == "" || len(target) < 3 || !sep(target[2]) { + return "", nil, errUnresolvableLink + } + return target[:2] + `\`, split(target[2:]), nil + case strings.Contains(target, ":"): + // Alternate data streams and other colon forms. + return "", nil, errUnresolvableLink + } + return dir, split(target), nil +} + +// windowsDrive is the "C:" a Windows path starts with, or "". +func windowsDrive(p string) string { + if len(p) >= 2 && p[1] == ':' && 'a' <= p[0]|0x20 && p[0]|0x20 <= 'z' { + return p[:2] + } + return "" +} + +func splitPath(p string) []string { + var parts []string + for _, part := range strings.FieldsFunc(p, func(r rune) bool { return r == filepath.Separator || r == '/' }) { + parts = append(parts, part) } + return parts } + func within(p, root string) bool { if root == "" { return false @@ -61,14 +186,23 @@ func normalizeScope(s Scope) (Scope, error) { } } s.Denied = append([]string(nil), s.Denied...) - s.Denied = append(s.Denied, config.Path(), config.DataDir(), config.DefaultDataDir(), config.SpoolDir()) + // Every Driga build's configuration, data, spool and state, release and + // development alike, overrides included. + s.Denied = append(s.Denied, config.StateDirs()...) if h, e := os.UserHomeDir(); e == nil { - for _, p := range []string{".claude", ".codex", ".ssh", ".aws", ".azure", ".kube", ".docker", ".config/gh", ".config/azure", ".config/gcloud", ".netrc", ".npmrc", ".git-credentials", ".config/driga", ".local/share/driga", "Library/Keychains", "Library/Application Support/driga"} { + for _, p := range []string{".claude", ".codex", ".ssh", ".aws", ".azure", ".kube", ".docker", ".config/gh", ".config/azure", ".config/gcloud", ".netrc", ".npmrc", ".git-credentials", "Library/Keychains"} { s.Denied = append(s.Denied, filepath.Join(h, p)) } // Claude also uses sibling files and directories (e.g. .claude.json). matches, _ := filepath.Glob(filepath.Join(h, ".claude*")) s.Denied = append(s.Denied, matches...) + // Driga's default roots under HOME, whatever XDG variables this + // process has, for every variant: driga, driga-dev, driga-. + for _, root := range []string{".config", ".local/share", ".local/state", "Library/Application Support", "Library/Logs"} { + s.Denied = append(s.Denied, filepath.Join(h, root, "driga"), filepath.Join(h, root, "driga-dev")) + matches, _ := filepath.Glob(filepath.Join(h, root, "driga-*")) + s.Denied = append(s.Denied, matches...) + } } if filepath.Separator == '/' { s.Denied = append(s.Denied, "/Library/Keychains") @@ -207,6 +341,9 @@ func classify(p Parsed, scope Scope, depth int) (Policy, error) { path = filepath.Join(p.Request.Cwd, path) } q, e := canonical(path) + if errors.Is(e, errUnresolvableLink) { + return Policy{Class: Never, Reason: "A path through an unresolvable symlink is refused"}, nil + } if e != nil { allRead = false continue diff --git a/internal/command/policy_state_test.go b/internal/command/policy_state_test.go new file mode 100644 index 00000000..a73c15eb --- /dev/null +++ b/internal/command/policy_state_test.go @@ -0,0 +1,163 @@ +package command + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// Commands run for an agent never reach any Driga build's state: release, +// development and driga- variants, by default and through overrides, +// including through a symlink or a relative path. +func TestPolicyDeniesEveryDrigaVariantsState(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix home layout") + } + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + home := filepath.Join(root, "home") + t.Setenv("HOME", home) + for _, key := range []string{"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"} { + t.Setenv(key, "") + } + t.Setenv("DRIGA_DATA_DIR", filepath.Join(root, "override-data")) + t.Setenv("DRIGA_SPOOL_DIR", filepath.Join(root, "override-spool")) + t.Setenv("DRIGA_CONFIG", filepath.Join(root, "override.toml")) + variant := filepath.Join(home, "Library", "Application Support", "driga-feature") + linuxVariant := filepath.Join(home, ".local", "share", "driga-feature") + for _, dir := range []string{variant, linuxVariant} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + } + project := filepath.Join(root, "project") + if err := os.MkdirAll(project, 0o700); err != nil { + t.Fatal(err) + } + link := filepath.Join(project, "link") + if err := os.Symlink(filepath.Join(home, "Library", "Application Support", "driga-dev"), link); err != nil { + t.Fatal(err) + } + s, err := normalizeScope(Scope{Project: project}) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{ + filepath.Join(home, "Library", "Application Support", "driga", "driga.db"), + filepath.Join(home, "Library", "Application Support", "driga-dev", "driga.db"), + filepath.Join(home, "Library", "Application Support", "driga-dev", "events", "20261102.jsonl"), + filepath.Join(home, "Library", "Logs", "driga-dev", "service.log"), + filepath.Join(variant, "driga.db"), + filepath.Join(home, ".local", "share", "driga", "driga.db"), + filepath.Join(home, ".local", "share", "driga-dev", "driga.db"), + filepath.Join(home, ".local", "state", "driga-dev", "events", "20261102.jsonl"), + filepath.Join(linuxVariant, "driga.db"), + filepath.Join(home, ".config", "driga", "config.toml"), + filepath.Join(root, "override-data", "driga.db"), + filepath.Join(root, "override-spool", "20261102.jsonl"), + filepath.Join(root, "override.toml"), + filepath.Join(link, "driga.db"), + filepath.Join(project, "..", "home", ".local", "share", "driga-dev", "fleet.key"), + } { + canon, err := canonical(path) + if err != nil { + t.Fatal(err) + } + if !denied(canon, s) { + t.Errorf("%s (%s) is not denied", path, canon) + } + } + if denied(filepath.Join(project, "notes.txt"), s) || denied(filepath.Join(root, "override-data-other"), s) { + t.Error("the project or a sibling of an override is denied") + } +} + +// canonical resolves symlinks as the kernel does, so no chain of links +// reaches a denied directory under another name. +func TestCanonicalFollowsSymlinksLikeTheKernel(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix symlinks") + } + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + denied := filepath.Join(root, "home", ".local", "share", "driga-dev") + deep := filepath.Join(root, "real", "a") + for _, dir := range []string{denied, deep} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + } + symlink := func(target, link string) { + t.Helper() + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + } + // A relative link inside a symlinked parent resolves against the + // parent's target, not against the alias. + symlink("../../home/.local/share/driga-dev", filepath.Join(deep, "link")) + symlink(deep, filepath.Join(root, "alias")) + // An absolute link, and a dangling one into a directory not yet made. + symlink(denied, filepath.Join(root, "absolute")) + symlink(filepath.Join(root, "home", ".local", "share", "driga-feature", "new"), filepath.Join(root, "dangling")) + // filepath.Join would clean the ".." lexically; the kernel does not. + // A loop. + symlink(filepath.Join(root, "loop-b"), filepath.Join(root, "loop-a")) + symlink(filepath.Join(root, "loop-a"), filepath.Join(root, "loop-b")) + for in, want := range map[string]string{ + filepath.Join(root, "alias", "link", "driga.db"): filepath.Join(denied, "driga.db"), + filepath.Join(root, "absolute", "events", "x.jsonl"): filepath.Join(denied, "events", "x.jsonl"), + filepath.Join(root, "dangling", "missing", "tail"): filepath.Join(root, "home", ".local", "share", "driga-feature", "new", "missing", "tail"), + root + "/alias/../a/link/../driga-dev": filepath.Join(root, "home", ".local", "share", "driga-dev"), + root + "/real/missing/../a": filepath.Join(root, "real", "a"), + } { + got, err := canonical(in) + if err != nil || got != want { + t.Errorf("canonical(%s) = %s, %v; want %s", in, got, err, want) + } + } + if _, err := canonical(filepath.Join(root, "loop-a", "x")); err == nil { + t.Error("a symlink loop resolved") + } +} + +// Symlink targets are rooted by the platform's rules, whatever the host, +// and a Windows target this walk does not model fails closed. +func TestLinkTargetRooting(t *testing.T) { + for _, tc := range []struct { + windows bool + dir, target string + base, components string + }{ + {false, "/home/a", "../b", "/home/a", "../b"}, + {false, "/home/a", "/var/driga", "/", "var/driga"}, + {true, `C:\Users\a`, `..\b`, `C:\Users\a`, "../b"}, + {true, `C:\Users\a`, `\Users\x\AppData\Local\driga`, `C:\`, "Users/x/AppData/Local/driga"}, + {true, `D:\work`, `/Users/x/AppData/Local/driga-dev`, `D:\`, "Users/x/AppData/Local/driga-dev"}, + {true, `C:\Users\a`, `D:\driga`, `D:\`, "driga"}, + } { + base, parts, err := linkTarget(tc.windows, tc.dir, tc.target) + if err != nil || base != tc.base || strings.Join(parts, "/") != tc.components { + t.Errorf("linkTarget(%t, %q, %q) = %q, %q, %v; want %q, %q", tc.windows, tc.dir, tc.target, base, parts, err, tc.base, tc.components) + } + } + for _, target := range []string{ + `C:driga`, `\\server\share\driga`, `\\?\C:\Users\x\driga`, `\\?\UNC\server\share\driga`, + `\\?\Volume{0b1c2d3e-0000-0000-0000-000000000000}\driga`, `\\?\GLOBALROOT\Device\HarddiskVolume1\driga`, + `\\.\C:\driga`, `\\.\pipe\driga`, `//server/share/driga`, `driga:stream`, + } { + if _, _, err := linkTarget(true, `C:\Users\a`, target); !errors.Is(err, errUnresolvableLink) { + t.Errorf("linkTarget(%q) = %v; want it to fail closed", target, err) + } + } + if _, _, err := linkTarget(true, `\\server\share\a`, `\driga`); !errors.Is(err, errUnresolvableLink) { + t.Errorf("a rooted target on a UNC share = %v; want it to fail closed", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 38401606..f85be712 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,8 +6,11 @@ import ( "os" "path/filepath" "runtime" + "slices" "strings" "time" + + "github.com/labstack/driga/internal/variant" ) // Config is the effective configuration (ยง12 of the design). @@ -116,6 +119,10 @@ func configPath() string { if p := os.Getenv("DRIGA_CONFIG"); p != "" { return p } + return defaultConfigPath() +} + +func defaultConfigPath() string { if runtime.GOOS == "windows" { return filepath.Join(windowsAppDir("APPDATA", "Roaming"), "driga", "config.toml") } @@ -138,19 +145,27 @@ func dataDirForOS(goos string) string { return defaultDataDirForOS(goos) } -func defaultDataDirForOS(goos string) string { +func defaultDataDirForOS(goos string) string { return dataDirNamed(goos, dataName()) } + +func dataDirNamed(goos, name string) string { if goos == "windows" { - return filepath.Join(windowsAppDir("LOCALAPPDATA", "Local"), "driga") + return filepath.Join(windowsAppDir("LOCALAPPDATA", "Local"), name) } if goos == "linux" { if p := os.Getenv("XDG_DATA_HOME"); filepath.IsAbs(p) { - return filepath.Join(p, "driga") + return filepath.Join(p, name) } - return filepath.Join(home(), ".local", "share", "driga") + return filepath.Join(home(), ".local", "share", name) } - return filepath.Join(home(), "Library", "Application Support", "driga") + return filepath.Join(home(), "Library", "Application Support", name) } +// dataName names the default data and spool directories: "driga" for a +// release and "driga-dev" for a development build, so a development service +// never shares the release's database or spool. The configuration file is +// shared. +func dataName() string { return "driga" + variant.Suffix() } + // SpoolDir holds hook/ack event files (env DRIGA_SPOOL_DIR overrides). func SpoolDir() string { return absolutePath(spoolDirForOS(runtime.GOOS)) } @@ -158,16 +173,42 @@ func spoolDirForOS(goos string) string { if p := os.Getenv("DRIGA_SPOOL_DIR"); p != "" { return p } - if goos == "windows" { - return filepath.Join(windowsAppDir("LOCALAPPDATA", "Local"), "driga", "events") - } + return spoolDirNamed(goos, dataName()) +} + +func spoolDirNamed(goos, name string) string { if goos == "linux" { if p := os.Getenv("XDG_STATE_HOME"); filepath.IsAbs(p) { - return filepath.Join(p, "driga", "events") + return filepath.Join(p, name, "events") } - return filepath.Join(home(), ".local", "state", "driga", "events") + return filepath.Join(home(), ".local", "state", name, "events") + } + return filepath.Join(dataDirNamed(goos, name), "events") +} + +// StateDirs lists every file and directory where any Driga build keeps +// state: the configuration, this process's data and spool directories with +// their overrides, and the default data and state directories of a release, +// a development build and every driga- variant found beside them. +// Commands Driga runs for an agent are denied all of them. +func StateDirs() []string { return stateDirsForOS(runtime.GOOS) } + +func stateDirsForOS(goos string) []string { + dirs := []string{absolutePath(configPath()), absolutePath(filepath.Dir(defaultConfigPath())), absolutePath(dataDirForOS(goos)), absolutePath(spoolDirForOS(goos))} + names := []string{"driga", "driga-dev"} + for _, root := range []string{filepath.Dir(dataDirNamed(goos, "driga")), filepath.Dir(filepath.Dir(spoolDirNamed(goos, "driga")))} { + matches, _ := filepath.Glob(filepath.Join(root, "driga-*")) + for _, match := range matches { + names = append(names, filepath.Base(match)) + } + } + for _, name := range names { + // The spool's parent is the data directory, or on Linux the state + // directory that also holds the service log. + dirs = append(dirs, absolutePath(dataDirNamed(goos, name)), absolutePath(filepath.Dir(spoolDirNamed(goos, name)))) } - return filepath.Join(home(), "Library", "Application Support", "driga", "events") + slices.Sort(dirs) + return slices.Compact(dirs) } func windowsAppDir(env, leaf string) string { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6f7fd324..04ca0d4d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/labstack/driga/internal/config" + "github.com/labstack/driga/internal/variant" ) func TestDefaults(t *testing.T) { @@ -80,6 +81,7 @@ func TestLoadRejectsBadCaptureText(t *testing.T) { } func TestPathsHonorEnv(t *testing.T) { + t.Cleanup(variant.SetForTest("")) // a release build if runtime.GOOS == "windows" { t.Skip("Unix path or action contract; Windows phase 1 has dedicated watch/refusal tests") } diff --git a/internal/config/contracts_test.go b/internal/config/contracts_test.go index 020f8604..ee164fb3 100644 --- a/internal/config/contracts_test.go +++ b/internal/config/contracts_test.go @@ -7,6 +7,8 @@ import ( "strings" "testing" "time" + + "github.com/labstack/driga/internal/variant" ) func TestStrictConfiguration(t *testing.T) { @@ -77,6 +79,7 @@ func TestUnknownEnvironmentEvenWithoutFile(t *testing.T) { } func TestConfigAndSpoolXDGPaths(t *testing.T) { + t.Cleanup(variant.SetForTest("")) // a release build if runtime.GOOS == "windows" { t.Skip("Unix path or action contract; Windows phase 1 has dedicated watch/refusal tests") } diff --git a/internal/config/paths_linux_test.go b/internal/config/paths_linux_test.go index ddc83e77..24e39e8e 100644 --- a/internal/config/paths_linux_test.go +++ b/internal/config/paths_linux_test.go @@ -3,12 +3,15 @@ package config_test import ( - "github.com/labstack/driga/internal/config" "path/filepath" "testing" + + "github.com/labstack/driga/internal/config" + "github.com/labstack/driga/internal/variant" ) func TestDataDirLinuxXDG(t *testing.T) { + t.Cleanup(variant.SetForTest("")) // a release build home := t.TempDir() t.Setenv("HOME", home) t.Setenv("DRIGA_DATA_DIR", "") diff --git a/internal/config/paths_test.go b/internal/config/paths_test.go index 37c928f6..ee5d1520 100644 --- a/internal/config/paths_test.go +++ b/internal/config/paths_test.go @@ -3,10 +3,16 @@ package config import ( "os" "path/filepath" + "runtime" + "slices" + "strings" "testing" + + "github.com/labstack/driga/internal/variant" ) func TestDataDirLinuxPaths(t *testing.T) { + t.Cleanup(variant.SetForTest("")) // a release build home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", os.Getenv("HOME")) @@ -70,3 +76,64 @@ func TestStartupPathsStayFixedAcrossChdir(t *testing.T) { } } } + +// A development build keeps its own database and spool, beside a release's, +// and shares the configuration file. +func TestDevelopmentBuildDataDirs(t *testing.T) { + t.Cleanup(variant.SetForTest("-dev")) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + for _, key := range []string{"DRIGA_DATA_DIR", "DRIGA_SPOOL_DIR", "DRIGA_CONFIG", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CONFIG_HOME"} { + t.Setenv(key, "") + } + for _, tc := range []struct{ got, want string }{ + {dataDirForOS("darwin"), filepath.Join(home, "Library/Application Support/driga-dev")}, + {spoolDirForOS("darwin"), filepath.Join(home, "Library/Application Support/driga-dev/events")}, + {dataDirForOS("linux"), filepath.Join(home, ".local/share/driga-dev")}, + {spoolDirForOS("linux"), filepath.Join(home, ".local/state/driga-dev/events")}, + {configPath(), filepath.Join(home, ".config/driga/config.toml")}, + } { + if runtime.GOOS == "windows" && tc.want == filepath.Join(home, ".config/driga/config.toml") { + continue + } + if tc.got != tc.want { + t.Errorf("got %s, want %s", tc.got, tc.want) + } + } +} + +// StateDirs names every variant's state, whichever variant asks, plus this +// process's overrides. +func TestStateDirsCoverEveryVariant(t *testing.T) { + for _, suffix := range []string{"", "-dev"} { + t.Run("variant"+suffix, func(t *testing.T) { + t.Cleanup(variant.SetForTest(suffix)) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + for _, key := range []string{"XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CONFIG_HOME"} { + t.Setenv(key, "") + } + t.Setenv("DRIGA_DATA_DIR", filepath.Join(home, "data")) + t.Setenv("DRIGA_SPOOL_DIR", filepath.Join(home, "spool")) + t.Setenv("DRIGA_CONFIG", filepath.Join(home, "custom.toml")) + if err := os.MkdirAll(filepath.Join(home, ".local", "share", "driga-feature"), 0o700); err != nil { + t.Fatal(err) + } + got := stateDirsForOS("linux") + for _, want := range []string{ + filepath.Join(home, ".local/share/driga"), filepath.Join(home, ".local/share/driga-dev"), filepath.Join(home, ".local/share/driga-feature"), + filepath.Join(home, ".local/state/driga"), filepath.Join(home, ".local/state/driga-dev"), + filepath.Join(home, ".config/driga"), filepath.Join(home, "data"), filepath.Join(home, "spool"), filepath.Join(home, "custom.toml"), + } { + if runtime.GOOS == "windows" && strings.Contains(want, ".config") { + continue + } + if !slices.Contains(got, want) { + t.Errorf("StateDirs lacks %s: %v", want, got) + } + } + }) + } +} diff --git a/internal/config/paths_windows_test.go b/internal/config/paths_windows_test.go index 9d778568..cb315f5c 100644 --- a/internal/config/paths_windows_test.go +++ b/internal/config/paths_windows_test.go @@ -3,9 +3,12 @@ package config import ( "path/filepath" "testing" + + "github.com/labstack/driga/internal/variant" ) func TestWindowsDefaultPathsAndOverrides(t *testing.T) { + t.Cleanup(variant.SetForTest("")) // a release build root := t.TempDir() for key, value := range map[string]string{"HOME": filepath.Join(root, "ignored"), "USERPROFILE": root, "APPDATA": filepath.Join(root, "roaming"), "LOCALAPPDATA": filepath.Join(root, "local"), "DRIGA_CONFIG": "", "DRIGA_DATA_DIR": "", "DRIGA_SPOOL_DIR": "", "XDG_CONFIG_HOME": filepath.Join(root, "xdg"), "XDG_STATE_HOME": filepath.Join(root, "state")} { t.Setenv(key, value) diff --git a/internal/config/settings.go b/internal/config/settings.go index 2c57531f..875b72bd 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -87,8 +87,8 @@ func (s Setting) Value(c Config) any { // exemptions are accepted. Internal names are inherited by helper/test processes. var runtimeSettings = []RuntimeSetting{ {"DRIGA_CONFIG", "path", "$XDG_CONFIG_HOME/driga/config.toml or ~/.config/driga/config.toml", "Configuration file override."}, - {"DRIGA_DATA_DIR", "path", "Linux: $XDG_DATA_HOME/driga or ~/.local/share/driga; macOS: ~/Library/Application Support/driga", "Database and fleet data directory override."}, - {"DRIGA_SPOOL_DIR", "path", "Linux: $XDG_STATE_HOME/driga/events or ~/.local/state/driga/events; macOS: ~/Library/Application Support/driga/events", "Hook/ack spool directory override."}, + {"DRIGA_DATA_DIR", "path", "Linux: $XDG_DATA_HOME/driga or ~/.local/share/driga; macOS: ~/Library/Application Support/driga; development builds use driga-dev", "Database and fleet data directory override."}, + {"DRIGA_SPOOL_DIR", "path", "Linux: $XDG_STATE_HOME/driga/events or ~/.local/state/driga/events; macOS: ~/Library/Application Support/driga/events; development builds use driga-dev", "Hook/ack spool directory override."}, {"DRIGA_CHAT_SOCKET", "path", "unset", "Chat broker socket for the MCP child of one Chat turn."}, {"DRIGA_CHAT_CONVERSATION", "id", "unset", "Chat conversation of the MCP child's turn."}, {"DRIGA_CHAT_TOKEN", "secret", "unset", "Per-turn token tying the MCP child's approval requests to its turn."}, diff --git a/internal/desktop/service.go b/internal/desktop/service.go index 7c715bc0..8eaf81cb 100644 --- a/internal/desktop/service.go +++ b/internal/desktop/service.go @@ -17,8 +17,14 @@ import ( // clients. On launch the app installs or starts it with the login shell's // environment, and restarts it when it runs another version than the app. -// servicePoll is how often the app checks that the service runs. -const servicePoll = 2 * time.Second +// servicePoll is how often the app checks that the service runs: its lock +// and process, which cost no subprocess. The service manager (launchctl, +// systemctl, Task Scheduler) is asked at most every serviceRegistrationTTL, +// or when the service starts or stops. +const ( + servicePoll = 2 * time.Second + serviceRegistrationTTL = 30 * time.Second +) // ensureService supplies the app environment; lifecycle owns startup decisions. func (s *shell) ensureService(ctx context.Context) error { @@ -26,7 +32,8 @@ func (s *shell) ensureService(ctx context.Context) error { s.lifecycle = service.NewLifecycle(service.LifecycleOptions{ Executable: s.serviceExe, DataDir: s.dataDir, Version: s.opts.Version, Platform: s.goos, Home: home, AccountHome: account, - Environment: childEnvironment(s.serviceEnv(ctx)), + Environment: childEnvironment(s.serviceEnv(ctx)), + RegistrationTTL: serviceRegistrationTTL, }) return s.lifecycle.Ensure(ctx) } @@ -44,6 +51,11 @@ func (s *shell) serviceEnv(ctx context.Context) map[string]string { s.mu.Unlock() if err != nil { slog.Warn("login environment unavailable", "event", "login_env_failed", "err", err.Error()) + // Installing refreshes the environment the service records; keep the + // one it has rather than replace it with the app's bare PATH. + if recorded := recordedServiceEnv(s.serviceExe); len(recorded) > 0 { + return recorded + } } if len(shadowed) > 0 { slog.Warn("the login shell sets DRIGA_* variables the app does not use", "event", "login_env_shadowed", "keys", strings.Join(shadowed, ",")) @@ -87,3 +99,17 @@ func accountHomes() (env, account string) { } return os.Getenv("HOME"), account } + +// recordedServiceEnv is the environment the installed service records, or +// nil where it records none or none is installed. +func recordedServiceEnv(exe string) map[string]string { + c, err := service.New(exe) + if err != nil { + return nil + } + env, err := service.RecordedEnvironment(c) + if err != nil { + return nil + } + return env +} diff --git a/internal/mcp/actions.go b/internal/mcp/actions.go index 128596a8..a046014d 100644 --- a/internal/mcp/actions.go +++ b/internal/mcp/actions.go @@ -15,14 +15,15 @@ func registerActions(s *sdk.Server, o Options) { for _, tool := range []struct { name, description string properties map[string]any + annotations *sdk.ToolAnnotations }{ - {"run_command", "Run one local bounded command through broker preparation and approval. Use literal argv or an explicit shell script; 120 second default, 600 maximum. Process and destructive commands always need a person. Commands run with HOME and TMPDIR in a private, initially empty scratch directory, so tools that default to the home directory see no user caches, config or credentials and act on scratch instead, and with PATH=/usr/bin:/bin:/usr/sbin:/sbin. To run other programs, set PATH in a script, such as PATH=/opt/homebrew/bin:$PATH npm test, since many of them start their interpreter through /usr/bin/env. Writes outside the project and scratch are refused.", map[string]any{"argv": map[string]any{"type": "array", "items": map[string]string{"type": "string"}, "minItems": 1}, "script": map[string]any{"type": "string", "minLength": 1}, "cwd": map[string]string{"type": "string"}, "timeout_seconds": map[string]any{"type": "integer", "minimum": 0, "maximum": 600}, "network": map[string]string{"type": "boolean"}}}, - {"answer_prompt", "Answer one pending local Claude permission prompt. Always requires a person in every mode.", map[string]any{"id": map[string]string{"type": "string"}, "allow": map[string]string{"type": "boolean"}}}, - {"ack_inbox", "Acknowledge a local inbox id or all local items.", map[string]any{"id": map[string]string{"type": "string"}, "all": map[string]string{"type": "boolean"}}}, - {"jump", "Jump to a local session terminal. Never types into the terminal.", map[string]any{"session_id": map[string]string{"type": "integer"}}}, - {"kill", "Kill a local agent session or orphan id after identity and idle verification. Force is refused here; use run_command for a second card over surviving identities.", map[string]any{"session_id": map[string]string{"type": "integer"}, "id": map[string]string{"type": "string"}, "force": map[string]string{"type": "boolean"}}}, - {"cleanup", "Clean one local candidate after re-verification.", map[string]any{"id": map[string]string{"type": "string"}}}, - {"summarize", "Refresh one local project summary.", map[string]any{"project": map[string]string{"type": "string"}}}, + {"run_command", "Run one local bounded command through broker preparation and approval. Use literal argv or an explicit shell script; 120 second default, 600 maximum. Process and destructive commands always need a person. Commands run with HOME and TMPDIR in a private, initially empty scratch directory, so tools that default to the home directory see no user caches, config or credentials and act on scratch instead, and with PATH=/usr/bin:/bin:/usr/sbin:/sbin. To run other programs, set PATH in a script, such as PATH=/opt/homebrew/bin:$PATH npm test, since many of them start their interpreter through /usr/bin/env. Writes outside the project and scratch are refused.", map[string]any{"argv": map[string]any{"type": "array", "items": map[string]string{"type": "string"}, "minItems": 1}, "script": map[string]any{"type": "string", "minLength": 1}, "cwd": map[string]string{"type": "string"}, "timeout_seconds": map[string]any{"type": "integer", "minimum": 0, "maximum": 600}, "network": map[string]string{"type": "boolean"}}, effect(true, false, true)}, + {"answer_prompt", "Answer one pending local Claude permission prompt. Always requires a person in every mode.", map[string]any{"id": map[string]string{"type": "string"}, "allow": map[string]string{"type": "boolean"}}, effect(true, false, false)}, + {"ack_inbox", "Acknowledge a local inbox id or all local items.", map[string]any{"id": map[string]string{"type": "string"}, "all": map[string]string{"type": "boolean"}}, effect(false, true, false)}, + {"jump", "Jump to a local session terminal. Never types into the terminal.", map[string]any{"session_id": map[string]string{"type": "integer"}}, effect(false, false, false)}, + {"kill", "Kill a local agent session or orphan id after identity and idle verification. Force is refused here; use run_command for a second card over surviving identities.", map[string]any{"session_id": map[string]string{"type": "integer"}, "id": map[string]string{"type": "string"}, "force": map[string]string{"type": "boolean"}}, effect(true, false, false)}, + {"cleanup", "Clean one local candidate after re-verification.", map[string]any{"id": map[string]string{"type": "string"}}, effect(true, false, false)}, + {"summarize", "Refresh one local project summary.", map[string]any{"project": map[string]string{"type": "string"}}, effect(false, false, true)}, } { name := tool.name schema := map[string]any{"type": "object", "properties": tool.properties, "additionalProperties": false} @@ -32,7 +33,7 @@ func registerActions(s *sdk.Server, o Options) { if name == "answer_prompt" { schema["required"] = []string{"id", "allow"} } - s.AddTool(&sdk.Tool{Name: name, Description: tool.description, InputSchema: schema}, func(ctx context.Context, req *sdk.CallToolRequest) (*sdk.CallToolResult, error) { + s.AddTool(&sdk.Tool{Name: name, Description: tool.description, InputSchema: schema, Annotations: tool.annotations}, func(ctx context.Context, req *sdk.CallToolRequest) (*sdk.CallToolResult, error) { if name == "run_command" { var in command.Request d := json.NewDecoder(bytes.NewReader(req.Params.Arguments)) @@ -62,7 +63,7 @@ func registerActions(s *sdk.Server, o Options) { return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: text}}}, nil }) } - s.AddTool(&sdk.Tool{Name: "approve", Description: "Request driga permission for a tool. Cannot decide cards or change trust mode.", InputSchema: map[string]any{"type": "object", "properties": map[string]any{"tool_name": map[string]string{"type": "string"}, "input": map[string]string{"type": "object"}, "tool_use_id": map[string]string{"type": "string"}}, "required": []string{"tool_name", "input", "tool_use_id"}, "additionalProperties": false}}, func(ctx context.Context, req *sdk.CallToolRequest) (*sdk.CallToolResult, error) { + s.AddTool(&sdk.Tool{Name: "approve", Description: "Request driga permission for a tool. Cannot decide cards or change trust mode.", Annotations: effect(false, false, false), InputSchema: map[string]any{"type": "object", "properties": map[string]any{"tool_name": map[string]string{"type": "string"}, "input": map[string]string{"type": "object"}, "tool_use_id": map[string]string{"type": "string"}}, "required": []string{"tool_name", "input", "tool_use_id"}, "additionalProperties": false}}, func(ctx context.Context, req *sdk.CallToolRequest) (*sdk.CallToolResult, error) { if o.Approve == nil { return nil, fmt.Errorf("Chat approval unavailable") } @@ -73,3 +74,12 @@ func registerActions(s *sdk.Server, o Options) { return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: string(raw)}}}, nil }) } + +// effect annotates a tool that changes state. The hints describe behavior for +// clients; Chat's approval cards and trust modes, not these, decide what runs. +// run_command, kill and cleanup can remove or end things; answer_prompt can +// let a waiting agent do so. ack_inbox repeats harmlessly. run_command can +// reach the network and summarize sends context through the Claude CLI. +func effect(destructive, idempotent, openWorld bool) *sdk.ToolAnnotations { + return &sdk.ToolAnnotations{DestructiveHint: boolPtr(destructive), IdempotentHint: idempotent, OpenWorldHint: boolPtr(openWorld)} +} diff --git a/internal/mcp/actions_test.go b/internal/mcp/actions_test.go index 84c0838d..4023c39b 100644 --- a/internal/mcp/actions_test.go +++ b/internal/mcp/actions_test.go @@ -74,3 +74,62 @@ func TestChatToolsOnlyWithChat(t *testing.T) { }) } } + +// Every tool declares its effect (Canon mcp.md): read tools are read-only, +// and each tool that changes state says whether it can destroy, repeats +// harmlessly and reaches beyond the machine. +func TestToolAnnotationsAreTruthful(t *testing.T) { + ctx := context.Background() + a, b := sdk.NewInMemoryTransports() + ss, err := drigamcp.NewServer(drigamcp.Options{Chat: true}).Connect(ctx, a, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + cs, err := sdk.NewClient(&sdk.Implementation{Name: "test"}, nil).Connect(ctx, b, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + list, err := cs.ListTools(ctx, nil) + if err != nil { + t.Fatal(err) + } + type effect struct{ destructive, idempotent, openWorld bool } + want := map[string]effect{ + "run_command": {true, false, true}, + "answer_prompt": {true, false, false}, + "kill": {true, false, false}, + "cleanup": {true, false, false}, + "ack_inbox": {false, true, false}, + "jump": {false, false, false}, + "summarize": {false, false, true}, + "approve": {false, false, false}, + } + for _, tool := range list.Tools { + h := tool.Annotations + if h == nil { + t.Errorf("%s has no annotations", tool.Name) + continue + } + if h.ReadOnlyHint { + if _, ok := want[tool.Name]; ok { + t.Errorf("%s changes state but is annotated read-only", tool.Name) + } + continue + } + if h.DestructiveHint == nil || h.OpenWorldHint == nil { + t.Errorf("%s changes state without destructive and open-world hints", tool.Name) + continue + } + if w, ok := want[tool.Name]; ok { + if got := (effect{*h.DestructiveHint, h.IdempotentHint, *h.OpenWorldHint}); got != w { + t.Errorf("%s annotations = %+v, want %+v", tool.Name, got, w) + } + delete(want, tool.Name) + } + } + if len(want) > 0 { + t.Errorf("tools not served with Chat: %v", want) + } +} diff --git a/internal/self/self.go b/internal/self/self.go index 849c7022..64cd762d 100644 --- a/internal/self/self.go +++ b/internal/self/self.go @@ -37,15 +37,15 @@ func executable(exe string) (string, error) { return cli, nil } -// CLI maps drigad to the CLI, in the outer Driga.app or beside an unbundled -// service executable. Other paths are unchanged. +// CLI maps drigad to the CLI, in the outer app (Driga.app under any name) +// or beside an unbundled service executable. Other paths are unchanged. func CLI(exe string) string { // The nested service has its own bundle identity. CLI clients must return // to the outer bundle rather than looking beside the helper executable. suffix := filepath.Join("Contents", "Helpers", "Driga Service.app", "Contents", "MacOS", "drigad") if strings.HasSuffix(exe, string(filepath.Separator)+suffix) { app := strings.TrimSuffix(exe, string(filepath.Separator)+suffix) - if filepath.Base(app) == "Driga.app" { + if filepath.Ext(app) == ".app" { return filepath.Join(app, "Contents", "MacOS", "driga") } } diff --git a/internal/self/self_test.go b/internal/self/self_test.go index 1de0fdbb..6e04b39d 100644 --- a/internal/self/self_test.go +++ b/internal/self/self_test.go @@ -11,13 +11,15 @@ import ( func TestCLI(t *testing.T) { for in, want := range map[string]string{ - "/Applications/Driga.app/Contents/Helpers/Driga Service.app/Contents/MacOS/drigad": "/Applications/Driga.app/Contents/MacOS/driga", - "/Applications/Driga.app/Contents/MacOS/drigad": "/Applications/Driga.app/Contents/MacOS/driga", - "/home/me/.local/libexec/drigad-dev": "/home/me/.local/libexec/driga-dev", - "/opt/drigad.exe": "/opt/driga.exe", - "/opt/driga": "/opt/driga", - "/opt/driga-dev": "/opt/driga-dev", - "/opt/drigadx": "/opt/drigadx", + "/Applications/Driga.app/Contents/Helpers/Driga Service.app/Contents/MacOS/drigad": "/Applications/Driga.app/Contents/MacOS/driga", + "/Applications/Driga.app/Contents/MacOS/drigad": "/Applications/Driga.app/Contents/MacOS/driga", + "/Applications/Driga Beta.app/Contents/Helpers/Driga Service.app/Contents/MacOS/drigad": "/Applications/Driga Beta.app/Contents/MacOS/driga", + "/opt/Contents/Helpers/Driga Service.app/Contents/MacOS/drigad": "/opt/Contents/Helpers/Driga Service.app/Contents/MacOS/driga", + "/home/me/.local/libexec/drigad-dev": "/home/me/.local/libexec/driga-dev", + "/opt/drigad.exe": "/opt/driga.exe", + "/opt/driga": "/opt/driga", + "/opt/driga-dev": "/opt/driga-dev", + "/opt/drigadx": "/opt/drigadx", } { in, want = filepath.FromSlash(in), filepath.FromSlash(want) if got := CLI(in); got != want { diff --git a/internal/service/bundle.go b/internal/service/bundle.go index b33d7e48..5db1387f 100644 --- a/internal/service/bundle.go +++ b/internal/service/bundle.go @@ -3,17 +3,25 @@ package service import ( "encoding/xml" "errors" + "fmt" "io" "os" "path/filepath" + "runtime" "strings" "github.com/labstack/driga/internal/config" "github.com/labstack/driga/internal/self" ) +// BundleID is Driga.app's bundle identifier. The app is found by it and by +// its layout, never by its name, so a renamed Driga.app still works. +const BundleID = "run.driga" + // BundlePath identifies our CLI's app layout, including when invoked via a -// Homebrew symlink. Other apps and ordinary development binaries are excluded. +// Homebrew symlink: driga or drigad in .app/Contents/MacOS, or the +// nested Driga Service.app's drigad, in a bundle whose Info.plist names +// run.driga. Other apps and ordinary development binaries are excluded. func BundlePath(binPath string) string { if resolved, err := filepath.EvalSymlinks(binPath); err == nil { binPath = resolved @@ -24,12 +32,49 @@ func BundlePath(binPath string) string { } contents := filepath.Dir(filepath.Dir(binPath)) app := filepath.Dir(contents) - if filepath.Base(contents) != "Contents" || filepath.Base(app) != "Driga.app" { + if filepath.Base(contents) != "Contents" || filepath.Ext(app) != ".app" || bundleIdentifier(app) != BundleID { return "" } return app } +// bundleIdentifier is the CFBundleIdentifier of the app at path, or "". +func bundleIdentifier(app string) string { + f, err := os.Open(filepath.Join(app, "Contents", "Info.plist")) + if err != nil { + return "" + } + defer f.Close() + decoder := xml.NewDecoder(f) + depth, key := 0, "" + for { + token, err := decoder.Token() + if err != nil { + return "" + } + switch t := token.(type) { + case xml.EndElement: + depth-- + case xml.StartElement: + depth++ + // plist > dict > its keys and values + if depth != 3 || t.Name.Local != "key" && t.Name.Local != "string" { + continue + } + var text string + if err := decoder.DecodeElement(&text, &t); err != nil { + return "" + } + depth-- + if t.Name.Local == "key" { + key = text + } else if key == "CFBundleIdentifier" { + return text + } + } + } +} + // ExecutablePath keeps ordinary builds' stable PATH entry, but launchd needs // the actual bundle executable for macOS background-item attribution. func ExecutablePath(binPath string) string { @@ -55,6 +100,23 @@ func DaemonPath(binPath string) string { return binPath } +// CheckDaemon reports a drigad the service for the CLI at binPath would run +// that is missing or not executable: a source build of driga alone has none. +func CheckDaemon(binPath string) error { + daemon := DaemonPath(ExecutablePath(binPath)) + info, err := os.Stat(daemon) + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("the Driga service executable %s does not exist; build drigad beside driga (just build makes both, or go build -o %s ./cmd/driga)", daemon, daemon) + } + if err != nil { + return err + } + if !info.Mode().IsRegular() || runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { + return fmt.Errorf("the Driga service executable %s is not an executable file", daemon) + } + return nil +} + // IsDaemon reports whether an executable name is drigad's. func IsDaemon(binPath string) bool { name := strings.TrimSuffix(filepath.Base(binPath), filepath.Ext(binPath)) @@ -143,19 +205,26 @@ func PlistStale(path, binPath string) (bool, error) { // plistEnv is the value the collector plist at path sets for the environment // variable key, or "" when it sets none. func plistEnv(path, key string) (string, error) { + env, err := plistEnvironment(path) + return env[key], err +} + +// plistEnvironment is the EnvironmentVariables dictionary of the collector +// plist at path; empty when it sets none. +func plistEnvironment(path string) (map[string]string, error) { data, err := os.Open(path) if err != nil { - return "", err + return nil, err } defer data.Close() decoder := xml.NewDecoder(data) for { token, err := decoder.Token() if err == io.EOF { - return "", nil + return map[string]string{}, nil } if err != nil { - return "", err + return nil, err } start, ok := token.(xml.StartElement) if !ok || start.Name.Local != "key" { @@ -163,7 +232,7 @@ func plistEnv(path, key string) (string, error) { } var name string if err := decoder.DecodeElement(&name, &start); err != nil { - return "", err + return nil, err } if name != "EnvironmentVariables" { continue @@ -178,23 +247,24 @@ func plistEnv(path, key string) (string, error) { for { token, err := decoder.Token() if err != nil { - return "", err + return nil, err } if start, ok = token.(xml.StartElement); ok { break } } if start.Name.Local != "dict" { - return "", errors.New("collector plist expects a dict for EnvironmentVariables") + return nil, errors.New("collector plist expects a dict for EnvironmentVariables") } if err := decoder.DecodeElement(&dict, &start); err != nil { - return "", err + return nil, err } + env := map[string]string{} for i := 0; i+1 < len(dict.Entries); i += 2 { - if dict.Entries[i].XMLName.Local == "key" && dict.Entries[i].Text == key { - return dict.Entries[i+1].Text, nil + if dict.Entries[i].XMLName.Local == "key" { + env[dict.Entries[i].Text] = dict.Entries[i+1].Text } } - return "", nil + return env, nil } } diff --git a/internal/service/bundle_test.go b/internal/service/bundle_test.go index 52dd85ca..9771117c 100644 --- a/internal/service/bundle_test.go +++ b/internal/service/bundle_test.go @@ -17,13 +17,7 @@ func TestLaunchdPlistBundleAndDevelopment(t *testing.T) { if err != nil { t.Fatal(err) } - bundled := filepath.Join(root, "Driga.app", "Contents", "MacOS", "driga") - if err := os.MkdirAll(filepath.Dir(bundled), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(bundled, []byte("fixture"), 0o700); err != nil { - t.Fatal(err) - } + bundled := writeTestBundle(t, filepath.Join(root, "Driga.app")) link := filepath.Join(root, "driga") if err := os.Symlink(bundled, link); err != nil { t.Fatal(err) @@ -164,3 +158,87 @@ func TestPlistStaleDataDir(t *testing.T) { }) } } + +// driga service install refuses a driga whose drigad is missing, as after +// go build ./cmd/driga alone. +func TestCheckDaemon(t *testing.T) { + dir := t.TempDir() + exe := "" + if runtime.GOOS == "windows" { + exe = ".exe" + } + cli, daemon := filepath.Join(dir, "driga"+exe), filepath.Join(dir, "drigad"+exe) + if err := os.WriteFile(cli, []byte("fixture"), 0o700); err != nil { + t.Fatal(err) + } + if err := CheckDaemon(cli); err == nil || !strings.Contains(err.Error(), "does not exist") { + t.Fatalf("missing drigad: %v", err) + } + if err := os.WriteFile(daemon, []byte("fixture"), 0o600); err != nil { + t.Fatal(err) + } + if err := CheckDaemon(cli); runtime.GOOS != "windows" && (err == nil || !strings.Contains(err.Error(), "not an executable")) { + t.Fatalf("non-executable drigad: %v", err) + } + if err := os.Chmod(daemon, 0o700); err != nil { + t.Fatal(err) + } + if err := CheckDaemon(cli); err != nil { + t.Fatal(err) + } +} + +// writeTestBundle makes a minimal Driga.app at app, with its Info.plist and +// executable, and returns the executable. +func writeTestBundle(t *testing.T, app string) string { + t.Helper() + bin := filepath.Join(app, "Contents", "MacOS", "driga") + if err := os.MkdirAll(filepath.Dir(bin), 0o700); err != nil { + t.Fatal(err) + } + info := ` +CFBundleExecutabledrigaCFBundleIdentifierrun.driga +` + if err := os.WriteFile(filepath.Join(app, "Contents", "Info.plist"), []byte(info), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(bin, []byte("fixture"), 0o700); err != nil { + t.Fatal(err) + } + return bin +} + +// Driga.app is found by its bundle identifier and layout, so renaming it +// keeps the service and its helper; another app with the same layout is not +// Driga. +func TestBundlePathSurvivesRename(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + app := filepath.Join(root, "Driga Beta.app") + bin := writeTestBundle(t, app) + helper := filepath.Join(app, "Contents", "Helpers", "Driga Service.app", "Contents", "MacOS", "drigad") + for _, path := range []string{bin, helper} { + if got := BundlePath(path); got != app { + t.Errorf("BundlePath(%s) = %q, want %q", path, got, app) + } + } + if got := DaemonPath(ExecutablePath(bin)); got != helper { + t.Errorf("DaemonPath = %s, want %s", got, helper) + } + if c := configForOS(bin, "darwin"); c.Executable != helper || !strings.Contains(c.Option["LaunchdConfig"].(string), "AssociatedBundleIdentifiers") { + t.Errorf("renamed bundle config: %+v", c) + } + other := filepath.Join(root, "Other.app") + otherBin := writeTestBundle(t, other) + if err := os.WriteFile(filepath.Join(other, "Contents", "Info.plist"), []byte(`CFBundleIdentifiercom.example.other`), 0o600); err != nil { + t.Fatal(err) + } + if got := BundlePath(otherBin); got != "" { + t.Errorf("another app's driga is Driga.app: %q", got) + } + if got := BundlePath(filepath.Join(root, "Missing.app", "Contents", "MacOS", "driga")); got != "" { + t.Errorf("a bundle without Info.plist: %q", got) + } +} diff --git a/internal/service/launchd.go b/internal/service/launchd.go index 8326b5c8..54caf31d 100644 --- a/internal/service/launchd.go +++ b/internal/service/launchd.go @@ -20,7 +20,6 @@ func renderLaunchd(c *ks.Config) ([]byte, error) { "Path": func() string { return c.Executable }, "Arguments": func() []string { return c.Arguments }, "EnvVars": func() []string { return env }, - "KeepAlive": func() string { return "true" }, "RunAtLoad": func() string { return "true" }, }).Parse(c.Option["LaunchdConfig"].(string)) if err != nil { diff --git a/internal/service/launchd_darwin.go b/internal/service/launchd_darwin.go index f1e8f5e5..17e8aa45 100644 --- a/internal/service/launchd_darwin.go +++ b/internal/service/launchd_darwin.go @@ -1,28 +1,92 @@ package service import ( + "bytes" + "context" + "encoding/xml" "errors" "fmt" + "io" "log/slog" "os" "os/exec" "path/filepath" + "regexp" + "slices" + "strings" + "time" ks "github.com/kardianos/service" "github.com/labstack/driga/internal/config" "github.com/labstack/driga/internal/secfile" ) +// launchctlTimeout bounds one launchctl call; bootout waits for the service +// to exit, which its graceful shutdown keeps well under this. +const launchctlTimeout = 30 * time.Second + +// launchd finishes tearing a job down after bootout returns. Driga asks +// print every unloadPoll until the job is gone, for at most unloadWait, and +// retries a bootstrap launchd refuses while it is busy with backoff from +// unloadPoll, at most bootstrapAttempts times. +// A job Start finds loaded but not running may be one a bootout is still +// tearing down; Start watches it for settleWait before it kicks it. +const ( + unloadPoll = 100 * time.Millisecond + unloadWait = 10 * time.Second + settleWait = time.Second + bootstrapAttempts = 6 +) + +// ErrNoGUISession reports that the account has no gui/ launchd domain: +// nobody is logged in to the desktop, as over SSH before any login. A +// LaunchAgent can run only in that domain; launchd loads its plist at the +// next login. +var ErrNoGUISession = errors.New("no macOS desktop session for this account; log in to the desktop session to start the Driga service") + +// launchctl runs /bin/launchctl and returns its combined output and exit +// code. err reports only a launchctl that could not run. +type launchctl func(args ...string) (out string, code int, err error) + +func runLaunchctl(args ...string) (string, int, error) { + ctx, cancel := context.WithTimeout(context.Background(), launchctlTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "/bin/launchctl", args...).CombinedOutput() + var exit *exec.ExitError + if errors.As(err, &exit) && ctx.Err() == nil { + return string(out), exit.ExitCode(), nil + } + if err != nil { + return string(out), -1, fmt.Errorf("launchctl %s: %w", strings.Join(args, " "), err) + } + return string(out), 0, nil +} + +// launchdController drives the per-user launch agent through launchctl's +// domain commands (bootstrap, bootout, kickstart, print), never the +// deprecated load and unload, which refuse a job launchd already has. type launchdController struct { - Controller binPath string + label string + domain string // gui/ plistPath string + launchctl launchctl register func(string) error + sleep func(time.Duration) + // refreshEnv records this process's environment in the plist. Without + // it, a plist that exists keeps the environment it records: the app's + // login-shell environment is the one source, and a terminal's install + // does not replace it. + refreshEnv bool } -func newLaunchdController(controller Controller, binPath string) *launchdController { - return &launchdController{Controller: controller, binPath: binPath, - plistPath: filepath.Join(config.ExpandHome("~"), "Library", "LaunchAgents", Label("darwin")+".plist"), +func newLaunchdController(binPath string) (Controller, error) { + label := Label("darwin") + return &launchdController{binPath: binPath, label: label, + domain: fmt.Sprintf("gui/%d", os.Getuid()), + plistPath: filepath.Join(config.ExpandHome("~"), "Library", "LaunchAgents", label+".plist"), + launchctl: runLaunchctl, + sleep: time.Sleep, register: func(app string) error { // Apple requires Launch Services to know the associated bundle, // wherever Driga.app was installed. @@ -32,64 +96,385 @@ func newLaunchdController(controller Controller, binPath string) *launchdControl } return nil }, - } + }, nil +} + +// recordedEnvironment is the environment the plist gives the collector. +func (s *launchdController) recordedEnvironment() (map[string]string, error) { + return plistEnvironment(s.plistPath) } +func (s *launchdController) target() string { return s.domain + "/" + s.label } + // recordedDataDir is the DRIGA_DATA_DIR the plist gives the collector. func (s *launchdController) recordedDataDir() (string, error) { return plistEnv(s.plistPath, "DRIGA_DATA_DIR") } -// Start makes repeating service install harmless when launchd already runs it. -func (s *launchdController) Start() error { - status, err := s.Controller.Status() - if err == nil && status == ks.StatusRunning { +func (s *launchdController) refreshEnvironment() { s.refreshEnv = true } + +// notLoaded reports launchctl's answers for a job the domain does not have: +// 3 (No such process) from bootout, 113 (Could not find service) from print +// and kickstart. +func notLoaded(code int) bool { return code == 3 || code == 113 } + +// domainAbsent reports launchctl's answer for a gui/ domain that does +// not exist: 112, Could not find domain. +func domainAbsent(code int, out string) bool { + return code == 112 || strings.Contains(out, "Could not find domain") +} + +// busy reports bootstrap's answers while launchd still tears down the job +// or another client bootstraps it: 5 (Input/output error), 36 (Operation +// now in progress) and 37 (Operation already in progress). +func busy(code int) bool { return code == 5 || code == 36 || code == 37 } + +// runningState matches print's top-level state line; nested dictionaries +// are indented further. +var runningState = regexp.MustCompile(`(?m)^\tstate = running$`) + +// state reports whether launchd has the job and whether its process runs. +// A job can be loaded but not running, for example after Driga.app moved +// and its executable vanished from under KeepAlive. Without a desktop +// session it returns ErrNoGUISession. +func (s *launchdController) state() (loaded, running bool, err error) { + out, code, err := s.launchctl("print", s.target()) + switch { + case err != nil: + return false, false, err + case domainAbsent(code, out): + return false, false, ErrNoGUISession + case notLoaded(code): + return false, false, nil + case code != 0: + return false, false, launchctlError("print", code, out) + } + return true, runningState.MatchString(out), nil +} + +func launchctlError(verb string, code int, out string) error { + return fmt.Errorf("launchctl %s exited %d: %s", verb, code, strings.TrimSpace(out)) +} + +// bootout removes the job from launchd, stopping its process, and returns +// once launchd no longer has it, so a bootstrap that follows never meets +// the old definition. A job launchd does not have is already booted out. +func (s *launchdController) bootout() error { + out, code, err := s.launchctl("bootout", s.target()) + switch { + case err != nil: + return err + case domainAbsent(code, out), notLoaded(code): return nil + case code != 0 && code != 36: + return launchctlError("bootout", code, out) } - if err != nil && !errors.Is(err, ks.ErrNotInstalled) { + for waited := time.Duration(0); ; waited += unloadPoll { + loaded, _, err := s.state() + if errors.Is(err, ErrNoGUISession) || err == nil && !loaded { + return nil + } + if err != nil { + return err + } + if waited >= unloadWait { + return fmt.Errorf("launchd still has %s %s after bootout", s.label, unloadWait) + } + s.sleep(unloadPoll) + } +} + +// bootstrap loads the plist into the domain, which starts it (RunAtLoad). +// It retries while launchd is busy with the job; a job another client +// loaded meanwhile counts as loaded. +func (s *launchdController) bootstrap() error { + delay := unloadPoll + for attempt := 1; ; attempt++ { + out, code, err := s.launchctl("bootstrap", s.domain, s.plistPath) + switch { + case err != nil || code == 0: + return err + case domainAbsent(code, out): + return ErrNoGUISession + case !busy(code): + return launchctlError("bootstrap", code, out) + } + // Another client's bootstrap won: its job runs. A job that is + // loaded but not running may still be on its way out. + if loaded, running, err := s.state(); err != nil || loaded && running { + return err + } + if attempt == bootstrapAttempts { + return launchctlError("bootstrap", code, out) + } + s.sleep(delay) + delay *= 2 + } +} + +// installed reports a missing plist as ks.ErrNotInstalled. +func (s *launchdController) installed() error { + if _, err := os.Stat(s.plistPath); errors.Is(err, os.ErrNotExist) { + return ks.ErrNotInstalled + } else if err != nil { return err } - return s.Controller.Start() + return nil } -func (s *launchdController) Install() error { - if app := BundlePath(s.binPath); app != "" { - for _, bundle := range []string{filepath.Join(app, "Contents", "Helpers", "Driga Service.app"), app} { - if err := s.register(bundle); err != nil { - return err - } +// Status is stopped for a plist launchd has not loaded. Without a desktop +// session it cannot tell, and says so. +func (s *launchdController) Status() (ks.Status, error) { + loaded, running, err := s.state() + switch { + case errors.Is(err, ErrNoGUISession): + if err := s.installed(); err != nil { + return ks.StatusUnknown, err } + return ks.StatusUnknown, ErrNoGUISession + case err != nil: + return ks.StatusUnknown, err + case running: + return ks.StatusRunning, nil + case loaded: + return ks.StatusStopped, nil } - _, err := os.Stat(s.plistPath) - if err == nil { - stale, err := PlistStale(s.plistPath, s.binPath) - if err != nil { + if err := s.installed(); err != nil { + return ks.StatusUnknown, err + } + return ks.StatusStopped, nil +} + +// Start makes repeating service install harmless when launchd already runs +// it: a running job is left alone, a loaded one that stays loaded is +// kicked, and an unloaded one is bootstrapped from its plist. A job Start +// bootstrapped starts by itself; Start never kicks a definition that a +// bootout is still tearing down. +func (s *launchdController) Start() error { + loaded, running, err := s.state() + if errors.Is(err, ErrNoGUISession) { + if err := s.installed(); err != nil { return err } - if !stale { - return nil + return ErrNoGUISession + } + if err != nil || running { + return err + } + if loaded { + if loaded, running, err = s.settle(); err != nil || running { + return err } - if dir, err := s.recordedDataDir(); err == nil && resolveDataDir(dir) != config.DataDir() { - slog.Info("the Driga service collected into another data directory; moving it to this one", "event", "service_data_dir_moved", "from", resolveDataDir(dir), "to", config.DataDir()) + if loaded { + return s.kickstart() } - status, err := s.Status() - if err != nil && !errors.Is(err, ks.ErrNotInstalled) { - return err + } + if err := s.installed(); err != nil { + return err + } + return s.bootstrap() +} + +// settle watches a loaded job that does not run for settleWait, so Start +// bootstraps one that a bootout was tearing down instead of kicking it. +func (s *launchdController) settle() (loaded, running bool, err error) { + for waited := time.Duration(0); ; waited += unloadPoll { + s.sleep(unloadPoll) + loaded, running, err = s.state() + if errors.Is(err, ErrNoGUISession) { + return false, false, err + } + if err != nil || !loaded || running || waited >= settleWait { + return loaded, running, err + } + } +} + +func (s *launchdController) kickstart(args ...string) error { + out, code, err := s.launchctl(append(append([]string{"kickstart"}, args...), s.target())...) + if err != nil || code == 0 { + return err + } + return launchctlError("kickstart", code, out) +} + +func (s *launchdController) Stop() error { return s.bootout() } + +// Restart replaces a loaded job's process in place; an unloaded one starts. +func (s *launchdController) Restart() error { + loaded, _, err := s.state() + if err != nil && !errors.Is(err, ErrNoGUISession) { + return err + } + if !loaded { + return s.Start() + } + return s.kickstart("-k") +} + +// Uninstall boots the job out and deletes its plist. Without a desktop +// session launchd has no job to boot out, and the plist is still deleted, +// so it does not load at the next login. +func (s *launchdController) Uninstall() error { + loaded, _, err := s.state() + if errors.Is(err, ErrNoGUISession) { + loaded, err = false, nil + } else if err == nil { + err = s.bootout() + } + if err != nil { + return err + } + err = os.Remove(s.plistPath) + if errors.Is(err, os.ErrNotExist) { + if loaded { + return nil } - if status == ks.StatusRunning { - if err := s.Stop(); err != nil { + return ks.ErrNotInstalled + } + return err +} + +// Install writes the launch agent's plist and loads it, or leaves both +// alone when the plist is already what this executable and environment +// would write. A plist that differs (another executable, a missing bundle +// association, an older definition, another data directory or a refreshed +// environment) is replaced: the new definition is rendered, checked and +// written beside the plist first, and only then is the job booted out (launchd +// keeps the definition it bootstrapped), the new plist renamed into place +// and bootstrapped. When launchd refuses it, the previous plist is restored +// and bootstrapped again. Without a desktop session the new plist stays for +// launchd to load at the next login. +func (s *launchdController) Install() error { + if app := BundlePath(s.binPath); app != "" { + for _, bundle := range []string{filepath.Join(app, "Contents", "Helpers", "Driga Service.app"), app} { + if err := s.register(bundle); err != nil { return err } } - } else if !errors.Is(err, os.ErrNotExist) { + } + c := configForOS(s.binPath, "darwin") + previous, err := os.ReadFile(s.plistPath) + if err != nil && !errors.Is(err, os.ErrNotExist) { return err } - data, err := renderLaunchd(configForOS(s.binPath, "darwin")) + var recorded map[string]string + if previous != nil { + if recorded, err = s.recordedEnvironment(); err != nil { + slog.Warn("could not read the Driga service's environment; recording this one", "event", "service_env_unreadable", "err", err.Error()) + } else if !s.refreshEnv && len(recorded) > 0 { + c.EnvVars = recorded + } + } + data, err := renderLaunchd(c) if err != nil { return err } + if bytes.Equal(previous, data) { + return nil + } + if err := checkDefinition(data, s.label, c.Executable); err != nil { + return fmt.Errorf("rendered service definition: %w", err) + } + if previous != nil { + if keys := changedKeys(recorded, c.EnvVars); len(keys) > 0 { + slog.Info("the Driga service's environment changed; restarting it", "event", "service_env_changed", "keys", strings.Join(keys, ",")) + } + if dir := resolveDataDir(c.EnvVars["DRIGA_DATA_DIR"]); recorded != nil && resolveDataDir(recorded["DRIGA_DATA_DIR"]) != dir { + slog.Info("the Driga service collected into another data directory; moving it to this one", "event", "service_data_dir_moved", "from", resolveDataDir(recorded["DRIGA_DATA_DIR"]), "to", dir) + } + } if err := secfile.MkdirPrivate(filepath.Dir(s.plistPath)); err != nil { return err } - return secfile.WriteFileAtomic(s.plistPath, data, nil) + // A job can outlive its plist; boot it out even when no file exists. + if err := secfile.WriteFileAtomic(s.plistPath, data, s.bootout); err != nil { + return err + } + err = s.bootstrap() + if err == nil || errors.Is(err, ErrNoGUISession) { + return nil + } + if previous == nil { + return err + } + restoreErr := secfile.WriteFileAtomic(s.plistPath, previous, nil) + if restoreErr == nil { + restoreErr = s.bootstrap() + } + if restoreErr != nil { + return fmt.Errorf("the new service definition did not load: %w; restoring the previous one failed too: %w", err, restoreErr) + } + return fmt.Errorf("the new service definition did not load, so the previous one runs again: %w", err) +} + +// changedKeys names the variables whose values differ between two +// environments; values are never logged. +func changedKeys(before, after map[string]string) []string { + var keys []string + for key, value := range after { + if old, ok := before[key]; !ok || old != value { + keys = append(keys, key) + } + } + for key := range before { + if _, ok := after[key]; !ok { + keys = append(keys, key) + } + } + slices.Sort(keys) + return keys +} + +// checkDefinition parses a rendered plist and checks that it names label and +// runs executable, before it replaces the one launchd runs. +func checkDefinition(data []byte, label, executable string) error { + decoder := xml.NewDecoder(bytes.NewReader(data)) + depth, key, gotLabel, program := 0, "", "", "" + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + return err + } + switch t := token.(type) { + case xml.EndElement: + depth-- + case xml.StartElement: + depth++ + // plist > dict > the dict's keys and values + if depth != 3 { + continue + } + var value struct { + Text string `xml:",chardata"` + Strings []string `xml:"string"` + } + switch { + case t.Name.Local == "key", key == "Label" && t.Name.Local == "string", key == "ProgramArguments" && t.Name.Local == "array": + if err := decoder.DecodeElement(&value, &t); err != nil { + return err + } + depth-- + default: + continue + } + switch t.Name.Local { + case "key": + key = value.Text + case "string": + gotLabel = value.Text + case "array": + if len(value.Strings) > 0 { + program = value.Strings[0] + } + } + } + } + if depth != 0 || gotLabel != label || program != executable { + return fmt.Errorf("label %q runs %q; want %q running %q", gotLabel, program, label, executable) + } + return nil } diff --git a/internal/service/launchd_darwin_test.go b/internal/service/launchd_darwin_test.go index a1c2e49d..458b312b 100644 --- a/internal/service/launchd_darwin_test.go +++ b/internal/service/launchd_darwin_test.go @@ -1,56 +1,156 @@ package service import ( + "errors" "os" "path/filepath" + "slices" "strings" "testing" + "time" ks "github.com/kardianos/service" "github.com/labstack/driga/internal/config" ) -type fakeLaunchd struct { - Controller - stops int - starts int - state ks.Status +// fakeLaunchctl models one launchd domain the way launchctl reports it: no +// real launchctl is reachable. bootstrap reads the plist at that moment, as +// launchd does, and refuses a job the domain already has. +type fakeLaunchctl struct { + t *testing.T + target, domain string + loaded, running bool + exists func() bool // whether the job's executable exists + definition string // the plist launchd bootstrapped + calls []string + bootouts, starts int + // noDomain models an account without a desktop session: gui/ + // does not exist. + noDomain bool + // linger keeps a booted-out job loaded for that many print calls, as + // launchd finishes tearing it down after bootout returns; bootstrap + // and kickstart meanwhile fail. + linger, tearingDown int + // refuse makes bootstrap fail with code 78 for a definition it matches. + refuse func(definition string) bool } -func (f *fakeLaunchd) Status() (ks.Status, error) { return f.state, nil } -func (f *fakeLaunchd) Stop() error { f.stops++; f.state = ks.StatusStopped; return nil } -func (f *fakeLaunchd) Start() error { f.starts++; f.state = ks.StatusRunning; return nil } +func (f *fakeLaunchctl) run(args ...string) (string, int, error) { + f.calls = append(f.calls, strings.Join(args, " ")) + if f.noDomain { + return "Bad request.\nCould not find domain for user gui: 501", 112, nil + } + switch { + case slices.Equal(args, []string{"print", f.target}): + if f.tearingDown > 0 { + f.tearingDown-- + if f.tearingDown == 0 { + f.loaded = false + } + return f.target + " = {\n\tstate = not running\n}\n", 0, nil + } + if !f.loaded { + return "Bad request.\nCould not find service in domain", 113, nil + } + state := "not running" + if f.running { + state = "running" + } + return f.target + " = {\n\tactive count = 1\n\tstate = " + state + "\n\n\tevent triggers = {\n\t\tstate = running\n\t}\n}\n", 0, nil + case slices.Equal(args, []string{"bootout", f.target}): + if !f.loaded { + return "Boot-out failed: 3: No such process", 3, nil + } + f.bootouts++ + f.running = false + if f.linger > 0 { + f.tearingDown = f.linger + } else { + f.loaded = false + } + return "", 0, nil + case len(args) == 3 && args[0] == "bootstrap" && args[1] == f.domain: + if f.tearingDown > 0 { + return "Bootstrap failed: 37: Operation already in progress", 37, nil + } + if f.loaded { + return "Bootstrap failed: 5: Input/output error", 5, nil + } + data, err := os.ReadFile(args[2]) + if err != nil { + return "Bootstrap failed: 2: No such file or directory", 2, nil + } + if f.refuse != nil && f.refuse(string(data)) { + return "Bootstrap failed: 78: Function not implemented", 78, nil + } + f.loaded, f.definition = true, string(data) + f.spawn() + return "", 0, nil + case slices.Equal(args, []string{"kickstart", f.target}), slices.Equal(args, []string{"kickstart", "-k", f.target}): + if f.tearingDown > 0 { + f.t.Fatalf("kickstart of a job being torn down; calls %q", f.calls) + } + if !f.loaded { + return "Could not find service", 113, nil + } + if !f.spawn() { + return "Could not kickstart service: 2: No such file or directory", 2, nil + } + return "", 0, nil + } + f.t.Fatalf("unexpected launchctl %q", args) + return "", 0, errors.New("unreachable") +} -func TestLaunchdInstallRepairsAndIsIdempotent(t *testing.T) { - // A plain fixture path, never Library/LaunchAgents. No launchctl or real - // registration is reachable through this fake controller. - root := t.TempDir() - bin := filepath.Join(root, "Driga.app", "Contents", "MacOS", "driga") - path := filepath.Join(root, "collector.plist") - old, err := renderLaunchd(configForOS(bin, "darwin")) - if err != nil { - t.Fatal(err) +func (f *fakeLaunchctl) spawn() bool { + if f.exists != nil && !f.exists() { + f.running = false + return false } - old = []byte(strings.ReplaceAll(strings.ReplaceAll(string(old), "Contents/Helpers/Driga Service.app/Contents/MacOS/drigad", "Contents/MacOS/drigad"), "run.driga.service", "run.driga")) - if err := os.WriteFile(path, old, 0o600); err != nil { + f.starts++ + f.running = true + return true +} + +// A plain fixture path, never Library/LaunchAgents, and a fake launchctl and +// registration, so no real launch agent is reachable. +func newTestLaunchd(t *testing.T) (*launchdController, *fakeLaunchctl, string) { + t.Helper() + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { t.Fatal(err) } - controller := &fakeLaunchd{state: ks.StatusRunning} - registrations := 0 - s := &launchdController{Controller: controller, binPath: bin, plistPath: path, + bin := writeTestBundle(t, filepath.Join(root, "Driga.app")) + fake := &fakeLaunchctl{t: t, domain: "gui/501", target: "gui/501/" + Label("darwin")} + s := &launchdController{binPath: bin, label: Label("darwin"), domain: fake.domain, + plistPath: filepath.Join(root, "collector.plist"), launchctl: fake.run, + sleep: func(time.Duration) {}, register: func(app string) error { if app != filepath.Join(root, "Driga.app") && app != filepath.Join(root, "Driga.app", "Contents", "Helpers", "Driga Service.app") { t.Fatal(app) } - registrations++ return nil }, } + return s, fake, bin +} + +func TestLaunchdInstallRepairsAndIsIdempotent(t *testing.T) { + s, fake, bin := newTestLaunchd(t) + old, err := renderLaunchd(configForOS(bin, "darwin")) + if err != nil { + t.Fatal(err) + } + old = []byte(strings.ReplaceAll(strings.ReplaceAll(string(old), "Contents/Helpers/Driga Service.app/Contents/MacOS/drigad", "Contents/MacOS/drigad"), "run.driga.service", "run.driga")) + if err := os.WriteFile(s.plistPath, old, 0o600); err != nil { + t.Fatal(err) + } + fake.loaded, fake.running, fake.definition = true, true, string(old) if err := s.Install(); err != nil { t.Fatal(err) } - if controller.stops != 1 { - t.Fatalf("stops = %d", controller.stops) + if fake.bootouts != 1 || !fake.loaded || !fake.running { + t.Fatalf("bootouts = %d, loaded = %t, running = %t", fake.bootouts, fake.loaded, fake.running) } if err := s.Start(); err != nil { t.Fatal(err) @@ -58,44 +158,189 @@ func TestLaunchdInstallRepairsAndIsIdempotent(t *testing.T) { if err := s.Start(); err != nil { t.Fatal(err) } - if controller.starts != 1 { + if fake.starts != 1 { t.Fatal("Start wasn't idempotent") } - before, err := os.ReadFile(path) + before, err := os.ReadFile(s.plistPath) if err != nil { t.Fatal(err) } - stale, err := PlistStale(path, bin) + if fake.definition != string(before) { + t.Fatal("launchd runs another definition than the plist") + } + stale, err := PlistStale(s.plistPath, bin) if err != nil || stale { t.Fatalf("repair: %t %v", stale, err) } if err := s.Install(); err != nil { t.Fatal(err) } - after, err := os.ReadFile(path) + after, err := os.ReadFile(s.plistPath) if err != nil { t.Fatal(err) } - if string(before) != string(after) || controller.stops != 1 || registrations != 4 { + if string(before) != string(after) || fake.bootouts != 1 { t.Fatal("reinstallation changed a current service") } // Missing association is repairable even when ProgramArguments is current. withoutAssociation := strings.Replace(string(after), "AssociatedBundleIdentifiersrun.driga.service", "", 1) - if err := os.WriteFile(path, []byte(withoutAssociation), 0o600); err != nil { + if err := os.WriteFile(s.plistPath, []byte(withoutAssociation), 0o600); err != nil { t.Fatal(err) } if err := s.Install(); err != nil { t.Fatal(err) } - if controller.stops != 2 { + if fake.bootouts != 2 { t.Fatal("association wasn't repaired") } } +// After Driga.app moves, launchd still has the job but KeepAlive cannot run +// its vanished executable: loaded, not running. Repair must boot it out and +// bootstrap the new plist; the deprecated load answered "already loaded". +func TestLaunchdRepairsLoadedJobThatIsNotRunning(t *testing.T) { + s, fake, bin := newTestLaunchd(t) + moved := strings.Replace(bin, "Driga.app", "Old/Driga.app", 1) + old, err := renderLaunchd(configForOS(moved, "darwin")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(s.plistPath, old, 0o600); err != nil { + t.Fatal(err) + } + newDaemon := DaemonPath(ExecutablePath(bin)) + fake.loaded, fake.definition = true, string(old) + fake.exists = func() bool { return strings.Contains(fake.definition, ""+newDaemon+"") } + if status, err := s.Status(); err != nil || status != ks.StatusStopped { + t.Fatalf("Status = %v, %v; want stopped", status, err) + } + if err := s.Install(); err != nil { + t.Fatal(err) + } + if err := s.Start(); err != nil { + t.Fatal(err) + } + if !fake.loaded || !fake.running || !strings.Contains(fake.definition, newDaemon) { + t.Fatalf("loaded=%t running=%t; launchd definition:\n%s", fake.loaded, fake.running, fake.definition) + } + for _, call := range fake.calls { + if strings.HasPrefix(call, "load") || strings.HasPrefix(call, "unload") { + t.Fatalf("deprecated launchctl %s", call) + } + } + if status, err := s.Status(); err != nil || status != ks.StatusRunning { + t.Fatalf("Status = %v, %v; want running", status, err) + } +} + +// A loaded job whose process exited is kicked, not bootstrapped again. +func TestLaunchdStartKicksLoadedJob(t *testing.T) { + s, fake, _ := newTestLaunchd(t) + if err := s.Install(); err != nil { + t.Fatal(err) + } + if err := s.Start(); err != nil { + t.Fatal(err) + } + fake.running = false + if err := s.Start(); err != nil { + t.Fatal(err) + } + if !fake.running || fake.calls[len(fake.calls)-1] != "kickstart "+fake.target { + t.Fatalf("calls: %q", fake.calls) + } + if err := s.Restart(); err != nil || fake.calls[len(fake.calls)-1] != "kickstart -k "+fake.target { + t.Fatalf("Restart: %v, calls %q", err, fake.calls) + } +} + +func TestLaunchdStopAndUninstall(t *testing.T) { + s, fake, _ := newTestLaunchd(t) + if err := s.Uninstall(); !errors.Is(err, ks.ErrNotInstalled) { + t.Fatalf("Uninstall of nothing = %v", err) + } + if err := s.Stop(); err != nil { + t.Fatalf("Stop of an unloaded job: %v", err) + } + if err := s.Install(); err != nil { + t.Fatal(err) + } + if err := s.Start(); err != nil { + t.Fatal(err) + } + if err := s.Stop(); err != nil || fake.loaded { + t.Fatalf("Stop: %v, loaded=%t", err, fake.loaded) + } + if status, err := s.Status(); err != nil || status != ks.StatusStopped { + t.Fatalf("Status = %v, %v; want stopped", status, err) + } + if err := s.Start(); err != nil { + t.Fatal(err) + } + if err := s.Uninstall(); err != nil || fake.loaded { + t.Fatalf("Uninstall: %v, loaded=%t", err, fake.loaded) + } + if _, err := os.Stat(s.plistPath); !errors.Is(err, os.ErrNotExist) { + t.Fatal("plist left behind") + } + if _, err := s.Status(); !errors.Is(err, ks.ErrNotInstalled) { + t.Fatalf("Status after uninstall: %v", err) + } +} + +// The login environment the service records is part of its definition: with +// --refresh-env, as the app installs, a changed PATH rewrites the plist and +// replaces the running job. +func TestLaunchdInstallRefreshesEnvironment(t *testing.T) { + s, fake, _ := newTestLaunchd(t) + s.refreshEnvironment() + t.Setenv("PATH", "/usr/bin:/bin") + if err := s.Install(); err != nil { + t.Fatal(err) + } + if err := s.Start(); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", "/opt/homebrew/bin:/usr/bin:/bin") + if err := s.Install(); err != nil { + t.Fatal(err) + } + if err := s.Start(); err != nil { + t.Fatal(err) + } + if fake.bootouts != 1 || !strings.Contains(fake.definition, "/opt/homebrew/bin:/usr/bin:/bin") { + t.Fatalf("bootouts = %d; definition:\n%s", fake.bootouts, fake.definition) + } + env, err := RecordedEnvironment(s) + if err != nil || env["PATH"] != "/opt/homebrew/bin:/usr/bin:/bin" { + t.Fatalf("RecordedEnvironment = %v, %v", env, err) + } +} + +// The plist keeps the job alive only while its executable exists, so a +// deleted Driga.app does not leave launchd respawning a missing helper. +func TestLaunchdPlistKeepsAliveWhileExecutableExists(t *testing.T) { + _, _, bin := newTestLaunchd(t) + data, err := renderLaunchd(configForOS(bin, "darwin")) + if err != nil { + t.Fatal(err) + } + daemon := DaemonPath(ExecutablePath(bin)) + for _, want := range []string{ + "KeepAlivePathState" + daemon + "", + "ProcessTypeBackground", + "ThrottleInterval30", + } { + if !strings.Contains(string(data), want) { + t.Errorf("plist lacks %s:\n%s", want, data) + } + } +} + func TestLaunchdDataDir(t *testing.T) { root := t.TempDir() path := filepath.Join(root, "collector.plist") - s := &launchdController{Controller: &fakeLaunchd{}, binPath: "/usr/local/bin/driga", plistPath: path} + s := &launchdController{binPath: "/usr/local/bin/driga", plistPath: path} // A service installed without DRIGA_DATA_DIR collects where an app // started without it does. t.Setenv("DRIGA_DATA_DIR", "") @@ -120,3 +365,192 @@ func TestLaunchdDataDir(t *testing.T) { } } } + +// launchd finishes tearing a job down after bootout returns. Install waits +// until print no longer has it before it bootstraps the new definition, and +// never kicks the old one. +func TestLaunchdInstallWaitsForTeardown(t *testing.T) { + s, fake, bin := newTestLaunchd(t) + old := strings.Replace(mustRender(t, bin), "ThrottleInterval", "OldThrottleInterval", 1) + if err := os.WriteFile(s.plistPath, []byte(old), 0o600); err != nil { + t.Fatal(err) + } + fake.loaded, fake.running, fake.definition, fake.linger = true, true, old, 3 + if err := s.Install(); err != nil { + t.Fatal(err) + } + if !fake.loaded || !fake.running || fake.definition == old { + t.Fatalf("loaded=%t running=%t; calls %q", fake.loaded, fake.running, fake.calls) + } + bootout, bootstrap := slices.Index(fake.calls, "bootout "+fake.target), slices.IndexFunc(fake.calls, func(c string) bool { return strings.HasPrefix(c, "bootstrap ") }) + if bootout < 0 || bootstrap-bootout != 5 { + t.Fatalf("bootstrap must follow three teardown prints and the unloaded one: %q", fake.calls) + } + if err := s.Start(); err != nil { + t.Fatal(err) + } + // Stop waits too, so Start right after it bootstraps. + fake.linger = 4 + if err := s.Stop(); err != nil || fake.loaded { + t.Fatalf("Stop: %v, loaded=%t", err, fake.loaded) + } + if err := s.Start(); err != nil || !fake.running { + t.Fatalf("Start: %v, running=%t", err, fake.running) + } +} + +// A job another client is booting out is not kicked: Start lets it go and +// bootstraps the plist. +func TestLaunchdStartDoesNotKickJobBeingTornDown(t *testing.T) { + s, fake, bin := newTestLaunchd(t) + if err := os.WriteFile(s.plistPath, []byte(mustRender(t, bin)), 0o600); err != nil { + t.Fatal(err) + } + fake.loaded, fake.tearingDown = true, 3 + if err := s.Start(); err != nil { + t.Fatal(err) + } + if !fake.loaded || !fake.running { + t.Fatalf("calls %q", fake.calls) + } +} + +// bootstrap retries while launchd answers that it is busy with the job, with +// growing backoff. +func TestLaunchdBootstrapRetriesWhileBusy(t *testing.T) { + s, fake, bin := newTestLaunchd(t) + if err := os.WriteFile(s.plistPath, []byte(mustRender(t, bin)), 0o600); err != nil { + t.Fatal(err) + } + var sleeps []time.Duration + s.sleep = func(d time.Duration) { sleeps = append(sleeps, d) } + fake.loaded, fake.tearingDown = true, 2 + if err := s.bootstrap(); err != nil { + t.Fatal(err) + } + if !fake.running || !slices.Equal(sleeps, []time.Duration{unloadPoll, 2 * unloadPoll}) { + t.Fatalf("running=%t sleeps=%v calls=%q", fake.running, sleeps, fake.calls) + } +} + +// When launchd never lets the old job go, Install gives up after unloadWait +// and leaves the old plist in place. +func TestLaunchdInstallBoundsTeardownWait(t *testing.T) { + s, fake, bin := newTestLaunchd(t) + old := strings.Replace(mustRender(t, bin), "ThrottleInterval", "OldThrottleInterval", 1) + if err := os.WriteFile(s.plistPath, []byte(old), 0o600); err != nil { + t.Fatal(err) + } + var waited time.Duration + s.sleep = func(d time.Duration) { waited += d } + fake.loaded, fake.definition, fake.linger = true, old, 1<<20 + if err := s.Install(); err == nil || !strings.Contains(err.Error(), "after bootout") { + t.Fatalf("Install = %v", err) + } + if waited != unloadWait { + t.Fatalf("waited %s, want %s", waited, unloadWait) + } + if data, _ := os.ReadFile(s.plistPath); string(data) != old { + t.Fatal("plist replaced although the old job was still loaded") + } + matches, _ := filepath.Glob(filepath.Join(filepath.Dir(s.plistPath), ".*")) + if len(matches) != 0 { + t.Fatalf("temporary plist left behind: %v", matches) + } +} + +// A definition launchd refuses does not cost the working service: the +// previous plist is restored and bootstrapped again. +func TestLaunchdInstallRestoresPreviousDefinition(t *testing.T) { + s, fake, bin := newTestLaunchd(t) + old := strings.Replace(mustRender(t, bin), "ThrottleInterval", "OldThrottleInterval", 1) + if err := os.WriteFile(s.plistPath, []byte(old), 0o600); err != nil { + t.Fatal(err) + } + fake.loaded, fake.running, fake.definition = true, true, old + fake.refuse = func(definition string) bool { return definition != old } + err := s.Install() + if err == nil || !strings.Contains(err.Error(), "previous one runs again") { + t.Fatalf("Install = %v", err) + } + if data, _ := os.ReadFile(s.plistPath); string(data) != old { + t.Fatal("previous plist not restored") + } + if !fake.loaded || !fake.running || fake.definition != old { + t.Fatalf("previous definition not running again: loaded=%t running=%t", fake.loaded, fake.running) + } + // With nothing to restore the error is launchd's. + if err := os.Remove(s.plistPath); err != nil { + t.Fatal(err) + } + fake.loaded, fake.running = false, false + if err := s.Install(); err == nil || !strings.Contains(err.Error(), "exited 78") { + t.Fatalf("fresh Install = %v", err) + } +} + +// Over SSH with nobody logged in to the desktop there is no gui/ +// domain. Uninstall still deletes the plist; start and status say what to +// do; install leaves the plist for launchd to load at the next login. +func TestLaunchdWithoutDesktopSession(t *testing.T) { + s, fake, bin := newTestLaunchd(t) + fake.noDomain = true + if _, err := s.Status(); !errors.Is(err, ks.ErrNotInstalled) { + t.Fatalf("Status without plist = %v", err) + } + if err := s.Install(); err != nil { + t.Fatal(err) + } + if data, err := os.ReadFile(s.plistPath); err != nil || string(data) != mustRender(t, bin) { + t.Fatalf("plist not written: %v", err) + } + for name, f := range map[string]func() error{"Start": s.Start, "Restart": s.Restart, "Status": func() error { _, err := s.Status(); return err }} { + if err := f(); !errors.Is(err, ErrNoGUISession) || !strings.Contains(err.Error(), "log in to the desktop session to start the Driga service") { + t.Errorf("%s = %v", name, err) + } + } + if err := s.Stop(); err != nil { + t.Fatalf("Stop = %v", err) + } + if err := s.Uninstall(); err != nil { + t.Fatalf("Uninstall = %v", err) + } + if _, err := os.Stat(s.plistPath); !errors.Is(err, os.ErrNotExist) { + t.Fatal("plist left behind") + } + if err := s.Uninstall(); !errors.Is(err, ks.ErrNotInstalled) { + t.Fatalf("second Uninstall = %v", err) + } +} + +// Without --refresh-env, as from a terminal, an installed service keeps the +// environment it records, so a terminal install does not restart it. +func TestLaunchdInstallKeepsRecordedEnvironment(t *testing.T) { + s, fake, _ := newTestLaunchd(t) + t.Setenv("PATH", "/login/bin:/usr/bin") + s.refreshEnv = true + if err := s.Install(); err != nil { + t.Fatal(err) + } + s.refreshEnv = false + t.Setenv("PATH", "/terminal/bin:/usr/bin") + t.Setenv("TMUX_TMPDIR", "/terminal/tmux") + if err := s.Install(); err != nil { + t.Fatal(err) + } + if fake.bootouts != 0 || !strings.Contains(fake.definition, "/login/bin:/usr/bin") || strings.Contains(fake.definition, "/terminal") { + t.Fatalf("bootouts = %d; definition:\n%s", fake.bootouts, fake.definition) + } + if got := changedKeys(map[string]string{"PATH": "a", "HOME": "h", "OLD": "x"}, map[string]string{"PATH": "b", "HOME": "h", "NEW": "y"}); !slices.Equal(got, []string{"NEW", "OLD", "PATH"}) { + t.Fatalf("changedKeys = %v", got) + } +} + +func mustRender(t *testing.T, bin string) string { + t.Helper() + data, err := renderLaunchd(configForOS(bin, "darwin")) + if err != nil { + t.Fatal(err) + } + return string(data) +} diff --git a/internal/service/launchd_other.go b/internal/service/launchd_other.go index 6058a82e..3e5a7d0c 100644 --- a/internal/service/launchd_other.go +++ b/internal/service/launchd_other.go @@ -2,4 +2,8 @@ package service -func newLaunchdController(controller Controller, _ string) Controller { return controller } +import "errors" + +func newLaunchdController(string) (Controller, error) { + return nil, errors.New("launchd requires macOS") +} diff --git a/internal/service/lifecycle.go b/internal/service/lifecycle.go index 72c990b4..de7f2d46 100644 --- a/internal/service/lifecycle.go +++ b/internal/service/lifecycle.go @@ -11,6 +11,7 @@ import ( "runtime" "strings" "sync" + "time" ks "github.com/kardianos/service" "github.com/labstack/driga/internal/collector" @@ -55,6 +56,14 @@ type LifecycleOptions struct { Home, AccountHome string Environment []string Output io.Writer + // RefreshEnvironment records Environment, or this process's environment, + // in an installed macOS service instead of keeping the one it records. + // The app sets it: its login-shell environment is the one source. + RefreshEnvironment bool + // RegistrationTTL lets Observe reuse the service manager's last answer + // for this long while the service's lock and process are unchanged. Zero + // asks it every time. + RegistrationTTL time.Duration } // lifecycleHost has two adapters: the local OS and a local registration/process @@ -75,6 +84,10 @@ type Lifecycle struct { host lifecycleHost mu sync.Mutex err error + // The last registration Observe read, when, and whether the service ran. + reg registration + regAt time.Time + regRunning bool } func NewLifecycle(opts LifecycleOptions) *Lifecycle { @@ -95,6 +108,7 @@ func (l *Lifecycle) Ensure(ctx context.Context) (err error) { l.mu.Lock() defer l.mu.Unlock() l.err = err + l.regAt = time.Time{} if errors.Is(err, ErrIsolatedHome) { l.err = nil } @@ -128,11 +142,19 @@ func (l *Lifecycle) Ensure(ctx context.Context) (err error) { } // Control implements explicit service administration, shared with the CLI. -// A failed install leaves the previous collector available. +// On macOS a failed install leaves the previous definition loaded: the new +// one replaces it only once written, and launchd refusing it restores the +// previous one. Every action makes the next Observe ask the service manager +// again. func (l *Lifecycle) Control(ctx context.Context, action string) error { if err := ctx.Err(); err != nil { return err } + defer func() { + l.mu.Lock() + l.regAt = time.Time{} + l.mu.Unlock() + }() c, err := l.host.controller() if err != nil { return fmt.Errorf("initialize service controller: %w", err) @@ -171,37 +193,65 @@ func (l *Lifecycle) Control(ctx context.Context, action string) error { } func (l *Lifecycle) Observe() Status { - s := Status{Registration: "unknown"} + s := Status{} s.Owner, s.LockHeld, s.Err = l.host.owner() s.Alive = l.host.alive() s.Running = s.LockHeld && s.Alive + r := l.registration(s.Running) + s.Installed, s.Registration, s.TaskState = r.installed, r.registration, r.taskState + l.mu.Lock() + defer l.mu.Unlock() + if s.Err == nil { + s.Err = l.err + } + if s.Err == nil { + s.Err = r.err + } + return s +} + +// registration is what the service manager reports, which costs a process +// (launchctl, systemctl) or a Task Scheduler query. Within RegistrationTTL +// Observe reuses the last answer while the service's lock and process tell +// the same story. +type registration struct { + installed bool + registration, taskState string + err error +} + +func (l *Lifecycle) registration(running bool) registration { + l.mu.Lock() + if l.opts.RegistrationTTL > 0 && !l.regAt.IsZero() && l.regRunning == running && time.Since(l.regAt) < l.opts.RegistrationTTL { + r := l.reg + l.mu.Unlock() + return r + } + l.mu.Unlock() + r := registration{registration: "unknown"} c, err := l.host.controller() if err == nil { var status ks.Status status, err = c.Status() - s.Installed = err == nil + r.installed = err == nil switch status { case ks.StatusRunning: - s.Registration = "running" + r.registration = "running" case ks.StatusStopped: - s.Registration = "stopped" + r.registration = "stopped" } if task, ok := c.(interface{ TaskState() (string, error) }); ok { - s.TaskState, err = task.TaskState() + r.taskState, err = task.TaskState() } } if errors.Is(err, ks.ErrNotInstalled) { - s.Registration, err = "not installed", nil + r.registration, err = "not installed", nil } + r.err = err l.mu.Lock() - defer l.mu.Unlock() - if s.Err == nil { - s.Err = l.err - } - if s.Err == nil { - s.Err = err - } - return s + l.reg, l.regAt, l.regRunning = r, time.Now(), running + l.mu.Unlock() + return r } func isolatedHome(home, account string) bool { diff --git a/internal/service/lifecycle_local.go b/internal/service/lifecycle_local.go index 9008df3d..e67fdf67 100644 --- a/internal/service/lifecycle_local.go +++ b/internal/service/lifecycle_local.go @@ -14,7 +14,13 @@ import ( type localLifecycle struct{ opts LifecycleOptions } -func (h localLifecycle) controller() (Controller, error) { return New(h.opts.Executable) } +func (h localLifecycle) controller() (Controller, error) { + c, err := New(h.opts.Executable) + if r, ok := c.(interface{ refreshEnvironment() }); ok && h.opts.RefreshEnvironment { + r.refreshEnvironment() + } + return c, err +} func (h localLifecycle) owner() (collector.Owner, bool, error) { return collector.ReadOwner(h.opts.DataDir) } @@ -30,7 +36,12 @@ func (h localLifecycle) removeLegacy() error { return RemoveLegacy() } func (h localLifecycle) run(ctx context.Context, action string) error { ctx, cancel := context.WithTimeout(ctx, time.Minute) defer cancel() - cmd := exec.CommandContext(ctx, h.opts.Executable, "service", action) + args := []string{"service", action} + if action == "install" { + // The app's login-shell environment is the one the service records. + args = append(args, "--refresh-env") + } + cmd := exec.CommandContext(ctx, h.opts.Executable, args...) cmd.Env = h.opts.Environment out, err := platform.ChildOutput(cmd, true) if err != nil { diff --git a/internal/service/lifecycle_test.go b/internal/service/lifecycle_test.go index f354303d..e2443a5c 100644 --- a/internal/service/lifecycle_test.go +++ b/internal/service/lifecycle_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "testing" + "time" ks "github.com/kardianos/service" "github.com/labstack/driga/internal/collector" @@ -21,6 +22,7 @@ type testLifecycleHost struct { platform, binaryVersion, fail string installed, legacy, held, live bool registrationRevision int + probes int process collector.Owner output io.Writer } @@ -89,6 +91,7 @@ func (c testController) Restart() error { } func (c testController) Uninstall() error { c.h.installed = false; return c.Stop() } func (c testController) Status() (ks.Status, error) { + c.h.probes++ if c.h.fail == "probe" { return ks.StatusUnknown, lifecycleFailure } @@ -252,3 +255,46 @@ func TestLifecycleStatusDiagnostics(t *testing.T) { t.Fatal("app restart did not take precedence") } } + +// The app polls Observe often; with a RegistrationTTL the service manager is +// asked again only when the lock and process change or the answer ages out. +func TestLifecycleObserveReusesRegistration(t *testing.T) { + l, h := testLifecycle("darwin") + l.opts.RegistrationTTL = time.Hour + h.installed, h.live, h.held = true, true, true + for range 3 { + if st := l.Observe(); !st.Running || st.Registration != "running" { + t.Fatalf("%+v", st) + } + } + if h.probes != 1 { + t.Fatalf("probes = %d, want 1", h.probes) + } + h.live, h.held = false, false + if st := l.Observe(); st.Running || st.Registration != "stopped" || h.probes != 2 { + t.Fatalf("a stopped service reused the old registration: %+v, probes = %d", st, h.probes) + } + l.opts.RegistrationTTL = 0 + l.Observe() + l.Observe() + if h.probes != 4 { + t.Fatalf("without a TTL, probes = %d, want 4", h.probes) + } +} + +// After an explicit action the tray shows the service manager's new answer +// at once, even while the lock and process tell the same story. +func TestLifecycleControlClearsRegistrationCache(t *testing.T) { + l, h := testLifecycle("darwin") + l.opts.RegistrationTTL = time.Hour + h.installed, h.live, h.held = true, true, true + if st := l.Observe(); st.Registration != "running" { + t.Fatalf("%+v", st) + } + if err := l.Control(context.Background(), "install"); err != nil { + t.Fatal(err) + } + if st := l.Observe(); st.Registration != "running" || h.probes != 2 { + t.Fatalf("Observe reused the registration from before Control: %+v, probes = %d", st, h.probes) + } +} diff --git a/internal/service/service.go b/internal/service/service.go index 3ee567cc..e2e5212c 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -12,6 +12,7 @@ import ( ks "github.com/kardianos/service" "github.com/labstack/driga/internal/config" + "github.com/labstack/driga/internal/variant" ) // LogPath is shared by launchd output and the service logs command. @@ -31,28 +32,10 @@ func logPathForOS(goos string) string { return filepath.Join(config.ExpandHome("~"), "Library", "Logs", "driga"+Variant(), "service.log") } -// Variant is what follows "driga" in this executable's name: empty for a -// release, "-dev" for a driga-dev build. Each variant installs its own -// service, with its own label and log, so a development copy never takes -// over the release's. -func Variant() string { - exe, err := os.Executable() - if err != nil { - return "" - } - return variantOf(exe) -} - -func variantOf(exe string) string { - name := strings.TrimSuffix(filepath.Base(exe), filepath.Ext(exe)) - if rest, ok := strings.CutPrefix(name, "drigad"); ok && (rest == "" || strings.HasPrefix(rest, "-")) { - return rest - } - if rest, ok := strings.CutPrefix(name, "driga"); ok && (rest == "" || strings.HasPrefix(rest, "-")) { - return rest - } - return "" -} +// Variant is what follows "driga" in this build's service label, log and +// default data directory: empty for a release and "-dev" for a development +// build. See internal/variant. +func Variant() string { return variant.Suffix() } // EnvKeys lists the environment variables a collector started outside the // user's shell needs from it. PATH and TMUX_TMPDIR matter most: launchd, @@ -146,14 +129,10 @@ func New(binPath string) (Controller, error) { if !filepath.IsAbs(binPath) { return nil, errors.New("service binary path must be absolute") } - controller, err := ks.New(controlOnly{}, Config(binPath)) - if err != nil { - return nil, err - } if runtime.GOOS == "darwin" { - return newLaunchdController(controller, binPath), nil + return newLaunchdController(binPath) } - return controller, nil + return ks.New(controlOnly{}, Config(binPath)) } // Installed reports whether the per-user service is registered, running or not. @@ -179,6 +158,19 @@ func DataDir(c Controller) (string, error) { return resolveDataDir(dir), nil } +// RecordedEnvironment is the environment the installed service's definition +// gives it: on macOS, the launch agent's EnvironmentVariables. Elsewhere the +// definition records none here and RecordedEnvironment returns an error. +func RecordedEnvironment(c Controller) (map[string]string, error) { + r, ok := c.(interface { + recordedEnvironment() (map[string]string, error) + }) + if !ok { + return nil, errors.New("the service does not record its environment here") + } + return r.recordedEnvironment() +} + // resolveDataDir is the directory a service definition's DRIGA_DATA_DIR // names, or the default when it records none. func resolveDataDir(dir string) string { @@ -198,6 +190,11 @@ func (controlOnly) Start(ks.Service) error { } func (controlOnly) Stop(ks.Service) error { return nil } +// launchdConfig keeps the service alive only while its executable exists: +// with Driga.app deleted, launchd stops respawning a helper that is gone, and +// it starts the job again when the path comes back. ProcessType Background +// applies launchd's background resource policy to the collector, and +// ThrottleInterval spaces out restarts after a crash. const launchdConfig = ` @@ -207,8 +204,10 @@ BUNDLE_ASSOCIATION {{Path | html}} {{range Arguments}}{{. | html}} {{end}} -KeepAlive<{{KeepAlive}}/> +KeepAlivePathState{{Path | html}} RunAtLoad<{{RunAtLoad}}/> +ProcessTypeBackground +ThrottleInterval30 {{if EnvVars}}EnvironmentVariables {{range EnvVars}}{{.}} {{end}}{{end}} diff --git a/internal/service/service_test.go b/internal/service/service_test.go index fb015a6f..9a930785 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -10,12 +10,14 @@ import ( "testing" ks "github.com/kardianos/service" + "github.com/labstack/driga/internal/variant" ) func TestServiceConfig(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("Unix service or hook path contract; Windows has watch/refusal tests") } + releaseBuild(t) home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", os.Getenv("HOME")) @@ -39,6 +41,7 @@ func TestServiceConfigLinux(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("Unix service or hook path contract; Windows has watch/refusal tests") } + releaseBuild(t) home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", os.Getenv("HOME")) @@ -148,12 +151,14 @@ func TestDaemonPathAndVariant(t *testing.T) { t.Errorf("DaemonPath(%q) = %q, want %q", in, got, want) } } - for exe, want := range map[string]string{"/opt/driga": "", "/opt/drigad": "", "/opt/driga-dev": "-dev", "/opt/drigad-dev": "-dev", "/opt/driga.exe": "", "/opt/service.test": ""} { - if got := variantOf(exe); got != want { - t.Errorf("variantOf(%q) = %q, want %q", exe, got, want) - } - } if !IsDaemon("/opt/drigad-dev") || !IsDaemon("drigad.exe") || IsDaemon("/opt/driga") || IsDaemon("/opt/driga-dev") { t.Error("IsDaemon") } } + +// releaseBuild makes this test binary a release build, as .goreleaser.yaml +// marks published ones, for the test's duration. +func releaseBuild(t *testing.T) { + t.Helper() + t.Cleanup(variant.SetForTest("")) +} diff --git a/internal/service/watch.go b/internal/service/watch.go new file mode 100644 index 00000000..e251dbcc --- /dev/null +++ b/internal/service/watch.go @@ -0,0 +1,35 @@ +package service + +import ( + "context" + "errors" + "log/slog" + "os" + "time" +) + +// ExecutableCheck is how often the macOS service checks that its executable +// still exists. +const ExecutableCheck = time.Minute + +// WatchExecutable calls gone once path no longer exists, then returns; it +// returns without calling gone when ctx ends. The macOS service cancels its +// own run with it after Driga.app is deleted without uninstalling the +// service, and launchd, which keeps it alive only while that path exists, +// does not start it again. +func WatchExecutable(ctx context.Context, path string, every time.Duration, gone func()) { + ticker := time.NewTicker(every) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + slog.Info("the Driga service's executable was removed; stopping", "event", "service_executable_removed", "path", path) + gone() + return + } + } +} diff --git a/internal/service/watch_test.go b/internal/service/watch_test.go new file mode 100644 index 00000000..44bca282 --- /dev/null +++ b/internal/service/watch_test.go @@ -0,0 +1,51 @@ +package service + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func TestWatchExecutableStopsWhenRemoved(t *testing.T) { + path := filepath.Join(t.TempDir(), "drigad") + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + gone := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + WatchExecutable(t.Context(), path, time.Millisecond, func() { close(gone) }) + }() + select { + case <-gone: + t.Fatal("gone before removal") + case <-time.After(20 * time.Millisecond): + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + select { + case <-gone: + case <-time.After(5 * time.Second): + t.Fatal("removal not noticed") + } + <-done +} + +func TestWatchExecutableEndsWithContext(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + go func() { + defer close(done) + WatchExecutable(ctx, filepath.Join(t.TempDir(), "drigad"), time.Hour, func() { t.Error("gone called") }) + }() + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("watch outlived its context") + } +} diff --git a/internal/spool/spool.go b/internal/spool/spool.go index 34bd9080..1f11adcb 100644 --- a/internal/spool/spool.go +++ b/internal/spool/spool.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/labstack/driga/internal/secfile" "io" "math" "os" @@ -17,6 +16,7 @@ import ( "unicode/utf8" "github.com/labstack/driga/internal/model" + "github.com/labstack/driga/internal/secfile" "github.com/labstack/driga/internal/tail" ) @@ -63,6 +63,11 @@ func AppendAll(dir string, evs []model.HookEvent) error { if err != nil { return err } + // A day file an older release made readable to others is tightened + // to its owner, as the store does. + if err := secfile.ProtectFile(f); err != nil { + return errors.Join(err, f.Close()) + } n, writeErr := f.Write(files[path]) if writeErr == nil && n != len(files[path]) { writeErr = io.ErrShortWrite @@ -71,12 +76,38 @@ func AppendAll(dir string, evs []model.HookEvent) error { return err } } + if err := secfile.Protect(dir); err != nil { + return err + } // Appending leaves directory mtime unchanged; wake the collector with one // directory stat even when writing an existing day file. now := time.Now() return os.Chtimes(dir, now, now) } +// Protect tightens the spool directory and its files to their owner: 0700 +// and 0600 on Unix, a private DACL on Windows. An existing spool that an +// older release left readable to others stays private from then on. A +// missing directory is fine. +func Protect(dir string) error { + entries, err := os.ReadDir(dir) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + errs := []error{secfile.Protect(dir)} + for _, entry := range entries { + if entry.Type().IsRegular() { + if err := secfile.Protect(filepath.Join(dir, entry.Name())); err != nil && !errors.Is(err, os.ErrNotExist) { + errs = append(errs, err) + } + } + } + return errors.Join(errs...) +} + func appendLine(ev model.HookEvent) ([]byte, error) { // First bound the input to the most runes that could possibly fit. Binary // search measures JSON escaping as well as UTF-8 bytes and the newline. @@ -161,7 +192,8 @@ func Drain(dir string, off map[string]model.Offset, since ...time.Time) ([]model return events, next, errors.Join(errs...) } -// Prune removes day files older than 30 days, retaining the cutoff day. +// Prune removes day files older than 30 days, retaining the cutoff day, and +// keeps the rest private (see Protect). func Prune(dir string, now time.Time) error { entries, err := os.ReadDir(dir) if errors.Is(err, os.ErrNotExist) { @@ -170,7 +202,7 @@ func Prune(dir string, now time.Time) error { if err != nil { return err } - var errs []error + errs := []error{Protect(dir)} for _, entry := range entries { if !entry.IsDir() && !RecentDay(entry.Name(), now.AddDate(0, 0, -30)) { if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil && !errors.Is(err, os.ErrNotExist) { diff --git a/internal/spool/spool_test.go b/internal/spool/spool_test.go index eeb3cb9a..1424052c 100644 --- a/internal/spool/spool_test.go +++ b/internal/spool/spool_test.go @@ -196,3 +196,55 @@ func TestAppendCreatesParentsAndWakesExistingDirectory(t *testing.T) { t.Fatal("lost batched appends", len(got), err) } } + +// A spool an older release made readable to others is tightened to its +// owner: by the next append, and by Protect for files nobody appends to. +func TestSpoolTightensExistingPermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix modes; Windows DACLs are covered by secfile") + } + dir := filepath.Join(t.TempDir(), "events") + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 11, 2, 12, 0, 0, 0, time.UTC) + today := filepath.Join(dir, "20261102.jsonl") + old := filepath.Join(dir, "20261101.jsonl") + for _, path := range []string{today, old} { + if err := os.WriteFile(path, nil, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.Chmod(dir, 0o755); err != nil { + t.Fatal(err) + } + mode := func(path string) os.FileMode { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + return info.Mode().Perm() + } + if err := Append(dir, model.HookEvent{TS: now}); err != nil { + t.Fatal(err) + } + if mode(dir) != 0o700 || mode(today) != 0o600 { + t.Fatalf("after append: dir %o, file %o", mode(dir), mode(today)) + } + if mode(old) != 0o644 { + t.Fatalf("append touched another day's file: %o", mode(old)) + } + if err := Protect(dir); err != nil { + t.Fatal(err) + } + if mode(old) != 0o600 { + t.Fatalf("after Protect: %o", mode(old)) + } + if err := Protect(filepath.Join(dir, "missing")); err != nil { + t.Fatalf("missing spool: %v", err) + } +} diff --git a/internal/variant/variant.go b/internal/variant/variant.go new file mode 100644 index 00000000..15834d0d --- /dev/null +++ b/internal/variant/variant.go @@ -0,0 +1,73 @@ +// Package variant names which installation of Driga this build is: a +// release, a development build, or a copy under its own name. Each variant +// has its own service, log and default data directory, so a development +// build never shares the release's service, database or spool. +package variant + +import ( + "os" + "path/filepath" + "strings" + "sync" +) + +// release is "true" in published, non-snapshot builds: .goreleaser.yaml sets +// it with -ldflags "-X github.com/labstack/driga/internal/variant.release=true". +// Every other build, including just build, go build, go run and a GoReleaser +// snapshot, is a development build. +var release string + +var ( + mu sync.Mutex + override *string +) + +// Suffix is what follows "driga" in this executable's name, "-dev" for a +// development build under the release's name, and empty for a release. +func Suffix() string { + mu.Lock() + o := override + mu.Unlock() + if o != nil { + return *o + } + exe, err := os.Executable() + if err != nil { + exe = "" + } + return For(exe, release == "true") +} + +// For is the suffix of the executable exe, built as a release or not. +func For(exe string, release bool) string { + if v := Of(exe); v != "" || release { + return v + } + return "-dev" +} + +// Of is the suffix exe's own name carries: "-" for driga- or +// drigad-, and empty otherwise. +func Of(exe string) string { + name := strings.TrimSuffix(filepath.Base(exe), filepath.Ext(exe)) + for _, prefix := range []string{"drigad", "driga"} { + if rest, ok := strings.CutPrefix(name, prefix); ok && (rest == "" || strings.HasPrefix(rest, "-")) { + return rest + } + } + return "" +} + +// SetForTest makes Suffix return suffix until restore is called. Only tests +// call it; they run as development builds otherwise. +func SetForTest(suffix string) (restore func()) { + mu.Lock() + previous := override + override = &suffix + mu.Unlock() + return func() { + mu.Lock() + override = previous + mu.Unlock() + } +} diff --git a/internal/variant/variant_test.go b/internal/variant/variant_test.go new file mode 100644 index 00000000..e135849c --- /dev/null +++ b/internal/variant/variant_test.go @@ -0,0 +1,36 @@ +package variant + +import "testing" + +func TestSuffix(t *testing.T) { + for exe, want := range map[string]string{"/opt/driga": "", "/opt/drigad": "", "/opt/driga-dev": "-dev", "/opt/drigad-dev": "-dev", "/opt/driga.exe": "", "/opt/service.test": "", "/opt/drigadx": ""} { + if got := Of(exe); got != want { + t.Errorf("Of(%q) = %q, want %q", exe, got, want) + } + } + // A build under the release's name is a development build unless the + // release marker says otherwise; a variant's name always wins. + for _, tc := range []struct { + exe string + release bool + want string + }{ + {"/opt/driga", true, ""}, {"/opt/driga", false, "-dev"}, {"/opt/drigad", false, "-dev"}, + {"/opt/driga-dev", true, "-dev"}, {"/opt/driga-foo", false, "-foo"}, {"", false, "-dev"}, + } { + if got := For(tc.exe, tc.release); got != tc.want { + t.Errorf("For(%q, %t) = %q, want %q", tc.exe, tc.release, got, tc.want) + } + } + if got := Suffix(); got != "-dev" { + t.Errorf("a test binary is a development build; Suffix = %q", got) + } + restore := SetForTest("") + if got := Suffix(); got != "" { + t.Errorf("SetForTest: Suffix = %q", got) + } + restore() + if got := Suffix(); got != "-dev" { + t.Errorf("restore: Suffix = %q", got) + } +} diff --git a/scripts/macos_app_test.go b/scripts/macos_app_test.go index 64eb3867..8cf9cbce 100644 --- a/scripts/macos_app_test.go +++ b/scripts/macos_app_test.go @@ -2,7 +2,6 @@ package scripts_test import ( "encoding/json" - "fmt" "os" "os/exec" "path/filepath" @@ -227,8 +226,11 @@ func TestMacOSReleaseVerificationOrder(t *testing.T) { } } -// Execute the generated Ruby block with a fake cask DSL. No installation or -// launchctl is reachable; only an existing service should trigger migration. +// Execute the generated Ruby block with a fake cask DSL under a temporary +// HOME. No installation or launchctl is reachable. Only an existing service, +// or the copy the same brew process's uninstall kept of it during an upgrade, +// should trigger migration; an ordinary uninstall leaves no copy behind, and +// a copy from another brew process is never restored. func TestMacOSCaskRepairsExistingServiceOnly(t *testing.T) { if _, err := exec.LookPath("ruby"); err != nil { t.Skip("ruby unavailable") @@ -244,37 +246,87 @@ func TestMacOSCaskRepairsExistingServiceOnly(t *testing.T) { if len(config.Casks) != 1 { t.Fatal("expected one cask") } - for _, existing := range []bool{false, true} { - setup := ` + setup := ` +require "fileutils" $calls = [] +$blocks = {} +$uninstall = nil +$zap = nil def app(name); end def appdir; "/Applications"; end -def postflight; yield; end -def system_command(name, **opts) - $calls << [name, opts] - Struct.new(:stdout).new('{"EnvironmentVariables":{"DRIGA_DATA_DIR":"/Users/me/Library/Application Support/driga","PATH":"/custom/bin:/usr/bin"}}') -end -class File - def self.exist?(path) - raise "wrong migration guard" unless path.end_with?("/Library/LaunchAgents/run.driga.service.plist") - ENV["EXISTING_SERVICE"] == "true" - end -end +def postflight(&block); $blocks[:postflight] = block; end +def uninstall_preflight(&block); $blocks[:uninstall_preflight] = block; end +def uninstall(**directives); $uninstall = directives; end +def zap(**directives); $zap = directives; end +def system_command(name, **opts); $calls << [name, opts]; end ` - check := ` -if ENV["EXISTING_SERVICE"] == "true" - raise "plist not read" unless $calls.length == 2 && $calls[0] == ["/usr/bin/plutil", {args: ["-convert", "json", "-o", "-", File.expand_path("~/Library/LaunchAgents/run.driga.service.plist")], must_succeed: true}] - # env: would reach the service as "Application\\ Support"; /usr/bin/env args do not. - expected = ["/usr/bin/env", {args: ["DRIGA_DATA_DIR=/Users/me/Library/Application Support/driga", "PATH=/custom/bin:/usr/bin", "/Applications/Driga.app/Contents/MacOS/driga", "service", "install"], must_succeed: true}] - raise "environment or command changed: #{$calls}" unless $calls[1] == expected -else - raise "fresh install started service" unless $calls.empty? + check := ` +plist = File.expand_path("~/Library/LaunchAgents/run.driga.service.plist") +caches = File.expand_path("~/Library/Caches/run.driga") +kept = File.join(caches, "run.driga.service.#{Process.pid}.plist") +install = [["/Applications/Driga.app/Contents/MacOS/driga", {args: ["service", "install"], must_succeed: true}]] +raise "uninstall does not remove the service: #{$uninstall}" unless $uninstall[:launchctl] == "run.driga.service" +raise "uninstall does not stop the app: #{$uninstall}" unless $uninstall[:signal] == [["TERM", "run.driga"]] +raise "zap keeps user data: #{$zap}" unless $zap[:trash].include?("~/Library/Application Support/driga") && $zap[:trash].include?("~/Library/Logs/driga") +def old_uninstall(plist) + # The old version's uninstall: its preflight keeps the plist, then + # uninstall launchctl removes the agent and its plist. + $blocks.fetch(:uninstall_preflight).call + File.delete(plist) +end +case ENV["CASK_SCENARIO"] +when "fresh" + $blocks.fetch(:postflight).call + raise "fresh install started service: #{$calls}" unless $calls.empty? +when "existing" + FileUtils.mkdir_p(File.dirname(plist)) + File.write(plist, "") + $blocks.fetch(:postflight).call + raise "existing service not migrated: #{$calls}" unless $calls == install +when "slow-upgrade" + FileUtils.mkdir_p(File.dirname(plist)) + File.write(plist, "") + old_uninstall(plist) + raise "plist not kept" unless File.read(kept) == "" && (File.stat(kept).mode & 0o777) == 0o600 + # However long the download took, the same brew process restores it. + File.utime(Time.now - 86400, Time.now - 86400, kept) + $blocks.fetch(:postflight).call + raise "upgrade did not restore the plist" unless File.read(plist) == "" && (File.stat(plist).mode & 0o777) == 0o600 + raise "upgrade did not reinstall the service: #{$calls}" unless $calls == install + raise "kept copy left behind" unless Dir.glob(File.join(caches, "*")).empty? +when "uninstall" + FileUtils.mkdir_p(File.dirname(plist)) + File.write(plist, "") + old_uninstall(plist) + # brew exits here; at_exit deletes the copy. +when "later-install" + $blocks.fetch(:postflight).call + raise "a past uninstall's service came back: #{$calls}" unless $calls.empty? && !File.exist?(plist) +when "foreign-copy" + FileUtils.mkdir_p(caches) + File.write(File.join(caches, "run.driga.service.1.plist"), "") + $blocks.fetch(:postflight).call + raise "another brew process's copy restored: #{$calls}" unless $calls.empty? && !File.exist?(plist) + raise "foreign copy left behind" unless Dir.glob(File.join(caches, "*")).empty? end ` + run := func(home, scenario string) { + t.Helper() cmd := exec.CommandContext(t.Context(), "ruby", "-e", setup+config.Casks[0].Block+check) - cmd.Env = append(releaseTestEnv(t), "EXISTING_SERVICE="+fmt.Sprint(existing)) + cmd.Env = append(releaseTestEnv(t), "HOME="+home, "CASK_SCENARIO="+scenario) if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("existing=%v: %v: %s", existing, err, out) + t.Fatalf("%s: %v: %s", scenario, err, out) } } + for _, scenario := range []string{"fresh", "existing", "slow-upgrade", "foreign-copy"} { + run(t.TempDir(), scenario) + } + // A recent ordinary uninstall: the copy goes when brew exits, and a + // later install does not bring the service back. + home := t.TempDir() + run(home, "uninstall") + if matches, _ := filepath.Glob(filepath.Join(home, "Library", "Caches", "run.driga", "*")); len(matches) != 0 { + t.Fatalf("uninstall left a copy: %v", matches) + } + run(home, "later-install") } diff --git a/scripts/release_publishers_test.go b/scripts/release_publishers_test.go index 203526a9..cd0844c8 100644 --- a/scripts/release_publishers_test.go +++ b/scripts/release_publishers_test.go @@ -1,12 +1,14 @@ package scripts_test import ( + "bytes" "fmt" "os" "regexp" "sort" "strings" "testing" + "text/template" "go.yaml.in/yaml/v3" ) @@ -127,3 +129,36 @@ func preflightedRepositories(t *testing.T) []string { sort.Strings(repositories) return repositories } + +// Only a tagged release is a release build: a snapshot, like any checkout +// build, installs the development service and data directory. +func TestReleaseMarkerOnlyOnTaggedReleases(t *testing.T) { + var config struct { + Builds []struct { + ID string `yaml:"id"` + LDFlags []string `yaml:"ldflags"` + } `yaml:"builds"` + } + if err := yaml.Unmarshal([]byte(readRepositoryFile(t, "../.goreleaser.yaml")), &config); err != nil { + t.Fatal(err) + } + if len(config.Builds) == 0 { + t.Fatal("no builds") + } + const marker = "-X github.com/labstack/driga/internal/variant.release=true" + for _, build := range config.Builds { + for _, snapshot := range []bool{false, true} { + flags, err := template.New("ldflags").Parse(strings.Join(build.LDFlags, " ")) + if err != nil { + t.Fatal(err) + } + var rendered bytes.Buffer + if err := flags.Execute(&rendered, map[string]any{"Os": "darwin", "Version": "v", "FullCommit": "c", "CommitDate": "d", "IsSnapshot": snapshot}); err != nil { + t.Fatal(err) + } + if got := strings.Contains(rendered.String(), marker); got == snapshot { + t.Errorf("%s (snapshot=%t) carries the release marker: %t: %s", build.ID, snapshot, got, rendered.String()) + } + } + } +} diff --git a/scripts/verify-release-dist.sh b/scripts/verify-release-dist.sh index 62dc3efa..0a5601ca 100755 --- a/scripts/verify-release-dist.sh +++ b/scripts/verify-release-dist.sh @@ -174,7 +174,7 @@ if grep -Fq 'caskroom_path' "$cask"; then echo "Homebrew Cask still references Caskroom paths." >&2 exit 1 fi -if ! grep -Fq 'if File.exist?(File.expand_path("~/Library/LaunchAgents/run.driga.service.plist"))' "$cask" || +if ! grep -Fq 'plist = File.expand_path("~/Library/LaunchAgents/run.driga.service.plist")' "$cask" || ! grep -Fq 'environment = JSON.parse(previous.stdout).fetch("EnvironmentVariables", {}).map { |name, value| "#{name}=#{value}" }' "$cask" || ! grep -Fq 'system_command "/usr/bin/env",' "$cask" || ! grep -Fq 'args: [*environment, "#{appdir}/Driga.app/Contents/MacOS/driga", "service", "install"],' "$cask" || @@ -182,6 +182,11 @@ if ! grep -Fq 'if File.exist?(File.expand_path("~/Library/LaunchAgents/run.driga echo "Homebrew Cask is missing the existing-service upgrade repair." >&2 exit 1 fi +if ! grep -Fq 'uninstall launchctl: "run.driga.service",' "$cask" || + ! grep -Fq 'zap trash: [' "$cask"; then + echo "Homebrew Cask does not remove the Driga service on uninstall." >&2 + exit 1 +fi if grep -Eq '^[[:space:]]+verified:' "$cask"; then echo "Homebrew Cask contains deprecated URL verification metadata." >&2 exit 1 diff --git a/scripts/verify_release_dist_test.go b/scripts/verify_release_dist_test.go index ac2cfb0d..1895b901 100644 --- a/scripts/verify_release_dist_test.go +++ b/scripts/verify_release_dist_test.go @@ -150,11 +150,15 @@ func releaseDistFixture(t *testing.T, version, tag string) (string, string) { end app "Driga.app" + require "fileutils" require "json" + uninstall launchctl: "run.driga.service", + signal: [["TERM", "run.driga"]] postflight do - if File.exist?(File.expand_path("~/Library/LaunchAgents/run.driga.service.plist")) + plist = File.expand_path("~/Library/LaunchAgents/run.driga.service.plist") + if File.exist?(plist) previous = system_command "/usr/bin/plutil", - args: ["-convert", "json", "-o", "-", File.expand_path("~/Library/LaunchAgents/run.driga.service.plist")], + args: ["-convert", "json", "-o", "-", plist], must_succeed: true environment = JSON.parse(previous.stdout).fetch("EnvironmentVariables", {}).map { |name, value| "#{name}=#{value}" } system_command "/usr/bin/env", @@ -162,6 +166,9 @@ func releaseDistFixture(t *testing.T, version, tag string) (string, string) { must_succeed: true end end + zap trash: [ + "~/Library/Application Support/driga", + ] binary "#{appdir}/Driga.app/Contents/MacOS/driga" end `, version, hashes[fmt.Sprintf("driga_%s_darwin_amd64.tar.gz", version)], hashes[fmt.Sprintf("driga_%s_darwin_arm64.tar.gz", version)]) diff --git a/site/src/content/docs/guides/mcp.mdx b/site/src/content/docs/guides/mcp.mdx index 6021d103..b4c0ca93 100644 --- a/site/src/content/docs/guides/mcp.mdx +++ b/site/src/content/docs/guides/mcp.mdx @@ -1,6 +1,6 @@ --- title: Connect an agent over MCP -description: Register driga mcp with Claude Code or Codex so your agent can read sessions, projects, inbox and machine health through read-only tools. +description: Register driga mcp with Claude Code or Codex so your agent can read sessions, projects, inbox and machine health through read-only tools, and edit Driga's own dashboards. summary: Registering driga's MCP server for read views and editable dashboards with Claude Code and Codex, what the tools return, and scoping requests. read_when: - You want your coding agent to ask Driga what other agents are doing. @@ -11,7 +11,8 @@ import { Tabs, TabItem } from "@astrojs/starlight/components"; `driga mcp` serves read views and editable Driga dashboards over stdio. Every response is the same schema-2 envelope as `--json`, and reading never acknowledges inbox items. The -server has no `run_command` or approval tools and cannot answer prompts, kill processes or remove anything; those actions +server has no `run_command` or approval tools and cannot answer prompts, kill processes or remove anything but +Driga's own dashboards (`delete_dashboard`, annotated destructive); those actions exist only inside Driga's own [Chat](/guides/chat), behind approval cards. Chat's private `driga mcp --chat` bridge binds requests to an active diff --git a/site/src/content/docs/index.mdx b/site/src/content/docs/index.mdx index b063018e..62d5fc71 100644 --- a/site/src/content/docs/index.mdx +++ b/site/src/content/docs/index.mdx @@ -55,7 +55,7 @@ The same views in a native window. It collects in the background, lives in the m -Read-only MCP tools and versioned `--json` on every read command. [MCP](/guides/mcp) +Read-only MCP views, Driga dashboard edits and versioned `--json` on every read command. [MCP](/guides/mcp) diff --git a/site/src/content/docs/reference/cli.mdx b/site/src/content/docs/reference/cli.mdx index b54579f6..46a79501 100644 --- a/site/src/content/docs/reference/cli.mdx +++ b/site/src/content/docs/reference/cli.mdx @@ -627,9 +627,13 @@ driga service run [flags] Install the Driga service as a login service and start it. ```sh -driga service install +driga service install [flags] ``` +| Flag | Default | Description | +|---|---|---| +| `--refresh-env` | | macOS: record this shell's PATH and Driga variables in the service instead of keeping the ones it records. | + ### driga service start Start the Driga service. diff --git a/site/src/content/docs/reference/mcp-tools.mdx b/site/src/content/docs/reference/mcp-tools.mdx index 788dd42f..499fe35e 100644 --- a/site/src/content/docs/reference/mcp-tools.mdx +++ b/site/src/content/docs/reference/mcp-tools.mdx @@ -24,22 +24,22 @@ Every read tool shares this contract: Served by `driga mcp`. All 14 are annotated read-only. -| Tool | Read-only | -|---|---| -| [`get_activity`](#get_activity) | yes | -| [`get_agent`](#get_agent) | yes | -| [`get_flow`](#get_flow) | yes | -| [`get_machine`](#get_machine) | yes | -| [`get_summary`](#get_summary) | yes | -| [`inspect_project`](#inspect_project) | yes | -| [`inspect_setup`](#inspect_setup) | yes | -| [`list_agents`](#list_agents) | yes | -| [`list_cleanup_candidates`](#list_cleanup_candidates) | yes | -| [`list_conflicts`](#list_conflicts) | yes | -| [`list_inbox`](#list_inbox) | yes | -| [`list_machines`](#list_machines) | yes | -| [`list_orphans`](#list_orphans) | yes | -| [`list_projects`](#list_projects) | yes | +| Tool | Read-only | Destructive | Idempotent | +|---|---|---|---| +| [`get_activity`](#get_activity) | yes | no | yes | +| [`get_agent`](#get_agent) | yes | no | yes | +| [`get_flow`](#get_flow) | yes | no | yes | +| [`get_machine`](#get_machine) | yes | no | yes | +| [`get_summary`](#get_summary) | yes | no | yes | +| [`inspect_project`](#inspect_project) | yes | no | yes | +| [`inspect_setup`](#inspect_setup) | yes | no | yes | +| [`list_agents`](#list_agents) | yes | no | yes | +| [`list_cleanup_candidates`](#list_cleanup_candidates) | yes | no | yes | +| [`list_conflicts`](#list_conflicts) | yes | no | yes | +| [`list_inbox`](#list_inbox) | yes | no | yes | +| [`list_machines`](#list_machines) | yes | no | yes | +| [`list_orphans`](#list_orphans) | yes | no | yes | +| [`list_projects`](#list_projects) | yes | no | yes | ### get_activity @@ -188,19 +188,19 @@ change only Driga's own dashboards: every save is a version that `restore_dashboard_version` can bring back. `run_panels` is called only by the rendered card, to refresh it. -| Tool | Read-only | -|---|---| -| [`create_dashboard`](#create_dashboard) | no | -| [`delete_dashboard`](#delete_dashboard) | no | -| [`describe_panels`](#describe_panels) | yes | -| [`edit_dashboard`](#edit_dashboard) | no | -| [`list_dashboard_versions`](#list_dashboard_versions) | yes | -| [`list_dashboards`](#list_dashboards) | yes | -| [`open_dashboard`](#open_dashboard) | yes | -| [`replace_dashboard`](#replace_dashboard) | no | -| [`restore_dashboard_version`](#restore_dashboard_version) | no | -| [`run_panels`](#run_panels) | yes | -| [`show_panels`](#show_panels) | yes | +| Tool | Read-only | Destructive | Idempotent | +|---|---|---|---| +| [`create_dashboard`](#create_dashboard) | no | no | no | +| [`delete_dashboard`](#delete_dashboard) | no | yes | no | +| [`describe_panels`](#describe_panels) | yes | no | yes | +| [`edit_dashboard`](#edit_dashboard) | no | no | no | +| [`list_dashboard_versions`](#list_dashboard_versions) | yes | no | yes | +| [`list_dashboards`](#list_dashboards) | yes | no | yes | +| [`open_dashboard`](#open_dashboard) | yes | no | yes | +| [`replace_dashboard`](#replace_dashboard) | no | yes | no | +| [`restore_dashboard_version`](#restore_dashboard_version) | no | yes | no | +| [`run_panels`](#run_panels) | yes | no | yes | +| [`show_panels`](#show_panels) | yes | no | yes | ### create_dashboard @@ -304,17 +304,17 @@ Offered only to Driga's own Chat turn through a private MCP child, never by `driga mcp`. Action tools run through Chat's approval cards and trust modes; see [Chat and actions](/guides/chat). -| Tool | Read-only | -|---|---| -| [`ack_inbox`](#ack_inbox) | no | -| [`answer_prompt`](#answer_prompt) | no | -| [`approve`](#approve) | no | -| [`cleanup`](#cleanup) | no | -| [`jump`](#jump) | no | -| [`kill`](#kill) | no | -| [`list_prompts`](#list_prompts) | yes | -| [`run_command`](#run_command) | no | -| [`summarize`](#summarize) | no | +| Tool | Read-only | Destructive | Idempotent | +|---|---|---|---| +| [`ack_inbox`](#ack_inbox) | no | no | yes | +| [`answer_prompt`](#answer_prompt) | no | yes | no | +| [`approve`](#approve) | no | no | no | +| [`cleanup`](#cleanup) | no | yes | no | +| [`jump`](#jump) | no | no | no | +| [`kill`](#kill) | no | yes | no | +| [`list_prompts`](#list_prompts) | yes | no | yes | +| [`run_command`](#run_command) | no | yes | no | +| [`summarize`](#summarize) | no | no | no | ### ack_inbox diff --git a/site/src/content/docs/reference/settings.mdx b/site/src/content/docs/reference/settings.mdx index 1d68804d..4a6f00fd 100644 --- a/site/src/content/docs/reference/settings.mdx +++ b/site/src/content/docs/reference/settings.mdx @@ -373,8 +373,8 @@ Relative values resolve against the working directory at startup. | Variable | Default | Purpose | |---|---|---| | `DRIGA_CONFIG` | $XDG_CONFIG_HOME/driga/config.toml or ~/.config/driga/config.toml | Configuration file override. | -| `DRIGA_DATA_DIR` | Linux: $XDG_DATA_HOME/driga or ~/.local/share/driga; macOS: ~/Library/Application Support/driga | Database and fleet data directory override. | -| `DRIGA_SPOOL_DIR` | Linux: $XDG_STATE_HOME/driga/events or ~/.local/state/driga/events; macOS: ~/Library/Application Support/driga/events | Hook/ack spool directory override. | +| `DRIGA_DATA_DIR` | Linux: $XDG_DATA_HOME/driga or ~/.local/share/driga; macOS: ~/Library/Application Support/driga; development builds use driga-dev | Database and fleet data directory override. | +| `DRIGA_SPOOL_DIR` | Linux: $XDG_STATE_HOME/driga/events or ~/.local/state/driga/events; macOS: ~/Library/Application Support/driga/events; development builds use driga-dev | Hook/ack spool directory override. | ## Internal names diff --git a/site/src/content/docs/start/first-run.mdx b/site/src/content/docs/start/first-run.mdx index 673f8419..163440fb 100644 --- a/site/src/content/docs/start/first-run.mdx +++ b/site/src/content/docs/start/first-run.mdx @@ -105,7 +105,8 @@ On macOS the label is `run.driga.service` and logs are at ## Undo `driga hooks uninstall` removes only Driga's entries, and -`driga service uninstall` removes the service. Turn off launch at login in +`driga service uninstall` removes the service; run it before deleting +Driga.app by hand. `brew uninstall --cask driga` removes the service too. Turn off launch at login in Driga's Settings window. Your agent configuration is changed only by these explicit commands. diff --git a/site/src/content/docs/start/install.mdx b/site/src/content/docs/start/install.mdx index be34e385..13863819 100644 --- a/site/src/content/docs/start/install.mdx +++ b/site/src/content/docs/start/install.mdx @@ -78,10 +78,15 @@ Use the Go version in `go.mod` or newer. This builds the CLI; the packaged app, with its name, icon and notifications, comes from a release: ```sh -CGO_ENABLED=0 go build ./cmd/driga -./driga version +CGO_ENABLED=0 go build -o bin/driga ./cmd/driga +ln -f bin/driga bin/drigad # the Driga service runs drigad, beside driga +./bin/driga version ``` +`just build` does both. Keep `drigad` beside `driga`: `driga service install` +refuses to install without it. A source build is a development build, with its +own service label and `driga-dev` data directory. + `go install ...@` does not work: CalVer tags use the year as their major version, which Go module rules reject for this module path. Use a package, an archive or a checkout.