dsh-home-hosted 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -114,6 +114,13 @@ A tool that changes something asks for approval **only** when the session is not
114
114
  When the process cannot elevate, the page prints the exact commands instead — including the
115
115
  `launchd-daemon` that starts a Mac **before** login.
116
116
 
117
+ A boot entry runs the panel as the person who installed it: the account comes from the login that
118
+ elevated (`SUDO_UID`) or from the panel root's owner, not from `$USER`.
119
+ <sub>An entry with no account to name runs the panel as root — that is the default, and the page
120
+ says so rather than hiding it. A `User=` systemd cannot resolve is refused too, because it makes
121
+ the unit fail to start. Moving an install from root to a user later needs
122
+ `chown -R <user> <home>`.</sub>
123
+
117
124
  </details>
118
125
 
119
126
  <details>
package/docs/DESIGN.md CHANGED
@@ -21,14 +21,18 @@ always wins — that is an instruction, not a preference.
21
21
 
22
22
  The range is stated twice and both must agree: `dependencies['home-hosted']` in
23
23
  `package.json` (what gets installed) and `EXPECTED_RANGE` in
24
- `src/home-hosted/resolve.ts` (what the page offers to install globally and warns
24
+ `src/home-hosted/resolve.ts` (what the page offers to install globally, and warns
25
25
  about when a global copy is older). Bumping one alone makes the page recommend a
26
- range the plugin is not actually built against.
26
+ range the plugin is not built against. A bump is therefore those two plus the
27
+ version-coupled tests — *unless* the release moved something this plugin names: a
28
+ path under `.hh`, `CONFIG_SCHEMA`, `serverSchema`, `/api/settings`, or a CLI
29
+ subcommand. 0.7.2 (the panel's reverse proxy, all of it under `.hh/.proxy/`) moved
30
+ none of them.
27
31
 
28
32
  That copy resolves to a pnpm path carrying a version and a peer hash
29
33
  (`…/.pnpm/home-hosted@0.6.1_zod@4.6.5/node_modules/home-hosted/…`), which moves
30
- on the next install and disappears when the profile is rebuilt. A boot entry
31
- that baked it in would fail exactly when it matters.
34
+ on the next install and disappears when the profile is rebuilt. A boot entry that
35
+ baked it in would fail exactly when it matters.
32
36
 
33
37
  So the boot entry runs a generated launcher in the plugin state directory
34
38
  (`bin/home-hosted.mjs`, rewritten on every plugin start). At boot the launcher
@@ -42,25 +46,23 @@ launcher the way the unit invokes it and shows the version it answers.
42
46
  The panel starts `dsh web` from a server row, so a boot entry never runs dsh
43
47
  itself. That row points at a second generated launcher (`bin/dsh.mjs`), and the
44
48
  launcher has to answer "which dsh" without the plugin running. A local build is
45
- the obvious case and the one that broke: a user ran a source checkout while
46
- sharing the global `~/.dsh`, and the panel started a *different* global dsh that
47
- shared the same data directory and crashed on the version mismatch. Choosing by
48
- highest version, with the recorded path only as a tie-breaker, is what allowed
49
- that: an unrelated install that reports a bigger number won.
49
+ the case that broke: a user ran a source checkout while sharing the global
50
+ `~/.dsh`, and the panel started a *different* global dsh on the same data
51
+ directory, which crashed on the version mismatch. Choosing by highest version,
52
+ with the recorded path only as a tie-breaker, is what allowed that.
50
53
 
51
54
  So the record (`bin/dsh-resolved.json`) carries `pinned` — the image this plugin
52
55
  was installed on — and the launcher checks it before any version sort. A pin is
53
- carried, never guessed: only a caller that *is* the running image adopts a new
54
- one (`repin`), so an upgrade that lands elsewhere is adopted but a copy found on
55
- PATH never displaces the pin. A build whose path is genuinely gone falls back to
56
- its recorded roots, then to real dsh installs, then to a bare build file, and
57
- only then to PATH.
56
+ carried, never guessed: only a caller that *is* the running image adopts a new one
57
+ (`repin`), so an upgrade that lands elsewhere is adopted but a copy found on PATH
58
+ never displaces the pin. A build whose path is gone falls back to its recorded
59
+ roots, then to real dsh installs, then to a bare build file, and only then PATH.
58
60
 
59
61
  Candidates are ordered by how they were found, not just by version: a dsh by
60
62
  construction (a manifest, `node_modules/dsh`, a pnpm store) beats a root that
61
- merely offers `lib/bin.js` (a clone), which beats a `dsh` on PATH. A clone has
62
- no manifest, so it is found by that build-file name — a guess that must never
63
- outrank a real install.
63
+ merely offers `lib/bin.js` (a clone), which beats a `dsh` on PATH — a clone has no
64
+ manifest, so it is found by that build-file name, a guess that must never outrank
65
+ a real install.
64
66
 
65
67
  The row's stored command is repaired while the plugin runs (`launcherRepair`),
66
68
  including a row that names a bare `dsh`: PATH is exactly what pointed at the copy
@@ -70,85 +72,82 @@ adopted row that deliberately runs a different absolute dsh is left alone.
70
72
  ### A project that depends on dsh keeps it, by name
71
73
 
72
74
  The launcher is the fallback, not the common case. `home-hosted` resolves a bare
73
- command itself (`resolveCommand(command, cwd, projectDir)` in its process
74
- provider) by checking `node_modules/.bin/<command>` under the row's cwd and then
75
- its own project directory, and only then PATH. So a row whose project installed
76
- dsh as a dependency is written as a bare `dsh` with that project as its cwd: it
77
- runs the copy the project's own lockfile chose, through the shim its own package
78
- manager wrote, with nothing absolute to move when the project or the store does.
79
-
80
- That is also why this plugin never shells out to a package manager for it:
81
- `npx dsh` falls back to the registry and to a global when the local bin is
82
- missing, `pnpm dsh` needs pnpm on the boot PATH, and `nlx --local` would add a
83
- global tool to a boot path for a lookup the panel already performs. The launcher
84
- is still what a row gets when a bare name would *not* reach the pinned image —
85
- a clone run without its own `.bin`, or a global-only machine.
75
+ command itself (`resolveCommand(command, cwd, projectDir)`) by checking
76
+ `node_modules/.bin/<command>` under the row's cwd and then its own project
77
+ directory, and only then PATH. So a row whose project installed dsh as a
78
+ dependency is written as a bare `dsh` with that project as its cwd: it runs the
79
+ copy the project's lockfile chose, through the shim its package manager wrote,
80
+ with nothing absolute to move.
81
+
82
+ That is also why this plugin never shells out to a package manager for it: `npx
83
+ dsh` falls back to the registry and to a global when the local bin is missing,
84
+ `pnpm dsh` needs pnpm on the boot PATH, and `nlx --local` would add a global tool
85
+ to a boot path for a lookup the panel already performs. The launcher is what a row
86
+ gets when a bare name would *not* reach the pinned image — a clone run without its
87
+ own `.bin`, or a global-only machine.
86
88
 
87
89
  ## One panel root and one boot artifact per state root
88
90
 
89
91
  Two dsh installs share `~/.dsh` far more often than they should, and each one
90
92
  installing this plugin used to mean two plugins editing one panel's state and one
91
- autostart unit under one ownership marker — so the second install quietly
92
- replaced the first's boot entry. A panel root is now derived per instance: an
93
- explicit `$HHOSTED_HOME` still wins, and a panel already living at
94
- `~/.home-hosted` is adopted rather than abandoned (an upgrade must never make
95
- every server look like it vanished), but otherwise the root is the instance's own
96
- `<stateDir>/panel`. A root counts when it is a 0.7 one (`.hh/workspaces.json` or
97
- `.hh/settings.json`), when it is a pre-0.7 one with a `servers.config.json`, or
98
- when a `run.json` in either layout has a live pid — a killed panel leaves a
99
- runtime file behind, and adopting on that would point a fresh install at
100
- somebody's dead root. Adoption is
101
- also limited to an instance that already exists: the machine's own harness home,
102
- or a state dir that already holds settings. A scratch `DSH_HOME` or a test
103
- project must not reach over and drive the panel some other install owns.
104
-
105
- The boot artifact name follows the state root too, but keyed on the *machine*
106
- default: `home-hosted` only for `~/.dsh/dsh-home-hosted`, `home-hosted-<hash>`
107
- otherwise. Keying it on the running process's own `$DSH_HOME` would name every
108
- install's default state dir `home-hosted` and reintroduce the collision. An
109
- install that used to write the old name retires it once its own artifact is in
110
- place (an upgrade would otherwise autostart the panel twice), and only an
93
+ autostart unit under one ownership marker — so the second install quietly replaced
94
+ the first's boot entry.
95
+
96
+ A panel root is derived per instance: an explicit `$HHOSTED_HOME` wins, and a
97
+ panel already living at `~/.home-hosted` is adopted rather than abandoned (an
98
+ upgrade must never make every server look like it vanished), but otherwise the
99
+ root is the instance's own `<stateDir>/panel`. A root counts when it is a 0.7 one
100
+ (`.hh/workspaces.json` or `.hh/settings.json`), a pre-0.7 one with a
101
+ `servers.config.json`, or when a `run.json` in either layout has a live pid — a
102
+ killed panel leaves a runtime file behind, and adopting on that would point a
103
+ fresh install at somebody's dead root. Adoption is limited to an instance that
104
+ already exists (the machine's harness home, or a state dir that already holds
105
+ settings): a scratch `DSH_HOME` must not reach over and drive the panel some other
106
+ install owns.
107
+
108
+ The boot artifact name follows the state root too, keyed on the *machine* default:
109
+ `home-hosted` only for `~/.dsh/dsh-home-hosted`, `home-hosted-<hash>` otherwise.
110
+ Keying it on the running process's `$DSH_HOME` would name every install's default
111
+ state dir `home-hosted` and reintroduce the collision. An install that used to
112
+ write the old name retires it once its own artifact is in place, and only an
111
113
  artifact carrying this plugin's marker is ever removed.
112
114
 
113
115
  ## Agent tools are merged and on by default
114
116
 
115
- Seven tools, not ten: `servers_lifecycle` carries start/stop/restart, `servers_edit`
116
- carries create/update/delete, `autostart_manage` carries install/uninstall, and
117
- `ui_manage` drives the panel's own UI (`ui-update` / `ui-revert` / `ui-switch`).
118
- `workspaces_list` is the read-only way to see every workspace, and every server
119
- call names its `workspace` explicitly.
120
- They are registered on by default; what gates a mutating call is the session's
121
- own permission mode, not a plugin-level default.
117
+ Seven tools, not ten: `servers_lifecycle` carries start/stop/restart,
118
+ `servers_edit` carries create/update/delete, `autostart_manage` carries
119
+ install/uninstall, `ui_manage` drives the panel's own UI (`ui-update` /
120
+ `ui-revert` / `ui-switch`), and `workspaces_list` is the read-only way to see
121
+ every workspace. They are registered on by default; what gates a mutating call is
122
+ the session's own permission mode, not a plugin-level default.
122
123
 
123
- A description is read at the approval prompt, so each one is a line a person can
124
+ A description is read at the approval prompt, so each is one line a person can
124
125
  glance at: what the tool does, and only the consequence they could not predict
125
126
  (restarting this session's own entry ends it; installing an entry stops the
126
127
  panel). Mechanics — which panel is reached how, which workspace a default means —
127
- belong in the argument schemas and the docs, not in the sentence somebody has to
128
- read before saying yes.
128
+ belong in the argument schemas and the docs.
129
129
 
130
- Structured tool parameters are declared as objects, never as `type: 'json'`: an
130
+ Structured parameters are declared as objects, never as `type: 'json'`: an
131
131
  author-only `json` node projects to a schema with no `type` at all, and a real
132
132
  session then delivers something the handler cannot read — `create`/`update` were
133
- unusable because of it, while every test passed, since tests call the handler
133
+ unusable because of it while every test passed, since tests call the handler
134
134
  directly. A declared object is validated by the runtime before the handler, so a
135
- wrong shape is rejected with the parameter named and an absent one is reported as
136
- absent.
135
+ wrong shape is rejected with the parameter named.
137
136
 
138
137
  ## The dsh entry is the only entry the page manages
139
138
 
140
- The page shows one toggle: manage `dsh` as a home-hosted entry, or not. Adding
141
- one sends a minimal intent (`{ id: 'dsh', autostart: true }`) and the host fills
142
- the rest from the platform default policy; removing one restores an entry this
143
- plugin merely adopted, and deletes one it created. Removing an entry the panel
144
- supervises stops that process — which may be the session asking for it.
139
+ One toggle: manage `dsh` as a home-hosted entry, or not. Adding one sends a
140
+ minimal intent (`{ id: 'dsh', autostart: true }`) and the host fills the rest from
141
+ the platform default policy; removing one restores an entry this plugin merely
142
+ adopted, and deletes one it created. Removing an entry the panel supervises stops
143
+ that process — which may be the session asking for it.
145
144
 
146
- Deleting on toggle-off is the intended reading of that switch, not an oversight:
147
- "manage" means the plugin owns the entry, so switching it off gives the entry
148
- back — removed if this plugin created it, restored if it only adopted one.
149
- Pausing instead would leave an entry nobody manages. The state that must never
150
- happen is the third one: a toggle that is on with no entry behind it, which is
151
- why the entry is recreated from the intent while the switch is on.
145
+ Deleting on toggle-off is the intended reading, not an oversight: "manage" means
146
+ the plugin owns the entry, so switching it off gives the entry back — removed if
147
+ created, restored if adopted. Pausing instead would leave an entry nobody manages.
148
+ The state that must never happen is the third one: a toggle on with no entry
149
+ behind it, which is why the entry is recreated from the intent while the switch is
150
+ on.
152
151
 
153
152
  The generated entry always binds loopback (`bind: 'local'`, `--host 127.0.0.1`):
154
153
  `dsh` rejects `--host 0.0.0.0` as a usage error, so repeating a running harness's
@@ -169,7 +168,12 @@ is passed through untouched rather than fought over. `authNotice` turns it off;
169
168
 
170
169
  A managed entry asks for `persistent: true`: home-hosted then runs it under its
171
170
  nanny, which owns the child and outlives `stopAll()`/`dispose()` — so restarting
172
- or stopping the *panel* no longer ends a prompt mid-flight.
171
+ or stopping the *panel* no longer ends a prompt mid-flight. Staying up across a
172
+ panel stop is the point, not a side effect: the panel's stop copy says as much,
173
+ and 0.7.2 gives the reverse-proxy engine the same treatment ("`down` leaves it
174
+ serving"). A `systemctl stop` still takes both with it unless the unit carries
175
+ `KillMode=process`, which is why that belongs on every unit this plugin writes —
176
+ see below.
173
177
 
174
178
  ## Approvals follow the session's sandbox
175
179
 
@@ -186,7 +190,7 @@ channel).
186
190
  `onPortConflict: kill` arrived in home-hosted 0.6.0 and `persistent` in 0.6.3, and
187
191
  an older panel handed either key can refuse to *boot* from the config. The plugin
188
192
  used to consult the answering panel's version and refuse or drop them
189
- (`KILL_UNSUPPORTED`). That is gone on purpose: it pins `home-hosted@^0.7.1` and
193
+ (`KILL_UNSUPPORTED`). That is gone on purpose: it pins `home-hosted@^0.7.2` and
190
194
  autostarts its own copy, so a panel older than those keys is only reachable by
191
195
  deliberately preferring an old global install — and this is pre-1.0. Supporting
192
196
  older panels again means bringing that check back from history, not re-deriving
@@ -224,73 +228,128 @@ its own.
224
228
 
225
229
  ## Several panels on one machine
226
230
 
227
- `home-hosted` is not one panel per machine: it can be installed per project, and
228
- one install can run several panels from different `--home` roots. This plugin
229
- drives exactly one of them — the state root it resolved — so a person with two
230
- panels used to be indistinguishable from a person with one.
231
+ `home-hosted` can be installed per project, and one install can run several panels
232
+ from different `--home` roots. This plugin drives exactly one of them — the state
233
+ root it resolved — so a person with two panels used to be indistinguishable from a
234
+ person with one.
231
235
 
232
236
  Discovery is bounded, never a scan of the disk: the managed root (always listed,
233
- even before its first start), `$HHOSTED_HOME`, the operator's `instanceRoots`,
234
- and the `~/.home-hosted*` siblings. Another root counts only when it holds a
235
- 0.7 `.hh`, a pre-0.7 `servers.config.json`, or a `run.json` with a live pid. The
236
- inventory is cached
237
- (`INSTANCES_CACHE_MS`); the prompt path re-measures a stale cache in place
238
- (`instancesNow()`), because a provider that only read a snapshot would never
239
- notice a panel started while this dsh was already running.
237
+ even before its first start), `$HHOSTED_HOME`, the operator's `instanceRoots`, and
238
+ the `~/.home-hosted*` siblings. Another root counts only when it holds a 0.7
239
+ `.hh`, a pre-0.7 `servers.config.json`, or a `run.json` with a live pid. The
240
+ inventory is cached (`INSTANCES_CACHE_MS`); the prompt path re-measures a stale
241
+ cache in place (`instancesNow()`), because a provider that only read a snapshot
242
+ would never notice a panel started while this dsh was already running.
240
243
 
241
244
  The agent is told rather than left to guess: with more than one panel found, the
242
- `instancesNotice` setting contributes one runtime-context line naming the managed
243
- panel and the others (`ctx.systemPrompt.context`, `instances-notice.ts`). Every
244
- tool also takes an optional `instance` (a state root or URL); a mutating call
245
- that names none asks the user which panel it means, through `ctx.userQuestions`,
246
- after the approval gate so a call the session may not make prompts for nothing.
247
- The question is asked only when a human channel can answer — no answerer, or a
248
- delegated child — and an unexpected ask failure is refused rather than treated as
249
- consent, because changing a panel the user never confirmed is the outcome this
250
- exists to prevent. Its wording is English like every other host-side message: the
251
- locale service is a browser-side seat, and first-party host plugins ask in English
252
- too.
253
-
254
- The page shows the same inventory under the panel details, including which panel
255
- hosts this dsh, and the Agent-tools section carries the `instancesNotice` toggle.
256
-
257
- A call that names another panel is no dead end. The plugin asks the user how to
258
- reach it, offering every way that endpoint has, least invasive first:
259
-
260
- - `file` edits that panel's workspace config file (`.hh/<workspace>/servers.config.json`,
261
- its default workspace unless the call names another) — no credential, but no live
262
- status either, and a running panel applies a changed definition at that
263
- entry's next start;
264
- - `cli` runs the home-hosted CLI against its state root — the only runtime path
265
- the panel itself offers (`start`/`stop`; a restart is a stop then a start,
266
- because the CLI has no server restart);
245
+ `instancesNotice` setting adds one runtime-context line naming the managed panel
246
+ and the others (`ctx.systemPrompt.context`, `instances-notice.ts`). Every tool
247
+ also takes an optional `instance` (a state root or URL); a mutating call that
248
+ names none asks which panel it means, through `ctx.userQuestions`, after the
249
+ approval gate so a call the session may not make prompts for nothing. The question
250
+ is asked only when a human channel can answer — no answerer, or a delegated child
251
+ — and an unexpected ask failure is refused rather than treated as consent, because
252
+ changing a panel the user never confirmed is the outcome this exists to prevent.
253
+ Its wording is English like every other host-side message: the locale service is a
254
+ browser-side seat, and first-party host plugins ask in English too.
255
+
256
+ A call that names another panel is no dead end. The plugin asks how to reach it,
257
+ offering every way that endpoint has, least invasive first:
258
+
259
+ - `file` edits that panel's workspace config file
260
+ (`.hh/<workspace>/servers.config.json`, its default workspace unless the call
261
+ names another) — no credential, but no live status either, and a running panel
262
+ applies a changed definition at that entry's next start;
263
+ - `cli` runs the CLI against its state root — the only runtime path the panel
264
+ itself offers (`start`/`stop`; a restart is a stop then a start, because the CLI
265
+ has no server restart);
267
266
  - `api` mints a token for that panel, enrols it and uses its HTTP API — the only
268
267
  way to get live status, and it replaces any token that panel had, which the
269
268
  question says outright.
270
269
 
271
- The endpoint's first mechanism is the default when a caller names none, so a
272
- host that cannot reach a person still does the least invasive thing. A read is
273
- the one silent upgrade: with a token this plugin already holds for that panel it
274
- uses the API, because that carries live status and mints nothing. Every such
275
- result ends by naming the panel and the mechanism it used.
270
+ The endpoint's first mechanism is the default when a caller names none, so a host
271
+ that cannot reach a person still does the least invasive thing. A read is the one
272
+ silent upgrade: with a token this plugin already holds for that panel it uses the
273
+ API, because that carries live status and mints nothing. Every such result ends by
274
+ naming the panel and the mechanism it used.
276
275
 
277
276
  A config edit reaches a running panel through its file watcher, so a newly added
278
277
  `autostart` entry is written disabled and then flipped — the same two-phase add
279
- the managed path uses.
280
-
281
- The credential for another panel lives beside the managed one, under its own slot
282
- (`panel-tokens/<slot>.token`, 0600), minted on first use of that mechanism and
283
- never shared with the managed panel's token.
278
+ the managed path uses. The credential for another panel lives beside the managed
279
+ one, under its own slot (`panel-tokens/<slot>.token`, 0600), minted on first use
280
+ of that mechanism and never shared with the managed panel's token.
284
281
 
285
282
  Machine-wide and self-referential endpoints are the exception: `home_hosted_status`
286
- describes the panel this plugin manages (its payload already lists the others),
287
- and the OS boot entry is one entry for that same panel. Those refuse a `home`
288
- rather than ignoring it, so a caller is never left believing it aimed a boot
289
- entry at a panel it did not.
290
-
291
- The settings stamp moved to 3 for `instancesNotice`, while the tool-name
292
- migration stays bound to the release that merged the tools: a stamp bump must
293
- never re-read a current file's explicit two-tool allowlist as "nobody chose".
283
+ describes the panel this plugin manages (its payload already lists the others), and
284
+ the OS boot entry is one entry for that same panel. Those refuse a `home` rather
285
+ than ignoring it, so a caller is never left believing it aimed a boot entry at a
286
+ panel it did not.
287
+
288
+ The settings stamp moved to 3 for `instancesNotice`, while the tool-name migration
289
+ stays bound to the release that merged the tools: a stamp bump must never re-read
290
+ a current file's explicit two-tool allowlist as "nobody chose".
291
+
292
+ ## Boot entries run the panel as a person
293
+
294
+ Two mechanisms drop privilege by name — a systemd `User=` and a launchd
295
+ `UserName` — and both used to take that name from `$USER`. That is right in the
296
+ common case and wrong in the one case it exists for: `sudo` and `pkexec` rewrite
297
+ `USER`, `LOGNAME` and `HOME` to the *target* account, so a panel started from a
298
+ root shell read `USER=root`, and a mechanism that already runs as root got an
299
+ entry that either repeated root or, with the name dropped, silently kept it.
300
+ `os.userInfo()` is no help: it reports the euid.
301
+
302
+ So the account comes from whoever this process is. An elevating tool's
303
+ `SUDO_UID`/`PKEXEC_UID` is an instruction and is used while it resolves, so a stale
304
+ value inherited from an ancestor cannot collapse the entry to root; otherwise this
305
+ session's own login — its database row, else the row for this uid; otherwise the
306
+ owner of the state root or the panel root, which names the person a root-launched
307
+ panel belongs to; and only then, honestly, root. The uid is the fact and `$UID` is
308
+ a claim: a shell variable rather than an exported one, so when it is present at all
309
+ it was inherited. It is read only when the platform gives no uid, and it also feeds
310
+ the launchd domain (`gui/$UID`).
311
+
312
+ Two answers are refused rather than written, and both fail loudly:
313
+
314
+ - **`User=root`**, because an entry with no `User=` already runs as root — naming
315
+ it only dresses up the silent default this change exists to surface;
316
+ - **a name the user database cannot place.** systemd matches names
317
+ case-sensitively and cannot resolve what it does not know, so `User=<such a
318
+ name>` makes the unit refuse to start (`status=217/USER`) and `Restart=always`
319
+ turns that into a crash-loop that never mentions the account. An LDAP or NIS
320
+ login has no `/etc/passwd` row, so those operators set `User=` themselves; the
321
+ warning says so.
322
+
323
+ `HOME` is written into every spec, because `Environment=HOME=` **overrides
324
+ `User=`** — verified on real systemd. So it has to be the account's home: left at
325
+ the installer's it is root's under `sudo`, and the panel would run as the person
326
+ with `~` pointing at `/root`, dying with a permission error that never mentions its
327
+ own user. It is written only when it differs, so an unchanged install stays
328
+ byte-identical and startup `reconcile()` does not rewrite the entry. When an
329
+ account resolves but its home is not knowable, the line is **omitted** instead:
330
+ systemd then supplies the right one from NSS, which leaving root's in place would
331
+ have prevented.
332
+
333
+ An installed entry that already runs as the wrong account is reported by `status()`,
334
+ and the `enabled-failing` repair heals it by installing again. A *working* entry is
335
+ deliberately left alone: re-installing stops the panel and hands it to the entry,
336
+ which is a person's decision and not a startup side effect.
337
+
338
+ ## KillMode=process: a restart must not take the entries with it
339
+
340
+ `persistent: true` puts an entry under a nanny the panel spawns, and the nanny is
341
+ what makes it outlive the panel: it owns the child's pipes, so a panel stop,
342
+ restart or kill cannot break them. The nanny is spawned `detached`, which gives it
343
+ its own session — but *not* its own cgroup, and systemd's default
344
+ `KillMode=control-group` signals every process in the unit's cgroup. So a
345
+ `systemctl stop`/`restart` — which is what this plugin's own activation, and any
346
+ `Restart=always`, runs — takes the nanny down with the panel, quietly falsifying
347
+ the guarantee the design depends on. `KillMode=process` signals the panel alone.
348
+
349
+ An ordinary entry is spawned `detached` too, so the same applies to every
350
+ supervised server and not only the nannies: the panel re-adopts a survivor by its
351
+ port, which is what makes keeping them the right answer rather than a leak.
352
+ It belongs on both units.
294
353
 
295
354
  ## macOS: agent or daemon
296
355
 
@@ -299,8 +358,9 @@ A `LaunchAgent` starts at login; starting before login means a `LaunchDaemon` in
299
358
  install. The daemon is therefore offered even when this process cannot elevate:
300
359
  install stages the plist and returns the three commands to run. The daemon plist
301
360
  names the invoking user in `UserName`, Apple's sanctioned way to avoid running
302
- the panel as root, and the recommendation still prefers a mechanism this process
303
- can install on its own so "Automatic" stays a single click.
361
+ the panel as root — resolved by the rule above, never from `$USER`. The
362
+ recommendation still prefers a mechanism this process can install on its own so
363
+ "Automatic" stays a single click.
304
364
 
305
365
  ## macOS: reachable launchd is not a requirement
306
366
 
@@ -342,11 +402,11 @@ So:
342
402
 
343
403
  ## What the plugin owns
344
404
 
345
- Only `autostart`, `onPortConflict` and `stop.killPortHolders`. Everything else —
346
- command, args, env, health, restart, labels — is the person's, and a patch never
347
- touches it.
405
+ Exactly `autostart`, `onPortConflict`, `persistent` and `stop.killPortHolders`
406
+ (`OWNED_ENTRY_KEYS`). Everything else — command, args, env, health, restart,
407
+ labels — is the person's, and a patch never touches it.
348
408
 
349
- Adopting an entry snapshots those three keys first (absence included). Disabling
409
+ Adopting an entry snapshots those keys first (absence included). Disabling
350
410
  restores the snapshot; a key the snapshot does not carry is restored to the
351
411
  panel's schema default, because a PATCH cannot delete a key. `kill` is the only
352
412
  policy that reclaims a port held by a process the panel cannot identify as the
@@ -370,14 +430,13 @@ nothing), and keeps it 0600 in its own state directory. It is never rendered,
370
430
  logged, or embedded in a unit file.
371
431
 
372
432
  Three facts about that CLI are load-bearing, and were read out of its source
373
- rather than guessed from `--help`: `set-token` replaces whatever hash is there
374
- (so `--clear` is not needed to overwrite, and is not used — see below),
375
- `HHOSTED_TOKEN` is the non-interactive input, and every command peels
376
- `--home <dir>` off before any state module is imported and turns it into
377
- `HHOSTED_HOME` — which is what makes the secrets file, `run.json` and every
378
- workspace's config live under the directory this plugin passes, so the plugin and
379
- the CLI always agree on where state is. A workspace-scoped command (`start`,
380
- `stop`) also takes `--workspace <id>`.
433
+ rather than guessed from `--help`: `set-token` replaces whatever hash is there (so
434
+ `--clear` is not needed, and is not used), `HHOSTED_TOKEN` is the non-interactive
435
+ input, and every command peels `--home <dir>` off before any state module is
436
+ imported and turns it into `HHOSTED_HOME` — which is what makes the secrets file,
437
+ `run.json` and every workspace's config live under the directory this plugin
438
+ passes, so the plugin and the CLI always agree on where state is. A
439
+ workspace-scoped command (`start`, `stop`) also takes `--workspace <id>`.
381
440
 
382
441
  The token state the page shows is measured, not assumed. When the panel answers,
383
442
  the plugin proves its token with a non-mutating `listServers()`: a refusal is
@@ -465,6 +524,9 @@ Nothing is stopped then — leaving a working panel up beats killing it for an
465
524
  entry that would not bring it back. A handover that could not be spawned is
466
525
  reported with the exact commands, and the panel is still up.
467
526
 
527
+ Enabling and switching differ only in the retirement: a switch already has an
528
+ entry keeping the panel alive, so its plan carries the old mechanism's removal.
529
+
468
530
  ## The panel's UI is changed by the panel's own CLI
469
531
 
470
532
  `ui.manage` shells out to `ui-switch` / `ui-update` / `ui-revert` rather than
@@ -475,9 +537,6 @@ matches the asset name and verifies the archive — the version logic stays in o
475
537
  place instead of being reimplemented against the GitHub API here. `--file` stays
476
538
  for a local build, which is the case the CLI cannot cover.
477
539
 
478
- Enabling and switching differ only in the retirement: a switch already has an
479
- entry keeping the panel alive, so its plan carries the old mechanism's removal.
480
-
481
540
  ## Privilege
482
541
 
483
542
  Boot scope needs privilege somewhere on every platform: a system unit, or
@@ -491,6 +550,13 @@ as the status moves: a person runs the commands and presses Re-check, and the no
491
550
  must not outlive the state it described. The persisted last attempt follows the
492
551
  same rule, so a failed install is not re-shown once an entry exists.
493
552
 
553
+ Switching those entries to a `User=` later is not just a unit edit. The moment a
554
+ panel runs as root, everything it writes — `<home>/.hh`, its workspaces, logs and
555
+ `snapshots.json` — is root-owned, so the unprivileged account the unit now names
556
+ cannot write its own state and the panel fails to boot with a permission error
557
+ that never mentions ownership. Moving an install from root to a user therefore
558
+ needs `chown -R <user> <home>` (and the plugin state dir) first.
559
+
494
560
  ## Tests
495
561
 
496
562
  No fixed ports: every stub listener binds `127.0.0.1` with port 0 and reads the