Repository navigation
Conversation
A job launchd still had but whose process was not running (Driga.app moved) could not be repaired: install unloaded only a running job, and start's launchctl load refused one already loaded. The launchd controller now boots the job out before writing a changed plist, bootstraps or kickstarts it, and never uses the deprecated load/unload. Install replaces the plist whenever it differs from what it would write, including the recorded login environment. The plist keeps the job alive only while its executable exists, runs it as a Background process with a 30-second throttle, and the service exits when its executable is deleted. Builds without the release link-time marker install the dev label, so just build and go build no longer take over a release's service. Claude-Session: https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ
brew uninstall left a KeepAlive launch agent pointing at a deleted helper. The cask now uninstalls run.driga.service, signals a running app, and zaps Driga's data. Its uninstall_preflight keeps the plist so an upgrade, which runs the old uninstall, still restores the service. Release builds carry the internal/service.release marker. Claude-Session: https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ
The app spawned launchctl every two seconds. Observe now reuses the service manager's answer for 30 seconds while the lock and process are unchanged. When the login shell cannot be read, the app keeps the environment the service records instead of reinstalling it with a bare PATH. Claude-Session: https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ
Action tools now declare destructive, idempotent and open-world hints, and the generated reference lists them. README no longer calls driga mcp read-only or says the app runs the service itself, and the service docs describe dev labels, uninstalling and environment refresh. Claude-Session: https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ
The release marker moves to internal/variant, which config and service both read. A development build now defaults to a driga-dev data and spool directory, so a development service never shares the release's database or hook spool; the configuration file stays shared. BREAKING CHANGE: a development build no longer reads the release's default data directory, and the link-time marker is now -X github.com/labstack/driga/internal/variant.release=true. Claude-Session: https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ
A GoReleaser snapshot set the release marker too, so a snapshot installed the release's service label. The marker is now empty in snapshots. The usage guide drops its dev-build removal advice: a release install replaces whatever holds the release's label. Claude-Session: https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ
The run_command denylist named only this process's data directory and the release's default paths, so with development data directories a command could read another variant's database, spool or keys. The policy now denies config.StateDirs (the configuration, this process's overrides, and the release, development and driga-<name> data and state directories) plus each variant under the HOME default roots, and follows a dangling symlink to the path it names. Claude-Session: https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ
canonical joined a relative symlink target to the unresolved directory of the link, so a link inside a symlinked parent resolved elsewhere than the kernel resolves it. It now walks the path from the root like the kernel: each component is checked against the already resolved prefix, a symlink's target is spliced in relative to that prefix, ".." is never cleaned lexically, and hops are bounded. A dangling link still resolves to the path it names. Claude-Session: https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ
A volume-less target such as \Users\x\AppData\Local\driga is rooted at the link's volume on Windows, but the walker treated it as relative to the link's directory. linkTarget now applies each platform's rules whatever the host: rooted volume-less targets, drive-relative C:x, UNC shares and \\?\ prefixes. An existing path goes to EvalSymlinks first; the walk covers missing tails and dangling links. Claude-Session: https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ
bootout returns before launchd has let the job go, so the bootstrap that followed could fail with 5, 36 or 37, and Start could kick a definition still being torn down. bootout now polls launchctl print until the job is gone (at most 10 seconds), bootstrap retries a busy launchd with backoff, and Start watches a loaded job that does not run before it kicks it. Install renders and checks the new plist and writes it beside the old one before it boots the job out, then renames it into place and bootstraps it; when launchd refuses it, the previous plist is restored and bootstrapped again. Without a gui/<uid> domain (exit 112, nobody logged in to the desktop) uninstall still deletes the plist, and start and status ask for a desktop login. The fake launchctl models a job that stays loaded after bootout and an absent domain. Claude-Session: https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ
Observe reused the last launchctl answer for 30 seconds while the lock and process looked the same, so the tray could show a stale registration right after an install, stop or uninstall. Control now clears that cache, and its comment says what a failed macOS install leaves behind. Claude-Session: https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ
A terminal's driga service install recorded the terminal's environment and the next app launch recorded the login shell's, so each flipped the plist and restarted the service. The app's login-shell environment is now the one source: the app installs with --refresh-env, and a plain driga service install keeps the environment the plist records. A restart caused by a changed environment logs the variable names, never their values. Doctor's stale-plist hint names --refresh-env. BREAKING CHANGE: driga service install on macOS no longer records the calling shell's environment over an installed service's; pass --refresh-env to do that. Claude-Session: https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ
The README's source install built only driga, but the service runs drigad beside it, so the installed service could not start. driga service install now checks that the drigad it would run exists and is executable, with a clear error, and the README, install guide and release guide build both. Claude-Session: https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ
After stripping \\?\, a target such as \\?\Volume{GUID}\, \\?\GLOBALROOT\
or \\.\pipe\ fell through to the relative branch. Rather than grow a
Windows path parser, the walker now accepts only relative, rooted
(\x, at the link's drive) and drive-absolute (C:\x) targets; UNC,
\\?\, \\.\, drive-relative and other colon forms fail closed, and the
classifier refuses a path through such a link. Existing paths still go
to EvalSymlinks first.
Claude-Session: https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ
BundlePath required the bundle to be named Driga.app, and self.CLI mapped the nested service back only into a Driga.app, so renaming the app lost the bundle association and pointed the service at the wrong executable. The app is now any <name>.app whose Info.plist names run.driga, with driga or drigad in Contents/MacOS; the helper maps back to the outer bundle whatever its name. BREAKING CHANGE: a bundle without an Info.plist naming run.driga is no longer treated as Driga.app. Claude-Session: https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ
MkdirPrivate and an O_CREATE open keep an existing mode, so a spool an older release left group- or world-readable stayed that way. Append now tightens the day file it writes and the directory, and the service tightens the whole spool when it starts and at each hourly prune: 0700 and 0600 on Unix, a private DACL on Windows, as the store does. Claude-Session: https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ
… process The postflight restored any kept plist under ten minutes old, so an upgrade slower than that lost the service and an ordinary uninstall followed by an install within ten minutes brought it back. The preflight now names its copy after the brew process and deletes it when that process exits; only the same process's postflight restores it, and copies from other processes are deleted. The restored plist keeps its recorded environment, so the postflight runs a plain service install instead of passing the environment through /usr/bin/env. Claude-Session: https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
Fixes the macOS Driga service lifecycle and corrects the MCP and service docs. The findings came from an independent code review; each was checked against
mainbefore changing it.internal/service/launchd_darwin.gonow runs launchctl itself instead of going through kardianos. Install always runslaunchctl bootout gui/$UID/<label>(and ignores "not loaded") before it writes a changed plist. Start runsbootstrap gui/$UID <plist>on an unloaded job andkickstarton a loaded job whose process has exited. Restart useskickstart -k. Status comes fromlaunchctl print. Nothing callsloadorunloadany more. The fake launchctl models a launchd domain. In the new loaded-but-not-running test, a stale plist's executable no longer exists.docs/release.mddescribes the new sequence.uninstall launchctl: "run.driga.service", signal: [["TERM", "run.driga"]]and azap trash:list covering data, logs, config, caches, prefs, WebKit, saved state and the legacy collector plist. Homebrew skipssignalon upgrade, where the app restarts itself. I left outquitbecause it can stall Homebrew for 10 seconds with an Automation prompt. An upgrade runs the old version's uninstall,launchctlincluded, souninstall_preflightfirst saves the plist to~/Library/Caches/run.driga. The postflight reinstalls from the live plist or that copy (see Review follow-ups, item 5, for how the copy is now tied to the upgrading brew process).For an app deleted without brew,
KeepAliveis nowPathState {<drigad path>: true}, so launchd stops respawning a missing helper and restarts the job when the path comes back. The service also checks its executable once a minute and exits cleanly when it is gone. The docs, the README and the cask caveats say to rundriga service uninstallbefore deleting the app by hand.-X github.com/labstack/driga/internal/variant.release=true(moved frominternal/servicein the follow-ups; empty in snapshots), which.goreleaser.yamlsets on all three builds. Every other build is a development build:just build,just install,go buildandgo run. It installsrun.driga.service.dev,driga-devon Linux orDriga service (dev)on Windows, and logs underdriga-dev. An executable nameddriga-<name>still uses<name>.docs/usage.md, the README source-build section and CONTRIBUTING are updated.driga mcpserves read views plus Driga dashboard edits. The README's paragraph saying the app "removes the service and runs it itself" now describes the current design and how to uninstall. The site's MCP guide and index are corrected too.All Chat action tools now have annotations, following Canon
mcp.md:run_command: destructive, open worldanswer_prompt,kill,cleanup: destructiveack_inbox: idempotent, not destructivejump,approve: not destructivesummarize: open world (it goes through the Claude CLI)The generated MCP reference now shows Destructive and Idempotent columns. A test requires every served tool to carry annotations, and every state-changing tool to set the destructive and open-world hints explicitly.
ProcessTypeBackgroundandThrottleInterval30. The app still checks the lock and process every 2 seconds, which needs no subprocess.Lifecyclereuses the service manager's answer (fromlaunchctl, systemd or Task Scheduler) for 30 seconds while those stay the same, and asks again as soon as the service starts or stops.service.RecordedEnvironment), so a bare Finder PATH never replaces it. On Linux and Windows an existing service keeps its environment, which the docs now state.Why
kardianos/service's darwin backend uses
launchctl load/unloadand checks status withlist. It cannot tell a loaded-but-not-running job from a stopped one, andloadrefuses a job launchd already has. The domain commands handle both cases. A link-time marker is the only build-independent way to tell a release from a checkout build, since nothing produced thedriga-devexecutable name.Risk and rollout
signalonly TERMs the Driga app when it is uninstalled.KeepAlive,ProcessTypeandThrottleIntervalkeys changed. That causes one service restart.run.driga.servicegets a second service under the.devlabel the next time they install. Rundriga service uninstallwith the old build first (documented in usage.md).ProcessType Backgroundapplies launchd's background resource policy (lower CPU and I/O priority) to the collector, which also serves Chat and actions. Watch whether the app feels slower;Adaptiveis the fallback.Contract changes
.dev/-devlabel.driga service install --refresh-env(see Review follow-ups). No config keys or schema changes.Verified
Run on macOS (darwin/arm64) on the host:
gofmt -l .is clean,go mod tidy -diff,go vet ./...(native,GOOS=windows,GOOS=linux)go run ./cmd/driga-docgen --check,go generate ./internal/configwith no.env.examplediffscripts/isolated.sh go test -count=1 ./...: all packages passscripts/isolated.sh go test -raceoninternal/service,internal/mcp,internal/desktop,scripts: pass (I did not run the fulljust race)just ui-check/design-check(UI untouched),goreleaser check/just snapshot(goreleaser is not installed locally; CI's snapshot job covers it),actionlint,govulncheckFollow-ups
SMAppService.agent(plistName:)with the plist inside the bundle) is deliberately left out of this PR. It would replace the launch agent registration and give users the Login Items UI.ProcessType Backgroundagainst real Chat and actions latency.Review follow-ups
Findings from the Opus and Codex reviews of this PR, a full Codex review of
main(breaking changes accepted), and automated security review of the follow-ups. Each was checked against the code first.bootoutnow pollslaunchctl printuntil launchd has let the job go (100 ms steps, at most 10 s) and treats 36 as in progress.bootstrapretries 5/36/37 with backoff (6 attempts) and counts a job another client loaded only when it runs. Start no longer kicks a job it just bootstrapped, and watches a loaded-but-not-running job for 1 s before kicking it, so a definition being torn down is bootstrapped instead. The fake launchctl gained a mode where a job stays loaded for N prints after bootout (kickstart during teardown fails the test). Tests: teardown wait, Stop then Start, Start on a job being torn down, bootstrap backoff, bounded wait.ErrNoGUISession("log in to the desktop session to start the Driga service"). Uninstall treats it as not loaded and still deletes the plist. Stop is a no-op. Start, Restart and Status return the message. Install writes the plist for launchd to load at the next login. Covered by a domain-absent fake.driga service install --refresh-env. A plaindriga service installkeeps the environment the plist records. A restart caused by a changed environment logs the variable names only (service_env_changed). Doctor's stale-plist hint names--refresh-env.Controlclears the registration cache, so the tray asks launchctl again right after any action.uninstall_preflightnames its copyrun.driga.service.<brew pid>.plistand deletes it fromat_exit. Only the same brew process's postflight restores it, however long the upgrade takes. Copies from other processes are deleted and never restored. The restored plist keeps its recorded environment, so the postflight runs a plainservice installwith no/usr/bin/envpass-through.scripts/macos_app_test.gocovers fresh, existing, a slow upgrade (day-old copy, must restore), an ordinary uninstall (no copy left after brew exits, and a later install must not restore) and another process's copy. Deviation: abrew reinstallalso restores the service, because it runsuninstall launchctltoo.{{ if not .IsSnapshot }}true{{ end }}on all three builds. A test renders the ldflags for both cases. CONTRIBUTING, usage and the release guide are updated.internal/variantpackage, whichconfigandserviceboth read. Development builds default todriga-devfor data and spool (~/Library/Application Support/driga-dev,~/.local/share/driga-dev,~/.local/state/driga-dev/events,%LOCALAPPDATA%\driga-dev). The config file is still shared. The settings reference and.env.exampleare regenerated.drigaand linkdrigad(orjust build).driga service installchecks that thedrigadit would run exists and is executable, and errors clearly if not.BundlePathaccepts any<name>.appwithdriga/drigadinContents/MacOSwhose Info.plist saysCFBundleIdentifierisrun.driga.self.CLImaps the nestedDriga Service.appback to the outer bundle whatever its name. Tests cover a renamed bundle, another app with the same layout, and a bundle without an Info.plist.Controlcomment now describes this. Tests cover a refused definition (the old one runs again) and a bounded teardown wait (old plist untouched, no temp file left behind).Appendfchmods the day file it writes to 0600 and the directory to 0700.spool.Protecttightens the directory and every file, and the service runs it at start and at each hourly prune (private DACL on Windows). Tested with a 0755 dir and 0644 files.driga mcpregisters action tools only for Chat's MCP child (o.Chat), so "read views plus dashboard edits" is accurate.Security review of the follow-ups (
internal/command, run_command sandbox policy):config.StateDirslists the config file and default config dir, this process's data/spool overrides, and the data and state directories of release,-devand anydriga-<name>variant found. The policy denies all of them, plusdriga,driga-devanddriga-*under each HOME default root regardless of XDG variables. Tested with defaults, overrides, a symlink and a relative path.canonicalnow resolves like the kernel. It walks from the root, splices each symlink target relative to the already-resolved prefix, never cleans..lexically, bounds hops at 40, and follows dangling links. Existing paths go toEvalSymlinksfirst. Tests: a relative link inside a symlinked parent, an absolute link, a dangling tail,..after links, and a loop.\x) resolve at the link's drive. Tests are platform-independent table tests.C:\x) targets resolve. UNC,\\?\(includingVolume{GUID}andGLOBALROOT),\\.\, drive-relativeC:xand other colon forms returnerrUnresolvableLink, and the classifier refuses a path through one (Never).Breaking changes
just build,go build,go run, GoReleaser snapshots) use their owndriga-devdata and spool directories, so they no longer see the release's database. The link-time marker is now-X github.com/labstack/driga/internal/variant.release=true.driga service installkeeps the service's recorded environment. Pass--refresh-envto record the calling shell's.run.drigais no longer treated as Driga.app.\\?\,\\.\or drive-relative.Follow-up verification
Run on macOS (darwin/arm64) on the host:
gofmt -l .clean,go mod tidy -diff,go vet ./...native,GOOS=windowsandGOOS=linuxgo run ./cmd/driga-docgen --check;go generate ./internal/configleaves no.env.examplediffscripts/isolated.sh go test -count=1 ./...: all packages pass-raceoninternal/service,variant,config,spool,self,command,cli,desktop,collectorandscripts: passjust ui-check,goreleaser checkandjust snapshotwere not run.https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ