@edgehero/pi-dispatch 2.0.0 → 3.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 (80) hide show
  1. package/.env.example +44 -7
  2. package/README.md +14 -6
  3. package/deploy/com.pi-dispatch.worker.plist +1 -1
  4. package/deploy/docker-compose.yml +12 -0
  5. package/deploy/egress-proxy.conf +28 -3
  6. package/deploy/pi-dispatch-egress-proxy.container +8 -2
  7. package/deploy/worker-env-wrapper.cmd +1 -1
  8. package/deploy/worker-env-wrapper.sh +3 -3
  9. package/package.json +9 -2
  10. package/src/allocation.mjs +731 -0
  11. package/src/backends.mjs +243 -0
  12. package/src/budget.mjs +40 -4
  13. package/src/cli.mjs +222 -11
  14. package/src/config.mjs +126 -5
  15. package/src/daemon-facts.mjs +3 -0
  16. package/src/deployment-venue.mjs +1 -0
  17. package/src/doctor.mjs +2316 -183
  18. package/src/dollar-budget.mjs +373 -0
  19. package/src/dollar-fingerprint.mjs +83 -0
  20. package/src/egress-cli.mjs +316 -0
  21. package/src/egress-proxy-state.mjs +35 -5
  22. package/src/egress.mjs +16 -3
  23. package/src/env-allowlist.mjs +142 -18
  24. package/src/env-file.mjs +194 -25
  25. package/src/envelope.mjs +413 -0
  26. package/src/exit-code.mjs +22 -0
  27. package/src/fleet-lease.mjs +85 -25
  28. package/src/get-token.mjs +16 -5
  29. package/src/git-dirty.mjs +67 -0
  30. package/src/github-app-setup.mjs +6 -3
  31. package/src/github-host.mjs +5 -3
  32. package/src/host-pi.mjs +19 -3
  33. package/src/identity.mjs +2 -1
  34. package/src/image-preflight.mjs +98 -24
  35. package/src/image-ref.mjs +37 -0
  36. package/src/import-pi.mjs +4 -2
  37. package/src/index.mjs +407 -62
  38. package/src/init.mjs +18 -0
  39. package/src/job-id.mjs +26 -3
  40. package/src/live-probes.mjs +24 -9
  41. package/src/model-catalog.mjs +297 -0
  42. package/src/model-endpoints.mjs +649 -0
  43. package/src/model-ref.mjs +151 -0
  44. package/src/models-json.mjs +262 -0
  45. package/src/money.mjs +144 -0
  46. package/src/octokit-log.mjs +65 -0
  47. package/src/outbox-plan.mjs +218 -0
  48. package/src/outbox.mjs +29 -9
  49. package/src/output-cap.mjs +157 -0
  50. package/src/packages.mjs +2 -2
  51. package/src/pause-windows.mjs +81 -2
  52. package/src/pi-model-loader.mjs +77 -0
  53. package/src/podman-stack.mjs +16 -3
  54. package/src/portfolio-snapshot.mjs +304 -0
  55. package/src/prepare-local.mjs +247 -12
  56. package/src/prepare.mjs +35 -3
  57. package/src/pricing.mjs +9 -5
  58. package/src/priorities.mjs +569 -0
  59. package/src/processor.mjs +603 -173
  60. package/src/project-id.mjs +17 -0
  61. package/src/projects.mjs +238 -0
  62. package/src/provider-key.mjs +32 -7
  63. package/src/provider-steering.mjs +214 -59
  64. package/src/queue.mjs +111 -6
  65. package/src/reserved-env.mjs +30 -0
  66. package/src/run-container.mjs +59 -5
  67. package/src/run-history.mjs +379 -24
  68. package/src/run-mirror.mjs +30 -0
  69. package/src/runtime-settings.mjs +104 -9
  70. package/src/schedules.mjs +33 -1
  71. package/src/scoped-limits.mjs +447 -27
  72. package/src/secrets.mjs +2 -1
  73. package/src/service.mjs +15 -4
  74. package/src/session-store.mjs +131 -6
  75. package/src/start.mjs +528 -40
  76. package/src/subscriptions.mjs +7 -3
  77. package/src/triggers-file.mjs +65 -4
  78. package/src/triggers.mjs +140 -9
  79. package/src/up.mjs +308 -34
  80. package/src/valkey-endpoint.mjs +3 -2
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * Build the EXACT environment a job container receives. Never a pass-through.
3
3
  *
4
- * `no-broad-env-into-container` is a BLOCKER, and for good reason: `ANTHROPIC_OAUTH_TOKEN`
5
- * silently outranks `ANTHROPIC_API_KEY`, so one stray host variable would redirect which
6
- * credential every job spends, with no error and no log line. So we forward a closed set.
4
+ * `no-broad-env-into-container` is a BLOCKER, and for good reason: `ANTHROPIC_OAUTH_TOKEN` (and, from
5
+ * the pi 0.99.1 pin on, `ANTHROPIC_AUTH_TOKEN` ahead of it) silently outranks `ANTHROPIC_API_KEY`, so one
6
+ * stray host variable would redirect which credential every job spends, with no error and no log line.
7
+ * So we forward a closed set.
7
8
  *
8
9
  * The provider key variable is DERIVED from pi's own table, not hardcoded. Deriving it means any of
9
- * pi's ~30 providers works with no code change here, and the list cannot drift when pi adds one, as a
10
+ * pi's ~40 providers works with no code change here, and the list cannot drift when pi adds one, as a
10
11
  * hand-copied table would. `getApiKeyEnvVars` (that table) is intentionally NOT exported by pi, which
11
12
  * is why `providerKeyCandidates` below has to recover it a different way.
12
13
  *
@@ -32,6 +33,8 @@ import { egressEnv } from "./egress.mjs";
32
33
  import { forgeSpec } from "./forges.mjs";
33
34
  import { apiKeyVariable } from "./provider-key.mjs";
34
35
  import { isDeterminateFsCode } from "./transient.mjs";
36
+ import { KEYLESS_HOW, keylessVerdict } from "./model-endpoints.mjs";
37
+ import { KEYLESS_ENV_NAME } from "./reserved-env.mjs";
35
38
 
36
39
  function configError(message) {
37
40
  const error = new Error(message);
@@ -89,10 +92,12 @@ export function providerKeyCandidates(provider) {
89
92
  * `getProviders` from "/compat", which is a @deprecated alias for this exact function and reaching it
90
93
  * means loading compat's module-scope provider registration.
91
94
  *
92
- * Only ever consulted AFTER `providerKeyCandidates` comes back empty, and the order is load-bearing:
93
- * the catalog is NOT a superset of the ids `findEnvKeys` answers for. `radius` is a purely dynamic
94
- * provider with a real key variable (PI_GATEWAY_API_KEY) and no catalog entry, so asking this first
95
- * would call a working configuration unknown.
95
+ * Only ever consulted AFTER `providerKeyCandidates` comes back empty. At the 0.80.7 pin that order was
96
+ * load-bearing: `radius` was a purely dynamic provider with a real key variable and no catalog entry, so
97
+ * asking this first would have called a working configuration unknown. At the 0.99.1 pin (issue #509)
98
+ * radius is a builtin provider (key variable RADIUS_API_KEY) and the catalog covers every id pi reads a key
99
+ * for; env-allowlist.test.mjs pins that, and the order stays because it costs nothing and is the safe one
100
+ * the day the two diverge again.
96
101
  */
97
102
  export function piProviders() {
98
103
  return getBuiltinProviders();
@@ -108,8 +113,15 @@ export function piProviders() {
108
113
  * API-key credentials only; an OAuth/subscription login is refused (it expires, the container cannot refresh
109
114
  * it, and it is not the credential for an unattended service). Throws a config-tagged error (pre-spend
110
115
  * refusal) when neither source yields a credential.
116
+ *
117
+ * Third way in (issue #503), only for a provider pi does not know: a custom provider in the overlay `models.json`
118
+ * whose every model is served by a declared `keyless` model endpoint passes with no credential, and the answer is
119
+ * `{ PI_DISPATCH_KEYLESS: "keyless" }`, the fixed value its `"apiKey": "$PI_DISPATCH_KEYLESS"` resolves to in the job.
120
+ * `modelEndpoints` is the pickup's snapshot `{ endpoints, models }` (never a read of its own), so the free gate and
121
+ * the container env decide on the same declaration. A builtin provider whose baseUrl an operator pointed at a local
122
+ * server is NOT keyless (a named residual): pi knows it, so it still needs its key.
111
123
  */
112
- export function resolveProviderCredential({ provider, hostEnv, authFromPi = false, agentDir, readFile = readFileSync, forwardEnv = [] }) {
124
+ export function resolveProviderCredential({ provider, hostEnv, authFromPi = false, agentDir, readFile = readFileSync, forwardEnv = [], modelEndpoints = null }) {
113
125
  // The NAMES come from pi, the VALUES from `hostEnv`, and this asks each question of the thing that can
114
126
  // answer it (issue #311). It used to be one `providerKeyVars(provider, hostEnv)` call, which conflates
115
127
  // them: that is `findEnvKeys`, whose presence test falls back to the REAL `process.env` for any name the
@@ -126,9 +138,52 @@ export function resolveProviderCredential({ provider, hostEnv, authFromPi = fals
126
138
  // is the DI seam that was dishonest, which is worth fixing where the seam is the whole test surface.
127
139
  // ONE read per name, checked and returned, for the same reason: two reads could decide on one value and
128
140
  // ship another.
129
- const held = providerKeyCandidates(provider)
141
+ const candidates = providerKeyCandidates(provider);
142
+ // Issue #503: a provider pi does not know at all. The same two questions, in the same order, as the auth.json
143
+ // refusal below and doctor's `noKeyVariableCheck`: no key variable, and not in pi's catalog. Such a provider can
144
+ // never take a key through this gate (there is no variable to write it under), so it is answered HERE, before
145
+ // auth.json is read: either it is keyless, or it is refused with both ways in named. That used to be decided
146
+ // after an auth.json read, which could only end in a refusal too, and whose wording ("set the key in .env, or run
147
+ // `pi login`") was advice no custom provider can follow.
148
+ if (candidates.length === 0 && !piProviders().includes(provider)) {
149
+ const ids = keylessEndpointsFor(provider, modelEndpoints);
150
+ // The one variable this branch writes, fixed and non-secret. It is the credential's slot in the closed map,
151
+ // so buildContainerEnv carries it exactly when this gate passes keyless, and on no other job.
152
+ if (ids !== null) return { [KEYLESS_ENV_NAME]: KEYLESS_VALUE };
153
+ // The overlay could not be read at this pickup for a TRANSIENT reason (index.mjs, `modelsUnreadable`): the provider
154
+ // may well be keyless, so this is no verdict at all. UNTAGGED and marked transient, never `piDispatchConfig`: a
155
+ // config refusal is permanent, refunded and commented publicly, and the same job may pass on the next attempt. The
156
+ // processor turns this into an infra retry (CONST-RETRY-INFRA-ONLY); absent or invalid JSON stays determinate.
157
+ const unreadable = modelEndpoints?.modelsUnreadable;
158
+ if (unreadable && Array.isArray(modelEndpoints?.endpoints) && modelEndpoints.endpoints.length > 0) {
159
+ const error = new Error(`the overlay models.json could not be read at this pickup (${unreadable.code}), so whether provider "${provider}" is keyless is not known yet`);
160
+ error.piDispatchTransient = true;
161
+ error.code = unreadable.code;
162
+ throw error;
163
+ }
164
+ throw configError(unknownProviderMessage(provider));
165
+ }
166
+ const held = candidates
130
167
  .map((name) => [name, hostEnv[name]])
131
168
  .filter(([, value]) => value);
169
+ // EVERY held candidate is forwarded, in pi's order, including the two that are not API keys: a host
170
+ // ANTHROPIC_OAUTH_TOKEN and (from the 0.99.1 pin, issue #509) a host ANTHROPIC_AUTH_TOKEN, which pi reads
171
+ // FIRST among the variables and sends as `Authorization: Bearer`. Forwarded rather than refused, and the
172
+ // choice is the same one for both: the operator put the variable in the worker's environment, a gateway
173
+ // bearer token is a legitimate service credential, and the job env should carry what the worker's own
174
+ // environment says rather than a filtered version of it.
175
+ //
176
+ // That does NOT make the job agree with pi on this host in every case, and the case where it does not
177
+ // is stated rather than implied: the ENVIRONMENT is this function's first source and auth.json only its
178
+ // fallback, while pi itself takes a stored api_key credential FIRST (pi-ai/dist/auth/helpers.js, "a
179
+ // stored credential key wins") and the environment only without one. So with an api_key login in
180
+ // auth.json AND a host token set, the job spends the token while `pi` on the host would spend the stored
181
+ // key. Kept that way because the env-first order is this function's documented contract (its header)
182
+ // and the token is the operator's explicit setting; `doctor` is where it is said out loud, a warning
183
+ // that names the API key the token shadows and, in this case, that the auth.json key is not used while
184
+ // the token is set.
185
+ // What is NEVER done is WRITING an auth.json key under either token name: that choice is
186
+ // `apiKeyVariable`'s, below.
132
187
  if (held.length > 0) {
133
188
  return Object.fromEntries(held);
134
189
  }
@@ -136,7 +191,26 @@ export function resolveProviderCredential({ provider, hostEnv, authFromPi = fals
136
191
  const { name, value } = credentialFromPiAuth(provider, agentDir ?? defaultAgentDir(hostEnv), readFile, { hostEnv, forwardEnv });
137
192
  return { [name]: value };
138
193
  }
139
- throw configError(`provider ${provider} has no configured credential in the worker environment`);
194
+ throw configError(`provider ${provider} has no configured credential in the worker environment. Set its key there, or, ${KEYLESS_HOW}.`);
195
+ }
196
+
197
+ /** The value of `PI_DISPATCH_KEYLESS`: fixed and non-secret, since the model server takes no key. */
198
+ export const KEYLESS_VALUE = "keyless";
199
+
200
+ /**
201
+ * The keyless endpoint ids serving this provider, or null (issue #503). Asked only for a provider pi does not know (the
202
+ * caller's predicate), against the pickup's snapshot `{ endpoints, models }` (index.mjs, one read per pickup). No
203
+ * snapshot, or no endpoint declared, is null: with nothing declared the overlay is never read, and nothing is keyless.
204
+ */
205
+ export function keylessEndpointsFor(provider, modelEndpoints) {
206
+ const endpoints = modelEndpoints?.endpoints;
207
+ if (!Array.isArray(endpoints) || endpoints.length === 0) return null;
208
+ const verdict = keylessVerdict({ models: modelEndpoints.models, provider, endpoints });
209
+ return verdict.keyless ? verdict.endpoints : null;
210
+ }
211
+
212
+ function unknownProviderMessage(provider) {
213
+ return `pi has no provider "${provider}", so there is no key variable to give it a key. Use one of pi's provider ids with its key in the worker environment or in pi's auth.json, or, ${KEYLESS_HOW}.`;
140
214
  }
141
215
 
142
216
  function defaultAgentDir(hostEnv) {
@@ -215,8 +289,8 @@ function credentialFromPiAuth(provider, agentDir, readFile, { hostEnv = {}, forw
215
289
  const name = resolveEnvName(provider);
216
290
  if (!name) {
217
291
  // Two different facts, and they need different fixes -- the same split `doctor` makes, in the same
218
- // order (candidates first, catalog second: `radius` has a key variable and no catalog entry, so
219
- // asking membership first would call a working configuration unknown). The old message said "set it
292
+ // order (candidates first, catalog second: at 0.80.7 `radius` had a key variable and no catalog entry,
293
+ // so asking membership first would have called a working configuration unknown). The old message said "set it
220
294
  // in the worker environment manually" for BOTH, which for the first is advice `doctor` correctly
221
295
  // calls impossible: there is no variable to set.
222
296
  if (piProviders().includes(provider)) {
@@ -224,7 +298,9 @@ function credentialFromPiAuth(provider, agentDir, readFile, { hostEnv = {}, forw
224
298
  `pi authenticates "${provider}" without an API-key environment variable (an AWS profile or an OAuth login), and the container env is a closed set of variables, so it has no way in. Configure a provider whose credential is a single environment variable.`,
225
299
  );
226
300
  }
227
- throw configError(`could not determine the environment variable pi expects for provider "${provider}" — pi has no such provider, so check PI_PROVIDER against pi's own ids`);
301
+ // Unreachable through resolveProviderCredential, which answers an unknown provider before auth.json is read. Kept
302
+ // as the backstop with the same words, so a later caller cannot reopen the old advice.
303
+ throw configError(unknownProviderMessage(provider));
228
304
  }
229
305
  return { name, value: cred.key };
230
306
  }
@@ -239,7 +315,7 @@ function credentialFromPiAuth(provider, agentDir, readFile, { hostEnv = {}, forw
239
315
  * plus a conventional `<PROVIDER>_API_KEY`/`_KEY`. That convention is the part that drifts, and it was
240
316
  * wrong for 13 of the 34 provider ids that have a key variable at the pin -- `google` is
241
317
  * `GEMINI_API_KEY`, `huggingface` is `HF_TOKEN`, `moonshotai` is `MOONSHOT_API_KEY`, `github-copilot` is
242
- * `COPILOT_GITHUB_TOKEN`, `radius` is `PI_GATEWAY_API_KEY` -- so for those pi recognized nothing and a
318
+ * `COPILOT_GITHUB_TOKEN`, `radius` was `PI_GATEWAY_API_KEY` (RADIUS_API_KEY at 0.99.1) -- so for those pi recognized nothing and a
243
319
  * valid `pi login` refused every job. The convention happening to be right for the other 21 is what let
244
320
  * this survive: `anthropic` and `openai` are both in that set.
245
321
  *
@@ -251,7 +327,9 @@ function credentialFromPiAuth(provider, agentDir, readFile, { hostEnv = {}, forw
251
327
  * environment where every name is present -- which also closes a hermeticity hole the synthetic object
252
328
  * had: pi's `getProviderEnvValue` falls back to the real `process.env`, so on a host that exported
253
329
  * `ANTHROPIC_OAUTH_TOKEN` the old code resolved to THAT name. `apiKeyVariable` then picks the same
254
- * variable `doctor` names, from the same module, so the two cannot diverge again.
330
+ * variable `doctor` names, from the same module, so the two cannot diverge again. It skips both
331
+ * non-API-key kinds: the OAuth token, and (from the 0.99.1 pin, issue #509) the bearer token
332
+ * ANTHROPIC_AUTH_TOKEN, which pi lists first and sends as `Authorization: Bearer`.
255
333
  */
256
334
  function resolveEnvName(provider) {
257
335
  return apiKeyVariable(providerKeyCandidates(provider));
@@ -273,12 +351,14 @@ function resolveEnvName(provider) {
273
351
  * `allowGlobalExtensions` defaults to TRUE here, matching loadConfig's default (REQ-GLOBAL-PI-OVERLAY): a
274
352
  * caller that says nothing gets the operator's staged setup, and only an explicit `false` withholds it.
275
353
  */
276
- export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync }) {
354
+ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false }) {
277
355
  // The provider credential(s), by pi's expected variable name(s) -- from the worker env, or (when
278
356
  // PI_AUTH_FROM_PI is set and the env has none) host-side from pi's auth.json. Throws (config) if
279
357
  // neither source yields one, which the processor turns into a policy refusal that refunds any reserve
280
358
  // (issue #310); the same call is made by its free credential gate, before anything is reserved at all.
281
- const credEnv = resolveProviderCredential({ provider, hostEnv, authFromPi, agentDir, readFile, forwardEnv });
359
+ // `modelEndpoints` is the pickup's snapshot (issue #503): the keyless branch answers `PI_DISPATCH_KEYLESS` in this
360
+ // map, so the variable is set exactly when the gate passed keyless and is absent on every other job.
361
+ const credEnv = resolveProviderCredential({ provider, hostEnv, authFromPi, agentDir, readFile, forwardEnv, modelEndpoints });
282
362
 
283
363
  const env = {
284
364
  PI_PROVIDER: provider,
@@ -333,6 +413,13 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId,
333
413
  // second-validator rule directly above). Absent means the full pinned default set, never an empty
334
414
  // string, for PI_PACKAGES' reason.
335
415
  PI_EXCLUDE_TOOLS: excludeTools.length > 0 ? excludeTools.join(",") : undefined,
416
+ // The job's EFFECTIVE allowed-model list (issue #502): its trigger's `run.models`, else the deployment's
417
+ // PI_ALLOWED_MODELS. Comma-joined `provider/model` entries, which the runner splits at each entry's first `/`
418
+ // (image/runner/src/config.mjs). Comma is safe because both list parsers refuse an entry carrying one. NULL is
419
+ // unrestricted and emits NO variable, never an empty string: the runner refuses an empty value as a config
420
+ // error, because an empty allow list read as "unset" would fail open. An image too old to enforce a list is
421
+ // refused before this is ever built (`modelPolicy`, CAPABILITY_GATES).
422
+ PI_ALLOWED_MODELS: Array.isArray(allowedModels) && allowedModels.length > 0 ? allowedModels.join(",") : undefined,
336
423
  // Kill switch for job-time package installation, UNCONDITIONAL for every job. pi's resolver shells out
337
424
  // to a REAL `npm install` for any npm:/git: source unless offline mode is on, and `~/.pi/agent` IS
338
425
  // writable in the container. We emit only local paths, so nothing should reach that branch -- this
@@ -408,6 +495,43 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId,
408
495
  // env-internal HOME: written into the job's closed env map here, never read from the worker's environment.
409
496
  if (typeof home === "string" && home !== "") env.HOME = home;
410
497
 
498
+ // Issue #503: PI_DISPATCH_KEYLESS is the credential gate's answer and nothing else's, so it is settled AFTER the
499
+ // PI_FORWARD_ENV and secrets loops, as HOME and the egress variables are: both lists refuse the name upstream, and
500
+ // this is the backstop that keeps a value from either one from replacing it, or from appearing on a keyed job.
501
+ if (credEnv[KEYLESS_ENV_NAME] === KEYLESS_VALUE) env[KEYLESS_ENV_NAME] = KEYLESS_VALUE;
502
+ else delete env[KEYLESS_ENV_NAME];
503
+
504
+ // Issue #501: the per-job dollar cap is the worker's answer and nothing else's, so it is settled again AFTER the
505
+ // PI_FORWARD_ENV and secrets loops, PI_DISPATCH_KEYLESS's backstop. Both lists refuse the name upstream (config.mjs
506
+ // refuses every CONTAINER_ENV_NAMES member in PI_FORWARD_ENV at boot, #502; the loader refuses it in run.secrets);
507
+ // this is the line that holds if either refusal is ever bypassed. A forwarded or secret PI_MAX_COST_MICROS would
508
+ // otherwise replace the computed cap (measured: a host value of 1e12 beat a computed 500000), and on a job with
509
+ // no cap it would send one past the `costCap` gate, which only runs for capped jobs, to an image that may ignore it.
510
+ // It is set ONLY here, once, after both loops. `!== null`, never truthiness: 0 is a real cap (no priced call at
511
+ // all, which a fail-closed job value reads as, `effectiveCostCapMicros`), and a truthy test would send NO cap for
512
+ // it, the widest one there is. Absent (null or undefined) leaves no variable, so a job with no cap carries the env
513
+ // it always did, and never an empty string (the runner refuses one).
514
+ // env-internal PI_MAX_COST_MICROS: written into the job's closed env map here, never read from the worker's environment.
515
+ if (maxCostMicros === null || maxCostMicros === undefined) delete env.PI_MAX_COST_MICROS;
516
+ else env.PI_MAX_COST_MICROS = String(maxCostMicros);
517
+
518
+ // Issue #545: the exit line's key waits on the container's stdin, and this variable tells the runner to read it.
519
+ // The value names the channel and is never the key: the environment is readable from /proc/1/environ by every
520
+ // process in the container (measured), which is why the key does not travel here. Settled after both loops like
521
+ // the cap above, and ONLY for a run the worker actually hands a key, so a runner never blocks on a stdin no one
522
+ // writes. `=== true`, so only run-container's own boolean asks for it.
523
+ // env-internal PI_EXIT_AUTH: written into the job's closed env map here, never read from the worker's environment.
524
+ if (exitAuth === true) env.PI_EXIT_AUTH = "stdin";
525
+ else delete env.PI_EXIT_AUTH;
526
+
527
+ // Issue #500: the runner sets these two in its own environment, for its child processes, and no value from outside
528
+ // may arrive first. Both lists refuse them upstream (config.mjs for PI_FORWARD_ENV, the triggers loader for
529
+ // run.secrets, through RUNNER_ENV_NAMES); this is the line that holds if either refusal is ever bypassed.
530
+ // env-internal PI_DISPATCH_CHILD_LEDGER: set by the runner inside the container, never by the worker, so removed here.
531
+ delete env.PI_DISPATCH_CHILD_LEDGER;
532
+ // env-internal PI_DISPATCH_RUNNER_PID: set by the runner inside the container, never by the worker, so removed here.
533
+ delete env.PI_DISPATCH_RUNNER_PID;
534
+
411
535
  // Forge-backed jobs, and local cron jobs that opted in via run.github. Other local-folder jobs have
412
536
  // no token (CONST-TOKEN-SCOPED-PER-JOB). The mint goes into BOTH of its forge's variables because
413
537
  // each CLI has its own preference -- gh prefers GH_TOKEN over GITHUB_TOKEN, glab prefers GITLAB_TOKEN
package/src/env-file.mjs CHANGED
@@ -17,7 +17,7 @@
17
17
  * Deliberately dependency-free (node:fs only, and only in the thin wrapper): it must stay importable
18
18
  * from any future setup command without dragging worker config or queue deps along.
19
19
  */
20
- import { chmodSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
20
+ import { chmodSync, chownSync, closeSync, constants as fsConstants, fchmodSync, fchownSync, fstatSync, fsyncSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
21
21
 
22
22
  /**
23
23
  * Pure transform over .env TEXT: set `key` to `value` only where nothing is set yet.
@@ -104,6 +104,15 @@ function replacementLines(key, value, bare, wasComment, opts) {
104
104
  * character, a `"`, a `%`, a `!`, a `^`, a character outside ASCII, and an `=` at the start of the value. Each
105
105
  * row there says whether it is cmd's documented behaviour or a cautious refusal. `'` is ordinary there.
106
106
  */
107
+ /**
108
+ * The `code` on every error `renderEnvValue` throws (issue #522): the VALUE cannot be written, so a caller may add advice
109
+ * about the value (`up` says where its path came from). Every other refusal of the writer is about the FILE and carries
110
+ * its own fix, and `up` once gave those this one's advice, telling a gid mismatch to move the deployment somewhere
111
+ * "without that character in its path".
112
+ */
113
+ export const ENV_VALUE_UNWRITABLE = "ENV_VALUE_UNWRITABLE";
114
+ const unwritableValue = (message) => Object.assign(new Error(message), { code: ENV_VALUE_UNWRITABLE });
115
+
107
116
  export function renderEnvValue(value, { platform = process.platform } = {}) {
108
117
  const v = String(value);
109
118
  // The Windows loader keeps surrounding quotes as part of the value ("Values MUST be UNQUOTED", its own
@@ -111,16 +120,16 @@ export function renderEnvValue(value, { platform = process.platform } = {}) {
111
120
  // refused rather than dressed in quotes that become part of a path.
112
121
  if (platform === "win32") {
113
122
  const bad = cmdValueRefusal(v);
114
- if (bad !== null) throw new Error(`cannot write this value into a .env on Windows: it contains ${bad}, and the .cmd wrapper (deploy/worker-env-wrapper.cmd) cannot be shown to read that back as written. Choose a value without it, or give the service this key through its own environment (pi-dispatch service install --env-setup)`);
123
+ if (bad !== null) throw unwritableValue(`cannot write this value into a .env on Windows: it contains ${bad}, and the .cmd wrapper (deploy/worker-env-wrapper.cmd) cannot be shown to read that back as written. Choose a value without it, or give the service this key through its own environment (pi-dispatch service install --env-setup)`);
115
124
  return v;
116
125
  }
117
126
  if (UNQUOTED_PLAIN.test(v)) return v;
118
- if (v.includes("'") || /[\n\r]/.test(v)) throw new Error(`cannot write this value into a .env safely: ${v.includes("'") ? "it contains a single quote" : "it contains a newline"}`);
127
+ if (v.includes("'") || /[\n\r]/.test(v)) throw unwritableValue(`cannot write this value into a .env safely: ${v.includes("'") ? "it contains a single quote" : "it contains a newline"}`);
119
128
  // Gate round 2 of PR #478: a control or invisible character is read alike by every loader inside single quotes, but
120
129
  // the reader never vouches for it (`QUOTED_CONTROL`: printing it rewrites a terminal), so writing it quoted gave doctor
121
130
  // a line it calls unread. Refused instead, naming the character, never the value.
122
131
  const hidden = invisibleCharacter(v);
123
- if (hidden !== null) throw new Error(`cannot write this value into a .env safely: it contains ${hidden}, which doctor would not show back. Remove it`);
132
+ if (hidden !== null) throw unwritableValue(`cannot write this value into a .env safely: it contains ${hidden}, which doctor would not show back. Remove it`);
124
133
  return `'${v}'`;
125
134
  }
126
135
 
@@ -869,6 +878,104 @@ export function envFileEditCheck(content, path, key, value, opts = {}) {
869
878
  return planEnvEdit(content, path, key, value, opts).error ?? null;
870
879
  }
871
880
 
881
+ /**
882
+ * The group-mismatch refusal (issue #522), its own sentence with its own fix. The group is named where `/etc/group`
883
+ * names it, because "gid 0" sends an operator to look it up and `wheel` does not. It is reached only where the writer
884
+ * could not keep the group (`updateEnvFile` gives the new file the old one wherever this account is in it), and only
885
+ * for a file this account OWNS (the owner refusal comes first), so "run it as another account" is no fix: any other
886
+ * account, a member of that group or root, gets the owner refusal instead. The two fixes are this account joining that
887
+ * group (a new login picks it up), or a group this account is in: `own`, this process's primary group, which an
888
+ * owner can always give its file, and which every later rewrite then keeps. The second is the operator's decision where
889
+ * a service reads the file through its present group, so the sentence says so rather than issuing the chgrp.
890
+ */
891
+ export function groupRefusal(target, group, made, own, groupName = groupNameOf) {
892
+ const named = (id) => {
893
+ const name = groupName(id);
894
+ return name ? `${name} (gid ${id})` : `gid ${id}`;
895
+ };
896
+ const fix = own === null ? "" : `; or, if nothing reads this file through its present group, run \`chgrp ${groupName(own) ?? own} ${quotedForShell(target)}\` and run this again`;
897
+ return `refusing to edit ${target}: its group is ${named(group)}, which this account could not give the new file, and a new file written beside it gets ${named(made)}, so rewriting it (a new file renamed over it) would change its group, which is how a .env the service reads through its group stops being readable. To fix it, add this account to ${groupName(group) ?? `gid ${group}`} and log in again${fix}; otherwise edit the file by hand. Nothing was written`;
898
+ }
899
+
900
+ /** A group's name from `/etc/group`, or `null` (no such line, no such file, or not a POSIX host). */
901
+ export function groupNameOf(id, read = () => readFileSync("/etc/group", "utf8")) {
902
+ try {
903
+ for (const line of String(read()).split("\n")) {
904
+ if (line.startsWith("#")) continue;
905
+ const [name, , gid] = line.split(":");
906
+ // A digits-only field, so an empty one (which `Number` reads as 0) never names gid 0.
907
+ if (name && /^\d+$/.test(gid ?? "") && Number(gid) === id) return name;
908
+ }
909
+ } catch {
910
+ // No group database to read: the number alone is said.
911
+ }
912
+ return null;
913
+ }
914
+
915
+ /** `p` as one shell word: bare when it is plainly safe, single-quoted otherwise. */
916
+ function quotedForShell(p) {
917
+ return /^[A-Za-z0-9_./@%+=:,-]+$/.test(p) ? p : `'${String(p).replace(/'/g, "'\\''")}'`;
918
+ }
919
+
920
+ /**
921
+ * Every call `updateEnvFile` makes, for a production caller's fs seam to spread (issue #522's review). The descriptor
922
+ * calls are optional on the seam, because a test fake has none, so a seam that drops one does not fail: the writer
923
+ * quietly goes back to chowning and chmodding the tmp BY PATH, which a swapped tmp redirects. One list, spread by
924
+ * `up`, `service install` and `setup github`, and pinned by a test that reads all four objects.
925
+ */
926
+ export const ENV_WRITER_FS = Object.freeze({ readFileSync, writeFileSync, renameSync, statSync, chmodSync, chownSync, realpathSync, unlinkSync, openSync, fstatSync, fchownSync, fchmodSync, fsyncSync, closeSync });
927
+
928
+ /** `open(2)` flags for the tmp: created here or refused, never through a link (`O_NOFOLLOW` is POSIX-only; 0 elsewhere). */
929
+ const EXCLUSIVE_CREATE = fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0);
930
+
931
+ /**
932
+ * The tmp, created exclusively at 0600 with `content`, and the four things the writer does to it. Through a DESCRIPTOR
933
+ * where the seam has one (every production seam), so a path swapped after the create is never what is chowned or
934
+ * chmodded; a seam without `openSync` (a test fake) gets the same calls by path, still with the exclusive create.
935
+ */
936
+ function createTmp(fs, tmp, content) {
937
+ if (typeof fs.openSync === "function") {
938
+ const fd = fs.openSync(tmp, EXCLUSIVE_CREATE, 0o600);
939
+ try {
940
+ fs.writeFileSync(fd, content);
941
+ } catch (err) {
942
+ fs.closeSync(fd);
943
+ try {
944
+ fs.unlinkSync?.(tmp);
945
+ } catch {
946
+ // The write's error is the one to report.
947
+ }
948
+ throw err;
949
+ }
950
+ let open = true;
951
+ const shut = () => {
952
+ if (open) fs.closeSync(fd);
953
+ open = false;
954
+ };
955
+ return {
956
+ gid: () => fs.fstatSync(fd).gid,
957
+ chgrp: (g) => fs.fchownSync(fd, -1, g),
958
+ chmod: (m) => fs.fchmodSync(fd, m),
959
+ close: () => {
960
+ try {
961
+ fs.fsyncSync?.(fd);
962
+ } finally {
963
+ shut();
964
+ }
965
+ },
966
+ abandon: shut,
967
+ };
968
+ }
969
+ fs.writeFileSync(tmp, content, { mode: 0o600, flag: "wx" });
970
+ return {
971
+ gid: () => fs.statSync(tmp).gid,
972
+ chgrp: (g) => fs.chownSync(tmp, -1, g),
973
+ chmod: (m) => fs.chmodSync(tmp, m),
974
+ close: () => {},
975
+ abandon: () => {},
976
+ };
977
+ }
978
+
872
979
  /**
873
980
  * Read → transform → write back ATOMICALLY (tmp + rename, the same shape as the admin's
874
981
  * writeTriggers), so a watcher or a concurrent reader never sees a half-written .env. When the
@@ -895,7 +1002,7 @@ export function updateEnvFile(path, key, value, deps = {}) {
895
1002
  // and the cmd wrapper kept the quotes, making the path the worker loads wrong behind a ✓ on the key
896
1003
  // that decides forge auth. A rendering rule that every writer of this file must remember is a rule one
897
1004
  // of them will forget.
898
- const { fs = { readFileSync, writeFileSync, renameSync, statSync, chmodSync, realpathSync }, overwrite = false, platform = process.platform, narrow = false } = deps;
1005
+ const { fs = ENV_WRITER_FS, overwrite = false, platform = process.platform, narrow = false } = deps;
899
1006
  // Every refusal is `planEnvEdit`'s (bytes first, never clobber, read back), made before anything is written.
900
1007
  const plan = planEnvEdit(fs.readFileSync(path), path, key, value, { overwrite, platform, verify: deps.verify });
901
1008
  if (plan.error) throw new Error(plan.error);
@@ -911,42 +1018,104 @@ export function updateEnvFile(path, key, value, deps = {}) {
911
1018
  } catch {
912
1019
  // Not resolvable (a dangling link, a fs without the call): edit the path we were given.
913
1020
  }
914
- // OWNERSHIP, before anything is written. `renameSync` makes a new inode owned by whoever runs this, so
1021
+ // OWNERSHIP, before the file is replaced. `renameSync` makes a new inode owned by whoever runs this, so
915
1022
  // a root- or `pi`-owned `.env` at 0640 that the service reads through its group comes back owned by the
916
1023
  // operator: the service account loses read access, and `deploy/worker.service` uses a bare
917
1024
  // `EnvironmentFile=` (fatal, not `-`), so the unit stops starting. Widening the mode to compensate
918
1025
  // would publish a file holding WEBHOOK_SECRET. Refusing is the only honest third option, and the caller
919
- // turns it into a line rather than a stack trace.
1026
+ // turns it into a line rather than a stack trace. Each refusal carries its own fix (issue #522).
920
1027
  const uid = typeof process.getuid === "function" ? process.getuid() : null;
921
1028
  const gid = typeof process.getgid === "function" ? process.getgid() : null;
1029
+ let group = null;
922
1030
  if (uid !== null) {
923
1031
  try {
924
- const { uid: owner, gid: group } = fs.statSync(target);
925
- // GID as well as UID, because the layout this protects is a `.env` at 0640 read by the service
926
- // THROUGH ITS GROUP. With `bob:pi 0640` and the operator in group `pi` the uid matches, the
927
- // rename still makes a new inode with the writer's primary gid, and the `pi` service loses read
928
- // access exactly as it would have on a uid mismatch.
929
- if (typeof owner === "number" && owner !== uid) throw new Error(`refusing to edit ${target}: it is owned by uid ${owner} and this process is uid ${uid}, and rewriting it would hand it to the wrong account`);
930
- if (typeof group === "number" && gid !== null && group !== gid) throw new Error(`refusing to edit ${target}: its group is gid ${group} and this process is gid ${gid}, and rewriting it would hand it to the wrong group, which is how a 0640 .env stops being readable by the service`);
1032
+ const { uid: owner, gid: had } = fs.statSync(target);
1033
+ if (typeof owner === "number" && owner !== uid) throw new Error(`refusing to edit ${target}: it is owned by uid ${owner} and this process is uid ${uid}, and rewriting it (a new file renamed over it) would give it to this account, which is how a .env the service reads stops being readable. To fix it, run this as the account that owns it, or edit the file by hand. Nothing was written`);
1034
+ if (typeof had === "number") group = had;
931
1035
  } catch (err) {
932
1036
  if (err instanceof Error && err.message.startsWith("refusing to edit")) throw err;
933
1037
  // Cannot stat: fall through to the write, which will fail on its own terms if it must.
934
1038
  }
935
1039
  }
936
1040
  const tmp = `${target}.tmp`;
937
- fs.writeFileSync(tmp, next, { mode: 0o600 });
1041
+ // CREATED HERE, exclusively, or not at all (issue #522's review). The folder may be one another account can write (a
1042
+ // setgid 2770 folder shared with the service's group), and a `.env.tmp` planted there as a symlink was followed by
1043
+ // the write, by the chown that keeps the group, and by the chmod: content at the link's target, and that file's
1044
+ // group and mode changed. `O_EXCL` refuses any existing path, a symlink included (POSIX: not followed), and
1045
+ // `O_NOFOLLOW` says so twice where the platform has it; everything after the create goes through the descriptor, so
1046
+ // a tmp swapped after the create is not what is chowned or chmodded. An existing tmp is refused and left alone: it is
1047
+ // not this writer's to remove.
1048
+ let handle;
938
1049
  try {
939
- // The operator's mode, whatever it is, not just 0600. `.env` holds WEBHOOK_SECRET and provider
940
- // keys, and a rename from a fresh tmp lands at the process umask: 0640 and 0400 both came back
941
- // 0644, world-readable, on the one file this project says must never reach a scrollback.
942
- // `narrow` keeps only the owner's bits of it.
943
- const mode = fs.statSync(target).mode & 0o7777;
944
- fs.chmodSync(tmp, narrow ? mode & 0o7700 : mode);
945
- } catch {
946
- // The file vanished between read and write, or the fs cannot stat: the tmp keeps the 0600 it was
947
- // created with rather than failing an edit that is otherwise sound.
1050
+ handle = createTmp(fs, tmp, next);
1051
+ } catch (err) {
1052
+ if (err?.code === "EEXIST" || err?.code === "ELOOP") throw new Error(`refusing to edit ${target}: ${tmp} already exists (left by an edit that was interrupted, or put there by another account that can write this folder), and this writer only writes through a file it has just created. Remove it, then run this again. Nothing was written`);
1053
+ throw err;
1054
+ }
1055
+ // GROUP, as well as owner, because the layout this protects is a `.env` at 0640 read by the service THROUGH ITS GROUP:
1056
+ // with `bob:pi 0640` and the operator in group `pi`, the uid matches and the rename can still change the group.
1057
+ // MEASURED on the tmp, not predicted from this process's gid (issue #522). Which group a new file gets is the
1058
+ // folder's business: on macOS and the BSDs it is always the folder's group, and on Linux it is the folder's group
1059
+ // where the folder is setgid (or mounted `grpid`), else this process's. The prediction refused every edit to a
1060
+ // `.env` that `init` had just made in a folder of group `wheel` (gid 0, as `/private/tmp` is): the file took the
1061
+ // folder's group, this process's was `staff`, and the rewrite would have kept `wheel` all along. The tmp holds the
1062
+ // new content at 0600 and is removed before the refusal, so nothing the operator reads has changed.
1063
+ // EVERYTHING after the create removes the tmp on any failure (issue #522's review, round 2): a flush or a rename that
1064
+ // throws (EPERM from a Windows scanner holding the file, a full disk at fsync) left this writer's OWN tmp behind, and
1065
+ // every later edit then refused it as one that "already exists". Removing it here is safe where removing one found at
1066
+ // the create is not: this one was created by this call, exclusively. The original error is rethrown; where the tmp
1067
+ // cannot be removed, its message says where the new content was left instead of claiming nothing was written.
1068
+ try {
1069
+ if (group !== null) {
1070
+ let made = null;
1071
+ try {
1072
+ made = handle.gid();
1073
+ } catch {
1074
+ // Cannot stat what was just written: predict as a folder without setgid would make it.
1075
+ }
1076
+ if (typeof made !== "number") made = gid;
1077
+ // KEPT where this account may keep it: an owner may give a file any group it is in, so the tmp takes the file's
1078
+ // group and the rewrite changes nothing (`bob:pi` with the operator in `pi`, or a `staff` file in a `wheel`
1079
+ // folder). Refused only where that fails, which is a group this account is not in.
1080
+ if (made !== null && made !== group) {
1081
+ try {
1082
+ handle.chgrp(group);
1083
+ made = handle.gid();
1084
+ } catch {
1085
+ // EPERM, a group this account is not in: the refusal below says so.
1086
+ }
1087
+ }
1088
+ if (made !== null && made !== group) {
1089
+ throw new Error(groupRefusal(target, group, made, gid, deps.groupName ?? groupNameOf));
1090
+ }
1091
+ }
1092
+ try {
1093
+ // The operator's mode, whatever it is, not just 0600. `.env` holds WEBHOOK_SECRET and provider
1094
+ // keys, and a rename from a fresh tmp lands at the process umask: 0640 and 0400 both came back
1095
+ // 0644, world-readable, on the one file this project says must never reach a scrollback.
1096
+ // `narrow` keeps only the owner's bits of it.
1097
+ const mode = fs.statSync(target).mode & 0o7777;
1098
+ handle.chmod(narrow ? mode & 0o7700 : mode);
1099
+ } catch {
1100
+ // The file vanished between read and write, or the fs cannot stat: the tmp keeps the 0600 it was
1101
+ // created with rather than failing an edit that is otherwise sound.
1102
+ }
1103
+ handle.close();
1104
+ fs.renameSync(tmp, target);
1105
+ } catch (err) {
1106
+ handle.abandon();
1107
+ let removed = typeof fs.unlinkSync === "function";
1108
+ try {
1109
+ if (removed) fs.unlinkSync(tmp);
1110
+ } catch {
1111
+ removed = false;
1112
+ }
1113
+ if (!removed && err instanceof Error) {
1114
+ const left = `${target} itself was not changed, but the new content was left in ${tmp} (mode 0600), which could not be removed: remove it`;
1115
+ err.message = err.message.endsWith(". Nothing was written") ? `${err.message.slice(0, -"Nothing was written".length)}${left}` : `${err.message}. ${left}`;
1116
+ }
1117
+ throw err;
948
1118
  }
949
- fs.renameSync(tmp, target);
950
1119
  return { changed: true, ...(narrow ? { narrowed: true } : {}) };
951
1120
  }
952
1121