@edgehero/pi-dispatch 1.10.3 → 2.1.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 (101) hide show
  1. package/.env.example +303 -150
  2. package/README.md +52 -0
  3. package/deploy/com.pi-dispatch.worker.plist +10 -4
  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 +12 -1
  15. package/deploy/worker-env-wrapper.sh +63 -37
  16. package/deploy/worker.service +18 -8
  17. package/package.json +15 -5
  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 +4756 -394
  36. package/src/egress-conf-copy.mjs +166 -0
  37. package/src/egress-proxy-state.mjs +151 -0
  38. package/src/egress.mjs +456 -25
  39. package/src/entry.mjs +27 -0
  40. package/src/env-allowlist.mjs +245 -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-pi.mjs +19 -3
  54. package/src/host-registry.mjs +29 -2
  55. package/src/identity.mjs +29 -4
  56. package/src/image-preflight.mjs +46 -11
  57. package/src/image-ref.mjs +21 -0
  58. package/src/index.mjs +363 -13
  59. package/src/init.mjs +197 -38
  60. package/src/job-user.mjs +252 -0
  61. package/src/json-duplicates.mjs +204 -0
  62. package/src/live-probes.mjs +1020 -0
  63. package/src/materialize.mjs +4 -11
  64. package/src/netns-keeper.mjs +264 -0
  65. package/src/on-failure.mjs +119 -0
  66. package/src/outbox.mjs +7 -0
  67. package/src/packages.mjs +2 -2
  68. package/src/podman-stack.mjs +1304 -0
  69. package/src/prepare-github.mjs +6 -6
  70. package/src/prepare-local.mjs +51 -17
  71. package/src/prepare.mjs +27 -6
  72. package/src/pricing.mjs +9 -5
  73. package/src/processor.mjs +506 -26
  74. package/src/provider-key.mjs +66 -0
  75. package/src/provider-steering.mjs +185 -0
  76. package/src/queue.mjs +35 -8
  77. package/src/redact.mjs +84 -0
  78. package/src/reserved-env.mjs +7 -3
  79. package/src/retention-sweep.mjs +178 -0
  80. package/src/run-container.mjs +181 -14
  81. package/src/run-history.mjs +105 -16
  82. package/src/runtime-observations.mjs +1152 -0
  83. package/src/runtime-settings.mjs +13 -8
  84. package/src/sandbox-cli.mjs +100 -95
  85. package/src/sandbox-store.mjs +612 -45
  86. package/src/sandbox.mjs +1459 -37
  87. package/src/schedules.mjs +16 -3
  88. package/src/secret-profiles.mjs +2 -1
  89. package/src/secrets.mjs +24 -6
  90. package/src/service-env.mjs +247 -0
  91. package/src/service.mjs +618 -28
  92. package/src/session-store.mjs +678 -53
  93. package/src/start.mjs +1348 -326
  94. package/src/subscriptions.mjs +7 -3
  95. package/src/transient.mjs +240 -0
  96. package/src/triggers-file.mjs +71 -15
  97. package/src/triggers.mjs +179 -19
  98. package/src/up.mjs +1399 -85
  99. package/src/valkey-auth.mjs +529 -0
  100. package/src/valkey-endpoint.mjs +367 -0
  101. package/src/watch-closer.mjs +158 -0
package/src/schedules.mjs CHANGED
@@ -80,6 +80,18 @@ export function servedSchedules(schedules) {
80
80
  return { served, unserved };
81
81
  }
82
82
 
83
+ /**
84
+ * WHERE a cron trigger runs, from this host's point of view (issue #57): `"here"` when its folder exists on this
85
+ * machine, `"elsewhere"` when it does not and this host is on a fleet (another machine's folder, not scheduled here),
86
+ * and `"refused"` when it does not and this host is alone (a typo the worker refuses to boot on). The rule and its
87
+ * reasons are `normalizeCronSchedule`'s, below, which is its first caller. Exported so `pi-dispatch doctor` asks the
88
+ * same question the same way (issue #433) rather than keeping a copy that could drift.
89
+ */
90
+ export function cronPlacement(run, { existsSync = fsExistsSync, fleet = false } = {}) {
91
+ if (existsSync(run.folder)) return "here";
92
+ return fleet ? "elsewhere" : "refused";
93
+ }
94
+
83
95
  function normalizeCronSchedule({ on, run }, path, existsSync, fleet) {
84
96
  // The pure validator already guaranteed a non-empty, `:`-free, charset-valid, unique id and a
85
97
  // well-formed pattern; folder existence is the one fs-dependent check it deferred to here.
@@ -97,8 +109,9 @@ function normalizeCronSchedule({ on, run }, path, existsSync, fleet) {
97
109
  // -- so a single-host deployment with one typo'd folder would reap containers, prune history and delete
98
110
  // sandboxes on every restart before refusing. Declaring a name is the operator saying "this is a
99
111
  // fleet", it is known before anything runs, and it keeps a single-host deployment byte-identical.
100
- if (!existsSync(run.folder)) {
101
- if (!fleet) {
112
+ const placement = cronPlacement(run, { existsSync, fleet });
113
+ if (placement !== "here") {
114
+ if (placement === "refused") {
102
115
  throw configError(`cron trigger "${on.id}": run.folder does not exist: ${run.folder} (${path})`);
103
116
  }
104
117
  // Not mine. Its skillsDir is not my business either: `isAbsolute` is OS-dependent, and judging
@@ -137,7 +150,7 @@ function normalizeCronSchedule({ on, run }, path, existsSync, fleet) {
137
150
  // key. A command trigger carries no flow/task at all (the validator enforces the XOR), so those two
138
151
  // keys hold undefined here and drop at JSON serialization -- the command schedule's data is exactly
139
152
  // kind/folder/command plus the shared fields.
140
- const data = { kind: "local", folder: run.folder, flow: run.flow, task: run.task, ...(run.command !== undefined && { command: run.command }), provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages: run.packages, image: run.image, ...(run.backend !== undefined && { backend: run.backend }), ...(run.skillsDir !== undefined && { skillsDir: run.skillsDir }), ...(run.secrets !== undefined && { secrets: run.secrets }), ...(run.secretsProfile !== undefined && { secretsProfile: run.secretsProfile }), resume: run.resume, trigger: { id: on.id, pattern: on.pattern } };
153
+ const data = { kind: "local", folder: run.folder, flow: run.flow, task: run.task, ...(run.command !== undefined && { command: run.command }), provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages: run.packages, image: run.image, ...(run.backend !== undefined && { backend: run.backend }), ...(run.excludeTools !== undefined && { excludeTools: run.excludeTools }), ...(run.skillsDir !== undefined && { skillsDir: run.skillsDir }), ...(run.secrets !== undefined && { secrets: run.secrets }), ...(run.secretsProfile !== undefined && { secretsProfile: run.secretsProfile }), resume: run.resume, trigger: { id: on.id, pattern: on.pattern } };
141
154
  // Retention only; the deterministic repeat:<id>:<millis> jobId supplies dedup, so no jobId here, and
142
155
  // scheduler jobs are not retried (DES-CRON-VIA-BULLMQ-SCHEDULER) so no attempts/backoff.
143
156
  const opts = { removeOnComplete: { age: 24 * 3600 }, removeOnFail: { age: 7 * 24 * 3600 } };
@@ -82,7 +82,8 @@ function isAbsolutePath(path) {
82
82
  * NEITHER SOURCE WINS, and that is a deliberate third answer. `runtime-settings.mjs` documents the
83
83
  * overlay's precedence as `overlay > env`, so quietly inverting it for this one key would leave two rules
84
84
  * in the codebase disagreeing about what an overlay is. But honouring it would let a settings file -- which
85
- * defaults into the OS temp directory -- redirect a profile the operator wrote in `.env`. So a collision is
85
+ * PI_SETTINGS_FILE can point anywhere, and which defaulted into the OS temp directory until issue #290 --
86
+ * redirect a profile the operator wrote in `.env`. So a collision is
86
87
  * refused instead, per delivery and naming only the profile name. This project already refuses ambiguity
87
88
  * rather than resolving it: `PI_EGRESS` refuses any value but 0 or 1 because "a typo must never leave you
88
89
  * believing you have a policy you do not", and two declarations of one profile is exactly that.
package/src/secrets.mjs CHANGED
@@ -25,7 +25,7 @@
25
25
 
26
26
  import { spawn } from "node:child_process";
27
27
  import { realpathSync, statSync } from "node:fs";
28
- import { providerKeyVars } from "./env-allowlist.mjs";
28
+ import { providerKeyCandidates } from "./env-allowlist.mjs";
29
29
  import { EXIT_POLICY } from "./exit-code.mjs";
30
30
  import { mergeSecretProfiles, withinRoots } from "./secret-profiles.mjs";
31
31
 
@@ -261,8 +261,8 @@ export function makeSecretsResolver({
261
261
  }
262
262
  if (!path) return { profileUnknown: wanted };
263
263
  // The bound is checked on the REALPATH, and in the WORKER rather than only where a profile is
264
- // written. A panel-side check alone would be cosmetic: the settings overlay defaults into the OS
265
- // temp directory, so on a multi-user host the directory can be pre-created by anyone. Checking here
264
+ // written. A panel-side check alone would be cosmetic: PI_SETTINGS_FILE can point the overlay
265
+ // anywhere, and its default was a world-writable OS temp path until issue #290. Checking here
266
266
  // caps a tampered overlay at "choose among scripts the operator allowlisted" instead of "name any
267
267
  // executable on the host". An env-declared profile is exempt: PI_SECRET_PROFILES already lives
268
268
  // beside the forge tokens and the App key, so requiring roots for it would bound nothing and would
@@ -276,8 +276,7 @@ export function makeSecretsResolver({
276
276
  // STATICALLY knowable name (the mint's, each forge's host var, the worker-only secrets, the egress
277
277
  // policy's, and the closed map's own PI_*/PLAYWRIGHT_*), but two more sets are DEPLOYMENT STATE:
278
278
  //
279
- // - the provider credential's variable names, which come from `findEnvKeys(provider, hostEnv)` and so
280
- // depend on the job's resolved provider AND on what this host has set;
279
+ // - the provider credential's variable names, which depend on the job's resolved provider;
281
280
  // - PI_FORWARD_ENV, which is an operator env list.
282
281
  //
283
282
  // Both are load-bearing rather than tidy. buildContainerEnv writes the provider credential BEFORE this
@@ -288,7 +287,26 @@ export function makeSecretsResolver({
288
287
  //
289
288
  // Refused rather than resolved by ordering, which is the division of labour the mint already keeps:
290
289
  // ordering is the backstop, the refusal is the gate.
291
- const reserved = new Set([...(providerKeyVars(job?.provider, hostEnv) ?? []), ...forwardEnv]);
290
+ //
291
+ // **`providerKeyCandidates`, not `providerKeyVars`** (issue #309). The question a reserved-name set
292
+ // asks is what a trigger may NOT NAME, which is a property of the provider. `providerKeyVars` answers
293
+ // a different one, what this host HOLDS, because it is presence-filtered -- and it returns `undefined`
294
+ // when the answer is none, which `?? []` made silent. Under PI_AUTH_FROM_PI, ON BY DEFAULT, the
295
+ // credential comes from pi's auth.json rather than the environment, so this set was EMPTY and the gate
296
+ // reserved nothing at all: a trigger could bind the provider's own credential variable and spend the
297
+ // trigger author's key, every job, which is the exact failure the paragraph above says this exists to
298
+ // prevent. Same conflated `undefined` as issue #286, one module over.
299
+ //
300
+ // It also closes a second hole that never needed auth.json. The worker never WRITES the OAuth variable
301
+ // or (from the 0.99.1 pin, issue #509) the bearer ANTHROPIC_AUTH_TOKEN (`apiKeyVariable` skips both
302
+ // deliberately) and pi reads both BEFORE `ANTHROPIC_API_KEY`, so a presence
303
+ // filter held it only on hosts that happened to export it, and a trigger binding it outranked the
304
+ // operator's own key on the pure-env path too.
305
+ //
306
+ // The widening is bounded: it reserves the variables of THIS JOB'S provider and nothing else, so an
307
+ // anthropic job binding OPENAI_API_KEY for a flow that talks to OpenAI itself is still allowed. That
308
+ // is deliberate, and tested; refusing it would be this project inventing a namespace it does not own.
309
+ const reserved = new Set([...providerKeyCandidates(job?.provider), ...forwardEnv]);
292
310
  for (const name of Object.keys(references)) {
293
311
  if (reserved.has(name)) return { reserved: name };
294
312
  }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * ONE resolver for the keys a pi-dispatch service reads from its deployment's `.env` (issue #471), shared by `doctor`
3
+ * and the admin panel so the two cannot answer "what does the service run with" two ways.
4
+ *
5
+ * The service never sees the environment of the shell an operator runs a command in: under systemd its environment is
6
+ * the unit's plus `EnvironmentFile=`, and the launchd and Windows wrappers source the file inside the child. A worker
7
+ * started by hand (`pi-dispatch worker`) sees that shell and no file at all. A command asked about the deployment
8
+ * cannot tell which of the two runs it, and does not guess. Its rule, the one `up` and doctor already apply to the
9
+ * venue keys (`deploymentVenueEnv`, issue #453) and doctor to `PI_WORKER_NAME` (#464), now for every key:
10
+ *
11
+ * - this shell's value where it sets the key (an operator who starts the worker by hand from this shell runs it);
12
+ * - else the `.env` value, read with the service's own loader, from a line that loader reads exactly as written;
13
+ * - both set and different is a DISAGREEMENT, returned for the caller to report: it is never resolved in silence;
14
+ * - a line naming the key that the loader reads differently from this reader (a quote, a `$`, a space) is UNREAD:
15
+ * its value is never used, and the line is named;
16
+ * - a file the service's loader reads differently somewhere, or refuses to load (`hazard`), gives no value for any
17
+ * key the file spells, and each such key is named.
18
+ *
19
+ * A key only this shell sets is taken as the shell's and not reported: the service may be given it by its
20
+ * `--env-setup` script or its unit, neither of which a command can read in general.
21
+ *
22
+ * Nothing here loads a `.env` into a process (`docs/secrets.md`): the result is an object the caller judges from.
23
+ */
24
+ import { constants as fsConstants } from "node:fs";
25
+ import { userInfo } from "node:os";
26
+ import { dirname, join } from "node:path";
27
+ import { SYSTEMD_HAZARD_SHAPES, decodeEnvFile, envFileHazard, readEnvAssignments } from "./env-file.mjs";
28
+
29
+ /** The loader of the deployment's `.env` on this platform, as `service.mjs` renders it: systemd, the cmd wrapper, or a
30
+ * sourcing shell everywhere else. ONE mapping for every `.env` read. */
31
+ export function serviceEnvLoader(platform) {
32
+ return platform === "linux" ? "systemd" : platform === "win32" ? "cmd" : "shell";
33
+ }
34
+
35
+ /**
36
+ * The deployment `.env` as the service is judged by (issue #464, gate round 3): decoded from its BYTES with the
37
+ * hardened reader, and `hazard` set to the first thing the service's loader reads differently from this reader, or
38
+ * refuses to load (a NUL, invalid UTF-8, an environment too big to exec, a lone CR), as `{ line, what, fix }`, or null.
39
+ */
40
+ export function serviceEnvFileOf(raw, path, loader) {
41
+ const { text, loadHazard } = decodeEnvFile(raw, { loader });
42
+ const h = loadHazard ?? envFileHazard(text, { loader });
43
+ let hazard = null;
44
+ if (h !== null) {
45
+ const shape = h.shape !== undefined ? SYSTEMD_HAZARD_SHAPES[h.shape] : null;
46
+ hazard = shape ? { line: h.line, what: `has ${shape.what}${h.detail ? ` (${h.detail})` : ""}`, fix: shape.fix } : { line: h.line, what: "is one this command cannot read the way the service's loader will (an open quote, a continuation, or a line that runs)", fix: "fix that line, then re-run doctor" };
47
+ }
48
+ return { text, path, loader, hazard };
49
+ }
50
+
51
+ /**
52
+ * Resolve `keys` by the rule in the header. `env` is this shell's environment, `file` a `serviceEnvFileOf` result or
53
+ * null (no file, or none this command could read). Returns
54
+ * `{ env, fromFile, disagreements, unread, hazardSkipped, shellOnly }`: `env` a copy of this shell's with the file's
55
+ * values filled in, `fromFile` only what the file supplied, `disagreements` `[{ key, shell, file }]`, `unread` `[{ key,
56
+ * line, shellSet }]`, `hazardSkipped` the keys a hazard kept this from reading while this shell sets none of them, and
57
+ * `shellOnly` the keys this shell sets and the file does not.
58
+ */
59
+ export function resolveServiceEnv({ env, file, keys }) {
60
+ const resolved = { ...env };
61
+ const out = { env: resolved, fromFile: {}, disagreements: [], unread: [], hazardSkipped: [], shellOnly: [] };
62
+ const wanted = [...new Set(keys)];
63
+ if (!file || wanted.length === 0) return out;
64
+ const reads = readEnvAssignments(file.text, wanted, { loader: file.loader });
65
+ for (const key of wanted) {
66
+ const shell = typeof env[key] === "string" ? env[key] : undefined;
67
+ // The hardened reader's verdict on the whole file: where it has a line the service's loader reads differently, or
68
+ // refuses to load, and spells this key, no value is taken from it, and that is said, never read as "unset".
69
+ if (file.hazard && file.text.includes(key)) {
70
+ if (shell === undefined) out.hazardSkipped.push(key);
71
+ continue;
72
+ }
73
+ // An empty value UNSETS under the cmd wrapper (`set "K="`), so there the service runs without the key.
74
+ const own = file.loader === "cmd" && reads[key]?.plain && reads[key].value === "" ? undefined : reads[key];
75
+ if (own === undefined) {
76
+ // Set only in this shell (issue #471, gate round 1): a worker started from this shell runs with it, a service
77
+ // reading this file does not, so the caller can name it where it knows a service is installed.
78
+ if (shell !== undefined) out.shellOnly.push(key);
79
+ continue;
80
+ }
81
+ if (!own.plain || typeof own.value !== "string") {
82
+ out.unread.push({ key, line: own.line, shellSet: shell !== undefined });
83
+ continue;
84
+ }
85
+ if (shell !== undefined) {
86
+ if (shell !== own.value) out.disagreements.push({ key, shell, file: own.value });
87
+ continue;
88
+ }
89
+ resolved[key] = own.value;
90
+ out.fromFile[key] = own.value;
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /**
96
+ * Account and group names from `/etc/passwd` and `/etc/group`, for a message that names an owner (gate round 3: the
97
+ * panel printed a bare uid). A name that cannot be read is the number, said as such.
98
+ */
99
+ export function accountNames(fs) {
100
+ const table = (path) => {
101
+ try {
102
+ return String(fs.readFileSync(path, "utf8")).split("\n").map((l) => l.split(":")).filter((f) => f.length >= 3 && f[0] !== "");
103
+ } catch {
104
+ return [];
105
+ }
106
+ };
107
+ let users = null;
108
+ let groups = null;
109
+ return {
110
+ // This account's own name from the OS where /etc/passwd does not list it (macOS keeps accounts in a directory
111
+ // service), so the panel's line names the owner rather than a number.
112
+ ownerName: (id) => ((users ??= table("/etc/passwd")).find((f) => f[2] === String(id))?.[0] ?? (id === process.geteuid?.() ? safeUserName() : null) ?? `uid ${id}`),
113
+ groupName: (id) => ((groups ??= table("/etc/group")).find((f) => f[2] === String(id))?.[0] ?? `gid ${id}`),
114
+ };
115
+ }
116
+
117
+ function safeUserName() {
118
+ try {
119
+ return userInfo().username || null;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Why `stat` fails the trust rule, in words that are true of it, or null (gate round 3 fixed the wording): owned by `uid`
127
+ * or root and writable by neither its group nor every account. A group-writable file is named with its group, and when
128
+ * that group has the owner's own name (a user-private group, what a umask 002 login makes) the line says so: its
129
+ * members may be the owner alone, which doctor cannot see, and it trusts only a file no group can write.
130
+ */
131
+ function trustReason(stat, uid, { ownerName = (id) => `uid ${id}`, groupName = (id) => `gid ${id}` } = {}, path = "") {
132
+ const owned = stat.uid === uid || stat.uid === 0;
133
+ const worldWritable = (stat.mode & 0o002) !== 0;
134
+ const groupWritable = (stat.mode & 0o020) !== 0;
135
+ const stickyRoot = stat.uid === 0 && (stat.mode & 0o1000) !== 0 && stat.isDirectory?.();
136
+ if (owned && ((!worldWritable && !groupWritable) || stickyRoot)) return null;
137
+ const mode = (stat.mode & 0o7777).toString(8).padStart(4, "0");
138
+ const owner = ownerName(stat.uid);
139
+ const at = path ? `${path} ` : "";
140
+ if (!owned) return `${at}is owned by ${owner} with mode ${mode}, not by this account or root`;
141
+ if (worldWritable) return `${at}is owned by ${owner} with mode ${mode}, writable by every account`;
142
+ const group = groupName(stat.gid);
143
+ return `${at}is owned by ${owner} with mode ${mode}, writable by the members of its group ${group}${group === owner ? ` (${owner}'s own group, which may have no other member; doctor trusts only a file no group can write)` : ""}`;
144
+ }
145
+
146
+ /**
147
+ * Whether a `.env` may steer anything (issue #471, gate round 1): `null` when it is owned by `uid` (the account asking)
148
+ * or by root and neither its group nor others may write it, else the reason, naming the owner, the mode and, for a
149
+ * group-writable file, the group. A value in a file another account can write is that account's choice: through a
150
+ * CONTAINERS_CONF naming a containers.conf with a `conmon_path`, a `.env` made doctor's own `podman info` run a program
151
+ * (measured on Podman 5.8.1). `uid` undefined (a platform with no uids) trusts the file. `names` is `accountNames`'s.
152
+ */
153
+ export function envFileTrust(stat, uid, names = {}) {
154
+ if (!Number.isInteger(uid) || !stat) return null;
155
+ return trustReason(stat, uid, typeof names === "function" ? { ownerName: names } : names);
156
+ }
157
+
158
+ /**
159
+ * Issue #471 (gate round 2): whether nobody but `uid` and root can change what `realPath` names, as `null` or the reason.
160
+ * Every directory from `/` down to it, and it, must be owned by `uid` or root and writable by neither group nor others;
161
+ * a root-owned directory with the sticky bit (`/tmp`) is the one writable exception, since there only an entry's owner
162
+ * can rename or remove it, and the next component is then judged on its own. Judged with `lstat` on a path `realpath`
163
+ * already resolved, so a component that is a symlink now was swapped in since, and refuses. One trusted-once check of a
164
+ * symlink target was measured racing: another account flipped a DOCKER_CONFIG link between the check and docker's own
165
+ * open, and its plugin ran as the doctor account. A chain nobody else can write cannot be flipped.
166
+ */
167
+ export function trustedChain(realPath, { fs, uid, names = {} }) {
168
+ if (!Number.isInteger(uid)) return null;
169
+ const parts = realPath.split("/").filter(Boolean);
170
+ for (let n = 0; n <= parts.length; n++) {
171
+ const here = `/${parts.slice(0, n).join("/")}`;
172
+ let st;
173
+ try {
174
+ st = fs.lstatSync(here);
175
+ } catch (err) {
176
+ return `${here} could not be read (${err?.code ?? err?.message})`;
177
+ }
178
+ if (st.isSymbolicLink?.()) return `${here} became a symbolic link after it was resolved`;
179
+ const why = trustReason(st, uid, names, here);
180
+ if (why !== null) return why;
181
+ }
182
+ return null;
183
+ }
184
+
185
+ /**
186
+ * The deployment `.env` in `dir`, read ONCE through one descriptor (issue #471, gate round 2), shared by doctor and the
187
+ * panel: `{ path, bytes, absent, unreadable, untrusted }`. Measured before: judged by `stat` and then read by path,
188
+ * another account's folder swapped a symlink between the two and its value was taken (2 of 20000 reads). Now the
189
+ * real path is opened with O_NOFOLLOW and O_NONBLOCK (a FIFO then cannot hang the read), judged by `fstat` of that same
190
+ * descriptor, and read from it; the file itself must be this account's or root's and closed (`envFileTrust`), and the
191
+ * deployment folder and the file's own folder, with every ancestor, must pass `trustedChain`, since an account that can
192
+ * write a folder can replace what is in it. `bytes` comes back even from an untrusted file, for a caller that only
193
+ * decides what to SAY from it; `untrusted` is then the reason, and no value may be taken.
194
+ */
195
+ export function readDeploymentEnv({ dir, fs, uid = process.geteuid?.(), names = accountNames(fs) }) {
196
+ const path = join(dir, ".env");
197
+ let real;
198
+ try {
199
+ real = fs.realpathSync(path);
200
+ } catch (err) {
201
+ if (err?.code === "ENOENT") return { path, bytes: null, absent: true, unreadable: null, untrusted: null };
202
+ return { path, bytes: null, absent: false, unreadable: err?.code ?? err?.message ?? "error", untrusted: null };
203
+ }
204
+ let fd;
205
+ try {
206
+ fd = fs.openSync(real, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
207
+ } catch (err) {
208
+ return { path, bytes: null, absent: false, unreadable: err?.code ?? err?.message ?? "error", untrusted: null };
209
+ }
210
+ try {
211
+ const st = fs.fstatSync(fd);
212
+ if (!st.isFile()) return { path, bytes: null, absent: false, unreadable: "not a regular file", untrusted: null };
213
+ const bytes = fs.readFileSync(fd);
214
+ let untrusted = envFileTrust(st, uid, names);
215
+ if (untrusted === null && Number.isInteger(uid)) {
216
+ let folder = null;
217
+ try {
218
+ folder = fs.realpathSync(dir);
219
+ } catch (err) {
220
+ untrusted = `${dir} could not be resolved (${err?.code ?? err?.message})`;
221
+ }
222
+ const why = folder === null ? null : (trustedChain(folder, { fs, uid, names }) ?? trustedChain(dirname(real), { fs, uid, names }));
223
+ if (why) untrusted = `is in a folder another account can change: ${why}`;
224
+ }
225
+ return { path, bytes, absent: false, unreadable: null, untrusted };
226
+ } catch (err) {
227
+ return { path, bytes: null, absent: false, unreadable: err?.code ?? err?.message ?? "error", untrusted: null };
228
+ } finally {
229
+ try {
230
+ fs.closeSync(fd);
231
+ } catch {}
232
+ }
233
+ }
234
+
235
+ /**
236
+ * The `.env` of the deployment in `dir`, read the way doctor reads it (a regular file only, as bytes, one read) and
237
+ * resolved for `keys` (`resolveServiceEnv`). For a caller that has no file of its own in hand (the admin panel).
238
+ * Returns the resolution plus `path`, `unreadable` (a reason, when something is at the path and could not be read) and
239
+ * `untrusted` (`envFileTrust`'s reason); in either case nothing was taken from it.
240
+ */
241
+ export function deploymentServiceEnv({ env, dir, keys, platform = process.platform, fs, uid = process.geteuid?.() }) {
242
+ // Issue #471 (gate rounds 1 and 2): one descriptor, and nothing taken from a file another account can change.
243
+ const { path, bytes, unreadable, untrusted } = readDeploymentEnv({ dir, fs, uid });
244
+ const raw = untrusted ? null : bytes;
245
+ const file = raw === null ? null : serviceEnvFileOf(raw, path, serviceEnvLoader(platform));
246
+ return { ...resolveServiceEnv({ env, file, keys }), path, unreadable, untrusted, hazard: file?.hazard ?? null };
247
+ }