@edgehero/pi-dispatch 1.10.2 → 2.0.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.
Files changed (97) hide show
  1. package/.env.example +300 -148
  2. package/README.md +50 -0
  3. package/deploy/com.pi-dispatch.worker.plist +9 -3
  4. package/deploy/docker-compose.yml +49 -16
  5. package/deploy/egress-proxy.conf +32 -2
  6. package/deploy/nssm-install.cmd +12 -6
  7. package/deploy/pi-dispatch-egress-out.network +10 -0
  8. package/deploy/pi-dispatch-egress-proxy.container +50 -0
  9. package/deploy/pi-dispatch-netns-keeper.container +80 -0
  10. package/deploy/pi-dispatch-netns-keeper.network +18 -0
  11. package/deploy/pi-dispatch-valkey.container +51 -0
  12. package/deploy/pi-dispatch-valkey.network +16 -0
  13. package/deploy/receiver.service +6 -0
  14. package/deploy/worker-env-wrapper.cmd +11 -0
  15. package/deploy/worker-env-wrapper.sh +60 -34
  16. package/deploy/worker.service +18 -8
  17. package/package.json +14 -4
  18. package/src/azure-host.mjs +19 -0
  19. package/src/azure-identity.mjs +18 -2
  20. package/src/backend-conformance.mjs +71 -18
  21. package/src/backend-local.mjs +637 -21
  22. package/src/backend-podman.mjs +1168 -0
  23. package/src/backend-registry.mjs +86 -3
  24. package/src/backends.mjs +489 -37
  25. package/src/branch.mjs +7 -2
  26. package/src/cancel-cli.mjs +174 -0
  27. package/src/cancel-state.mjs +125 -0
  28. package/src/cli.mjs +188 -90
  29. package/src/config.mjs +503 -43
  30. package/src/connection.mjs +374 -8
  31. package/src/container-spec.mjs +102 -7
  32. package/src/daemon-facts.mjs +167 -0
  33. package/src/deployment-venue.mjs +158 -0
  34. package/src/docker-run.mjs +146 -15
  35. package/src/doctor.mjs +4701 -414
  36. package/src/egress-conf-copy.mjs +166 -0
  37. package/src/egress-proxy-state.mjs +151 -0
  38. package/src/egress.mjs +455 -25
  39. package/src/entry.mjs +27 -0
  40. package/src/env-allowlist.mjs +222 -40
  41. package/src/env-file.mjs +1869 -33
  42. package/src/exit-code.mjs +15 -0
  43. package/src/flow-gate.mjs +5 -3
  44. package/src/forgejo-host.mjs +19 -0
  45. package/src/forgejo-identity.mjs +21 -2
  46. package/src/get-token.mjs +67 -18
  47. package/src/git-dirty.mjs +9 -1
  48. package/src/git-hardening.mjs +33 -0
  49. package/src/github-app-setup.mjs +29 -12
  50. package/src/github-prompt.mjs +4 -1
  51. package/src/gitlab-host.mjs +19 -0
  52. package/src/gitlab-identity.mjs +19 -2
  53. package/src/host-registry.mjs +32 -5
  54. package/src/identity.mjs +29 -4
  55. package/src/image-preflight.mjs +46 -11
  56. package/src/image-ref.mjs +21 -0
  57. package/src/index.mjs +387 -17
  58. package/src/init.mjs +197 -38
  59. package/src/job-user.mjs +252 -0
  60. package/src/json-duplicates.mjs +204 -0
  61. package/src/live-probes.mjs +1020 -0
  62. package/src/materialize.mjs +4 -11
  63. package/src/netns-keeper.mjs +264 -0
  64. package/src/on-failure.mjs +119 -0
  65. package/src/outbox.mjs +7 -0
  66. package/src/podman-stack.mjs +1304 -0
  67. package/src/prepare-github.mjs +6 -6
  68. package/src/prepare-local.mjs +51 -17
  69. package/src/prepare.mjs +27 -6
  70. package/src/processor.mjs +505 -26
  71. package/src/provider-key.mjs +41 -0
  72. package/src/provider-steering.mjs +144 -0
  73. package/src/queue.mjs +35 -8
  74. package/src/redact.mjs +84 -0
  75. package/src/reserved-env.mjs +7 -3
  76. package/src/retention-sweep.mjs +178 -0
  77. package/src/run-container.mjs +181 -14
  78. package/src/run-history.mjs +105 -16
  79. package/src/runtime-observations.mjs +1152 -0
  80. package/src/runtime-settings.mjs +13 -8
  81. package/src/sandbox-cli.mjs +100 -95
  82. package/src/sandbox-store.mjs +612 -45
  83. package/src/sandbox.mjs +1459 -37
  84. package/src/schedules.mjs +16 -3
  85. package/src/secret-profiles.mjs +2 -1
  86. package/src/secrets.mjs +23 -6
  87. package/src/service-env.mjs +247 -0
  88. package/src/service.mjs +618 -28
  89. package/src/session-store.mjs +678 -53
  90. package/src/start.mjs +1395 -268
  91. package/src/transient.mjs +240 -0
  92. package/src/triggers-file.mjs +71 -15
  93. package/src/triggers.mjs +176 -19
  94. package/src/up.mjs +1399 -85
  95. package/src/valkey-auth.mjs +529 -0
  96. package/src/valkey-endpoint.mjs +367 -0
  97. package/src/watch-closer.mjs +158 -0
package/src/config.mjs CHANGED
@@ -5,14 +5,16 @@
5
5
  * Errors are tagged `piDispatchConfig` so the CLI/entry can print them cleanly and exit non-zero.
6
6
  */
7
7
 
8
- import { existsSync } from "node:fs";
9
- import { hostname } from "node:os";
10
- import { delimiter } from "node:path";
8
+ import { chmodSync, existsSync, lstatSync, mkdirSync, realpathSync, statSync } from "node:fs";
9
+ import { homedir, hostname, tmpdir } from "node:os";
10
+ import { delimiter, isAbsolute, posix } from "node:path";
11
11
  import { DEFAULT_BACKEND, backendRefusals, parseBackendFloor, parseBackendList } from "./backends.mjs";
12
- import { DEFAULT_EGRESS_PROXY, egressArmed } from "./egress.mjs";
12
+ import { egressArmed, egressProxyName } from "./egress.mjs";
13
13
  import { MINTED_TOKEN_VARS } from "./forges.mjs";
14
+ import { SWEEP_INTERVAL_HOURS, SWEEP_INTERVAL_MAX_HOURS } from "./retention-sweep.mjs";
14
15
  import { parseSecretProfiles } from "./secret-profiles.mjs";
15
16
  import { WAIT_AFTER_MAX_DEFAULT_MS, WAIT_INTERVAL_FLOOR_MS, parseWaitProfiles } from "./wait-for.mjs";
17
+ import { imageRefProblem } from "./image-ref.mjs";
16
18
 
17
19
  export function configError(message) {
18
20
  const error = new Error(message);
@@ -27,14 +29,30 @@ export function configError(message) {
27
29
  export const CHAIN_DEPTH_MAX_DEFAULT = 1; // DES-JOB-OUTBOX-CHAINING; 0 = chaining kill-switch (fail-closed)
28
30
  export const CHAIN_MAX_PER_JOB_DEFAULT = 2; // INT-OUTBOX-CONTRACT: max request-<n>.json collected per parent
29
31
 
30
- function boundedInt(env, name, fallback, min, want) {
32
+ // The failure hook's command (issue #288). Unset/blank -> null (the feature is off). Set -> one
33
+ // ABSOLUTE path, verbatim; a relative path refuses at boot, because resolving it against a service
34
+ // manager's working directory would make the hook fire or vanish depending on who started the worker.
35
+ // `isAbsolute` is the PLATFORM's: on Windows it accepts `C:\...` where the posix one would refuse it,
36
+ // and that half is only testable on Windows itself -- the same only-runnable-there caveat this file
37
+ // already records for the drive-letter split below.
38
+ function parseOnFailure(raw) {
39
+ if (raw === undefined || raw === "") return null;
40
+ if (typeof raw !== "string" || !isAbsolute(raw)) {
41
+ throw configError(`invalid PI_ON_FAILURE: ${JSON.stringify(raw)} (want an absolute path to one executable)`);
42
+ }
43
+ return raw;
44
+ }
45
+
46
+ // `max` is optional and defaults to no upper bound, which is the convention `optionalBoundedInt` below
47
+ // already documents -- so every existing caller is unchanged by its arrival.
48
+ function boundedInt(env, name, fallback, min, want, max = undefined) {
31
49
  const raw = env[name];
32
50
  if (raw === undefined || raw === "") {
33
51
  if (fallback !== undefined) return fallback;
34
52
  throw configError(`missing required env: ${name}`);
35
53
  }
36
54
  const n = Number.parseInt(raw, 10);
37
- if (!Number.isInteger(n) || n < min || String(n) !== String(raw).trim()) {
55
+ if (!Number.isInteger(n) || n < min || (max !== undefined && n > max) || String(n) !== String(raw).trim()) {
38
56
  throw configError(`invalid ${name}: ${JSON.stringify(raw)} (want ${want})`);
39
57
  }
40
58
  return n;
@@ -45,8 +63,8 @@ export function positiveInt(env, name, fallback) {
45
63
  }
46
64
 
47
65
  // min=0: accepts 0 (a sentinel, e.g. "keep forever" for log retention), still rejects negatives and non-integers.
48
- function nonNegativeInt(env, name, fallback) {
49
- return boundedInt(env, name, fallback, 0, "a non-negative integer");
66
+ function nonNegativeInt(env, name, fallback, max = undefined) {
67
+ return boundedInt(env, name, fallback, 0, max === undefined ? "a non-negative integer" : `an integer 0-${max}`, max);
50
68
  }
51
69
 
52
70
  // An OPTIONAL bounded int: an unset or empty var is `null` (the feature it gates is disabled), a present
@@ -107,10 +125,21 @@ function commaList(raw) {
107
125
  * `GITHUB_APP_PRIVATE_KEY_PATH` is deliberately NOT here: a path string with no mount behind it is inert
108
126
  * inside a container, and refusing harmless things is how a refusal stops being read.
109
127
  *
128
+ * `VALKEY_PASSWORD` (issue #468) is the queue's password. Whoever holds it can read every queued job (its task text,
129
+ * its repository), enqueue work this deployment's worker runs with its provider key and forge credentials, and delete
130
+ * the queue: the very thing the password was added to keep from another account on the host, and so no more a job
131
+ * container's than theirs.
132
+ *
110
133
  * Kept separate from `MINTED_TOKEN_VARS`, which is defined as "every name any forge's mint can write"
111
134
  * and derived from the forge table. This is not that, and folding it in would make that definition a lie.
112
135
  */
113
- export const WORKER_ONLY_SECRET_VARS = new Set(["GITHUB_APP_PRIVATE_KEY"]);
136
+ export const WORKER_ONLY_SECRET_VARS = new Set(["GITHUB_APP_PRIVATE_KEY", "VALKEY_PASSWORD"]);
137
+
138
+ /** Why each of `WORKER_ONLY_SECRET_VARS` stays in the worker, for the refusal that names it. */
139
+ const WORKER_ONLY_WHY = Object.freeze({
140
+ GITHUB_APP_PRIVATE_KEY: "the App's signing key mints tokens for every repository the App is installed on",
141
+ VALKEY_PASSWORD: "the queue's password lets whoever holds it read, enqueue and delete this deployment's jobs",
142
+ });
114
143
 
115
144
  /**
116
145
  * The proxy variables the egress policy writes into the closed container env (REQ-EGRESS-ALLOWLIST).
@@ -133,7 +162,7 @@ function forwardEnvList(raw, egressArmed = false) {
133
162
  const workerOnly = names.filter((n) => WORKER_ONLY_SECRET_VARS.has(n));
134
163
  if (workerOnly.length > 0) {
135
164
  throw configError(
136
- `PI_FORWARD_ENV must not forward ${workerOnly.join(", ")} -- the App's signing key mints tokens for every repository the App is installed on, and a job container is the last place it belongs (CONST-TOKEN-SCOPED-PER-JOB)`,
165
+ `PI_FORWARD_ENV must not forward ${workerOnly.join(", ")} -- ${workerOnly.map((n) => WORKER_ONLY_WHY[n]).join("; ")}, and a job container is the last place it belongs (CONST-TOKEN-SCOPED-PER-JOB)`,
137
166
  );
138
167
  }
139
168
  const egress = egressArmed ? names.filter((n) => EGRESS_ENV_VARS.has(n)) : [];
@@ -200,6 +229,19 @@ function refuseBackendShortfall(config) {
200
229
  if (first) throw configError(first);
201
230
  }
202
231
 
232
+ /**
233
+ * PI_JOB_IMAGE as the worker runs it (issue #471): unset or empty is pi-job:latest (`||`, so "" falls back), and any
234
+ * other value is judged by the one image rule `run.image` is (`image-ref.mjs`). A refused value is a config error at
235
+ * boot (exit 2), naming the key: before #471 a dash-leading value booted, and every job then handed the runtime a flag
236
+ * where the image belongs, after its budget slot was reserved.
237
+ */
238
+ export function jobImageFrom(env) {
239
+ const image = env.PI_JOB_IMAGE || "pi-job:latest";
240
+ const problem = imageRefProblem(image);
241
+ if (problem) throw configError(`PI_JOB_IMAGE ${problem.reason} (got ${JSON.stringify(image)})`);
242
+ return image;
243
+ }
244
+
203
245
  // The operator's global pi overlay dir (REQ-GLOBAL-PI-OVERLAY). Unset/empty = feature off. When set it
204
246
  // must EXIST at boot -- a typo pointing at nothing would silently drop the operator's whole setup on
205
247
  // every job, so fail loud like every other config error rather than degrade to nothing.
@@ -278,7 +320,7 @@ export function loadConfig(env = process.env, { fileExists = existsSync } = {})
278
320
  maxTurns: positiveInt(env, "PI_MAX_TURNS", 30), // pi has no turn limit; we impose one
279
321
  maxTokens: optionalBoundedInt(env, "PI_MAX_TOKENS", 1), // issue #25; null = per-job token budget disabled (lagging in-run backstop)
280
322
  dailyTokenCap: optionalBoundedInt(env, "PI_DAILY_TOKEN_CAP", 1), // issue #25; null = daily token counter disabled (check-AFTER, host-side)
281
- jobImage: env.PI_JOB_IMAGE || "pi-job:latest", // || (not ??) so an empty string falls back; "" is falsy and would throw inside buildDockerRunArgs AFTER a budget slot was reserved
323
+ jobImage: jobImageFrom(env), // || (not ??) so an empty string falls back; "" is falsy and would throw inside buildDockerRunArgs AFTER a budget slot was reserved
282
324
  globalPiDir: resolveGlobalPiDir(env, fileExists), // REQ-GLOBAL-PI-OVERLAY: operator's ~/.pi/agent subset, :ro-mounted; null = off
283
325
  allowGlobalExtensions: globalExtensionsEnabled(env), // REQ-GLOBAL-PI-OVERLAY: ON unless PI_GLOBAL_ALLOW_EXTENSIONS=0
284
326
  // REQ-EGRESS-ALLOWLIST. `egress` gates the whole feature; `egressProxy` names the component the
@@ -294,10 +336,10 @@ export function loadConfig(env = process.env, { fileExists = existsSync } = {})
294
336
  // trigger says otherwise". `parseBackendList` never returns empty, so the fallback is belt-and-braces
295
337
  // against a future edit rather than a reachable branch today.
296
338
  defaultBackend: backends[0] ?? DEFAULT_BACKEND,
297
- egressProxy: env.PI_EGRESS_PROXY || DEFAULT_EGRESS_PROXY, // || (not ??) so an empty string falls back
339
+ egressProxy: egressProxyName(env), // one derivation, shared with the panel's sandbox (#277)
298
340
  forwardEnv: forwardEnvList(env.PI_FORWARD_ENV, egressEnabled(env)), // extra host var NAMES to forward (e.g. a custom provider's key); explicit allowlist, GitHub token names refused
299
341
  authFromPi: env.PI_AUTH_FROM_PI !== "0", // ON by default: use the key in ~/.pi/agent/auth.json when the env has none (api-key only). PI_AUTH_FROM_PI=0 forces env-only.
300
- jobsDir: env.PI_JOBS_DIR ?? defaultJobsDir(),
342
+ jobsDir: jobsDirPath(env),
301
343
  // REQ-RESURRECTABLE-SANDBOX. `||` (not `??`) so an empty string falls back, matching logsDir.
302
344
  sandboxDir: env.PI_SANDBOX_DIR || defaultSandboxDir(env),
303
345
  // Hours a finished run's directory stays re-openable. NOTE THE SENTINEL, which is the OPPOSITE of
@@ -309,18 +351,26 @@ export function loadConfig(env = process.env, { fileExists = existsSync } = {})
309
351
  sandboxPinDays: nonNegativeInt(env, "PI_SANDBOX_PIN_DAYS", 7), // `--pin` extends to now + this, never to forever
310
352
  sandboxIdleMinutes: nonNegativeInt(env, "PI_SANDBOX_IDLE_MINUTES", 30), // bash's own TMOUT inside a sandbox; 0 = no idle logout
311
353
  triggersFile: env.PI_TRIGGERS_FILE ?? null, // DES-CRON-VIA-BULLMQ-SCHEDULER: unified triggers file; null = cron disabled for the worker (it selects on.type:"cron")
312
- pauseWindowsFile: env.PI_PAUSE_WINDOWS_FILE ?? null, // REQ-SCOPED-PAUSE-WINDOWS: per-folder/repo timed pause; null = no scoped pauses
313
- scopedLimitsFile: env.PI_SCOPED_LIMITS_FILE ?? null, // issue #242: per-scope run caps + concurrency (INT-SCOPED-LIMITS-FILE-CONTRACT); null = none. The one-job-per-folder mutex for local jobs is code, not configuration, and holds regardless
354
+ pauseWindowsFile: pauseWindowsFilePath(env), // REQ-SCOPED-PAUSE-WINDOWS: per-folder/repo timed pause; null = no scoped pauses
355
+ scopedLimitsFile: scopedLimitsFilePath(env), // issue #242: per-scope run caps + concurrency (INT-SCOPED-LIMITS-FILE-CONTRACT); null = none. The one-job-per-folder mutex for local jobs is code, not configuration, and holds regardless
314
356
  schedulerStallMax: positiveInt(env, "PI_SCHEDULER_STALL_MAX", 2), // CONST-RETRY-INFRA-ONLY: per-scheduler stall backstop; positiveInt rejects <1 so a 0 threshold fails closed
315
- logsDir: env.PI_LOGS_DIR || defaultLogsDir(), // || (not ??) so an empty string falls back to the default
316
- settingsFile: env.PI_SETTINGS_FILE || defaultSettingsFile(), // || (not ??) so an empty string falls back; INT-CONFIG-OVERLAY-CONTRACT
357
+ logsDir: logsDirPath(env), // || (not ??) inside logsDirPath, so an empty string falls back to the default
358
+ settingsFile: settingsFilePath(env), // || (not ??) inside settingsFilePath, so an empty string falls back; INT-CONFIG-OVERLAY-CONTRACT
317
359
  captureJobLogs: env.PI_CAPTURE_JOB_LOGS === "1", // no-pii-in-logs: raw job-log capture is opt-in; anything but "1" is off
318
360
  logRetentionDays: nonNegativeInt(env, "PI_LOG_RETENTION_DAYS", 30), // 0 = keep forever
319
- // REQ-RESUMABLE-SESSION. NO DEFAULT, deliberately unlike logsDir/jobsDir: unset means the feature
320
- // is unavailable, and a trigger that armed run.resume then refuses PRE-SPEND rather than running
321
- // silently without persistence. A transcript is the most PII-bearing artifact this system holds --
322
- // tool output, file contents, the agent's own reasoning -- and defaulting it into <OS temp>, which
323
- // is mode 1777 on POSIX, is not a place to put that by accident.
361
+ // issue #292, OQ-007: how often the three host-side retention sweeps re-run while the worker is up.
362
+ // 0 = BOOT-ONLY, which is byte-identical to every version before this one -- start.mjs does not even
363
+ // CONSTRUCT the sweep at 0, so no timer, no closer and no second invocation of any reaper is reachable.
364
+ // The ceiling is enforced because setInterval clamps a delay past 2^31-1 ms to 1ms (see
365
+ // SWEEP_INTERVAL_MAX_HOURS); a hot loop over the filesystem is a worse failure than a slow sweep.
366
+ sweepIntervalHours: nonNegativeInt(env, "PI_SWEEP_INTERVAL_HOURS", SWEEP_INTERVAL_HOURS, SWEEP_INTERVAL_MAX_HOURS),
367
+ // REQ-RESUMABLE-SESSION. NO DEFAULT AT ALL, deliberately unlike every sibling above: unset means the
368
+ // feature is unavailable, and a trigger that armed run.resume then refuses PRE-SPEND rather than
369
+ // running silently without persistence. A transcript is the most PII-bearing artifact this system
370
+ // holds -- tool output, file contents, the agent's own reasoning -- and a DEFAULT would turn that
371
+ // refusal into a silent success on a path nobody chose. That reasoning stands on its own and does
372
+ // not rest on where the other stores default: issue #290 moved logs and settings to ~/.pi-dispatch
373
+ // and this one still has no default, for the same reason it never did.
324
374
  sessionsDir: env.PI_SESSIONS_DIR || null,
325
375
  sessionsTtlDays: nonNegativeInt(env, "PI_SESSIONS_TTL_DAYS", 14), // 0 = keep forever
326
376
  // A bound on how large a transcript may be before it stops being resumed. Not disk hygiene: an
@@ -395,6 +445,16 @@ export function loadConfig(env = process.env, { fileExists = existsSync } = {})
395
445
  // broken check loud in minutes instead of silent for a day (OQ-027: most CLIs exit 1 for everything).
396
446
  waitMaxChecks: positiveInt(env, "PI_WAIT_MAX_CHECKS", 96),
397
447
  waitMaxFaults: positiveInt(env, "PI_WAIT_MAX_FAULTS", 5),
448
+ // Issue #288, the operator's failure hook: ONE command, exec'd with id-only argv when a paid job
449
+ // reaches a terminal failure. Unset = off, byte-identically. Set = must be an ABSOLUTE path,
450
+ // refused at boot otherwise (parseWaitProfiles' fail-loud posture, and sharper here: a silently
451
+ // dropped hook is a notification the operator believes is wired, discovered at the failure it was
452
+ // wired for). Existence/executability are probed at FIRE time, not boot, so a script installed
453
+ // mid-day works and one deleted mid-day logs `unresolvable` rather than pretending.
454
+ onFailure: parseOnFailure(env.PI_ON_FAILURE),
455
+ // Bounds a LEAKED CHILD after the job, not a pre-spend wait -- which is why this differs from its
456
+ // two 10s twins in what it protects: nothing here holds a slot or delays a container.
457
+ onFailureTimeoutMs: positiveInt(env, "PI_ON_FAILURE_TIMEOUT_MS", 10000),
398
458
  github: { ...loadGitHubAuth(env, fileExists), allowGhResume: env.PI_SESSIONS_ALLOW_GH_SOURCE === "1" },
399
459
  gitlab: loadGitLabAuth(env),
400
460
  forgejo: loadForgejoAuth(env),
@@ -498,28 +558,433 @@ export function loadGitHubAuth(env, fileExists) {
498
558
  return { source, patVar, appId, installationId, privateKeyPath, privateKey };
499
559
  }
500
560
 
501
- // env-internal TMPDIR, TEMP: the OS temp dir, read to place the default job, log, graph and settings
502
- // paths below. Not a variable of this project's and not a deployment knob: PI_JOBS_DIR, PI_LOGS_DIR,
503
- // PI_GRAPH_DIR and PI_SETTINGS_FILE are how an operator moves any of them, and .env.example says so.
504
- function defaultJobsDir() {
505
- // Under the OS temp dir by default. Holds only the read-only /job inputs (prompt + .pi/); the
561
+ // env-internal TMPDIR, TEMP: the OS temp dir, read to place the default job, sandbox and graph paths
562
+ // below -- and, only on a host with no home directory to name, as the last-resort fallback for the
563
+ // durable state root. Not a variable of this project's and not a deployment knob: PI_JOBS_DIR,
564
+ // PI_LOGS_DIR, PI_GRAPH_DIR and PI_SETTINGS_FILE are how an operator moves any of them, and
565
+ // .env.example says so.
566
+
567
+ /** This process's effective uid, the one its files are created as, or null where the platform has none (Windows). */
568
+ function currentUid() {
569
+ return typeof process.geteuid === "function" ? process.geteuid() : null;
570
+ }
571
+
572
+ /**
573
+ * The per-account root under the OS temp dir that the default jobs, sandbox and graph paths live in (issue #464):
574
+ * `<tmp>/pi-dispatch-<uid>`, or `<tmp>/pi-dispatch` where the platform has no uid (Windows, whose TEMP is per user).
575
+ *
576
+ * Per ACCOUNT because the OS temp dir is shared by every account on a host. The old `<tmp>/pi-dispatch` was created by
577
+ * whichever account ran a job first (mode 775 or 755 under that account), and every other account's jobs then failed
578
+ * `EACCES` at `mkdtemp`, their worker service included, while doctor said ready (measured on Fedora 44 and Ubuntu
579
+ * 24.04). Not `$XDG_RUNTIME_DIR`: that is a small tmpfs (10% of RAM by default) that a retained sandbox, a whole repo
580
+ * clone, would fill, it is removed at the account's last logout without linger, and a shell reached through `sudo -iu`
581
+ * has none, so the worker and doctor could disagree about where the jobs are. `ensureAccountTempRoot` creates this
582
+ * directory 0700 and refuses one this account does not own, since anyone can create a name under a sticky /tmp first.
583
+ */
584
+ export function accountTempRoot(env = process.env, uid = currentUid()) {
585
+ const suffix = Number.isInteger(uid) && uid >= 0 ? `-${uid}` : "";
586
+ return `${tempRoot(env)}/pi-dispatch${suffix}`;
587
+ }
588
+
589
+ function defaultJobsDir(env = process.env, uid = currentUid()) {
590
+ // Under the OS temp dir by default, and it STAYS there (issue #290 moved only the two durable
591
+ // stores). Holds only the read-only /job inputs (prompt + .pi/), rebuilt from scratch every job; the
506
592
  // workspace for a local job is the operator's own folder, not here.
507
- return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/jobs`.replace(/\\/g, "/");
593
+ //
594
+ // Takes `env` because its caller `defaultSandboxDir` advertises one and could not honour it while
595
+ // this function read `process.env` directly -- an injected TMPDIR was silently ignored one frame in.
596
+ return `${accountTempRoot(env, uid)}/jobs`;
597
+ }
598
+
599
+ /**
600
+ * The per-job directory root, ONE derivation (issue #278). `doctor --live` builds its fixture here, and a probe that
601
+ * derived the path on its own could disagree with the worker about `PI_JOBS_DIR=""` (kept as given, `??` not `||`,
602
+ * as `loadConfig` always has) or an injected `TMPDIR`, and read back a directory no job uses. `loadConfig` read
603
+ * `defaultJobsDir()` without its `env` before this, which ignored an injected TMPDIR exactly as `defaultSandboxDir`'s
604
+ * comment below records for itself; identical on the real path, where env is process.env.
605
+ */
606
+ export function jobsDirPath(env = process.env, uid = currentUid()) {
607
+ return env.PI_JOBS_DIR ?? defaultJobsDir(env, uid);
608
+ }
609
+
610
+ /**
611
+ * The jobs dir, made ready for this account's jobs, or a thrown Error saying why it cannot be (issue #464). A jobs dir
612
+ * inside the per-account root (`accountTempRoot`, where the default lives) secures that root first; then the jobs dir
613
+ * is created (0700 where it is new) and must be a directory this account owns. A `PI_JOBS_DIR` elsewhere that another
614
+ * account owns is refused too: the owner of the directory holding a job's inputs can rename them and put its own in
615
+ * their place. `fs` is a seam; with no uid (Windows) nothing is checked and the dir is created as before. A refusal is a
616
+ * `configError` (determinate: the next try meets the same owner); a failed mkdir is thrown as the fs error it is.
617
+ */
618
+ export function ensureJobsDir(jobsDir, { env = process.env, uid = currentUid(), fs = { mkdirSync, lstatSync, statSync, chmodSync } } = {}) {
619
+ ensureUnderAccountRoot(jobsDir, { env, uid, fs });
620
+ fs.mkdirSync(jobsDir, { recursive: true, mode: 0o700 });
621
+ if (!Number.isInteger(uid)) return;
622
+ const st = fs.statSync(jobsDir);
623
+ if (!st.isDirectory()) throw configError(`the jobs dir ${jobsDir} is not a directory`);
624
+ if (st.uid !== uid) throw configError(`the jobs dir ${jobsDir} is owned by uid ${st.uid}, not by this account (uid ${uid}), and that account could replace a job's inputs: ${jobsDirOwnerFix(jobsDir, false)}`);
625
+ }
626
+
627
+ /**
628
+ * The sandbox dir (retained workspaces), checked at boot as the jobs dir is (issue #464, gate round 1): inside the
629
+ * per-account root that root is secured first; a sandbox dir that exists must be a directory this account owns. Not
630
+ * created here: the retention step makes it 0700 when it first keeps a run (`retainJobDir`), and asks the owner again
631
+ * then. The owner of the directory holding a retained workspace can rename it and plant a manifest of its own in its
632
+ * place, which `pi-dispatch sandbox` then lists and opens (measured: an explicit PI_SANDBOX_DIR another account made
633
+ * 0777 was used, and that account swapped the entry). A `configError`, since a restart meets the same owner.
634
+ */
635
+ export function ensureSandboxDir(sandboxDir, { env = process.env, uid = currentUid(), fs = { mkdirSync, lstatSync, statSync, chmodSync } } = {}) {
636
+ ensureUnderAccountRoot(sandboxDir, { env, uid, fs });
637
+ if (!Number.isInteger(uid)) return;
638
+ let st;
639
+ try {
640
+ st = fs.statSync(sandboxDir);
641
+ } catch (err) {
642
+ if (err?.code === "ENOENT") return;
643
+ throw configError(`the sandbox dir ${sandboxDir} could not be read (${err?.code ?? err?.message}): point PI_SANDBOX_DIR at a directory this account owns`);
644
+ }
645
+ if (!st.isDirectory()) throw configError(`the sandbox dir ${sandboxDir} is not a directory: point PI_SANDBOX_DIR at a directory this account owns`);
646
+ if (st.uid !== uid) throw configError(`the sandbox dir ${sandboxDir} is owned by uid ${st.uid}, not by this account (uid ${uid}), and that account could swap a retained workspace for one of its own: ${sandboxDirOwnerFix(sandboxDir)}`);
647
+ }
648
+
649
+ /** The remedy for a sandbox dir another account owns, shared with doctor. */
650
+ export function sandboxDirOwnerFix(dir) {
651
+ return `point PI_SANDBOX_DIR at a directory this account owns (or remove the line for the default), or chown ${dir} to this account`;
508
652
  }
509
653
 
510
- export function defaultSandboxDir(env = process.env) {
654
+ /**
655
+ * Secure the per-account root (`ensureAccountTempRoot`) when `path` lies in it, else do nothing (issue #464). The one
656
+ * rule for every default that can live there: the jobs, sandbox and graph dirs, and, on an account with no home, the
657
+ * run history and the settings overlay (`defaultStateDir`). Returns whether it did.
658
+ */
659
+ export function ensureUnderAccountRoot(path, { env = process.env, uid = currentUid(), fs = { mkdirSync, lstatSync, statSync, chmodSync } } = {}) {
660
+ const root = accountTempRoot(env, uid);
661
+ const p = String(path ?? "").replace(/\\/g, "/");
662
+ if (p !== root && !p.startsWith(`${root}/`)) return false;
663
+ ensureAccountTempRoot(root, { uid, fs });
664
+ return true;
665
+ }
666
+
667
+ /** The remedy for a jobs dir (`inRoot` false) or a per-account root (`inRoot` true) another account owns, shared with doctor. */
668
+ export function jobsDirOwnerFix(dir, inRoot) {
669
+ return inRoot
670
+ ? `another account created ${dir} before this one; remove it as its owner or as root (sudo rm -rf ${dir}), or set PI_JOBS_DIR in .env to a directory this account owns`
671
+ : `point PI_JOBS_DIR at a directory this account owns, or chown ${dir} to this account`;
672
+ }
673
+
674
+ /**
675
+ * Create `root` mode 0700 when it is absent, and refuse it unless it is a real directory (never a symlink, lstat) owned
676
+ * by `uid`; one this account owns with group or other bits is tightened to 0700. Exported for the admin's graph dir,
677
+ * which lives under the same root. Throws a `configError` naming the path and the remedy.
678
+ *
679
+ * lstat FIRST, never a recursive mkdir first (gate round 1, measured on Fedora 44): with fs.protected_symlinks on (the
680
+ * default on Fedora and Ubuntu), following another account's symlink under the sticky /tmp fails EACCES, and the
681
+ * recursive mkdir's own stat of the existing name did exactly that, so a symlink squat reached the worker as a raw
682
+ * EACCES (exit 1, a systemd restart loop) and each job as a generic failure instead of this refusal. The root itself is
683
+ * made with a plain mkdir, so a name another account creates between the lstat and the mkdir is EEXIST, judged again.
684
+ */
685
+ export function ensureAccountTempRoot(root, { uid = currentUid(), fs = { mkdirSync, lstatSync, statSync, chmodSync } } = {}) {
686
+ if (!Number.isInteger(uid)) {
687
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
688
+ return;
689
+ }
690
+ const refuse = (why) => configError(`${root} ${why}: ${jobsDirOwnerFix(root, true)}`);
691
+ const look = () => {
692
+ try {
693
+ return fs.lstatSync(root);
694
+ } catch (err) {
695
+ if (err?.code === "ENOENT") return null;
696
+ throw refuse(`could not be read (${err?.code ?? err?.message})`);
697
+ }
698
+ };
699
+ let st = look();
700
+ if (st === null) {
701
+ try {
702
+ // The temp dir itself (a TMPDIR the operator named may not exist yet), then the root on its own.
703
+ fs.mkdirSync(posix.dirname(root), { recursive: true });
704
+ fs.mkdirSync(root, { mode: 0o700 });
705
+ } catch (err) {
706
+ if (err?.code !== "EEXIST") throw refuse(`could not be created (${err?.code ?? err?.message})`);
707
+ }
708
+ st = look();
709
+ if (st === null) throw refuse("vanished as it was created");
710
+ }
711
+ if (st.isSymbolicLink() || !st.isDirectory()) throw refuse("is not a directory (a symlink or a file there is refused, since any account can create a name under the temp dir)");
712
+ if (st.uid !== uid) throw refuse(`is owned by uid ${st.uid}, not by this account (uid ${uid})`);
713
+ if ((st.mode & 0o077) !== 0) fs.chmodSync(root, 0o700);
714
+ }
715
+
716
+ export function defaultSandboxDir(env = process.env, uid = currentUid()) {
511
717
  // Beside the per-job dirs, because a retained directory IS a per-job dir -- `cleanup` renames it here
512
718
  // rather than copying, which only stays atomic while both live on one filesystem. Created mode 0700 by
513
719
  // the retention step, since the OS temp dir is 1777 on POSIX and a retained tree holds a repository
514
720
  // clone plus the run's prompt.md/event.json. Exported so the admin extension resolves the same default
515
721
  // without calling loadConfig, which throws on unrelated env problems.
516
- return `${env.PI_JOBS_DIR ?? defaultJobsDir()}/sandboxes`.replace(/\\/g, "/");
722
+ //
723
+ // Under temp deliberately, and unmoved by issue #290: a retained workspace is bounded by
724
+ // PI_SANDBOX_RETENTION_HOURS and is disposable by design, so a swept temp dir costs nothing that the
725
+ // sweep was not already going to take.
726
+ return `${jobsDirPath(env, uid)}/sandboxes`.replace(/\\/g, "/");
727
+ }
728
+
729
+ /**
730
+ * The home directory, or "" when this host cannot name one.
731
+ *
732
+ * `homedir()` throws on a host with no passwd entry (a bare uid in a container) and returns "" when
733
+ * HOME is set but empty -- both measured. This takes `defaultWorkerName`'s posture below: a path is
734
+ * never worth refusing boot for, so an unanswerable home degrades to the temp fallback rather than
735
+ * throwing, and `underOsTempDir` is what makes that degradation VISIBLE instead of silent.
736
+ */
737
+ export function safeHomeDir() {
738
+ try {
739
+ return homeOrEmpty(homedir());
740
+ } catch {
741
+ return "";
742
+ }
517
743
  }
518
744
 
519
- export function defaultLogsDir() {
520
- // Under the OS temp dir by default. Holds durable per-run history/log artifacts written host-side;
521
- // a worker-owned path that never enters the container env allowlist (no-broad-env-into-container).
522
- return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/logs`.replace(/\\/g, "/");
745
+ /**
746
+ * Normalise anything offered as a home directory to a usable string, or "".
747
+ *
748
+ * Split out of `safeHomeDir` because `home` is also a SEAM (doctor injects one to exercise the no-home
749
+ * case on a host that has one), and a seam that only the guarded path validates is a guard with a hole:
750
+ * `defaultLogsDir({}, null)` threw a TypeError before this existed. Blank-but-present is "" for the same
751
+ * reason `loadConfig` spells the durable paths with `||` -- an empty value can only mask a working
752
+ * default, never express one.
753
+ */
754
+ function homeOrEmpty(home) {
755
+ return typeof home === "string" ? home.trim() : "";
756
+ }
757
+
758
+ /**
759
+ * The root of the two DURABLE stores: the run history and the settings overlay (issue #290).
760
+ *
761
+ * Under the home directory, NOT the OS temp dir. Linux leaves TMPDIR unset so the old default was
762
+ * literally `/tmp/pi-dispatch`, and on the distros where /tmp is tmpfs that is RAM: the run history
763
+ * REQ-DURABLE-RUN-HISTORY promises and every cap the operator tuned from the panel were gone on the
764
+ * next reboot, while the queue beside them survived on Valkey's AOF volume.
765
+ *
766
+ * `~/.pi-dispatch` rather than a per-platform state dir (XDG_STATE_HOME, ~/Library/Application
767
+ * Support, LOCALAPPDATA), for four reasons. This project has exactly ONE home-dir pattern and it is
768
+ * this shape (`PI_CODING_AGENT_DIR || ~/.pi/agent`). docs/sessions.md already teaches
769
+ * `~/.pi-dispatch/sessions` as the place to put the session store, so these two land beside a
770
+ * directory the docs already tell an operator to create, and docs/backup.md can name one target.
771
+ * `~/Library/Application Support` contains a space, and doctor prints copy-pasteable fix lines. And a
772
+ * per-platform trio would be three new ENV reads needing their own env-internal markers, where
773
+ * `homedir()` is a node:os call that the env-docs scan does not see at all.
774
+ *
775
+ * WITH NO HOME, this falls back to a temp path, and that is load-bearing rather than a leak: the fallback
776
+ * is exactly what `underOsTempDir` detects, so doctor prints one line naming the path that will not
777
+ * survive a reboot. Since issue #464 that fallback is the PER-ACCOUNT root (`accountTempRoot`,
778
+ * `<tmp>/pi-dispatch-<euid>`), secured by `ensureUnderAccountRoot` as the jobs dir is: the old shared
779
+ * `<tmp>/pi-dispatch` was created by whichever homeless account wrote first, and every other account's
780
+ * records then failed to write, or landed in a directory another account controls. `legacyTempStateDir`
781
+ * still names that OLD shared address, for doctor's migration hint, and is spelled out on its own.
782
+ *
783
+ * The temp fallback reads a BLANK `TMPDIR`/`TEMP` as absent rather than using it.
784
+ * The pre-#290 code spelled this `??`, so `TMPDIR=""` composed the bare `/pi-dispatch` -- a path at the
785
+ * filesystem root that a non-root worker cannot create, and one `underOsTempDir` cannot recognise either,
786
+ * because an empty root is not a prefix of anything. The detector would then have reported "survives a
787
+ * reboot" over a directory that does not even exist. `||` in both places is what keeps the fallback and
788
+ * the detector describing the same world; it is the same rule `loadConfig` applies to PI_LOGS_DIR itself.
789
+ */
790
+ function defaultStateDir(env = process.env, home = safeHomeDir(), uid = currentUid()) {
791
+ // Strip the home dir's own trailing separator BEFORE composing, not after: stripping the composed
792
+ // string only reaches a slash at the END, and `${"/home/u/"}/.pi-dispatch` puts one in the MIDDLE.
793
+ const h = homeOrEmpty(home);
794
+ if (h !== "") return `${h.replace(/\\/g, "/").replace(/\/+$/, "")}/.pi-dispatch`;
795
+ return accountTempRoot(env, uid);
796
+ }
797
+
798
+ /**
799
+ * The OS temp dir as this project reads it, or "/tmp".
800
+ *
801
+ * TRIMMED and stripped of a trailing separator, which is not cosmetic on either count. A whitespace-only
802
+ * `TMPDIR` composed a RELATIVE path (" /pi-dispatch/logs") that would resolve against whatever cwd the
803
+ * worker was started in, and macOS hands back a trailing slash that otherwise rides into the `//` doctor
804
+ * prints in its copy-pasteable fix lines. Blank reads as absent for the reason `defaultStateDir` explains.
805
+ */
806
+ function tempRoot(env = process.env) {
807
+ for (const raw of [env.TMPDIR, env.TEMP]) {
808
+ const v = typeof raw === "string" ? raw.trim() : "";
809
+ if (v !== "") return v.replace(/\\/g, "/").replace(/(?!^)\/+$/, "");
810
+ }
811
+ return "/tmp";
812
+ }
813
+
814
+ /** The address the durable stores had before issue #290, so doctor's migration hint can name it. */
815
+ export function legacyTempStateDir(env = process.env) {
816
+ // The SHARED address every account used before issues #290 and #464; not a path anything writes any more.
817
+ return `${tempRoot(env)}/pi-dispatch`;
818
+ }
819
+
820
+ export function defaultLogsDir(env = process.env, home = safeHomeDir(), uid = currentUid()) {
821
+ // Durable by default (issue #290, REQ-DURABLE-RUN-HISTORY): holds the per-run history sidecars and,
822
+ // when PI_CAPTURE_JOB_LOGS=1, the raw logs. A worker-owned path that never enters the container env
823
+ // allowlist (no-broad-env-into-container). Exported so the admin extension resolves the same default
824
+ // without calling loadConfig, which throws on unrelated env problems -- and the admin resolving the
825
+ // SAME answer is the whole point: a panel reading a different directory shows an empty history and
826
+ // says nothing about why.
827
+ return `${defaultStateDir(env, home, uid)}/logs`;
828
+ }
829
+
830
+ export function defaultSettingsFile(env = process.env, home = safeHomeDir(), uid = currentUid()) {
831
+ // Durable by default, beside the run history (issue #290). Holds the runtime-tunable settings overlay
832
+ // shared with the admin extension (INT-CONFIG-OVERLAY-CONTRACT); a worker-owned path that never
833
+ // enters the container env allowlist (no-broad-env-into-container).
834
+ //
835
+ // Losing this file is not neutral: readOverlay treats a missing file as an EMPTY overlay, so a swept
836
+ // settings.json silently restores the wider env cap. That is the same fail-open
837
+ // DES-RUNTIME-SETTINGS-FILE-OVERLAY already refuses on a bad parse, arriving through the filesystem
838
+ // instead of through the parser.
839
+ return `${defaultStateDir(env, home, uid)}/settings.json`;
840
+ }
841
+
842
+ /**
843
+ * The resolved run-history directory. ONE derivation, so loadConfig, the admin and doctor cannot drift.
844
+ *
845
+ * `home` is forwarded rather than resolved here: doctor injects a home seam (it has to, to exercise the
846
+ * no-home case on a host that has one), and a helper that read the real homedir behind that seam's back
847
+ * would answer a different question than the one doctor is asking. Passing `undefined` is what keeps the
848
+ * ordinary caller on `safeHomeDir()`, since an undefined argument activates a default parameter.
849
+ */
850
+ /**
851
+ * The two boot files' paths, `??` and not `||`, EXPORTED so doctor asks the same question the worker does.
852
+ *
853
+ * The distinction is the whole reason these exist (issue #384). `??` keeps an empty string, so a blank
854
+ * `PI_PAUSE_WINDOWS_FILE=` survives into the config, `start.mjs` loads it unconditionally and the boot
855
+ * refuses; `logsDirPath` below uses `||` and falls back instead. Doctor had a copy of the `??` rule written
856
+ * out by hand, which is how it came to warn about a deployment its own sibling check failed.
857
+ */
858
+ export function pauseWindowsFilePath(env = process.env) {
859
+ return env.PI_PAUSE_WINDOWS_FILE ?? null;
860
+ }
861
+
862
+ export function scopedLimitsFilePath(env = process.env) {
863
+ return env.PI_SCOPED_LIMITS_FILE ?? null;
864
+ }
865
+
866
+ export function logsDirPath(env = process.env, home) {
867
+ return env.PI_LOGS_DIR || defaultLogsDir(env, home);
868
+ }
869
+
870
+ /**
871
+ * The resolved settings-overlay path. Lives HERE, beside `logsDirPath`, so the pair of durable stores has
872
+ * one derivation each and `loadConfig` does not open-code either. `runtime-settings.mjs` re-exports it, so
873
+ * `@edgehero/pi-dispatch/runtime-settings` stays the import path the admin and the probe fixture already
874
+ * use; the alternative (config importing runtime-settings) would be a cycle, since that module reads
875
+ * `defaultSettingsFile` from here.
876
+ */
877
+ export function settingsFilePath(env = process.env, home) {
878
+ return env.PI_SETTINGS_FILE || defaultSettingsFile(env, home);
879
+ }
880
+
881
+ /** One path in the slash alphabet these defaults are written in, with any trailing separators dropped. */
882
+ function slashy(p) {
883
+ const s = posix.normalize(String(p).replace(/\\/g, "/")).replace(/\/+$/, "");
884
+ return s === "" ? "/" : s;
885
+ }
886
+
887
+ /** A root's spellings: as written, plus its realpath when one resolves. Both count, for a root. */
888
+ function spellings(p, realpath) {
889
+ const out = new Set([slashy(p)]);
890
+ try {
891
+ out.add(slashy(realpath(p)));
892
+ } catch {
893
+ // ENOENT is the NORMAL case, not an error: doctor asks this question before these directories
894
+ // exist. The lexical spelling always remains, and the ROOT usually resolves even when the
895
+ // candidate does not, which is what still catches macOS's /var -> /private/var on a fresh host.
896
+ }
897
+ return out;
898
+ }
899
+
900
+ /**
901
+ * WHERE THE CANDIDATE'S BYTES ACTUALLY LAND, as one spelling.
902
+ *
903
+ * Deliberately not `spellings()`: unioning the written and resolved forms and accepting any match makes a
904
+ * symlink that points OUT of the temp dir a false positive, and this check's stated posture is that a
905
+ * false alarm on a durable path is the worse error. A resolved path is the honest answer to "will the OS
906
+ * sweep this", so it wins whenever it exists. The dirname retry covers the ordinary case where the leaf
907
+ * has not been created yet but its parent is a symlink; when neither resolves, the written form is all
908
+ * there is, which is doctor's situation on a fresh host.
909
+ */
910
+ function landingSpelling(p, realpath) {
911
+ try {
912
+ return slashy(realpath(p));
913
+ } catch {
914
+ // fall through to the parent
915
+ }
916
+ const written = slashy(p);
917
+ const cut = written.lastIndexOf("/");
918
+ if (cut > 0) {
919
+ try {
920
+ return `${slashy(realpath(written.slice(0, cut)))}${written.slice(cut)}`;
921
+ } catch {
922
+ // fall through to the written form
923
+ }
924
+ }
925
+ return written;
926
+ }
927
+
928
+ /**
929
+ * Directories a POSIX host clears without being asked, beyond whatever TMPDIR names.
930
+ *
931
+ * `/tmp` is here because it is what the pre-#290 default resolved to on Linux, where TMPDIR is unset,
932
+ * and it is tmpfs on several distributions. The other three are the ones an operator most plausibly
933
+ * reaches for while looking for somewhere fast: `/dev/shm` and `/run` are tmpfs by definition, so they
934
+ * are RAM and do not survive a reboot at all (`/run` does not survive a service restart on some
935
+ * layouts), and `/var/tmp` is swept by systemd-tmpfiles on a 30 day timer, which is the same order as
936
+ * the run history's own retention window. Each of these would otherwise have been reported as durable.
937
+ */
938
+ const POSIX_VOLATILE_ROOTS = Object.freeze(["/tmp", "/var/tmp", "/dev/shm", "/run"]);
939
+
940
+ /**
941
+ * Does this path sit under a directory the OS is entitled to sweep (issue #290)?
942
+ *
943
+ * ADVISORY, and therefore FAILS OPEN -- the opposite posture from `withinRoots` in
944
+ * secret-profiles.mjs and `folderUnderRoots` in the admin's read-model, which are boundaries and fail
945
+ * closed. Nothing is gated on this answer; it decides whether doctor prints one warning. A false
946
+ * alarm on a durable path is the worse error, because a warning an operator learns to skim is a
947
+ * warning that stops working.
948
+ *
949
+ * It reuses `withinRoots`' ALGORITHM -- normalize, strip trailing separators, then `===` or
950
+ * `startsWith(base + separator)` so a sibling like `<tmp>-notmine` cannot match -- but deliberately
951
+ * does not call it. `withinRoots` compares in the NATIVE alphabet (path.sep), while every default in
952
+ * this file is stored slash-normalized, so its Windows branch would be untestable anywhere but
953
+ * Windows. Comparing in the alphabet the values are written in makes this checkable on every platform.
954
+ *
955
+ * Four cases it has to get right, all measured on this project's own hosts:
956
+ * - macOS TMPDIR carries a TRAILING SLASH, so the old default composed `<tmp>//pi-dispatch/logs`;
957
+ * - macOS /var/folders/... realpaths to /private/var/folders/..., so an operator's /private path
958
+ * is a FALSE NEGATIVE unless both sides are expanded;
959
+ * - `<tmp>-notmine/logs` is a FALSE POSITIVE for a bare prefix test;
960
+ * - realpathSync THROWS on a path that does not exist yet, which is doctor's ordinary situation.
961
+ *
962
+ * os.tmpdir() is checked beside TMPDIR/TEMP because it consults TMP internally, which covers a third
963
+ * variable without naming one here. The literal "/tmp" is checked because it is what the pre-#290
964
+ * default resolved to on Linux, where TMPDIR is unset.
965
+ */
966
+ export function underOsTempDir(candidate, env = process.env, { realpath = realpathSync, osTmpDir = tmpdir, platform = process.platform } = {}) {
967
+ if (typeof candidate !== "string" || candidate.trim() === "") return false;
968
+ let tmp = "";
969
+ try {
970
+ tmp = osTmpDir();
971
+ } catch {
972
+ // A host that cannot name its own temp dir is not one to throw over from an advisory check.
973
+ }
974
+ const target = landingSpelling(candidate, realpath);
975
+ // TEMP is consulted on WINDOWS ONLY. On POSIX it is not an OS temp variable, and plenty of shells
976
+ // export one for a ported toolchain -- honouring it there turns an ordinary durable subtree into a
977
+ // false "swept" verdict, which is the direction this check says it cares about most.
978
+ const win = platform === "win32";
979
+ for (const root of [env.TMPDIR, win ? env.TEMP : "", tmp, ...(win ? [] : POSIX_VOLATILE_ROOTS)]) {
980
+ if (typeof root !== "string" || root.trim() === "") continue;
981
+ for (const base of spellings(root, realpath)) {
982
+ // `base` is "/" only when the root IS the filesystem root, where `${base}/` would be "//" and
983
+ // match nothing. Everything absolute is under it, which is the honest answer for TMPDIR=/.
984
+ if (target === base || target.startsWith(base === "/" ? "/" : `${base}/`)) return true;
985
+ }
986
+ }
987
+ return false;
523
988
  }
524
989
 
525
990
  /**
@@ -596,23 +1061,18 @@ function workerName(env) {
596
1061
  return declared;
597
1062
  }
598
1063
 
599
- export function defaultGraphDir(env = process.env) {
1064
+ export function defaultGraphDir(env = process.env, uid = currentUid()) {
600
1065
  // Under the OS temp dir by default, beside logs/ and jobs/ -- the admin's graph HTML artifact
601
1066
  // (issue #54) is host-side display output on the defaultLogsDir doctrine, and deliberately NOT
602
1067
  // inside logsDir: INT-RUN-HISTORY-FILE-CONTRACT names that directory's filename shape, and a
603
1068
  // stray .html beside the sidecars would widen a contract for a file that is not a record.
604
1069
  // Overridable with PI_GRAPH_DIR; exported so the admin resolves the same default without
605
1070
  // loadConfig, like defaultSandboxDir above.
606
- return `${env.TMPDIR ?? env.TEMP ?? "/tmp"}/pi-dispatch/graph`.replace(/\\/g, "/");
1071
+ // Issue #464: under the per-account root, for the jobs dir's reason: another account's `<tmp>/pi-dispatch` made
1072
+ // this one uncreatable.
1073
+ return `${accountTempRoot(env, uid)}/graph`;
607
1074
  }
608
1075
 
609
- export function defaultSettingsFile() {
610
- // Under the OS temp dir by default. Holds the runtime-tunable settings overlay shared with the admin
611
- // extension (INT-CONFIG-OVERLAY-CONTRACT); a worker-owned path that never enters the container env
612
- // allowlist (no-broad-env-into-container). Exported so the admin extension resolves the same default
613
- // without calling loadConfig, which throws on unrelated env problems.
614
- return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/settings.json`.replace(/\\/g, "/");
615
- }
616
1076
 
617
1077
  /**
618
1078
  * The worker's Azure DevOps auth config, or `null` when none is configured -- same presence rule as the