dsh-home-hosted 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -114,6 +114,13 @@ A tool that changes something asks for approval **only** when the session is not
114
114
  When the process cannot elevate, the page prints the exact commands instead — including the
115
115
  `launchd-daemon` that starts a Mac **before** login.
116
116
 
117
+ A boot entry runs the panel as the person who installed it: the account comes from the login that
118
+ elevated (`SUDO_UID`) or from the panel root's owner, not from `$USER`.
119
+ <sub>An entry with no account to name runs the panel as root — that is the default, and the page
120
+ says so rather than hiding it. A `User=` systemd cannot resolve is refused too, because it makes
121
+ the unit fail to start. Moving an install from root to a user later needs
122
+ `chown -R <user> <home>`.</sub>
123
+
117
124
  </details>
118
125
 
119
126
  <details>
package/docs/DESIGN.md CHANGED
@@ -292,6 +292,71 @@ The settings stamp moved to 3 for `instancesNotice`, while the tool-name
292
292
  migration stays bound to the release that merged the tools: a stamp bump must
293
293
  never re-read a current file's explicit two-tool allowlist as "nobody chose".
294
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
+
295
360
  ## macOS: agent or daemon
296
361
 
297
362
  A `LaunchAgent` starts at login; starting before login means a `LaunchDaemon` in
@@ -299,8 +364,9 @@ A `LaunchAgent` starts at login; starting before login means a `LaunchDaemon` in
299
364
  install. The daemon is therefore offered even when this process cannot elevate:
300
365
  install stages the plist and returns the three commands to run. The daemon plist
301
366
  names the invoking user in `UserName`, Apple's sanctioned way to avoid running
302
- the panel as root, and the recommendation still prefers a mechanism this process
303
- can install on its own so "Automatic" stays a single click.
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.
304
370
 
305
371
  ## macOS: reachable launchd is not a requirement
306
372
 
@@ -491,6 +557,13 @@ as the status moves: a person runs the commands and presses Re-check, and the no
491
557
  must not outlive the state it described. The persisted last attempt follows the
492
558
  same rule, so a failed install is not re-shown once an entry exists.
493
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.
566
+
494
567
  ## Tests
495
568
 
496
569
  No fixed ports: every stub listener binds `127.0.0.1` with port 0 and reads the
package/lib/index.js CHANGED
@@ -70,77 +70,6 @@ import path15 from "node:path";
70
70
  import process10 from "node:process";
71
71
  import { fileURLToPath } from "node:url";
72
72
 
73
- // src/boot/ladder.ts
74
- import os2 from "node:os";
75
- import process3 from "node:process";
76
-
77
- // src/util/exec.ts
78
- import { spawn } from "node:child_process";
79
- async function run(command, args = [], options = {}) {
80
- const maxBytes = options.maxBytes ?? 256 * 1024;
81
- const timeoutMs = options.timeoutMs ?? 2e4;
82
- return await new Promise((resolve) => {
83
- let settled = false;
84
- let stdout = "";
85
- let stderr = "";
86
- let timedOut = false;
87
- const finish = (result) => {
88
- if (settled)
89
- return;
90
- settled = true;
91
- resolve({
92
- command,
93
- args,
94
- code: result.code ?? null,
95
- signal: result.signal ?? null,
96
- stdout: result.stdout ?? stdout,
97
- stderr: result.stderr ?? stderr,
98
- timedOut,
99
- error: result.error ?? null
100
- });
101
- };
102
- let child;
103
- try {
104
- child = spawn(command, args, {
105
- cwd: options.cwd,
106
- env: { ...process.env, ...options.env },
107
- shell: false,
108
- windowsHide: true,
109
- stdio: ["ignore", "pipe", "pipe"]
110
- });
111
- } catch (error) {
112
- finish({ error: error instanceof Error ? error.message : String(error) });
113
- return;
114
- }
115
- const timer = setTimeout(() => {
116
- timedOut = true;
117
- child.kill("SIGKILL");
118
- }, timeoutMs);
119
- child.stdout?.on("data", (chunk) => {
120
- if (stdout.length < maxBytes)
121
- stdout += chunk.toString("utf8").slice(0, maxBytes - stdout.length);
122
- });
123
- child.stderr?.on("data", (chunk) => {
124
- if (stderr.length < maxBytes)
125
- stderr += chunk.toString("utf8").slice(0, maxBytes - stderr.length);
126
- });
127
- child.on("error", (error) => {
128
- clearTimeout(timer);
129
- finish({ error: error.message });
130
- });
131
- child.on("close", (code, signal) => {
132
- clearTimeout(timer);
133
- finish({ code, signal });
134
- });
135
- });
136
- }
137
- async function sudoAvailable() {
138
- if (process.getuid?.() === 0)
139
- return true;
140
- const result = await run("sudo", ["-n", "true"], { timeoutMs: 5e3 });
141
- return result.code === 0;
142
- }
143
-
144
73
  // src/boot/common.ts
145
74
  import fs2 from "node:fs";
146
75
  import os from "node:os";
@@ -211,32 +140,129 @@ function tempDir(ctx) {
211
140
  const value = ctx.env.TMPDIR?.trim() || ctx.env.TEMP?.trim() || ctx.env.TMP?.trim();
212
141
  return value || os.tmpdir();
213
142
  }
214
- function currentUser(ctx) {
215
- const value = ctx.env.USER?.trim() || ctx.env.LOGNAME?.trim() || ctx.env.USERNAME?.trim();
216
- if (value)
217
- return value;
143
+ function parsePasswd(text) {
144
+ return text.split("\n").map((line) => {
145
+ const fields = line.split(":");
146
+ const uid = Number.parseInt(fields[2] ?? "", 10);
147
+ return fields.length < 6 || !Number.isFinite(uid) || (fields[0] ?? "").length === 0 ? null : { name: fields[0] ?? "", uid, home: fields[5] ?? "" };
148
+ }).filter((entry) => entry !== null);
149
+ }
150
+ function readPasswd() {
218
151
  try {
219
- return os.userInfo().username;
152
+ return parsePasswd(fs2.readFileSync("/etc/passwd", "utf8"));
220
153
  } catch {
154
+ return [];
155
+ }
156
+ }
157
+ function numberFrom(value) {
158
+ const text = value?.trim();
159
+ if (text === void 0 || text.length === 0)
160
+ return null;
161
+ return /^\d+$/.test(text) ? Number.parseInt(text, 10) : null;
162
+ }
163
+ function accountFrom(row, ambientHome) {
164
+ const home = row.home.length > 0 ? row.home : ambientHome?.trim() || null;
165
+ return { name: row.name, uid: row.uid, home, root: row.uid === 0, verified: true };
166
+ }
167
+ function declaredName(env) {
168
+ const value = env.SUDO_USER?.trim() || env.PKEXEC_USER?.trim();
169
+ return value !== void 0 && value.length > 0 ? value : null;
170
+ }
171
+ function sessionName(env) {
172
+ const value = env.LOGNAME?.trim() || env.USER?.trim() || env.USERNAME?.trim();
173
+ return value !== void 0 && value.length > 0 ? value : null;
174
+ }
175
+ function ownerAccount(paths, passwd) {
176
+ for (const path17 of paths ?? []) {
177
+ try {
178
+ const uid = fs2.statSync(path17).uid;
179
+ if (uid === 0)
180
+ continue;
181
+ const row = passwd.find((entry) => entry.uid === uid);
182
+ if (row !== void 0)
183
+ return accountFrom(row);
184
+ } catch {
185
+ }
186
+ }
187
+ return null;
188
+ }
189
+ function accountOf(input) {
190
+ const passwd = input.passwd ?? readPasswd();
191
+ const byUid = (uid) => uid === null ? null : passwd.find((entry) => entry.uid === uid) ?? null;
192
+ const byName = (name2) => name2 === null ? null : passwd.find((entry) => entry.name === name2) ?? null;
193
+ const elevatedUid = numberFrom(input.env.SUDO_UID) ?? numberFrom(input.env.PKEXEC_UID);
194
+ const elevatedName = declaredName(input.env);
195
+ if (elevatedUid !== null || elevatedName !== null) {
196
+ const row = byUid(elevatedUid) ?? byName(elevatedName);
197
+ if (row !== null)
198
+ return accountFrom(row);
199
+ if (elevatedUid !== null && elevatedUid !== 0 && elevatedName !== null)
200
+ return { name: elevatedName, uid: elevatedUid, home: null, root: false, verified: false };
201
+ }
202
+ const euid = input.uid === void 0 ? process.getuid?.() ?? null : input.uid;
203
+ const ownUid = euid ?? numberFrom(input.env.UID);
204
+ const session = sessionName(input.env);
205
+ if (session !== null && session.toLowerCase() !== "root") {
206
+ const sessionRow = byName(session);
207
+ if (sessionRow !== null)
208
+ return accountFrom(sessionRow, input.env.HOME);
209
+ const uidRow = byUid(ownUid === 0 ? null : ownUid);
210
+ if (uidRow !== null)
211
+ return accountFrom(uidRow, input.env.HOME);
212
+ return { name: session, uid: ownUid, home: input.env.HOME?.trim() || null, root: false, verified: false };
213
+ }
214
+ const ownRow = ownUid === 0 ? null : byUid(ownUid);
215
+ if (ownRow !== null)
216
+ return accountFrom(ownRow);
217
+ const owner = ownerAccount(input.ownerPaths, passwd);
218
+ if (owner !== null)
219
+ return owner;
220
+ if (ownUid === 0 || session?.toLowerCase() === "root")
221
+ return { name: "root", uid: 0, home: input.env.HOME?.trim() || "/root", root: true, verified: true };
222
+ return null;
223
+ }
224
+ function currentUser(ctx) {
225
+ const account = accountOf(ctx);
226
+ if (account !== null && !account.root)
227
+ return account.name;
228
+ const value = sessionName(ctx.env);
229
+ return value !== null && value.toLowerCase() !== "root" ? value : null;
230
+ }
231
+ function bootUserName(ctx) {
232
+ const account = accountOf(ctx);
233
+ if (account === null) {
234
+ warnAccount(ctx, "no account could be resolved for the entry, so a system unit would run the panel as root. Install it from the account that should own the panel, or set User= yourself.");
235
+ return null;
236
+ }
237
+ if (account.root) {
238
+ warnAccount(ctx, "the only account available is root; a system unit would run the panel as root. Install it from the account that should own the panel, or set User= yourself.");
239
+ return null;
240
+ }
241
+ if (!isUserName(account.name)) {
242
+ warnAccount(ctx, `the account name ${JSON.stringify(account.name)} is not a usable user name, so no User= line was written`);
221
243
  return null;
222
244
  }
245
+ if (!account.verified) {
246
+ warnAccount(ctx, `the account ${JSON.stringify(account.name)} could not be found in the user database, so no User= line was written: systemd would refuse to start the unit (status=217/USER). Set User= yourself if that account resolves through LDAP or NIS.`);
247
+ return null;
248
+ }
249
+ return account.name;
250
+ }
251
+ function isUserName(name2) {
252
+ return /^[A-Za-z_][A-Za-z0-9._-]*$/.test(name2);
223
253
  }
224
- function userNameOf(ctx) {
225
- const configured = ctx.env.USER?.trim() ?? ctx.env.USERNAME?.trim();
226
- if (configured !== void 0 && configured.length > 0)
227
- return configured;
254
+ function warnAccount(ctx, message) {
228
255
  try {
229
- return os.userInfo().username;
256
+ (ctx.warn ?? ((line) => console.warn(`[dsh-home-hosted] ${line}`)))(message);
230
257
  } catch {
231
- return null;
232
258
  }
233
259
  }
234
260
  function uidOf(ctx) {
235
- const configured = ctx.env.UID?.trim();
236
- if (configured)
237
- return configured;
238
- const uid = process.getuid?.();
239
- return uid === void 0 ? null : String(uid);
261
+ const configured = numberFrom(ctx.env.UID);
262
+ if (configured !== null)
263
+ return String(configured);
264
+ const uid = ctx.uid === void 0 ? process.getuid?.() ?? null : ctx.uid;
265
+ return uid === null ? null : String(uid);
240
266
  }
241
267
  function inspectOwned(file, marker) {
242
268
  if (!fs2.existsSync(file))
@@ -284,6 +310,77 @@ function isInstalledState(state) {
284
310
  return state === "installed-disabled" || state === "enabled-running" || state === "enabled-failing";
285
311
  }
286
312
 
313
+ // src/boot/ladder.ts
314
+ import os2 from "node:os";
315
+ import process3 from "node:process";
316
+
317
+ // src/util/exec.ts
318
+ import { spawn } from "node:child_process";
319
+ async function run(command, args = [], options = {}) {
320
+ const maxBytes = options.maxBytes ?? 256 * 1024;
321
+ const timeoutMs = options.timeoutMs ?? 2e4;
322
+ return await new Promise((resolve) => {
323
+ let settled = false;
324
+ let stdout = "";
325
+ let stderr = "";
326
+ let timedOut = false;
327
+ const finish = (result) => {
328
+ if (settled)
329
+ return;
330
+ settled = true;
331
+ resolve({
332
+ command,
333
+ args,
334
+ code: result.code ?? null,
335
+ signal: result.signal ?? null,
336
+ stdout: result.stdout ?? stdout,
337
+ stderr: result.stderr ?? stderr,
338
+ timedOut,
339
+ error: result.error ?? null
340
+ });
341
+ };
342
+ let child;
343
+ try {
344
+ child = spawn(command, args, {
345
+ cwd: options.cwd,
346
+ env: { ...process.env, ...options.env },
347
+ shell: false,
348
+ windowsHide: true,
349
+ stdio: ["ignore", "pipe", "pipe"]
350
+ });
351
+ } catch (error) {
352
+ finish({ error: error instanceof Error ? error.message : String(error) });
353
+ return;
354
+ }
355
+ const timer = setTimeout(() => {
356
+ timedOut = true;
357
+ child.kill("SIGKILL");
358
+ }, timeoutMs);
359
+ child.stdout?.on("data", (chunk) => {
360
+ if (stdout.length < maxBytes)
361
+ stdout += chunk.toString("utf8").slice(0, maxBytes - stdout.length);
362
+ });
363
+ child.stderr?.on("data", (chunk) => {
364
+ if (stderr.length < maxBytes)
365
+ stderr += chunk.toString("utf8").slice(0, maxBytes - stderr.length);
366
+ });
367
+ child.on("error", (error) => {
368
+ clearTimeout(timer);
369
+ finish({ error: error.message });
370
+ });
371
+ child.on("close", (code, signal) => {
372
+ clearTimeout(timer);
373
+ finish({ code, signal });
374
+ });
375
+ });
376
+ }
377
+ async function sudoAvailable() {
378
+ if (process.getuid?.() === 0)
379
+ return true;
380
+ const result = await run("sudo", ["-n", "true"], { timeoutMs: 5e3 });
381
+ return result.code === 0;
382
+ }
383
+
287
384
  // src/boot/systemd.ts
288
385
  import fs3 from "node:fs";
289
386
 
@@ -306,6 +403,11 @@ function assertMarker(marker) {
306
403
  throw new Error("marker must not be empty");
307
404
  return assertNoControl(marker, "marker");
308
405
  }
406
+ function assertUserName(name2) {
407
+ if (!/^[A-Za-z_][A-Za-z0-9._-]*$/.test(name2))
408
+ throw new Error(`user name ${JSON.stringify(name2)} must match ^[A-Za-z_][A-Za-z0-9._-]*$`);
409
+ return name2;
410
+ }
309
411
  function assertEnvKey(key) {
310
412
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
311
413
  throw new Error(`environment key ${JSON.stringify(key)} is not a valid name`);
@@ -462,6 +564,15 @@ function envLines(spec) {
462
564
  return Object.entries(spec.env).map(([key, value]) => `Environment=${systemdEnvLine(key, value)}`);
463
565
  }
464
566
  var MANAGED = (spec) => `# Managed by ${spec.marker}`;
567
+ function codeOf(result) {
568
+ return result.error ? null : result.code;
569
+ }
570
+ function accountNote(own, ctx) {
571
+ if (!own.owned || own.text === null)
572
+ return "";
573
+ const declared = /^User=(.*)$/m.exec(own.text)?.[1]?.trim() ?? "";
574
+ return declared === "" || declared === "root" ? "; the unit names no other user, so the panel runs as root \u2014 install again from the account that should own it" : "";
575
+ }
465
576
  function systemdUserUnit(spec) {
466
577
  validate(spec);
467
578
  return `${[
@@ -474,6 +585,14 @@ function systemdUserUnit(spec) {
474
585
  "Type=exec",
475
586
  `ExecStart=${[spec.command, ...spec.args].map(systemdExecWord).join(" ")}`,
476
587
  `WorkingDirectory=${systemdPath(spec.cwd, "spec.cwd")}`,
588
+ // A persistent entry survives a panel restart only because its nanny is not
589
+ // the panel. The nanny is spawned `detached`, which gives it its own session —
590
+ // but *not* its own cgroup, so the default `KillMode=control-group` kills it
591
+ // with the panel and takes the entry down too, quietly falsifying what
592
+ // `persistent: true` promises. `process` signals the panel alone. (An ordinary
593
+ // entry is spawned detached as well, so this keeps every supervised server, not
594
+ // only the nannies — the panel re-adopts such a survivor by its port.)
595
+ "KillMode=process",
477
596
  "Restart=always",
478
597
  "RestartSec=5",
479
598
  ...envLines(spec),
@@ -494,10 +613,12 @@ function systemdSystemUnit(spec, user = null) {
494
613
  "StartLimitBurst=5",
495
614
  "",
496
615
  "[Service]",
497
- ...user ? [`User=${user}`] : [],
616
+ ...user === null || user === "" || user === "root" ? [] : [`User=${assertUserName(user)}`],
498
617
  "Type=exec",
499
618
  `ExecStart=${[spec.command, ...spec.args].map(systemdExecWord).join(" ")}`,
500
619
  `WorkingDirectory=${systemdPath(spec.cwd, "spec.cwd")}`,
620
+ // See the user unit: `control-group` would kill the nannies too.
621
+ "KillMode=process",
501
622
  "Restart=always",
502
623
  "RestartSec=5",
503
624
  ...envLines(spec),
@@ -507,9 +628,6 @@ function systemdSystemUnit(spec, user = null) {
507
628
  ""
508
629
  ].join("\n")}`;
509
630
  }
510
- function codeOf(result) {
511
- return result.error ? null : result.code;
512
- }
513
631
  function parseKeyValues(stdout) {
514
632
  const out = {};
515
633
  for (const line of stdout.split("\n")) {
@@ -734,10 +852,6 @@ function createSystemdUserProvider(ctx) {
734
852
  }
735
853
  };
736
854
  }
737
- function systemUserName(ctx) {
738
- const user = currentUser(ctx);
739
- return user && /^[A-Za-z_][A-Za-z0-9._-]*$/.test(user) ? user : null;
740
- }
741
855
  function stageUnit(ctx, unit, content) {
742
856
  const staged = posixJoin(tempDir(ctx), `home-hosted-${unit}`);
743
857
  fs3.writeFileSync(staged, content, { mode: 420 });
@@ -747,7 +861,7 @@ function createSystemdSystemProvider(ctx) {
747
861
  const mechanism = "systemd-system";
748
862
  const unitOf = (spec) => `${assertUnitName(spec.unitName)}.service`;
749
863
  const pathOf = (spec) => posixJoin("/etc", "systemd", "system", unitOf(spec));
750
- const contentOf = (spec) => systemdSystemUnit(spec, ctx.isRoot ? systemUserName(ctx) : null);
864
+ const contentOf = (spec) => systemdSystemUnit(spec, bootUserName(ctx));
751
865
  const installCommands = (staged, file, unit) => [
752
866
  shellCommand("sudo", ["install", "-m", "0644", staged, file]),
753
867
  shellCommand("sudo", ["systemctl", "daemon-reload"]),
@@ -761,12 +875,14 @@ function createSystemdSystemProvider(ctx) {
761
875
  if (!existsOf(ctx, "/run/systemd/system"))
762
876
  return { mechanism, available: false, bootCapable: false, privileged: false, reason: "systemd is not the init system here (no /run/systemd/system)" };
763
877
  const privileged = ctx.isRoot || await ctx.sudo();
878
+ const who = accountOf(ctx);
879
+ const scope = who === null || who.root ? "no account was resolved for the entry, so it would run the panel as root" : `the entry runs the panel as ${who.name}`;
764
880
  return {
765
881
  mechanism,
766
882
  available: privileged,
767
883
  bootCapable: privileged,
768
884
  privileged,
769
- reason: privileged ? "systemd is PID 1 and this process can use root, so a system unit starts at boot" : "systemd is PID 1, but installing a system unit needs root or passwordless sudo"
885
+ reason: privileged ? `systemd is PID 1 and this process can use root, so a system unit starts at boot; ${scope}` : "systemd is PID 1, but installing a system unit needs root or passwordless sudo"
770
886
  };
771
887
  },
772
888
  async status(spec) {
@@ -786,7 +902,7 @@ function createSystemdSystemProvider(ctx) {
786
902
  return {
787
903
  state,
788
904
  unitPath: file,
789
- detail: `${probe.detail}${privileged ? "" : "; needs root to change"}`,
905
+ detail: `${probe.detail}${privileged ? "" : "; needs root to change"}${accountNote(own, ctx)}`,
790
906
  commands
791
907
  };
792
908
  } catch (error) {
@@ -983,7 +1099,8 @@ function launchdLabel(spec) {
983
1099
  function launchdPlist(spec, options = {}) {
984
1100
  validate2(spec);
985
1101
  const label = launchdLabel(spec);
986
- const userName = options.userName?.trim();
1102
+ const named = options.userName?.trim();
1103
+ const userName = named === void 0 || named.length === 0 ? void 0 : assertUserName(named);
987
1104
  const out = path3.posix.join(spec.logDir, `${label}.out.log`);
988
1105
  const err = path3.posix.join(spec.logDir, `${label}.err.log`);
989
1106
  const envKeys = Object.keys(spec.env);
@@ -1127,7 +1244,7 @@ function createLaunchdProvider(ctx, mode) {
1127
1244
  validate2(spec);
1128
1245
  const file = pathOf(spec);
1129
1246
  const label = launchdLabel(spec);
1130
- const content = launchdPlist(spec, mode === "daemon" ? { userName: userNameOf(ctx) } : {});
1247
+ const content = launchdPlist(spec, mode === "daemon" ? { userName: bootUserName(ctx) } : {});
1131
1248
  const own = inspectOwned(file, spec.marker);
1132
1249
  if (own.exists && !own.owned)
1133
1250
  return failed(own.reason ?? "foreign file");
@@ -2025,7 +2142,8 @@ function bootProviders(ctx, platform = ctx.platform) {
2025
2142
  }
2026
2143
  function createBootLadder(options = {}) {
2027
2144
  const platform = options.platform ?? process3.platform;
2028
- const isRoot = process3.getuid?.() === 0;
2145
+ const uid = options.uid === void 0 ? process3.getuid?.() ?? null : options.uid;
2146
+ const isRoot = uid === 0;
2029
2147
  const ctx = {
2030
2148
  platform,
2031
2149
  home: options.home ?? os2.homedir(),
@@ -2033,6 +2151,10 @@ function createBootLadder(options = {}) {
2033
2151
  run: options.run ?? run,
2034
2152
  sudo: options.sudo ?? (async () => isRoot ? true : await sudoAvailable()),
2035
2153
  isRoot,
2154
+ uid: options.uid,
2155
+ ownerPaths: options.ownerPaths,
2156
+ passwd: options.passwd,
2157
+ warn: options.warn,
2036
2158
  exists: options.exists
2037
2159
  };
2038
2160
  const providers = bootProviders(ctx, platform);
@@ -2525,6 +2647,13 @@ function homeHostedEnv(facts, launch, extra = {}) {
2525
2647
  HHOSTED_HOME: facts.home,
2526
2648
  ...extra
2527
2649
  };
2650
+ const accountHome = facts.accountHome?.trim();
2651
+ if (accountHome !== void 0 && accountHome.length > 0) {
2652
+ if (accountHome !== env.HOME)
2653
+ env.HOME = accountHome;
2654
+ } else if (facts.accountHome === null) {
2655
+ delete env.HOME;
2656
+ }
2528
2657
  if (facts.projectDir)
2529
2658
  env.HHOSTED_PROJECT = facts.projectDir;
2530
2659
  return env;
@@ -2539,7 +2668,16 @@ function buildHomeHostedBootSpec(facts, launch, options) {
2539
2668
  command: launch.program,
2540
2669
  args,
2541
2670
  cwd: facts.projectDir ?? facts.home,
2542
- env: homeHostedEnv(facts, launch),
2671
+ env: homeHostedEnv(
2672
+ {
2673
+ ...facts,
2674
+ // `undefined` means the option was not given; `null` is a decision (an
2675
+ // account whose home is unknowable), and `??` would erase it.
2676
+ accountHome: options.accountHome === void 0 ? facts.accountHome : options.accountHome,
2677
+ env: options.env ?? facts.env
2678
+ },
2679
+ launch
2680
+ ),
2543
2681
  marker: options.marker ?? "managed by dsh-home-hosted",
2544
2682
  // One artifact per state root, so two plugin instances cannot overwrite or
2545
2683
  // delete each other's entry. The machine-default install keeps the old name.
@@ -5006,10 +5144,34 @@ var HomeHostedService = class extends Service {
5006
5144
  // -------------------------------------------------------------------------
5007
5145
  // Boot autostart
5008
5146
  // -------------------------------------------------------------------------
5147
+ /**
5148
+ * Everything account resolution reads.
5149
+ *
5150
+ * The state paths matter: they name the person the panel belongs to, so a root
5151
+ * process launched at boot (a system unit that names no `User=`) can still be
5152
+ * told whose panel it is running.
5153
+ */
5154
+ /** This process's environment, from the test seam when one is supplied. */
5155
+ processEnv() {
5156
+ return this.options.env ?? process10.env;
5157
+ }
5158
+ accountFacts() {
5159
+ return {
5160
+ env: this.processEnv(),
5161
+ uid: this.options.uid,
5162
+ ownerPaths: [this.options.home, this.options.stateDir],
5163
+ passwd: this.options.passwd
5164
+ };
5165
+ }
5166
+ /**
5167
+ * The ladder resolves the account per provider, and the spec below resolves it
5168
+ * for the entry's environment. Both are handed the *same* facts, so the `User=`
5169
+ * line and the `HOME=` line can never describe two different people.
5170
+ */
5009
5171
  ladder() {
5010
5172
  if (this.options.createLadder !== void 0)
5011
5173
  return this.options.createLadder();
5012
- return createBootLadder({ env: process10.env });
5174
+ return createBootLadder(this.accountFacts());
5013
5175
  }
5014
5176
  async bootSpec() {
5015
5177
  const { resolution, launcher } = await this.cli();
@@ -5017,6 +5179,7 @@ var HomeHostedService = class extends Service {
5017
5179
  if (launch === null)
5018
5180
  return null;
5019
5181
  const runtime = this.runtime();
5182
+ const account = accountOf(this.accountFacts());
5020
5183
  const spec = buildHomeHostedBootSpec(
5021
5184
  {
5022
5185
  home: this.options.home,
@@ -5024,7 +5187,16 @@ var HomeHostedService = class extends Service {
5024
5187
  version: runtime?.version ?? null
5025
5188
  },
5026
5189
  launch,
5027
- { stateDir: this.options.stateDir, unitName: bootUnitName(this.options.stateDir) }
5190
+ {
5191
+ stateDir: this.options.stateDir,
5192
+ unitName: bootUnitName(this.options.stateDir),
5193
+ // `undefined` when no account was resolved: the entry then runs as this
5194
+ // process (or as root), and this process's own HOME is the right one. A
5195
+ // spec that merely repeats this environment stays byte-identical, so
5196
+ // reconcile does not rewrite the entry on every start.
5197
+ accountHome: account === null ? void 0 : account.home,
5198
+ env: this.processEnv()
5199
+ }
5028
5200
  );
5029
5201
  if (launcher !== null) {
5030
5202
  const cliArgs2 = spec.args.slice(launch.args.length);