dsh-home-hosted 0.6.2 → 0.7.0

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/docs/DESIGN.md CHANGED
@@ -2,570 +2,47 @@
2
2
 
3
3
  Decisions that are not obvious from the code, and the hazards behind them.
4
4
 
5
- ## A plugin cannot act at boot
5
+ Depth is split by topic, the way [home-hosted](https://github.com/NamesMT/home-hosted) splits its own:
6
+ [boot autostart](BOOT.md) · [the panel](PANEL.md) · [server entries](ENTRIES.md) ·
7
+ [workspaces and several panels](WORKSPACES.md) · [agent tools](AGENT-TOOLS.md).
6
8
 
7
- The plugin runs inside dsh. At boot nothing runs yet, so autostart cannot be a
8
- runtime behaviour: the plugin installs an OS entry while dsh runs, and the OS
9
- starts home-hosted from then on. Every plugin start re-asserts that entry
10
- (rewrite-if-changed), which is also what repairs a node path that moved after an
11
- upgrade.
9
+ Read the file for the area you are about to touch, not all of them.
12
10
 
13
- ## Which home-hosted runs
11
+ ## What this plugin is
14
12
 
15
- The plugin depends on `home-hosted` at a pinned range and prefers it by default,
16
- so the panel is the version the plugin was built against. The preference is a
17
- setting (Options → which home-hosted to run); a global install is a supported
18
- choice, and the page shows both candidates with their versions and can install
19
- the pinned range globally. An operator's `homeHostedCommand` in the plugin row
20
- always wins — that is an instruction, not a preference.
13
+ Server management first: a person adds their Gitea, Jellyfin, cache or bot — from the page or by
14
+ prompting an agent — and the plugin writes and supervises those entries through home-hosted. The
15
+ `dsh` entry and the OS boot entry are a highlight on top of that, which is why the boot, launcher and
16
+ sign-in machinery below is long and the server path is short.
21
17
 
22
- The range is stated twice and both must agree: `dependencies['home-hosted']` in
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
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.
18
+ ## Desktop is a second surface
27
19
 
28
- That copy resolves to a pnpm path carrying a version and a peer hash
29
- (`…/.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.
20
+ `profileContext.name` (registered by dsh's own profile boot, and what dsh's shipped composition keys
21
+ its Desktop rows on) says which surface this is; anything else, including a host too old to register
22
+ it, reads as `web`.
32
23
 
33
- So the boot entry runs a generated launcher in the plugin state directory
34
- (`bin/home-hosted.mjs`, rewritten on every plugin start). At boot the launcher
35
- finds the pinned copy again — recorded path, then `$DSH_HOME/profiles/*`
36
- node_modules (flat or pnpm), then PATH — and forwards its argv, so the entry
37
- survives plugin upgrades and profile reinstalls. The plugin preflights the
38
- launcher the way the unit invokes it and shows the version it answers.
24
+ Desktop starts its own reserved profile inside Electron, so the harness is never one of the panel's
25
+ server entries there, and `dsh --profile desktop` is refused by the CLI — a row this plugin wrote
26
+ would only crash-loop. So the surface is reported (`status.surface`) and exactly one thing is refused:
27
+ managing the *harness* entry (`DESKTOP_ENTRY_UNSUPPORTED`, from `applyIntents`, `removeManagedEntry`
28
+ and `restoreEntry`), with `reconcile`/`ensureManagedEntry`/`repairManagedCommand` skipping it so a
29
+ stored intent cannot resurrect it. The page shows that toggle disabled and web-only.
39
30
 
40
- ## The dsh entry boots the image the plugin was installed on
41
-
42
- The panel starts `dsh web` from a server row, so a boot entry never runs dsh
43
- itself. That row points at a second generated launcher (`bin/dsh.mjs`), and the
44
- 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.
50
-
51
- So the record (`bin/dsh-resolved.json`) carries `pinned` — the image this plugin
52
- 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.
58
-
59
- Candidates are ordered by how they were found, not just by version: a dsh by
60
- 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.
64
-
65
- The row's stored command is repaired while the plugin runs (`launcherRepair`),
66
- including a row that names a bare `dsh`: PATH is exactly what pointed at the copy
67
- that stopped working. A row this plugin created has its command replaced; an
68
- adopted row that deliberately runs a different absolute dsh is left alone.
69
-
70
- ### A project that depends on dsh keeps it, by name
71
-
72
- 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.
86
-
87
- ## One panel root and one boot artifact per state root
88
-
89
- Two dsh installs share `~/.dsh` far more often than they should, and each one
90
- 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
111
- artifact carrying this plugin's marker is ever removed.
112
-
113
- ## Agent tools are merged and on by default
114
-
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.
122
-
123
- A description is read at the approval prompt, so each one is a line a person can
124
- glance at: what the tool does, and only the consequence they could not predict
125
- (restarting this session's own entry ends it; installing an entry stops the
126
- 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.
129
-
130
- Structured tool parameters are declared as objects, never as `type: 'json'`: an
131
- author-only `json` node projects to a schema with no `type` at all, and a real
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
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.
137
-
138
- ## The dsh entry is the only entry the page manages
139
-
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.
145
-
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.
152
-
153
- The generated entry always binds loopback (`bind: 'local'`, `--host 127.0.0.1`):
154
- `dsh` rejects `--host 0.0.0.0` as a usage error, so repeating a running harness's
155
- network bind would produce an entry that can never start.
156
-
157
- ## The sign-in page points at the log
158
-
159
- dsh web's unauthenticated 401 is a plain-text line written by the in-box
160
- connection plugin; it has no config and no event. The plugin wraps that one
161
- internal writer (`connection.browserAuth.writeUnauthorized`) and appends where
162
- the tokenised URL is — `<panel url>/logs?server=<entry>`. It rewrites **only** the
163
- exact stock body, guards every assignment, and replaces the response's `end` for
164
- the single call, so an authentication plugin's own page (or an exotic response)
165
- is passed through untouched rather than fought over. `authNotice` turns it off;
166
- `{url}` is read per 401.
167
-
168
- ## Persistence keeps dsh alive across a panel restart
169
-
170
- A managed entry asks for `persistent: true`: home-hosted then runs it under its
171
- nanny, which owns the child and outlives `stopAll()`/`dispose()` — so restarting
172
- or stopping the *panel* no longer ends a prompt mid-flight.
173
-
174
- ## Approvals follow the session's sandbox
175
-
176
- A mutating agent tool asks the approval service only when the calling session is
177
- *not* already `danger-full-access` (`ctx.sandboxPolicy.resolve({ session })`).
178
- Asking anyway made a Full-access run fail whenever the deployment's approvals
179
- auto-reject — the session had already granted exactly what the tool was asking
180
- about. Below full access the tool still asks and still fails closed, and the
181
- refusal names the mode and the remedy (Full access, or an answerable approval
182
- channel).
31
+ Everything else is untouched: servers, workspaces, the panel, tokens, UI and the *panel's* boot
32
+ autostart are not surface-bound, and only the harness entry is keyed on `defaultEntryId`. Desktop is
33
+ the browser surface in Electron — the same `dsh-web-app` bundles — so `platform: "web"` already covers
34
+ its page.
183
35
 
184
36
  ## Config compatibility was dropped, deliberately
185
37
 
186
- `onPortConflict: kill` arrived in home-hosted 0.6.0 and `persistent` in 0.6.3, and
187
- an older panel handed either key can refuse to *boot* from the config. The plugin
188
- 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
190
- autostarts its own copy, so a panel older than those keys is only reachable by
191
- deliberately preferring an old global install — and this is pre-1.0. Supporting
192
- older panels again means bringing that check back from history, not re-deriving
193
- it.
194
-
195
- ## Workspaces: one managed, all reachable
196
-
197
- home-hosted 0.7 made a workspace the ownership boundary: each has its own
198
- `servers.config.json`, settings, secrets, logs and nanny state under
199
- `.hh/<workspace>/`, while the listener, auth, TLS, host vitals, backups, UI and
200
- `run.json` stay at `.hh/`. A server id is therefore only unique *inside* a
201
- workspace, and every call has to say which one it means.
202
-
203
- The plugin manages exactly one workspace — **the panel's `default`**, fixed —
204
- because its intent (the `dsh` entry, snapshots, reconcile) is a promise about one
205
- entry in one place. Making that a setting bought nothing but a way to write into
206
- the wrong workspace, so it is a constant. The page and the agent tools are not
207
- limited to it: they list every workspace the panel serves
208
- (`workspaces.list`, `/api/workspaces` when it answers, the registry plus the
209
- workspace files when it does not) and act on any of them by naming it
210
- (`workspace` on the payload, `?workspace=` on the API, `.hh/<workspace>/…` on
211
- disk). Only `default` is *reconciled*; another workspace is managed the way a
212
- person would manage it.
213
-
214
- Every server-shaped tool call therefore carries an explicit `workspace`, required
215
- in the JSON: it is one argument instead of a paragraph, and it keeps the same call
216
- from meaning different things on two machines. The RPC endpoint still defaults an
217
- absent value (the page always sends one, and a hand-written call should not break),
218
- but a model is never left to guess.
219
-
220
- A pre-0.7 root is not read or written at all. It is recognised (a `legacyRoot`
221
- flag on the status), refused on every write, and fixed through one action that
222
- runs `home-hosted migrate --yes` — the same relocation a first 0.7 start does on
223
- its own.
224
-
225
- ## Several panels on one machine
226
-
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
-
232
- 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.
240
-
241
- 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);
267
- - `api` mints a token for that panel, enrols it and uses its HTTP API — the only
268
- way to get live status, and it replaces any token that panel had, which the
269
- question says outright.
270
-
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.
276
-
277
- A config edit reaches a running panel through its file watcher, so a newly added
278
- `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.
284
-
285
- 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".
294
-
295
- ## Boot entries run the panel as a person
296
-
297
- Two mechanisms drop privilege by name — a systemd `User=` and a launchd
298
- `UserName` — and both used to take that name from `$USER`. That is right in the
299
- common case and wrong in the one case it exists for: `sudo` and `pkexec` rewrite
300
- `USER`, `LOGNAME` and `HOME` to the *target* account, so a panel started from a
301
- root shell or a root unit read `USER=root`, and a mechanism that already runs as
302
- root got an entry that either repeated root or (with the name dropped) silently
303
- kept it. `os.userInfo()` is no help: it reports the euid.
304
-
305
- So the account comes from whoever this process is. An elevating tool's
306
- `SUDO_UID`/`PKEXEC_UID` is an instruction and is used while it resolves, so a
307
- stale value inherited from an ancestor cannot collapse the entry to root;
308
- otherwise this session's own login — its database row, else the row for this uid;
309
- otherwise the owner of the state root or the panel root, which names the person a
310
- root-launched panel belongs to; and only then, honestly, root.
311
-
312
- The uid is the fact and `$UID` is a claim: it is a shell variable rather than an
313
- exported one, so when it is present at all it was inherited, and it also feeds the
314
- launchd domain (`gui/$UID`). It is read only when the platform gives no uid.
315
-
316
- Two answers are refused rather than written, and both fail loudly:
317
-
318
- - **`User=root`**, because an entry with no `User=` already runs as root — naming
319
- it only dresses up the silent default this change exists to surface;
320
- - **a name the user database cannot place.** systemd matches names
321
- case-sensitively and cannot resolve what it does not know, so
322
- `User=<such a name>` makes the unit refuse to start (`status=217/USER`) and
323
- `Restart=always` turns that into a crash-loop that never mentions the account —
324
- worse than running as root, and the same unexplained failure in a new costume.
325
- An LDAP or NIS login has no `/etc/passwd` row, so those operators set `User=`
326
- themselves; the warning says so.
327
-
328
- `HOME` is written into every spec, because `Environment=HOME=` **overrides
329
- `User=`** — verified on real systemd. So it has to be the account's home: left at
330
- the installer's it is root's under `sudo`, and the panel would run as the person
331
- with `~` pointing at `/root`, dying with a permission error that never mentions
332
- its own user. It is written only when it differs, so an unchanged install stays
333
- byte-identical and startup `reconcile()` does not rewrite the entry. When an
334
- account resolves but its home is not knowable, the line is **omitted** instead:
335
- systemd then supplies the right one from NSS, which leaving root's in place would
336
- have prevented.
337
-
338
- An installed entry that already runs as the wrong account is reported by
339
- `status()`, and the `enabled-failing` repair heals it by installing again. A
340
- *working* entry is deliberately left alone: re-installing stops the panel and
341
- hands it to the entry, which is a person's decision and not a startup side
342
- effect.
343
-
344
- ## KillMode=process: a restart must not take the entries with it
345
-
346
- `persistent: true` puts an entry under a nanny the panel spawns, and the nanny is
347
- what makes it outlive the panel: it owns the child's pipes, so a panel stop,
348
- restart or kill cannot break them. The nanny is spawned `detached`, which gives it
349
- its own session — but *not* its own cgroup, and systemd's default
350
- `KillMode=control-group` signals every process in the unit's cgroup. So a
351
- `systemctl stop`/`restart` — which is what this plugin's own activation, and any
352
- `Restart=always`, runs — takes the nanny down with the panel, quietly falsifying
353
- the guarantee the design depends on. `KillMode=process` signals the panel alone.
354
-
355
- An ordinary entry is spawned `detached` too, so the same applies to every
356
- supervised server and not only the nannies: the panel re-adopts a survivor by its
357
- port, which is what makes keeping them the right answer rather than a leak.
358
- It belongs on both units.
359
-
360
- ## macOS: agent or daemon
361
-
362
- A `LaunchAgent` starts at login; starting before login means a `LaunchDaemon` in
363
- `/Library/LaunchDaemons`, which is root-owned and needs a one-time `sudo`
364
- install. The daemon is therefore offered even when this process cannot elevate:
365
- install stages the plist and returns the three commands to run. The daemon plist
366
- names the invoking user in `UserName`, Apple's sanctioned way to avoid running
367
- the panel as root — resolved by the rule above, never from `$USER`. The
368
- recommendation still prefers a mechanism this process can install on its own so
369
- "Automatic" stays a single click.
370
-
371
- ## macOS: reachable launchd is not a requirement
372
-
373
- A plist in `~/Library/LaunchAgents` is loaded by launchd at the next login whether
374
- or not the process that wrote it can reach `gui/$UID` (SSH sessions often cannot).
375
- So the agent stays `available` and its install writes the plist and reports "loads
376
- at the next login" instead of refusing; only the `bootstrap`/`enable`/`print`
377
- steps need the domain, and they are skipped when it is unreachable. The daemon
378
- scope still needs root for `/Library/LaunchDaemons`.
379
-
380
- ## Starting and replacing the panel
381
-
382
- `panel.start` runs the preferred CLI's `up`, which detaches itself and returns
383
- once the panel answers — that is the whole out-of-the-box path when nothing is
384
- running.
385
-
386
- `panel.takeover` replaces an answering panel with the preferred copy. Stopping
387
- the old panel also stops every server it supervises, this dsh included, so a
388
- detached helper does the work and the guard refuses unless this session is an
389
- adopted entry with `autostart: true` (something must bring it back). The response
390
- says the page will disconnect; the helper logs to `bin/panel-takeover.log`.
391
-
392
- ## Two writes, and why the API comes first
393
-
394
- home-hosted's config store compares the bytes it last wrote, so a write through
395
- `/api/servers` never triggers its file watcher. A write to a workspace's
396
- `servers.config.json` does — and the watcher *starts* a newly added entry with
397
- `autostart: true`.
398
-
399
- So:
400
-
401
- - panel answering + this plugin holds a token → API write, nothing is started or
402
- stopped by surprise;
403
- - panel not answering → direct file write (nobody is watching the file);
404
- - panel answering but no token available → file write with the two-phase add:
405
- write the entry with `autostart: false`, then flip `autostart`. A later write
406
- to an existing entry is a *changed definition*, which only takes effect at that
407
- entry's next start.
408
-
409
- ## What the plugin owns
410
-
411
- Only `autostart`, `onPortConflict` and `stop.killPortHolders`. Everything else —
412
- command, args, env, health, restart, labels — is the person's, and a patch never
413
- touches it.
414
-
415
- Adopting an entry snapshots those three keys first (absence included). Disabling
416
- restores the snapshot; a key the snapshot does not carry is restored to the
417
- panel's schema default, because a PATCH cannot delete a key. `kill` is the only
418
- policy that reclaims a port held by a process the panel cannot identify as the
419
- entry's own successor, and it still refuses to touch the panel or any process the
420
- panel supervises.
421
-
422
- ## Self-reference
423
-
424
- This process may itself be the entry being managed (`HHOSTED_SERVER_ID`).
425
-
426
- - Deleting that entry is refused: it would stop the session doing the deleting.
427
- - Starting/restarting it is allowed — that is the "reclaim now" case — but the UI
428
- says the page will disconnect, and the agent tools need approval first.
429
-
430
- ## Credentials
431
-
432
- home-hosted keeps only an API token's hash, so a token this plugin did not create
433
- can never be recovered. On the first write with none enrolled, the plugin mints
434
- one, passes it to `home-hosted set-token` through `HHOSTED_TOKEN` (the CLI prints
435
- nothing), and keeps it 0600 in its own state directory. It is never rendered,
436
- logged, or embedded in a unit file.
437
-
438
- Three facts about that CLI are load-bearing, and were read out of its source
439
- rather than guessed from `--help`: `set-token` replaces whatever hash is there
440
- (so `--clear` is not needed to overwrite, and is not used — see below),
441
- `HHOSTED_TOKEN` is the non-interactive input, and every command peels
442
- `--home <dir>` off before any state module is imported and turns it into
443
- `HHOSTED_HOME` — which is what makes the secrets file, `run.json` and every
444
- workspace's config live under the directory this plugin passes, so the plugin and
445
- the CLI always agree on where state is. A workspace-scoped command (`start`,
446
- `stop`) also takes `--workspace <id>`.
447
-
448
- The token state the page shows is measured, not assumed. When the panel answers,
449
- the plugin proves its token with a non-mutating `listServers()`: a refusal is
450
- `stale` and the write path drops to the config file, while a panel that never
451
- answered leaves the state `unknown` — a timeout is not a refusal, and
452
- `tokenVerified` records whether a proof actually happened.
453
-
454
- A refused token, or a hash this plugin never had, is replaced by
455
- `panel.reclaimToken`: one `set-token` write with a freshly minted token in
456
- `HHOSTED_TOKEN`, proved against the panel before the refreshed status is
457
- returned. It is one write and not a `--clear`-then-set pair, because `set-token`
458
- replaces whatever hash is there (probed against the real CLI) and clearing first
459
- would leave a window in which the panel has no token at all. Every token
460
- operation on one state directory is serialised; two concurrent mints used to
461
- leave the stored plaintext and the panel's hash disagreeing, and nothing could
462
- ever recover. A failed write replaced nothing, so the previously stored token is
463
- kept — it may still be the panel's.
464
-
465
- Only a panel that was never started is refused: a missing or refused token is
466
- itself a common reason the panel cannot be reached, so requiring it to answer
467
- would make the repair impossible in exactly the case it exists for. A panel that
468
- is up but silent gets the token enrolled without a proof, and the page says so
469
- rather than claiming a verification it did not take.
470
-
471
- ## A managed entry that was deleted
472
-
473
- `manageDsh` with an `autostart` intent is the whole instruction: it says this
474
- plugin manages the harness entry. If someone deletes that entry — from the panel,
475
- a tool call, or a hand edit — the page would otherwise show a managed entry that
476
- is missing with no way to act on it. So the status read puts it back, and startup
477
- `reconcile()` does the same for a deletion that happened while the plugin was
478
- not running. A paused intent (`autostart: false`) is a deliberate stop and is
479
- left alone, and `entries.apply` records `manageDsh` from the intent rather than
480
- forcing it on, so applying a paused intent cannot leave the toggle stuck.
481
-
482
- Recovery runs from a read, which the page polls, so it is throttled
483
- (`ENTRY_RECOVERY_INTERVAL_MS`) and claims its attempt before its first await:
484
- a panel that refuses the create must not be hit once per poll, and two status
485
- calls racing each other must not both create the same entry.
486
-
487
- ## Enabling autostart hands the panel over
488
-
489
- An install alone proves nothing. The panel the plugin started keeps running, so
490
- nothing shows whether the entry would ever start one — and the CLI refuses to
491
- start a second panel while `run.json` names a live pid, so `enable --now` on a
492
- freshly written unit leaves it *failed* while an orphan panel holds the port. So
493
- a successful install hands the running panel to the entry: the panel stops, and
494
- the entry starts it. That is the only way to know autostart works rather than
495
- merely that a file was written.
496
-
497
- The stop takes the panel's servers with it, and this plugin is usually one of
498
- them, so the work goes to a generated detached helper (`bin/panel-activate.mjs`)
499
- that outlives the process. Its order is the whole design:
500
-
501
- 1. prove the start can work — the unit file exists, `systemctl is-active` agrees
502
- — while the panel is still up, because a start that could never work must not
503
- be attempted once the old panel is already gone;
504
- 2. on a switch, stop the panel through the *previous* mechanism
505
- (`systemctl stop`, `launchctl kill`): its own restart policy
506
- (`Restart=always`, `KeepAlive`) would bring the panel back the moment `down`
507
- returned, racing the new entry;
508
- 3. stop it with the CLI's `down`, then wait for the pid, escalating to SIGTERM
509
- and SIGKILL;
510
- 4. start the entry;
511
- 5. only then remove the other mechanisms' entries.
512
-
513
- The stop is always the CLI's `down`, never the new mechanism's own `restart`: the
514
- entry would then be starting a *second* panel, which home-hosted refuses while
515
- `run.json` names a live pid, so a restart-in-place over an orphan only produces a
516
- failed unit. Waiting on the pid matters for the same reason — a `down` that
517
- returned before the process exited would race the start.
518
-
519
- Retirement is last, and deliberately not part of `install()`. Switching mechanism
520
- has to uninstall the old one, but the old entry is often what is currently
521
- running the panel: removing it during the install would stop the panel — and this
522
- plugin — before the new entry was ever told to start. So the ladder carries the
523
- other mechanisms' removal steps in the activation plan, and each provider's
524
- `retireCommands()` is the no-`--now` form for the same reason. launchd has no
525
- unload-without-stopping, so its retirement is only safe once the panel is down
526
- and the new entry is up.
527
-
528
- A mechanism that cannot start anything now answers `activate() → null`: a `Run`
529
- value and a `.desktop` file are read by the shell or the session at login.
530
- Nothing is stopped then — leaving a working panel up beats killing it for an
531
- entry that would not bring it back. A handover that could not be spawned is
532
- reported with the exact commands, and the panel is still up.
533
-
534
- ## The panel's UI is changed by the panel's own CLI
535
-
536
- `ui.manage` shells out to `ui-switch` / `ui-update` / `ui-revert` rather than
537
- downloading a release and installing a file. An official UI is named by its
538
- release asset (`--asset noc-console`, plus `--repo`/`--tag` when it lives
539
- somewhere else), and the panel then picks the release matching its own version,
540
- matches the asset name and verifies the archive — the version logic stays in one
541
- place instead of being reimplemented against the GitHub API here. `--file` stays
542
- for a local build, which is the case the CLI cannot cover.
543
-
544
- Enabling and switching differ only in the retirement: a switch already has an
545
- entry keeping the panel alive, so its plan carries the old mechanism's removal.
546
-
547
- ## Privilege
548
-
549
- Boot scope needs privilege somewhere on every platform: a system unit, or
550
- `loginctl enable-linger` on Linux; a LaunchDaemon on macOS; an elevated task on
551
- Windows. Login scope needs none. When this process cannot elevate, the page shows
552
- the exact commands instead of prompting for a password — a headless panel has no
553
- askpass and `sudo -n` is the only honest probe.
554
-
555
- That refusal is shown with the status it answered, and the page retires it as soon
556
- as the status moves: a person runs the commands and presses Re-check, and the note
557
- must not outlive the state it described. The persisted last attempt follows the
558
- same rule, so a failed install is not re-shown once an entry exists.
559
-
560
- Switching those entries to a `User=` later is not just a unit edit. The moment a
561
- panel runs as root, everything it writes — `<home>/.hh`, its workspaces, logs and
562
- `snapshots.json` — is root-owned, so the unprivileged account the unit now names
563
- cannot write its own state and the panel fails to boot with a permission error
564
- that never mentions ownership. Moving an install from root to a user therefore
565
- needs `chown -R <user> <home>` (and the plugin state dir) first.
38
+ `onPortConflict: kill` arrived in home-hosted 0.6.0 and `persistent` in 0.6.3, and an older panel
39
+ handed either key can refuse to *boot* from the config. The plugin used to consult the answering
40
+ panel's version and refuse or drop them (`KILL_UNSUPPORTED`). That is gone on purpose: it pins
41
+ `home-hosted@^0.7.2` and autostarts its own copy, so a panel older than those keys is only reachable by
42
+ deliberately preferring an old global install — and this is pre-1.0. Supporting older panels again means
43
+ bringing that check back from history, not re-deriving it.
566
44
 
567
45
  ## Tests
568
46
 
569
- No fixed ports: every stub listener binds `127.0.0.1` with port 0 and reads the
570
- assigned port. No test starts dsh, home-hosted, or an OS service manager; the
571
- subprocess seam and the boot ladder are injected.
47
+ No fixed ports: every stub listener binds `127.0.0.1` with port 0 and reads the assigned port. No test
48
+ starts dsh, home-hosted, or an OS service manager; the subprocess seam and the boot ladder are injected.