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 +7 -0
- package/docs/DESIGN.md +214 -148
- package/lib/index.js +277 -105
- package/lib/index.js.map +4 -4
- package/lib/types/boot/common.d.ts +81 -4
- package/lib/types/boot/escape.d.ts +9 -0
- package/lib/types/boot/index.d.ts +3 -1
- package/lib/types/boot/systemd.d.ts +6 -2
- package/lib/types/boot/types.d.ts +43 -0
- package/lib/types/home-hosted/launch.d.ts +29 -0
- package/lib/types/home-hosted/resolve.d.ts +1 -1
- package/lib/types/service.d.ts +22 -0
- package/package.json +2 -2
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
|
|
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
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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)`
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
That is also why this plugin never shells out to a package manager for it:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
`.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
or a state dir that already holds
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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,
|
|
116
|
-
carries create/update/delete, `autostart_manage` carries
|
|
117
|
-
`ui_manage` drives the panel's own UI (`ui-update` /
|
|
118
|
-
`workspaces_list` is the read-only way to see
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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
|
|
128
|
-
read before saying yes.
|
|
128
|
+
belong in the argument schemas and the docs.
|
|
129
129
|
|
|
130
|
-
Structured
|
|
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
|
|
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
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
the
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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.
|
|
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`
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
inventory is cached
|
|
237
|
-
(`
|
|
238
|
-
|
|
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
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
- `
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
The settings stamp moved to 3 for `instancesNotice`, while the tool-name
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
303
|
-
can install on its own so
|
|
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
|
-
|
|
346
|
-
command, args, env, health, restart,
|
|
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
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
`
|
|
378
|
-
|
|
379
|
-
|
|
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
|