Repository navigation
service: add native service mode with deterministic runtime paths - #154
Merged
Merged
Conversation
bashrusakh
marked this pull request as draft
October 21, 2026 16:21
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configuration
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
bashrusakh
marked this pull request as ready for review
October 21, 2026 17:35
- Add internal/runtimepaths with the startup-resolved path model: the config file, the state root and the log directory, plus the derived jobs state, download history and mirror target files. - Resolve the model once and install it process-wide, so a supervised instance never depends on the working directory, HOME or the user profile of the account the supervisor happens to run it as. - Keep the interactive resolution per-call and byte-identical to the previous behaviour: an existing config next to the launch/executable directory still wins over the per-user config directory, and the per-user directory is still where new saves go. - Route the config file, the jobs state, the download history and the mirror targets of internal/server through the model, so one instance keeps a single coherent state identity. - Add internal/logsink, a size-bounded rotating log file writer, so a supervised process can keep its own diagnostics without unbounded growth. Co-Authored-By: opencode <noreply@opencode.ai>
- Define the service contract every platform manager implements: the scope, the lifecycle vocabulary, the status report, the installation options and the install/uninstall/start/stop/restart/status operations. - Own the command semantics in one place: the scope parsing, the listen address parsing and validation, the loopback policy, the installation validation and the canonical, non-secret argument vector of the registered process. - Define where an instance keeps its state per scope, so a user scope instance and a system scope instance can never read or write each other's state root. - Add the headless host: it installs the runtime path model, pins the cache environment, redirects the log into the bounded log file of the instance and runs the existing server with its graceful shutdown path. It never opens a browser and never shows a tray icon. - Parse the argument vector of the registered process back into the run options, so the command line and the supervisor registration cannot drift. Co-Authored-By: opencode <noreply@opencode.ai>
- Linux: register a systemd user unit by default and a machine-wide unit for the explicit system scope, managed through systemctl --user and systemctl. The unit stays a foreground process (Type=simple) and never forks, writes a PID file or daemonizes itself. Linger is never enabled silently: booting without a login stays an explicit user or admin decision. - macOS: register a per-user LaunchAgent by default and a LaunchDaemon for the explicit system scope, managed with launchctl bootstrap, bootout and kickstart. The plist keeps the program arguments absolute and free of credentials, and HFDesk writes its own bounded log file because launchd does not rotate the ones it owns. - Windows: register the service with the service control manager through the Go API, never through sc.exe or PowerShell. The service runs as the least-privilege LocalService account, its state under ProgramData carries restrictive ACLs, and the handler reports StartPending, Running only once the HTTP server is usable, StopPending and Stopped around the graceful shutdown, with lifecycle and fatal messages in the Windows Event Log. - Make install, uninstall, start, stop and restart idempotent with deterministic outcomes, roll a failed installation back instead of leaving a half-configured registration, and report a registration whose executable was moved or deleted as broken. - Keep uninstall free of data deletion: it removes the registration only and never touches models, cache, config, jobs/history or logs. Co-Authored-By: opencode <noreply@opencode.ai>
- Add the install, uninstall, start, stop, restart, status and run subcommands with the standard library flag package, one flag set per subcommand and no new dependency. - Resolve the runtime paths, the cache directories and the listen address of the selected scope, record them in the registration and report them back, so the instance keeps one coherent state identity. - Bind loopback by default and make a non-loopback address visible in the install and status output. - Warn when the registered executable lives in a temporary or version-stamped path, because the next release would break the registration. - Keep the interactive command line untouched: only the bare "service" token selects the new family, and hfdesk [flags] behaves exactly as before. Co-Authored-By: opencode <noreply@opencode.ai>
- Add docs/SERVICE.md as the reference for the command surface, the scopes, the runtime paths, the credential handling, the logs, the lifecycle semantics and the per-platform notes. - Describe the service mode in the README with the quick start, the loopback default and a link to the reference. - Record the feature in the changelog, the repository layout, the runtime entry points and the agent rules, and add the service commands to the contribution guide. Co-Authored-By: opencode <noreply@opencode.ai>
- Add a macOS job that builds, vets and tests the repository, so the launchd manager, the plutil validation of the generated property list and the launchctl lifecycle run on a real macOS runner. - Run the service package tests on the Windows job, so the service control manager code is compiled and its tests can execute there. Co-Authored-By: opencode <noreply@opencode.ai>
- The cache root and the flat output directory of a service instance now resolve from the config file the instance owns, with the values recorded in the registration as the fallback. The settings API of the instance writes that file, so a change of the cache directory is honoured by the next start instead of being saved and then ignored. - The hub directory follows the cache root when the config file defines it, and stays the registered one otherwise. - Pin the cache environment from the effective values, after the configuration was loaded and before the server captures the cache identity of the process. - Create the state and the config directory owner-only: they hold the job list, the download history, the mirror targets and the credentials of the instance. - Drop the unused InstallOptions fields and export the loader of a config file at an explicit path. Co-Authored-By: opencode <noreply@opencode.ai>
- Refuse a machine-wide installation that would adopt the state of the user who runs the install command, on Linux as well as on macOS, and run that check before any directory is created so a refused installation leaves nothing behind. - Require an elevated shell for a machine-wide installation and for a machine-wide removal on macOS, like the other platforms, and report that requirement clearly instead of a raw permission error. - Preflight an explicitly reused data, cache or output path on Windows: the path must be a directory the service identity can reach, and the installation fails with a clear permission error before the registration is written. The preflight only reads the descriptor and never changes the ACL of a reused tree. - Restrict the config directory, the state directory, the log directory and the config file of a Windows instance, so every directory the instance owns carries the descriptor of the service identity. - Report the effective cache root of the instance in the status of every platform, so the status describes the instance that runs. Co-Authored-By: opencode <noreply@opencode.ai>
- Report the cache root the instance actually uses in the status and in the uninstall summary. The scope default was reported before, which named the personal cache of the invoking user for a user scope instance and disagreed with the instance that runs. - Report the state the supervisor reports after an installation instead of claiming that the instance was not started: a macOS LaunchAgent starts with its registration, a systemd unit does not. - Document the effective cache root of an instance, the identity of a machine-wide instance per platform and the migration path of a future macOS app bundle to SMAppService. Co-Authored-By: opencode <noreply@opencode.ai>
- Move the hub directory with the cache root when only --cache-dir is given, so a registration never names two different trees. An explicit --hub-dir is still honoured as given: it is the deliberate separation of the two locations that HF_HOME and HF_HUB_CACHE have. - Report the hub directory of the instance in the install summary, next to the cache root, so both describe the layout the instance will use. - Name the subcommand that owns --listen in the usage block instead of advertising it for the whole family, and correct the description of the install command: a macOS LaunchAgent starts the instance when it is registered, a systemd unit is only registered and enabled. Co-Authored-By: opencode <noreply@opencode.ai>
- A wildcard bind has no address a client can connect to, so the readiness probe of the Windows service now dials the loopback address of the same family. Without it a service that listens on every interface could fail its own startup and be reported as stopped while it is running. - Extract the decision of a broken registration into a testable helper and cover it, so the criterion that a moved or deleted executable is reported clearly is proven on Windows as well. Co-Authored-By: opencode <noreply@opencode.ai>
- Extract the decision of a broken registration into a helper and drive the real status path with a unit file in a temporary unit directory, so the criterion that a moved or deleted executable is reported clearly is proven on Linux as well. Co-Authored-By: opencode <noreply@opencode.ai>
- The directories a server owns are owner-only when a supervised instance owns them and keep their historical mode for an interactive run, so the per-user layout of an existing installation does not change. - Let the server create the state directory before the mirror targets are written, so the mode of the directory does not depend on which code path created it. Co-Authored-By: opencode <noreply@opencode.ai>
- A machine-wide instance runs as a dedicated, stable, unprivileged account instead of root: hfdesk on Linux, _hfdesk on macOS, the built-in LocalService on Windows. Root is the platform's LocalSystem analogue, and the issue asks for explicit privilege boundaries and a least-privilege identity. - HFDesk never creates an account and never shells out: it resolves the account, refuses the installation clearly when it does not exist and names the command that creates it. - The account is granted only the directories the installation created. A directory that existed before keeps its owner and its permissions, and the account has to be able to write into it already. Every directory is checked before the first one is granted, so a refused installation leaves every ownership as it was. - A named cache, hub or output directory is validated for accessibility and never rewritten: the installation gives ownership guidance instead. - Refuse a top-level directory of the machine as an instance directory on both platforms, so the same command line is answered the same way. - Let the instance configuration come from one source: it installs the runtime path model itself instead of relying on an ambient global. - Create the directories of an instance owner-only, and leave the permissions of a directory that already exists alone. Co-Authored-By: opencode <noreply@opencode.ai>
- Look the registration up with a read-only connection to the service control manager before the elevation requirement applies, so uninstalling a service that was never installed is a clean success in every shell. - Report the elevation requirement when a registration exists or when the caller may not look, instead of a raw access-denied error, and make the start, stop and status answers independent of the rights of the shell. Co-Authored-By: opencode <noreply@opencode.ai>
- Register the path the binary was invoked with when it is a usable path, so a stable name is registered instead of the version-stamped file it points at, and add an --executable option to name the path explicitly. - Follow the upgrade warning from the path that is registered instead of from the resolved target. - Add the --service-user option of a machine-wide installation, and report the account of the instance in the install summary. Co-Authored-By: opencode <noreply@opencode.ai>
- Drive the service host through a complete lifecycle: it serves, it shuts down on the cancellation of its context without an error, and it persists the job list into the state directory of the instance. Co-Authored-By: opencode <noreply@opencode.ai>
- Describe the identity of a machine-wide instance per platform, the account that has to exist, the directories that are granted to it and the guidance for a named cache directory. - Correct the account creation commands, the command surface, the registered executable path, the cache layout and the directory permissions so the document matches the implementation. Co-Authored-By: opencode <noreply@opencode.ai>
- The launchd manager, the plutil validation of the generated property list and the launchctl lifecycle are darwin-only code, so they need a macOS runner. The full suite still has pre-existing macOS-compatibility gaps, so the job stays narrowed to the service package. Co-Authored-By: opencode <noreply@opencode.ai>
- The log directory and the log file are created owner-only, and a directory or a file that already exists keeps its mode. The instance no longer requires an ownership the installation never checked, so an instance whose log directory belongs to another owner can start instead of failing with a permission error before it serves. Co-Authored-By: opencode <noreply@opencode.ai>
- The jobs state, the download history and the mirror targets resolve their state directory directly, so a save no longer re-scans the working directory and the executable directory for a config file it does not need. Co-Authored-By: opencode <noreply@opencode.ai>
- A failed installation leaves the machine as it was: the directories it created are removed when they are still empty, and a directory that held something is kept. A directory that existed before is never touched. Co-Authored-By: opencode <noreply@opencode.ai>
- The status of a registered instance is read through a query-only connection to the service control manager, so a normal shell can see the state of the instance, including a registration whose executable is gone. Only the operations that change the registration keep the privileged path. Co-Authored-By: opencode <noreply@opencode.ai>
- The service mode adds process-global mutable state, so the Linux job runs the race detector over the whole suite. Co-Authored-By: opencode <noreply@opencode.ai>
- A command line that cannot be understood exits with 2 on every subcommand: an unknown flag, mutually exclusive scope selectors, a relative path, an unusable executable, an unparsable listen address. The failure of an operation keeps exit code 1. - Report honestly that there was no registration to remove when the uninstall found nothing. Co-Authored-By: opencode <noreply@opencode.ai>
- Stopping a service that is not installed is a clean success on every platform, because there is nothing running to stop. Starting or restarting one still fails with a readable not-installed error. - The machine-wide start, stop and restart report the elevation requirement the same way the installation and the removal do. The not-installed check comes first, so it needs no privilege and gives the more useful answer. Co-Authored-By: opencode <noreply@opencode.ai>
- The service control manager has no state of its own for a failure, so it reports a crashed instance as stopped; the exit code is the only fact that distinguishes it from a clean stop. A non-zero exit code is now reported as failed, like the Linux and macOS managers do, so the status of a crashed instance is the same on every platform. Co-Authored-By: opencode <noreply@opencode.ai>
- The refusal for a cache directory the account cannot write names the directory, the account and the missing access, and recommends only non-recursive alternatives: a recursive ownership change of a model or cache tree is out of scope. - Correct the note about the account lookup: on macOS it goes through the directory service, and only the explicit osusergo build tag selects the reader of /etc/passwd. Co-Authored-By: opencode <noreply@opencode.ai>
- The default scope of an installation, the existence of the user scope and the possibility to name the account of a machine-wide instance are facts of the platform. They now live in the service layer behind its build tags, so the command family does not branch on the operating system itself. Co-Authored-By: opencode <noreply@opencode.ai>
- The service control manager reports a service it never started as stopped with the exit code 1077, so the rule that turns a non-zero exit code into a failure marked a freshly installed instance as failed. The never-started code is excluded, and the instance is reported as stopped like on the other platforms. - Correct the Windows expectations of the data-path preflight and of the argument candidates: a path is quoted in the message, and the candidates the handler assembles are not identical. The assertions that matter are unchanged. Co-Authored-By: opencode <noreply@opencode.ai>
…vely - The state of an instance is now parsed from the output of launchctl whatever shape it has: the key and the value may be quoted or terminated, the field may sit at any nesting depth, and the first occurrence wins. - A state that cannot be parsed is reported with what the supervisor actually said, so an administrator and a failing test can see it. The excerpt is bounded and a value that looks like a credential is redacted. - The launchd lifecycle test skips with that evidence when the environment cannot run a LaunchAgent, and still fails when the job is not loaded after a successful installation. Co-Authored-By: opencode <noreply@opencode.ai>
- The server publishes the budget of its orderly shutdown, and every supervisor-side wait is derived from it: the launchd job, the systemd unit and the service control manager now wait for the same budget. The macOS manager waited ten seconds while the orderly path needs up to twenty, so it could unload a job that was still saving its state; launchd's own kill deadline had the same collision. - The check whether the service account can use a reused directory asks for the effective permission: it needs write and execute on the directory and execute on every directory above it, with the supplementary groups of the account considered. Owning a directory with the mode 0500 is not writing to it. The check lives in one file for the Unix platforms. Co-Authored-By: opencode <noreply@opencode.ai>
…oken - The membership of the service identity is read from a token of the identity itself instead of a static list of presumed groups, so a descriptor that grants access only to Interactive Users is refused: the instance runs a service logon, not an interactive one. - When the platform refuses the logon, the check falls back to the subset that is certain and says so in the refusal. - The waits of the service control manager and of its handler use the shared shutdown budget. Co-Authored-By: opencode <noreply@opencode.ai>
…them - The fixtures of the access check lived in the per-user temporary directory of macOS, which the account under test cannot traverse, so the check correctly refused them and the tests measured the temporary directory instead of the fixture. They now live in the world-traversable scratch directory, and the tests assert that precondition explicitly. Co-Authored-By: opencode <noreply@opencode.ai>
- State the two phases of an orderly shutdown, the time they may take and the budget every supervisor waits for, so an operator can tell what a stop does and why it takes as long as it does. Co-Authored-By: opencode <noreply@opencode.ai>
- A restart replaced the running process with kickstart -k, which does not deliver the signal the service host turns into the cancellation of the application context, so the server never drained its connections and never wrote its job list back. A restart is now the graceful stop followed by the start, and the replacement starts only when the stop succeeded. - A stop that exceeded its budget is reported instead of looking like a success. - A failed reinstall restores the supervisor state and not only the property list, so an instance that was running before the attempt runs again. Co-Authored-By: opencode <noreply@opencode.ai>
- The logon of the service account is made in the documented form: the user name, the domain and a null password, so the platform does not refuse it and the effective membership of the account is really the one of its token. The Interactive Users group is never part of it. - An entry that only applies to the children of a directory does not grant the identity access to the directory itself. - A stop of an instance that is still starting delivers the control again instead of giving up on it. - A failed start waits for the instance to finish its shutdown before it is reported as stopped, like the normal stop path. - A named data directory that does not exist is refused with the guidance instead of being created. Co-Authored-By: opencode <noreply@opencode.ai>
- The access check resolved the target of a symbolic link but validated the ancestors of the link instead of the ancestors of its target, so a directory the account could not reach was accepted. The check now judges the path the kernel really uses, and still judges the way to the link. - A named directory inside the state root of the instance is not checked against the account, like on the other platform: the installation grants the state root to that account. - The systemd rendering escapes the dollar sign, and a path with a control character is refused before anything is written. Co-Authored-By: opencode <noreply@opencode.ai>
- The executable of an installation was checked for a POSIX execute bit, which no file on Windows ever has, so an explicit path was always refused. The command layer now checks what holds on every platform and leaves the execute bit to the platform managers. - The hint of the install summary names the scope of the instance that was installed, so the printed command starts that instance. - The command layer is now exercised on the Windows and macOS runners, and the macOS job has a timeout. Co-Authored-By: opencode <noreply@opencode.ai>
- The standard output and the log file were written through a multi writer that stops at the first failing destination, so a process without a valid standard output - the service control manager gives none - never wrote a line into the log file of the instance. The two destinations are now written independently. - A rotation that failed left the sink without a file, so every later write was refused and the log of the instance stayed silent. The sink recovers. Co-Authored-By: opencode <noreply@opencode.ai>
- The logon of the service account is tried in the documented forms, because the domain NT AUTHORITY made the authority look for a domain instead of the local account. The form that worked is logged. - When the platform refuses every form, the membership falls back to the documented one of a service logon - the account, everybody and the authenticated users - and never to the interactive users, which belong to an interactive logon. The fallback is announced in the log and in the refusal. - The membership tests pass with either source; only the test of the token path itself may skip, with the error of the platform in its message. - The refusal for a named directory that does not exist names the account as well, and the tests of the installation match what it creates. Co-Authored-By: opencode <noreply@opencode.ai>
- The tests of the command layer assumed the platform facts of a POSIX system: a user scope and a nameable service account. On Windows neither exists, and the runner that now executes them showed it. They drive the platform seam instead, so every platform is asserted from every host. Co-Authored-By: opencode <noreply@opencode.ai>
- The exemption for a named directory inside the state root of the instance was decided on the path as it was written, so a link inside the state root that points out of it was exempted and the installation was accepted even though the account could not reach the target. The exemption now compares the resolved locations of the two paths, and a link that leaves the state root is checked like any other path. Co-Authored-By: opencode <noreply@opencode.ai>
- A deny entry refused the path as soon as it named the identity, whatever it denied, so a directory that denies only the right to change its access control list was refused although the instance can use it. The denial is now judged by its intersection with the rights the instance needs. - The exemption for a named directory inside the state root is decided on the resolved location, like on the other platforms. - The membership of the service identity is the documented one of a service logon. The attempt to obtain a token of the account was never proven on any machine, and it is gone: the account itself, everybody and the authenticated users, and never the interactive users. Co-Authored-By: opencode <noreply@opencode.ai>
- The exemption for a named directory inside the state root of the instance was granted to a directory that already exists as well. Owning the parent grants no right on an existing child, so a cache directory that belongs to another account was accepted and the service failed later with a permission error. The exemption now covers only a directory that does not exist yet and that the service will really create under its own management. Co-Authored-By: opencode <noreply@opencode.ai>
- The mask of an access control entry was mapped into the rights the instance needs by its generic and aggregate forms only, so a denial of a single right - of FILE_ADD_FILE, the right to create a file - was ignored and the directory was accepted. The individual rights are mapped now, and a denial is judged by the right it denies. - A named directory that does not exist yet is judged through its nearest existing ancestor, like on the other platforms, instead of being refused: the instance creates it at run time, as the account. Co-Authored-By: opencode <noreply@opencode.ai>
- The refusal of a named directory names the nearest existing ancestor in its resolved location, because that is the path the operator has to grant the access on. The test compared it with the path as it was written, which on Windows is the short form of the temporary directory and never matches. Co-Authored-By: opencode <noreply@opencode.ai>
- The service host redirected the standard logger into a file of its own, while the server logs through the shared structured pipeline. The records of the instance therefore reached the log file as plain text, went to standard output instead of standard error, and never reached the journal of the supervisor. The instance now installs the shared pipeline: the console sink carries the records to the supervisor, the file sink writes them into the log directory of the instance. - The log directory of an instance is created owner-only and a log file that cannot be written does not stop the instance. - The package that implemented a second rotating file sink is gone; the shared pipeline owns that job now. Co-Authored-By: opencode <noreply@opencode.ai>
- The test looked for the flag registrations in the entrypoint. The shared application layer owns them now, so the test builds the flag set the interactive path builds and asserts the flags and their defaults. Co-Authored-By: opencode <noreply@opencode.ai>
- The log of an instance is the file of the shared pipeline: the records of the server land in it, it rotates at the size of the shared pipeline and keeps three rotated files, and the records reach the supervisor through standard error. Co-Authored-By: opencode <noreply@opencode.ai>
bashrusakh
force-pushed
the
feature/service-mode
branch
from
October 22, 2026 04:50
96d4aa6 to
1c686ae
Compare
- The test released the blocked transfer and only then cancelled the job, so a transfer that finished in between left the cancel with nothing to cancel and the test failed. The cancel now happens while the transfer is still blocked, which is deterministic; the behaviour under test is unchanged. Co-Authored-By: opencode <noreply@opencode.ai>
bashrusakh
pushed a commit
that referenced
this pull request
Oct 22, 2026
Integrate the upstream native service mode (#154) with the #140 downloader model, so both survive the merge. The merge auto-resolved without textual conflicts: the overlapping files changed in disjoint regions, so both sides are kept in full. pkg/hfdownloader keeps the #140 model byte for byte. The only upstream change in that package is targets.go, a comment on DefaultTargetsPath that we did not touch, so it takes upstream's version; the #140 files (downloader.go, ranged.go, blob_domain.go, blob_publish.go, hfcache.go, staging_*.go, verify.go, types.go) are unchanged by this merge. internal/server/api.go and internal/server/mirror.go keep both sides: the runtime-path wiring of the service mode and the #140 staging-artifact exclusion. The exclusion still covers every artifact name the #140 model introduces - the .incomplete payload, the .meta resume record, the .meta.tmp atomic record temp and the persistent .lock ownership files (the staging lock, the record lock and the blob-key domain lock blobs/tmp-<sha>.lock) - and it stays applied at every blobs/ listing, selection, mirroring and deletion site. Upstream added no new blobs listing or selection path, so the filter needed no new application. AGENTS.md, CHANGELOG.md and docs/API.md keep both sides: the service-mode rules, the service-mode reference and the service-mode changelog entry alongside the #140 ownership/publication model, the job contract, the cleanup ownership rule and the Verify-mode semantics. No section is lost and the two rule sets do not overlap semantically: the service mode governs the lifecycle and the runtime paths, the #140 rules govern the downloader. Co-Authored-By: opencode <noreply@opencode.ai>
9 of 12 tasks
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.
Summary
Adds an explicit service/background runtime mode for HFDesk, driven by the native supervisor of each platform:
systemctl --user), machine-wide unit for the explicit server mode--user(default),--systemlaunchctl bootstrap/bootout/kickstart)--user(default),--systemgolang.org/x/sys/windows/svc)The supervisor owns the lifecycle: HFDesk stays a normal foreground process inside it. No self-daemonization, no PID file, no shell wrapper, no
nohup, no Task Scheduler, and no HTTP endpoint for service administration.Why
The supervisor integration was the easy half. The part that made this a real feature is the path model: a service must not resolve its config, jobs, history, logs and cache from the working directory,
HOMEor the profile the supervisor happens to hand over, and it must never silently look like a fresh, unrelated installation.internal/runtimepathsresolves the paths once at startup and injects them. The interactive resolution is unchanged byte for byte.--listen, andinstallandstatusmake a non-loopback bind visible.The machine-wide identity
A machine-wide instance runs as a dedicated, stable, unprivileged account on every platform —
hfdeskon Linux,_hfdeskon macOS, the built-in passwordlessLocalServiceon Windows. Root is the platform'sLocalSystemanalogue, and the issue asks for explicit privilege boundaries and a least-privilege identity.HFDesk never creates an account and never shells out: it resolves the account, refuses the installation clearly when it is missing, and names the command that creates it. The account is granted only the directories the installation created; a directory that existed before keeps its owner and its permissions and must already be writable by the account. A named cache directory is validated for accessibility and never rewritten — the installation gives ownership guidance instead. On Windows the identity cannot be chosen (
--service-useris rejected): a service under a user account would need a credential, and a credential must never reach the command line.Behaviour worth knowing
stopanduninstallof a service that is not installed are clean successes;startandrestartfail with a readable "not installed" error. Exit codes:0succeeded,1the operation failed,2the command line could not be understood.loginctl enable-lingersilently, and never escalates privileges itself.uninstallremoves the registration only — models, cache, config, jobs/history and logs stay.Full reference:
docs/SERVICE.md.Validation
Executed on the development host (Linux, with a live systemd user session):
go build ./...,go vet ./...— clean;GOOS=windowsandGOOS=darwinbuild, vet and test-binary compile — clean.go test ./...andgo test ./... -race— green.systemd-analyze verify— passed for both the generated user unit and the generated machine-wide unit.systemctl --userlifecycle: install → enable → start → running → stop → uninstall →not-installed, with no residue.HOME/XDG_CONFIG_HOME/working directory, a credential-free unit file, bounded log rotation, clean SIGTERM shutdown.hfdesk --helpmatches the base commit, andhfdesk --port N --no-openstill serves/api/health.New CI coverage: a macOS job for the launchd manager (including
plutil -lintand thelaunchctlcycle), the service package on the Windows job, and the race detector over the whole suite on Linux.CI on this pull request is green on all three platforms — Linux,
windows-latestandmacos-latest. On the macOS runner the launchd lifecycle test runs and passes (install → running → stop → uninstall), andplutil -lintaccepts the generated property list for both the LaunchAgent and the LaunchDaemon. On the Windows runner the service-control-manager tests run as an administrator, including a real service registration.Not executed locally: the Windows SCM runtime and the macOS launchd runtime — the development host is Linux. Both platforms are covered by the CI jobs above. The machine-wide install path on Linux/macOS needs root and a pre-created service account, so only its refusal logic is exercised locally.
Risk
internal/servernow resolves its config, jobs, history and mirror-target paths through the runtime path model. The interactive resolution is asserted against an independent reference implementation of the previous search order.Closes #142