@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
@@ -1,9 +1,38 @@
1
+ /**
2
+ * Build the EXACT environment a job container receives. Never a pass-through.
3
+ *
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.
8
+ *
9
+ * The provider key variable is DERIVED from pi's own table, not hardcoded. Deriving it means any of
10
+ * pi's ~40 providers works with no code change here, and the list cannot drift when pi adds one, as a
11
+ * hand-copied table would. `getApiKeyEnvVars` (that table) is intentionally NOT exported by pi, which
12
+ * is why `providerKeyCandidates` below has to recover it a different way.
13
+ *
14
+ * **There was a second wrapper here, `providerKeyVars`, and it is GONE (issue #309).** It was a
15
+ * one-line pass-through to `findEnvKeys(provider, hostEnv)`, which filters pi's list by PRESENCE and
16
+ * returns a single `undefined` for both "no such provider" and "known provider, nothing set". Every
17
+ * caller it ever had asked it the wrong question: `resolveProviderCredential` wanted the names and
18
+ * read the values elsewhere (issue #311), and `secrets.mjs`'s reserved-name gate wanted what a trigger
19
+ * may not NAME, which is a property of the provider and not of this host. The presence filter also
20
+ * leaks: pi's `getProviderEnvValue` is `env?.[name] || process.env[name]`, so it reports names the
21
+ * MACHINE carries as well. It is deleted rather than re-documented because an exported helper that
22
+ * answers a subtly wrong question does not stay uncalled; both defects in this cluster were somebody
23
+ * reaching for it. pi's own behaviour is still pinned, against `findEnvKeys` directly, in
24
+ * `worker/test/env-allowlist.test.mjs`.
25
+ */
26
+
1
27
  import { readFileSync } from "node:fs";
2
28
  import { homedir } from "node:os";
3
29
  import { join } from "node:path";
4
30
  import { findEnvKeys } from "@earendil-works/pi-ai/compat";
31
+ import { getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
5
32
  import { egressEnv } from "./egress.mjs";
6
33
  import { forgeSpec } from "./forges.mjs";
34
+ import { apiKeyVariable } from "./provider-key.mjs";
35
+ import { isDeterminateFsCode } from "./transient.mjs";
7
36
 
8
37
  function configError(message) {
9
38
  const error = new Error(message);
@@ -12,25 +41,64 @@ function configError(message) {
12
41
  }
13
42
 
14
43
  /**
15
- * Build the EXACT environment a job container receives. Never a pass-through.
44
+ * An environment in which EVERY variable is set, used only to interrogate pi.
16
45
  *
17
- * `no-broad-env-into-container` is a BLOCKER, and for good reason: `ANTHROPIC_OAUTH_TOKEN`
18
- * silently outranks `ANTHROPIC_API_KEY`, so one stray host variable would redirect which
19
- * credential every job spends, with no error and no log line. So we forward a closed set.
46
+ * `findEnvKeys(provider, env)` filters pi's own candidate list by PRESENCE, so against an env where
47
+ * nothing is absent the filter is the identity and the return value IS pi's table for that provider,
48
+ * in pi's own precedence order. That is how `providerKeyCandidates` recovers a list pi deliberately
49
+ * does not export (`getApiKeyEnvVars` is module-private). It is a dependency on pi filtering rather
50
+ * than short-circuiting, which is stated here rather than discovered later: it holds at the pin, it is
51
+ * round-tripped for every provider by env-allowlist.test.mjs, and CONST-PI-VERSION-PINNED is what
52
+ * makes that test the upgrade gate.
20
53
  *
21
- * The provider key variable is DERIVED from pi's own table, not hardcoded. `findEnvKeys(provider,
22
- * env)` returns the provider's key variable names that are actually PRESENT in `env`, in
23
- * precedence order (OAuth before API key). Deriving it means:
24
- * - any of pi's ~30 providers works with no code change here;
25
- * - the list cannot drift when pi adds a provider (a hand-copied table would);
26
- * - `undefined` return === "this provider is not configured on this host" === refuse the job
27
- * BEFORE spending, rather than launch a container that will fail auth on the first call.
54
+ * A Proxy rather than a literal, and the second reason is the load-bearing one:
55
+ * - the candidate NAMES are the thing being asked for, so they cannot be enumerated in advance;
56
+ * - `getProviderEnvValue` (pi-ai/dist/utils/provider-env.js) falls back to the REAL `process.env`
57
+ * for any name the injected env has no value for. A plain `{}` therefore answers with whatever the
58
+ * machine happens to export -- a doctor run on a laptop would disagree with the server it is
59
+ * diagnosing, and every test injecting a fake env would be non-hermetic. A trap that always returns
60
+ * a non-empty string short-circuits that `||` before the fallback is reached.
61
+ * Strings only: a symbol read (Symbol.toPrimitive and friends) answering with a string would make this
62
+ * object lie about being one.
63
+ */
64
+ const EVERY_VAR_SET = new Proxy({}, { get: (_target, name) => (typeof name === "string" ? "set" : undefined) });
65
+
66
+ /**
67
+ * Every variable pi reads this provider's key from, in pi's precedence order, set or not. `[]` means pi
68
+ * reads no API-key variable for this id -- either there is no such provider, or it authenticates some
69
+ * other way. `piProviders` is what tells those two apart.
70
+ *
71
+ * This is the ONLY question this module asks pi about variable names, and that is the point (#286, #311,
72
+ * #309). Asking `findEnvKeys(provider, hostEnv)` instead answers "what does this HOST have", conflates
73
+ * "unknown provider" with "known provider, nothing set" in one `undefined`, and decides presence partly
74
+ * from the real `process.env`. Three callers wanted three different things from it and all three were
75
+ * better served by the candidate list plus a presence test of their own: `doctor` NAMES a variable an
76
+ * operator should set, `resolveProviderCredential` reads values out of the env it was handed, and
77
+ * `secrets.mjs` reserves what a trigger may not name.
78
+ */
79
+ export function providerKeyCandidates(provider) {
80
+ // The string filter is on the RESULT, not just on the trap. pi looks its provider up in a plain object
81
+ // literal, so `__proto__` and `constructor` resolve up the prototype chain and hand back a non-string
82
+ // "variable name"; against a real environment that candidate reads as undefined and pi drops it, but
83
+ // an environment where everything is present keeps it, and doctor would print `set [object Object] in
84
+ // .env`. pricing.mjs guards pi's other generated lookups the same way and for the same reason.
85
+ return (findEnvKeys(provider, EVERY_VAR_SET) ?? []).filter((name) => typeof name === "string");
86
+ }
87
+
88
+ /**
89
+ * pi's provider catalog, through the same side-effect-free specifier `pricing.mjs` uses -- never
90
+ * `getProviders` from "/compat", which is a @deprecated alias for this exact function and reaching it
91
+ * means loading compat's module-scope provider registration.
28
92
  *
29
- * `getApiKeyEnvVars` (the full candidate list) is intentionally NOT exported by pi; `findEnvKeys`
30
- * against our own process.env is the right tool anyway, because we only ever forward keys we have.
93
+ * Only ever consulted AFTER `providerKeyCandidates` comes back empty. At the 0.80.7 pin that order was
94
+ * load-bearing: `radius` was a purely dynamic provider with a real key variable and no catalog entry, so
95
+ * asking this first would have called a working configuration unknown. At the 0.99.1 pin (issue #509)
96
+ * radius is a builtin provider (key variable RADIUS_API_KEY) and the catalog covers every id pi reads a key
97
+ * for; env-allowlist.test.mjs pins that, and the order stays because it costs nothing and is the safe one
98
+ * the day the two diverge again.
31
99
  */
32
- export function providerKeyVars(provider, hostEnv) {
33
- return findEnvKeys(provider, hostEnv);
100
+ export function piProviders() {
101
+ return getBuiltinProviders();
34
102
  }
35
103
 
36
104
  /**
@@ -44,13 +112,49 @@ export function providerKeyVars(provider, hostEnv) {
44
112
  * it, and it is not the credential for an unattended service). Throws a config-tagged error (pre-spend
45
113
  * refusal) when neither source yields a credential.
46
114
  */
47
- export function resolveProviderCredential({ provider, hostEnv, authFromPi = false, agentDir, readFile = readFileSync }) {
48
- const envNames = providerKeyVars(provider, hostEnv);
49
- if (envNames && envNames.length > 0) {
50
- return Object.fromEntries(envNames.map((name) => [name, hostEnv[name]]));
115
+ export function resolveProviderCredential({ provider, hostEnv, authFromPi = false, agentDir, readFile = readFileSync, forwardEnv = [] }) {
116
+ // The NAMES come from pi, the VALUES from `hostEnv`, and this asks each question of the thing that can
117
+ // answer it (issue #311). It used to be one `providerKeyVars(provider, hostEnv)` call, which conflates
118
+ // them: that is `findEnvKeys`, whose presence test falls back to the REAL `process.env` for any name the
119
+ // given env lacks, so a name could be "present" on the strength of the machine and then be read out of
120
+ // an env that does not carry it, yielding `{ NAME: undefined }` -- a credential-shaped answer with no
121
+ // credential in it, and no fall through to the auth.json login that would have worked.
122
+ //
123
+ // `providerKeyCandidates` is the hermetic half of the same oracle, so the leaky question is not asked at
124
+ // all rather than asked and corrected. The result is identical by construction: a name truthy in
125
+ // `hostEnv` is always one `findEnvKeys` would have returned, and pi's precedence order is preserved.
126
+ // Truthiness matches pi's own filter, so a name kept here is a name pi would read.
127
+ //
128
+ // Unreachable in the shipped worker, where `hostEnv` IS `process.env` and the two agree by identity. It
129
+ // is the DI seam that was dishonest, which is worth fixing where the seam is the whole test surface.
130
+ // ONE read per name, checked and returned, for the same reason: two reads could decide on one value and
131
+ // ship another.
132
+ const held = providerKeyCandidates(provider)
133
+ .map((name) => [name, hostEnv[name]])
134
+ .filter(([, value]) => value);
135
+ // EVERY held candidate is forwarded, in pi's order, including the two that are not API keys: a host
136
+ // ANTHROPIC_OAUTH_TOKEN and (from the 0.99.1 pin, issue #509) a host ANTHROPIC_AUTH_TOKEN, which pi reads
137
+ // FIRST among the variables and sends as `Authorization: Bearer`. Forwarded rather than refused, and the
138
+ // choice is the same one for both: the operator put the variable in the worker's environment, a gateway
139
+ // bearer token is a legitimate service credential, and the job env should carry what the worker's own
140
+ // environment says rather than a filtered version of it.
141
+ //
142
+ // That does NOT make the job agree with pi on this host in every case, and the case where it does not
143
+ // is stated rather than implied: the ENVIRONMENT is this function's first source and auth.json only its
144
+ // fallback, while pi itself takes a stored api_key credential FIRST (pi-ai/dist/auth/helpers.js, "a
145
+ // stored credential key wins") and the environment only without one. So with an api_key login in
146
+ // auth.json AND a host token set, the job spends the token while `pi` on the host would spend the stored
147
+ // key. Kept that way because the env-first order is this function's documented contract (its header)
148
+ // and the token is the operator's explicit setting; `doctor` is where it is said out loud, a warning
149
+ // that names the API key the token shadows and, in this case, that the auth.json key is not used while
150
+ // the token is set.
151
+ // What is NEVER done is WRITING an auth.json key under either token name: that choice is
152
+ // `apiKeyVariable`'s, below.
153
+ if (held.length > 0) {
154
+ return Object.fromEntries(held);
51
155
  }
52
156
  if (authFromPi) {
53
- const { name, value } = credentialFromPiAuth(provider, agentDir ?? defaultAgentDir(hostEnv), readFile);
157
+ const { name, value } = credentialFromPiAuth(provider, agentDir ?? defaultAgentDir(hostEnv), readFile, { hostEnv, forwardEnv });
54
158
  return { [name]: value };
55
159
  }
56
160
  throw configError(`provider ${provider} has no configured credential in the worker environment`);
@@ -61,13 +165,30 @@ function defaultAgentDir(hostEnv) {
61
165
  return hostEnv.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
62
166
  }
63
167
 
64
- function credentialFromPiAuth(provider, agentDir, readFile) {
168
+ function credentialFromPiAuth(provider, agentDir, readFile, { hostEnv = {}, forwardEnv = [] } = {}) {
65
169
  const path = join(agentDir, "auth.json");
66
170
  let auth;
67
171
  try {
68
172
  auth = JSON.parse(readFile(path, "utf8"));
69
- } catch {
70
- throw configError(`no credential for provider "${provider}": not in the worker environment, and no pi login at ${path} — set the key in .env, or run \`pi login\``);
173
+ } catch (error) {
174
+ // ONLY the determinate failures are tagged. This used to be a bare `catch {}` that relabelled every
175
+ // read failure as "no pi login", and issue #310 is what made that expensive: the processor now turns
176
+ // a `piDispatchConfig` error into a refusal that is never retried, refunds the reserve, and posts
177
+ // publicly that the deployment is misconfigured. Under the old catch an EMFILE from fd exhaustion, an
178
+ // EIO on a network filesystem, an EACCES, an EISDIR, or a torn read while `pi login` rewrites the file
179
+ // all produced that verdict, on a deployment that was correctly configured the microsecond before and
180
+ // after. A determinate refusal is one the same inputs reproduce; these are not.
181
+ //
182
+ // Absent is determinate (there is no login), and so is unparseable (the file is there and wrong, and
183
+ // an operator has to fix it). Everything else propagates untagged, which is `CONST-RETRY-INFRA-ONLY`
184
+ // putting it back where it belongs: an infrastructure fault reported as itself, with its own message.
185
+ // The codes come from the shared rule rather than a second copy of them here (issue #316): this
186
+ // file's argument IS that rule, and restating it was how the identity modules came to disagree
187
+ // with the host modules about the same three conditions.
188
+ const absent = isDeterminateFsCode(error?.code);
189
+ if (!absent && !(error instanceof SyntaxError)) throw error;
190
+ const why = absent ? `nothing readable at ${path}` : `the pi login at ${path} is not valid JSON`;
191
+ throw configError(`no credential for provider "${provider}": not in the worker environment, and ${why} — set the key in .env, or run \`pi login\``);
71
192
  }
72
193
  const cred = auth?.[provider];
73
194
  if (!cred) throw configError(`no credential for provider "${provider}": not in the worker environment, and ${path} has no "${provider}" login — set the key in .env, or run \`pi login\``);
@@ -77,24 +198,86 @@ function credentialFromPiAuth(provider, agentDir, readFile) {
77
198
  );
78
199
  }
79
200
  if (cred.type !== "api_key" || !cred.key) throw configError(`unsupported pi credential for "${provider}" in ${path} — set an API key in .env`);
80
- const name = resolveEnvName(provider, cred);
201
+ // `typeof`, not truthiness. A hand-edited auth.json can hold a number, an array or an object here (pi
202
+ // parses the file and validates no schema), and the old `!cred.key` guard passed all three: an object
203
+ // became `-e ANTHROPIC_API_KEY=[object Object]` in a paid container. Issue #311 made this reachable for
204
+ // twelve more providers, so it is guarded here rather than left to the coercion.
205
+ if (typeof cred.key !== "string") throw configError(`the pi login for "${provider}" in ${path} does not hold a string API key — run \`pi login\` again, or set the key in .env`);
206
+ // **The worker forwards this value; pi RESOLVES it.** `auth-storage.js` reads the stored key through
207
+ // `resolveConfigValue(cred.key, cred.env)`, a grammar where a leading "!" runs the rest as a SHELL
208
+ // COMMAND and takes stdout, "$VAR"/"${VAR}" interpolate from the environment, and "$$"/"$!" escape. An
209
+ // environment variable is read raw, through no such grammar, so a login stored in any of those forms
210
+ // arrives in the container as its own source text and every job spends a container to fail auth, with
211
+ // `doctor` green because the field is a non-empty string.
212
+ //
213
+ // This REFUSES rather than resolving. Running the command host-side is not on the table (it would
214
+ // execute operator shell out of a credential file, per job, in the worker); interpolating "$VAR" would
215
+ // be reimplementing a grammar pi does not export, which is `no-reimplementing-pi` and the same trap
216
+ // #311 is about. The test is deliberately a superset of pi's parse: any "$" at all, not a parse of one.
217
+ // A key that pi would have passed through unchanged contains neither character, and over-refusing
218
+ // pre-spend costs nothing, which is exactly the trade `CONST-BUDGET-BEFORE-TOKENS` asks for.
219
+ if (cred.key.startsWith("!") || cred.key.includes("$")) {
220
+ throw configError(
221
+ `the pi login for "${provider}" in ${path} is a command or a variable reference, not a literal key. pi resolves that form itself; this service forwards the value to a container, where it is read as-is. Resolve it and set the key in .env instead.`,
222
+ );
223
+ }
224
+ // The credential's companion config (`cred.env`), which is NOT a variable-name hint -- it is a
225
+ // `Record<string, string>` of provider settings, and for both Cloudflare providers pi returns NO auth at
226
+ // all without `CLOUDFLARE_ACCOUNT_ID` (and `CLOUDFLARE_GATEWAY_ID` for the gateway). The container env
227
+ // is a closed set, so these names do not ride along with the key; pi does fall back to the ambient
228
+ // environment for them, which makes `PI_FORWARD_ENV` a real answer rather than a shrug. Refuse only for
229
+ // the names that will NOT arrive, so a deployment that already forwards them is untouched.
230
+ const missing = Object.keys(cred.env ?? {}).filter((n) => !(forwardEnv.includes(n) && hostEnv?.[n]));
231
+ if (missing.length > 0) {
232
+ throw configError(
233
+ `the pi login for "${provider}" carries provider settings the container will not receive (${missing.join(", ")}). The job env is a closed set. Set ${missing.join(" and ")} in the worker environment and list ${missing.length > 1 ? "them" : "it"} in PI_FORWARD_ENV.`,
234
+ );
235
+ }
236
+ const name = resolveEnvName(provider);
81
237
  if (!name) {
82
- throw configError(`could not determine the environment variable pi expects for provider "${provider}" — set it in the worker environment manually`);
238
+ // Two different facts, and they need different fixes -- the same split `doctor` makes, in the same
239
+ // order (candidates first, catalog second: at 0.80.7 `radius` had a key variable and no catalog entry,
240
+ // so asking membership first would have called a working configuration unknown). The old message said "set it
241
+ // in the worker environment manually" for BOTH, which for the first is advice `doctor` correctly
242
+ // calls impossible: there is no variable to set.
243
+ if (piProviders().includes(provider)) {
244
+ throw configError(
245
+ `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.`,
246
+ );
247
+ }
248
+ 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`);
83
249
  }
84
250
  return { name, value: cred.key };
85
251
  }
86
252
 
87
253
  /**
88
- * The env var name pi reads this provider's key from. Discovered through pi's OWN `findEnvKeys` (the
89
- * oracle) rather than a hand-maintained provider→var table that would drift: try the credential's own
90
- * `env` hint plus the conventional `<PROVIDER>_API_KEY`/`_KEY`, and forward the one pi recognizes.
254
+ * The env var name to write this provider's `auth.json` api key under. `null` when pi reads no key
255
+ * variable for the provider at all, which `credentialFromPiAuth` turns into a refusal.
256
+ *
257
+ * The oracle instinct in the old version of this comment was right and its execution was not, which is
258
+ * worth recording rather than deleting (issue #311). It asked pi's own `findEnvKeys` rather than keeping a
259
+ * provider→var table, but the CANDIDATES it asked with were hand-generated: the credential's `env` field
260
+ * plus a conventional `<PROVIDER>_API_KEY`/`_KEY`. That convention is the part that drifts, and it was
261
+ * wrong for 13 of the 34 provider ids that have a key variable at the pin -- `google` is
262
+ * `GEMINI_API_KEY`, `huggingface` is `HF_TOKEN`, `moonshotai` is `MOONSHOT_API_KEY`, `github-copilot` is
263
+ * `COPILOT_GITHUB_TOKEN`, `radius` was `PI_GATEWAY_API_KEY` (RADIUS_API_KEY at 0.99.1) -- so for those pi recognized nothing and a
264
+ * valid `pi login` refused every job. The convention happening to be right for the other 21 is what let
265
+ * this survive: `anthropic` and `openai` are both in that set.
266
+ *
267
+ * The `env` candidate was dead on arrival besides: at the pin `ApiKeyCredential.env` is a
268
+ * `Record<string, string>` of provider config (Cloudflare account and gateway ids), never a variable
269
+ * name, so the string filter dropped it on every call.
270
+ *
271
+ * `providerKeyCandidates` is pi's whole list, so there is nothing left to guess, and it asks against an
272
+ * environment where every name is present -- which also closes a hermeticity hole the synthetic object
273
+ * had: pi's `getProviderEnvValue` falls back to the real `process.env`, so on a host that exported
274
+ * `ANTHROPIC_OAUTH_TOKEN` the old code resolved to THAT name. `apiKeyVariable` then picks the same
275
+ * variable `doctor` names, from the same module, so the two cannot diverge again. It skips both
276
+ * non-API-key kinds: the OAuth token, and (from the 0.99.1 pin, issue #509) the bearer token
277
+ * ANTHROPIC_AUTH_TOKEN, which pi lists first and sends as `Authorization: Bearer`.
91
278
  */
92
- function resolveEnvName(provider, cred) {
93
- const upper = provider.toUpperCase().replace(/[^A-Z0-9]/g, "_");
94
- const candidates = [cred.env, `${upper}_API_KEY`, `${upper}_KEY`].filter((s) => typeof s === "string" && s.length > 0);
95
- const synthetic = Object.fromEntries(candidates.map((name) => [name, cred.key]));
96
- const recognized = findEnvKeys(provider, synthetic);
97
- return recognized?.[0] ?? null;
279
+ function resolveEnvName(provider) {
280
+ return apiKeyVariable(providerKeyCandidates(provider));
98
281
  }
99
282
 
100
283
  /**
@@ -103,7 +286,9 @@ function resolveEnvName(provider, cred) {
103
286
  * run.github).
104
287
  *
105
288
  * Throws if the provider is not configured -- a deterministic misconfiguration the caller maps to
106
- * a pre-spend refusal, never a launched-then-failed container.
289
+ * a pre-spend refusal, never a launched-then-failed container. Both halves of that are real as of issue
290
+ * #310: the processor probes this resolution among its free gates, and classifies the throw if one still
291
+ * reaches it.
107
292
  *
108
293
  * `packagePaths` is the operator-staged pi package set for THIS job: already-resolved absolute
109
294
  * CONTAINER paths under the :ro overlay, empty for a job whose trigger opted OUT (or when nothing is staged).
@@ -111,11 +296,12 @@ function resolveEnvName(provider, cred) {
111
296
  * `allowGlobalExtensions` defaults to TRUE here, matching loadConfig's default (REQ-GLOBAL-PI-OVERLAY): a
112
297
  * caller that says nothing gets the operator's staged setup, and only an explicit `false` withholds it.
113
298
  */
114
- export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, authFromPi = false, egress = false, egressProxy, agentDir, readFile = readFileSync }) {
299
+ 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 }) {
115
300
  // The provider credential(s), by pi's expected variable name(s) -- from the worker env, or (when
116
301
  // PI_AUTH_FROM_PI is set and the env has none) host-side from pi's auth.json. Throws (config) if
117
- // neither source yields one, which the processor turns into a pre-spend refusal.
118
- const credEnv = resolveProviderCredential({ provider, hostEnv, authFromPi, agentDir, readFile });
302
+ // neither source yields one, which the processor turns into a policy refusal that refunds any reserve
303
+ // (issue #310); the same call is made by its free credential gate, before anything is reserved at all.
304
+ const credEnv = resolveProviderCredential({ provider, hostEnv, authFromPi, agentDir, readFile, forwardEnv });
119
305
 
120
306
  const env = {
121
307
  PI_PROVIDER: provider,
@@ -163,6 +349,13 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId,
163
349
  // exclusive by parse (command XOR flow); that is deliberately NOT re-enforced here -- a second
164
350
  // validator is a second place to disagree with the first.
165
351
  PI_COMMAND: command || undefined,
352
+ // The trigger's run.excludeTools, STRUCTURALLY (issue #291): the pi tool names the runner must
353
+ // withhold from createAgentSession, so a "read-only" trigger stops being prompt text. Comma is a
354
+ // safe delimiter because the loader admits only names from its pinned built-in set, none of which
355
+ // carries one -- that is a load-time guarantee, deliberately NOT re-enforced here (PI_COMMAND's
356
+ // second-validator rule directly above). Absent means the full pinned default set, never an empty
357
+ // string, for PI_PACKAGES' reason.
358
+ PI_EXCLUDE_TOOLS: excludeTools.length > 0 ? excludeTools.join(",") : undefined,
166
359
  // Kill switch for job-time package installation, UNCONDITIONAL for every job. pi's resolver shells out
167
360
  // to a REAL `npm install` for any npm:/git: source unless offline mode is on, and `~/.pi/agent` IS
168
361
  // writable in the container. We emit only local paths, so nothing should reach that branch -- this
@@ -177,9 +370,14 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId,
177
370
  // Operator-declared extra vars (PI_FORWARD_ENV), forwarded by EXACT name -- the allowlist
178
371
  // no-broad-env-into-container prescribes, not a host pass-through. This is how a CUSTOM provider's
179
372
  // key (one pi's findEnvKeys table does not know) reaches the container. A name whose value is unset
180
- // on the host is skipped, never forwarded as empty.
373
+ // on the host is skipped, never forwarded as empty. That second half was a claim rather than a check
374
+ // until issue #311: the guard tested `!== undefined`, so a name set to "" WAS forwarded, and since this
375
+ // loop runs after the credential assign above, `PI_FORWARD_ENV=<the provider's key variable>` with a
376
+ // blank value in the host env blanked a working auth.json credential and the job spent a container to
377
+ // fail auth. Same emptiness rule as the secrets loop below, which had it right.
181
378
  for (const name of forwardEnv) {
182
- if (hostEnv[name] !== undefined) env[name] = hostEnv[name];
379
+ const value = hostEnv[name];
380
+ if (value !== undefined && value !== "") env[name] = value;
183
381
  }
184
382
 
185
383
  // The trigger's own secrets (REQ-TRIGGER-SECRETS), resolved HOST-SIDE by the processor before anything
@@ -226,6 +424,13 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId,
226
424
  // turn. It rides the closed map, never PI_FORWARD_ENV, so arming the policy cannot half-work.
227
425
  Object.assign(env, egressEnv({ proxy: egressProxy, armed: egress }));
228
426
 
427
+ // Issue #341: the job's HOME, only when the worker runs it under `--user` (`home` is passed then and only then).
428
+ // AFTER the PI_FORWARD_ENV and secrets loops, for the egress variables' reason: a forwarded or secret HOME must
429
+ // not win, because a uid with no passwd entry in the image would otherwise get `HOME=/` (Docker) or
430
+ // `HOME=/workspace` (Podman, measured), and pi's auth.json lands nowhere or in the operator's repository.
431
+ // env-internal HOME: written into the job's closed env map here, never read from the worker's environment.
432
+ if (typeof home === "string" && home !== "") env.HOME = home;
433
+
229
434
  // Forge-backed jobs, and local cron jobs that opted in via run.github. Other local-folder jobs have
230
435
  // no token (CONST-TOKEN-SCOPED-PER-JOB). The mint goes into BOTH of its forge's variables because
231
436
  // each CLI has its own preference -- gh prefers GH_TOKEN over GITHUB_TOKEN, glab prefers GITLAB_TOKEN