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/README.md +56 -33
- package/docs/AGENT-TOOLS.md +30 -0
- package/docs/BOOT.md +154 -0
- package/docs/DESIGN.md +31 -554
- package/docs/ENTRIES.md +117 -0
- package/docs/PANEL.md +107 -0
- package/docs/WORKSPACES.md +83 -0
- package/lib/client.js +8 -8
- package/lib/index.js +122 -28
- package/lib/index.js.map +4 -4
- package/lib/types/boot/types.d.ts +8 -2
- package/lib/types/home-hosted/resolve.d.ts +1 -1
- package/lib/types/service.d.ts +16 -2
- package/lib/types/shared/contracts.d.ts +10 -0
- package/lib/types/util/surface.d.ts +16 -0
- package/package.json +15 -14
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
11
|
+
## What this plugin is
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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.
|