@edgehero/pi-dispatch 2.0.0 → 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.
package/.env.example CHANGED
@@ -19,9 +19,10 @@
19
19
  # in two different places depending on which deployment reads it. Write the path out in full, not $HOME.
20
20
 
21
21
  # --- Provider credential ---
22
- # pi supports ~30 providers; set the key for the one you use, under the variable name pi expects.
22
+ # pi supports ~40 providers; set the key for the one you use, under the variable name pi expects.
23
23
  # The worker forwards ONLY the configured provider's key into the job container -- nothing else.
24
- # Anthropic: ANTHROPIC_API_KEY (or ANTHROPIC_OAUTH_TOKEN, which takes precedence)
24
+ # Anthropic: ANTHROPIC_API_KEY (pi reads ANTHROPIC_AUTH_TOKEN, a bearer token, and ANTHROPIC_OAUTH_TOKEN
25
+ # BEFORE it, so leave both unset: either one set silently wins over the API key)
25
26
  # OpenAI: OPENAI_API_KEY Google: GEMINI_API_KEY Groq: GROQ_API_KEY ... etc.
26
27
  # You can LEAVE THIS BLANK if you are already logged into pi: when the env has no key, the worker reads the
27
28
  # API key from ~/.pi/agent/auth.json (host-side) and env-injects it -- on by default, nothing to set.
package/README.md CHANGED
@@ -12,7 +12,9 @@ from cron triggers, or from forge events that the
12
12
 
13
13
  ## Start
14
14
 
15
- You need Docker or Podman, Node 22.19 or newer, and an API key for a model provider that pi supports.
15
+ You need Docker or Podman and Node 22.19 or newer on one machine that stays on (a Linux server or VM, or
16
+ your own Mac or Windows machine with Docker Desktop), and an API key for a model provider that pi supports.
17
+ pi-dispatch itself needs no AI key: the key is for pi, inside each job.
16
18
 
17
19
  ```bash
18
20
  mkdir my-dispatch && cd my-dispatch
@@ -15,7 +15,7 @@
15
15
  (`pi-dispatch service render` composes exactly that argv with this host's real paths; the wrapper
16
16
  refuses an empty argv rather than guessing what to run). NO secrets are inlined here: there is
17
17
  deliberately no EnvironmentVariables dict, since that would commit credentials into this file. The
18
- wrapper reads `.env` at runtime instead (note the ANTHROPIC_OAUTH_TOKEN over ANTHROPIC_API_KEY
18
+ wrapper reads `.env` at runtime instead (note the ANTHROPIC_AUTH_TOKEN/ANTHROPIC_OAUTH_TOKEN over ANTHROPIC_API_KEY
19
19
  precedence trap documented in the wrapper).
20
20
 
21
21
  Graceful shutdown needs NO macOS-specific code: `launchctl bootout` sends SIGTERM, which the wrapper
@@ -21,7 +21,7 @@ REM `pi-dispatch service install` passes them via nssm AppParameters. This w
21
21
  REM decides WHAT to run -- only the env it runs in and what its exit code means -- so an empty
22
22
  REM argument list is a configuration error, refused below.
23
23
  REM
24
- REM TRAP: inside pi, ANTHROPIC_OAUTH_TOKEN silently takes precedence over ANTHROPIC_API_KEY. Set exactly
24
+ REM TRAP: inside pi, ANTHROPIC_AUTH_TOKEN and ANTHROPIC_OAUTH_TOKEN silently take precedence over ANTHROPIC_API_KEY. Set exactly
25
25
  REM one in `.env`.
26
26
  REM
27
27
  REM `.env` FORMAT for this loader: KEY=VALUE, one per line. Values MUST be UNQUOTED -- cmd's `set` keeps
@@ -24,9 +24,9 @@
24
24
  # profile happens to export. Nothing here contains a credential -- the secrets live in `.env`, which is
25
25
  # gitignored and read at runtime.
26
26
  #
27
- # TRAP: inside pi, `ANTHROPIC_OAUTH_TOKEN` silently takes precedence over `ANTHROPIC_API_KEY`. Set exactly
28
- # one in `.env`; this wrapper only ADDS the `.env` vars on top of the current environment, it does not
29
- # clear a stray pre-existing one, so a leaked host `ANTHROPIC_OAUTH_TOKEN` would still win.
27
+ # TRAP: inside pi, `ANTHROPIC_AUTH_TOKEN` and `ANTHROPIC_OAUTH_TOKEN` silently take precedence over
28
+ # `ANTHROPIC_API_KEY`. Set exactly one in `.env`; this wrapper only ADDS the `.env` vars on top of the
29
+ # current environment, it does not clear a stray pre-existing one, so a leaked host token would still win.
30
30
  #
31
31
  # One worker per host (DES-CONCURRENCY-3): parallelism is PI_CONCURRENCY inside the single process, not
32
32
  # multiple daemons. Requires the AOF-enabled Valkey from deploy/docker-compose.yml.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "description": "The pi-dispatch worker and CLI: runs the pi coding agent as a self-hosted service, one locked down Docker or Podman container per job, with spend caps checked before anything is spent, plus init, up, doctor and service install.",
6
6
  "keywords": [
@@ -98,7 +98,7 @@
98
98
  "start": "node src/cli.mjs worker"
99
99
  },
100
100
  "dependencies": {
101
- "@earendil-works/pi-ai": "0.80.7",
101
+ "@earendil-works/pi-ai": "0.99.1",
102
102
  "@octokit/auth-app": "8.2.0",
103
103
  "@octokit/rest": "22.0.1",
104
104
  "bullmq": "5.80.4",
package/src/doctor.mjs CHANGED
@@ -93,7 +93,7 @@ import { BOOT_REFUSING_JOB_USER_CAUSES, DAEMON_FACTS_TIMEOUT_MS, JOB_USER_FIX, m
93
93
  import { parseSecretProfiles } from "./secret-profiles.mjs";
94
94
  // The OAuth-suffix rule and the variable it selects live in their own import-free module so the worker
95
95
  // can share them: doctor NAMES a variable and env-allowlist WRITES one, and they must never differ.
96
- import { OAUTH_KEY_RE, apiKeyVariable } from "./provider-key.mjs";
96
+ import { apiKeyVariable, nonApiKeyKind } from "./provider-key.mjs";
97
97
  import { parseTriggers } from "./triggers.mjs";
98
98
  import { cronPlacement } from "./schedules.mjs";
99
99
 
@@ -3468,9 +3468,10 @@ function providerKeyCheck({ provider, env, agentDir, oracle, nodeOk }) {
3468
3468
  // still refused -- one step further down, against the variable pi actually reads, where it is a fact
3469
3469
  // about THAT credential rather than a reason to pretend the variable is unset.
3470
3470
  const set = candidates.filter((name) => (env[name] ?? "") !== "");
3471
- // The variable to TELL an operator to set is never the OAuth token, and it is the SAME choice the
3472
- // worker makes when it writes an `auth.json` key into a container (issue #311). One function, one
3473
- // module, so a doctor line cannot name a variable the job path does not use.
3471
+ // The variable to TELL an operator to set is never the OAuth token or the bearer token, and it is the
3472
+ // SAME choice the worker makes when it writes an `auth.json` key into a container (issue #311; the
3473
+ // bearer half is issue #509). One function, one module, so a doctor line cannot name a variable the job
3474
+ // path does not use.
3474
3475
  const apiKeyVar = apiKeyVariable(candidates);
3475
3476
 
3476
3477
  if (set.length > 0) {
@@ -3489,19 +3490,57 @@ function providerKeyCheck({ provider, env, agentDir, oracle, nodeOk }) {
3489
3490
  fix: `set a real value for ${using}, or unset it: pi reads it as present, so every job spends a container to fail auth`,
3490
3491
  };
3491
3492
  }
3492
- if (!OAUTH_KEY_RE.test(using)) return { ok: true, label: `Provider key set (${provider}: ${using})` };
3493
+ const kind = nonApiKeyKind(using);
3494
+ if (kind === null && isOAuthTokenValue(provider, env[using])) {
3495
+ // The variable is the API key's, the VALUE is a subscription login: pi decides by the value
3496
+ // (anthropic-messages.js `isOAuthToken`, a substring test for "sk-ant-oat"), not by the name, and
3497
+ // sends it as `Authorization: Bearer` with Claude Code's identity headers. So it is the OAuth
3498
+ // case under the API key's name, and it gets that warning rather than a green line.
3499
+ return {
3500
+ ok: false,
3501
+ warn: true,
3502
+ label: `Provider key set (${provider}: ${using}) -- but the value is an OAuth/subscription token, not an API key`,
3503
+ fix: `put a real API key in ${using}: pi recognises the value as a subscription login whatever variable holds it and sends it as an Authorization: Bearer header; it expires, and the container cannot refresh it`,
3504
+ };
3505
+ }
3506
+ if (kind === null) return { ok: true, label: `Provider key set (${provider}: ${using})` };
3493
3507
  // Warn, not fail, and the choice is deliberate: the worker forwards this variable and the job WILL
3494
3508
  // run, so failing here would put doctor in disagreement with the worker -- the exact disease this
3495
3509
  // issue is about. What doctor must stop doing is what it did before: pass in silence, as though a
3496
3510
  // subscription login were a service credential.
3497
3511
  const shadowed = set[1] ?? null;
3512
+ // The pi login this token silently displaces. The worker takes the ENVIRONMENT first and reads
3513
+ // auth.json only when the environment holds no candidate, while pi on this host takes a stored
3514
+ // api_key credential FIRST (pi-ai/dist/auth/helpers.js: "a stored credential key wins"). So with both
3515
+ // present the job spends the token and pi on the host spends the stored key, and without this line
3516
+ // nothing says so. Appended only to the fix lines for a token with no other env candidate beside it:
3517
+ // with one, that env key is the ignored credential and the line already names it.
3518
+ const ignoredLogin = env.PI_AUTH_FROM_PI !== "0" && usablePiLoginKey(agentDir, provider) !== null
3519
+ ? `; the API key in pi auth.json is NOT used while ${using} is set (the worker reads the environment first, although pi on this host would use the stored key): unset ${using} to spend the pi login, or keep it only if this token is the credential you mean jobs to spend`
3520
+ : "";
3521
+ if (kind === "bearer") {
3522
+ // The bearer token (ANTHROPIC_AUTH_TOKEN at the pi 0.99.1 pin, issue #509) gets the OAuth token's
3523
+ // treatment, a forwarded variable and a warning, and a line of its own because the hazard is a
3524
+ // different one: it is often a real gateway credential rather than a login that expires, but pi
3525
+ // reads it BEFORE the API key and sends it as `Authorization: Bearer`, so while it is set the API
3526
+ // key is ignored. The API-key variable is named whether or not it is set, because an operator who
3527
+ // sets it later without unsetting this one gets no change at all.
3528
+ return {
3529
+ ok: false,
3530
+ warn: true,
3531
+ label: `Provider key set (${provider}: ${using}) -- a bearer token, not an API key`,
3532
+ fix: shadowed
3533
+ ? `unset ${using}: pi reads it BEFORE ${shadowed} and sends it as an Authorization: Bearer header, so every job spends it and ${shadowed} is ignored`
3534
+ : `set ${apiKeyVar} and unset ${using}: pi reads ${using} BEFORE ${apiKeyVar} and sends it as an Authorization: Bearer header, so while it is set ${apiKeyVar} is ignored${ignoredLogin}`,
3535
+ };
3536
+ }
3498
3537
  return {
3499
3538
  ok: false,
3500
3539
  warn: true,
3501
3540
  label: `Provider key set (${provider}: ${using}) -- an OAuth/subscription login, not an API key`,
3502
3541
  fix: shadowed
3503
3542
  ? `unset ${using}: pi reads it BEFORE ${shadowed}, so every job spends the subscription login and your API key is ignored`
3504
- : `set ${apiKeyVar} instead -- an OAuth/subscription token expires, the container cannot refresh it, and it is not the credential for an unattended service`,
3543
+ : `set ${apiKeyVar} instead -- an OAuth/subscription token expires, the container cannot refresh it, and it is not the credential for an unattended service${ignoredLogin}`,
3505
3544
  };
3506
3545
  }
3507
3546
 
@@ -3525,6 +3564,16 @@ function providerKeyCheck({ provider, env, agentDir, oracle, nodeOk }) {
3525
3564
  // itself when IT reads auth.json, but this service forwards the value into a container where it is
3526
3565
  // read raw, so a login stored that way is not a key this deployment can spend.
3527
3566
  if (cred?.type === "api_key" && key && !key.startsWith("!") && !key.includes("$") && key.trim()) {
3567
+ if (isOAuthTokenValue(provider, key)) {
3568
+ // The env arm's value rule, for the same reason: the worker writes this under the API key's
3569
+ // name and pi sends it as a subscription login anyway.
3570
+ return {
3571
+ ok: false,
3572
+ warn: true,
3573
+ label: `Provider key set (${provider}) -- from pi auth.json, but the stored key is an OAuth/subscription token, not an API key`,
3574
+ fix: `run \`pi login\` with a real API key for ${provider}: pi recognises the stored value as a subscription login and sends it as an Authorization: Bearer header; it expires, and the container cannot refresh it`,
3575
+ };
3576
+ }
3528
3577
  return { ok: true, label: `Provider key set (${provider}) -- from pi auth.json` };
3529
3578
  }
3530
3579
  if (cred?.type === "api_key" && key && (key.startsWith("!") || key.includes("$")))
@@ -3542,6 +3591,32 @@ function providerKeyCheck({ provider, env, agentDir, oracle, nodeOk }) {
3542
3591
  };
3543
3592
  }
3544
3593
 
3594
+ /**
3595
+ * pi's own test for a subscription token in an API-key slot, restated: `isOAuthToken` in
3596
+ * pi-ai/dist/api/anthropic-messages.js is `apiKey.includes("sk-ant-oat")`, applied to the value whatever
3597
+ * variable carried it. Restricted to `anthropic`, the provider whose credential this is; the value itself is
3598
+ * never printed.
3599
+ */
3600
+ function isOAuthTokenValue(provider, value) {
3601
+ return provider === "anthropic" && typeof value === "string" && value.includes("sk-ant-oat");
3602
+ }
3603
+
3604
+ /**
3605
+ * The API key a pi login holds for `provider`, when it is one the worker would forward (a non-blank string
3606
+ * that is not pi's command or variable-reference form), else null. The same acceptance the auth.json arm of
3607
+ * providerKeyCheck applies; never throws, never printed.
3608
+ */
3609
+ function usablePiLoginKey(agentDir, provider) {
3610
+ let cred;
3611
+ try {
3612
+ cred = JSON.parse(readFileSync(join(agentDir, "auth.json"), "utf8"))?.[provider];
3613
+ } catch {
3614
+ return null;
3615
+ }
3616
+ const key = cred?.type === "api_key" && typeof cred.key === "string" ? cred.key : null;
3617
+ return key && key.trim() && !key.startsWith("!") && !key.includes("$") ? key : null;
3618
+ }
3619
+
3545
3620
  /**
3546
3621
  * pi reads no API-key variable for this id, and the two reasons need different fixes -- which is exactly
3547
3622
  * the distinction `findEnvKeys`'s single `undefined` cannot make, and the reason issue #286 needed a
package/src/egress.mjs CHANGED
@@ -40,9 +40,10 @@ import { makeDetachGate } from "./netns-keeper.mjs";
40
40
  * Hence a hostname allowlist and no address rule that allows anything, which is the mechanism OQ-004's
41
41
  * close condition actually names (the proxy's one address rule only denies this host's loopback and
42
42
  * link-local addresses, issue #428).
43
- * BUT THAT WAS MEASURED WITHOUT PI LOADED (issue #427). The pinned pi 0.80.7 depends on npm `undici`
44
- * 8.5.0, whose load replaces the global dispatcher the flag installs with one that ignores the proxy
45
- * variables, so in the runner the provider call went direct and every egress-armed job died at its first
43
+ * BUT THAT WAS MEASURED WITHOUT PI LOADED (issue #427). pi depends on npm `undici` (8.5.0 at the 0.80.7
44
+ * pin where this was measured, 8.10.2 at the 0.99.1 pin, where loading pi was re-measured to drop the
45
+ * env proxy the same way), whose load replaces the global dispatcher the flag installs with one that
46
+ * ignores the proxy variables, so in the runner the provider call went direct and every egress-armed job died at its first
46
47
  * turn. The runner now re-installs an env-proxy dispatcher after loading pi
47
48
  * (image/runner/src/env-proxy.mjs), and doctor's canary takes that same path instead of a plain fetch.
48
49
  */
@@ -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
  *
@@ -89,10 +90,12 @@ export function providerKeyCandidates(provider) {
89
90
  * `getProviders` from "/compat", which is a @deprecated alias for this exact function and reaching it
90
91
  * means loading compat's module-scope provider registration.
91
92
  *
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.
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.
96
99
  */
97
100
  export function piProviders() {
98
101
  return getBuiltinProviders();
@@ -129,6 +132,24 @@ export function resolveProviderCredential({ provider, hostEnv, authFromPi = fals
129
132
  const held = providerKeyCandidates(provider)
130
133
  .map((name) => [name, hostEnv[name]])
131
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.
132
153
  if (held.length > 0) {
133
154
  return Object.fromEntries(held);
134
155
  }
@@ -215,8 +236,8 @@ function credentialFromPiAuth(provider, agentDir, readFile, { hostEnv = {}, forw
215
236
  const name = resolveEnvName(provider);
216
237
  if (!name) {
217
238
  // 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
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
220
241
  // in the worker environment manually" for BOTH, which for the first is advice `doctor` correctly
221
242
  // calls impossible: there is no variable to set.
222
243
  if (piProviders().includes(provider)) {
@@ -239,7 +260,7 @@ function credentialFromPiAuth(provider, agentDir, readFile, { hostEnv = {}, forw
239
260
  * plus a conventional `<PROVIDER>_API_KEY`/`_KEY`. That convention is the part that drifts, and it was
240
261
  * wrong for 13 of the 34 provider ids that have a key variable at the pin -- `google` is
241
262
  * `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
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
243
264
  * valid `pi login` refused every job. The convention happening to be right for the other 21 is what let
244
265
  * this survive: `anthropic` and `openai` are both in that set.
245
266
  *
@@ -251,7 +272,9 @@ function credentialFromPiAuth(provider, agentDir, readFile, { hostEnv = {}, forw
251
272
  * environment where every name is present -- which also closes a hermeticity hole the synthetic object
252
273
  * had: pi's `getProviderEnvValue` falls back to the real `process.env`, so on a host that exported
253
274
  * `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.
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`.
255
278
  */
256
279
  function resolveEnvName(provider) {
257
280
  return apiKeyVariable(providerKeyCandidates(provider));
package/src/host-pi.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  * What it must never become. Nothing on the worker's BOOT path may import this file. It reads host paths and
16
16
  * may spawn a package manager, and neither belongs anywhere near `start.mjs`.
17
17
  *
18
- * Everything here MIRRORS a private detail of the pinned pi (0.80.7) rather than calling it: pi exports no
18
+ * Everything here MIRRORS a private detail of the pinned pi (0.99.1) rather than calling it: pi exports no
19
19
  * public answer to "where is this package installed" or "is this resource enabled", and importing the whole
20
20
  * coding-agent SDK to read two well-known paths is not worth the weight. That mirroring is a real risk --
21
21
  * pi could change the grammar and we would silently start staging something the operator turned off -- so it
@@ -66,7 +66,12 @@ export const PINNED_PI_NEEDLES = {
66
66
  "function isEnabledByOverrides(filePath, patterns, baseDir) {",
67
67
  // The npm spec split that yields a name from `@scope/name@version`.
68
68
  "const match = spec.match(/^(@?[^@]+(?:\\/[^@]+)?)(?:@(.+))?$/);",
69
+ // Built-in extensions (0.99.1, issue #509) are addressed in the SAME `extensions` list as files, as
70
+ // `builtin:<name>` paths, and resolved in a loop of their own. The mirror ignores those entries
71
+ // (see isOverridePattern); these two pin that they are still a separate namespace resolved apart.
72
+ "const path = `${BUILTIN_PATH_PREFIX}${name}`;",
69
73
  ],
74
+ "dist/core/source-info.js": ['export const BUILTIN_PATH_PREFIX = "builtin:";'],
70
75
  "dist/core/settings-manager.d.ts": [
71
76
  "export type PackageSource = string | {",
72
77
  " autoload?: boolean;",
@@ -148,9 +153,20 @@ export function parsePackageSource(source) {
148
153
  return { kind: "git", name, spec: null, requested: null, raw };
149
154
  }
150
155
 
151
- /** The prefixes pi treats as overrides; a plain pattern is not one (getOverridePatterns). */
156
+ /** pi's BUILTIN_PATH_PREFIX (source-info.js): a path naming a built-in extension, never a file. */
157
+ const BUILTIN_PATH_PREFIX = "builtin:";
158
+
159
+ /**
160
+ * The override patterns that can address a FILE: pi's prefixes (getOverridePatterns), minus the ones whose
161
+ * body is a `builtin:<name>` path. From the 0.99.1 pin on (issue #509) the same `extensions` list holds
162
+ * `-builtin:mcp` and friends, which pi resolves against its built-in extensions only, never against a file
163
+ * on disk. Kept, they would change no verdict pi reaches, but they would still make a settings file with
164
+ * only a builtin entry look like one with overrides, and a `!builtin:*` glob would mark every extension
165
+ * "could not be evaluated", which is a note about nothing. Dropped here, before any verdict is computed.
166
+ */
152
167
  function isOverridePattern(pattern) {
153
- return typeof pattern === "string" && (pattern.startsWith("!") || pattern.startsWith("+") || pattern.startsWith("-"));
168
+ if (typeof pattern !== "string" || !(pattern.startsWith("!") || pattern.startsWith("+") || pattern.startsWith("-"))) return false;
169
+ return !normalizeExact(pattern.slice(1)).startsWith(BUILTIN_PATH_PREFIX);
154
170
  }
155
171
 
156
172
  /** Any minimatch magic we decline to interpret. Deliberately over-broad: a false "unknown" only costs a note. */
package/src/packages.mjs CHANGED
@@ -61,7 +61,7 @@ export const NPM_NAME_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
61
61
  * would add an edge to the import-pi <-> packages cycle that only survives because both sides use their
62
62
  * bindings at call time.
63
63
  *
64
- * This list is pi's, not ours: at the 0.80.7 pin `collectPackageResources` falls through to exactly these
64
+ * This list is pi's, not ours: at the 0.99.1 pin (as at 0.80.7) `collectPackageResources` falls through to exactly these
65
65
  * four directory names when `readPiManifest` returns null, so a package with no `pi` key and a `skills/`
66
66
  * dir IS a pi package. See host-pi.mjs's PINNED_PI_NEEDLES for the assertion that keeps that true.
67
67
  */
@@ -281,7 +281,7 @@ export function readStageManifest({ globalPiDir, readFile = readFileSync, fileEx
281
281
  * readStageManifest's policy, because the consumers are advisory (doctor's per-trigger flow lines,
282
282
  * and issue #188's topology) and a half-staged tree must degrade to "nothing visible", not a crash.
283
283
  *
284
- * The semantics mirror pi's collectPackageResources at the 0.80.7 pin EXACTLY, because an enumerator
284
+ * The semantics mirror pi's collectPackageResources at the 0.99.1 pin EXACTLY (unchanged since 0.80.7), because an enumerator
285
285
  * that agrees with pi by hand is how doctor comes to report a tier pi then ignores:
286
286
  * - a `pi` manifest object means its `skills` entries are the ONLY sources -- a manifest WITHOUT a
287
287
  * `skills` key contributes NO skills and gets NO convention fallback (readPiManifest short-circuits
package/src/pricing.mjs CHANGED
@@ -29,8 +29,10 @@ import { getBuiltinModel, getBuiltinModels, getBuiltinProviders } from "@earendi
29
29
  /**
30
30
  * Every builtin pi-ai model, flattened to `{ provider, id, cost }`. `cost` is pi-ai's ModelCost
31
31
  * object passed BY REFERENCE -- it is pi-ai's data, callers treat it read-only. Rates are USD per
32
- * 1M tokens; an all-zero table is CORRECT data for subscription-backed providers (kimi-coding,
33
- * zai-coding-cn), not missing data -- `isZeroRated` is how callers tell the two apart.
32
+ * 1M tokens. An all-zero table is pi-ai's data, not missing data: at the 0.99.1 pin the all-zero
33
+ * providers include the qwen-token-plan family. It is NOT a subscription signal any more (issue #509):
34
+ * kimi-coding and zai-coding-cn, all-zero at 0.80.7, now carry implied API-equivalent rates on most
35
+ * models although their plans are prepaid, and plenty of pay-per-token providers list a free model.
34
36
  */
35
37
  export function listPricedModels() {
36
38
  const out = [];
@@ -58,9 +60,11 @@ export function getPricedModel(provider, id) {
58
60
  }
59
61
 
60
62
  /**
61
- * True when all four base rates are zero -- the signature of a subscription-backed provider, whose
62
- * runs meter at $0 because the plan is prepaid (see subscriptions.mjs for where the real price
63
- * lives). Tiers are deliberately ignored: a zero-rate provider ships no tiers, and a priced provider
63
+ * True when all four base rates are zero: pi-ai rated the model at $0, which is a fact about the table
64
+ * and nothing more. It USED to be read as the signature of a subscription-backed provider; at the 0.99.1
65
+ * pin it is not (issue #509: kimi-coding and zai-coding-cn carry implied prices, and free models of
66
+ * metered providers are zero too), so a subscription is known only from subscriptions.json. Tiers are
67
+ * deliberately ignored: a zero-rate provider ships no tiers, and a priced provider
64
68
  * with a zero base rate somewhere does not become "free" by it. Null/malformed input is false --
65
69
  * "not zero-rated" is the safe answer for a thing that is not a model.
66
70
  */
package/src/processor.mjs CHANGED
@@ -766,9 +766,10 @@ export async function runJob(job, deps) {
766
766
  // - the worker WRITES the name: buildContainerEnv assigns the provider credential and
767
767
  // PI_FORWARD_ENV before this feature's values, so the trigger's value replaces the operator's
768
768
  // and every job of that trigger spends the trigger author's key;
769
- // - the worker does NOT write the name but pi READS it first: the OAuth token variable is
770
- // deliberately never written (apiKeyVariable skips it), so a trigger binding it lands beside
771
- // the operator's key and outranks it in pi's own precedence.
769
+ // - the worker does NOT write the name but pi READS it first: the OAuth token variable, and from
770
+ // the 0.99.1 pin the bearer ANTHROPIC_AUTH_TOKEN (issue #509), are deliberately never written
771
+ // (apiKeyVariable skips both), so a trigger binding one lands beside the operator's key and
772
+ // outranks it in pi's own precedence.
772
773
  // The old message asserted the first for both, which is exactly backwards for the second.
773
774
  await comment(job, `Refused: this trigger's \`run.secrets\` binds \`${resolved.reserved}\`, which is a variable this deployment already uses for the job's own credentials. Whichever of the two values reached the container, one of them would be silently ignored. Rename it in the triggers file. Not run.`);
774
775
  // The variable NAME only. It is the operator's own choice of name, not payload, and naming it is what
@@ -27,15 +27,40 @@
27
27
  // worker/test/provider-key.test.mjs -- never against a second copy of a table.
28
28
  export const OAUTH_KEY_RE = /_OAUTH_TOKEN$/;
29
29
 
30
+ // The second fact of the same kind, added with the pi 0.99.1 bump (issue #509): a variable pi reads for a
31
+ // provider that is NOT an API key, because pi sends it as a different header. At 0.99.1 pi lists
32
+ // ANTHROPIC_AUTH_TOKEN FIRST for `anthropic` (pi-ai/dist/env-api-keys.js getApiKeyEnvVars), and its
33
+ // resolver (pi-ai/dist/providers/anthropic.js) sends it as `Authorization: Bearer <value>` AHEAD of both
34
+ // ANTHROPIC_OAUTH_TOKEN and ANTHROPIC_API_KEY. Without this rule `apiKeyVariable` picked it: an auth.json
35
+ // API key would have been written into the container under the bearer name, so pi would send it as
36
+ // `Authorization: Bearer` rather than as the `x-api-key` an API key travels in, and doctor would have told
37
+ // an operator to set the bearer variable.
38
+ // pi's own `getEnvApiKey` skips it for the same reason, which is the evidence that the distinction is real
39
+ // rather than ours. A suffix rule, for OAUTH_KEY_RE's reason: a set naming one variable would silently
40
+ // bless the next provider's bearer variable. Pinned against pi in worker/test/provider-key.test.mjs.
41
+ export const BEARER_KEY_RE = /_AUTH_TOKEN$/;
42
+
43
+ /**
44
+ * Why a variable pi reads is not an API key, or null when it is one. The two answers need different
45
+ * advice, so the caller is told which: an OAuth token is a subscription login that expires, a bearer token
46
+ * is a credential pi sends in another header and reads BEFORE the API key.
47
+ */
48
+ export function nonApiKeyKind(name) {
49
+ if (OAUTH_KEY_RE.test(name)) return "oauth";
50
+ if (BEARER_KEY_RE.test(name)) return "bearer";
51
+ return null;
52
+ }
53
+
30
54
  /**
31
55
  * The api-key variable to write and to name, given pi's candidate list for a provider in pi's own
32
- * precedence order. Never the OAuth token, whatever that precedence says: pi returns
33
- * ANTHROPIC_OAUTH_TOKEN first, "set your subscription login" is wrong advice for an unattended service,
34
- * and an api-key credential written under that name would be a value whose variable lies about what it
35
- * is. Falls back to the first candidate only for a provider with no non-OAuth variable at all, which is
36
- * no provider pi has today; `null` for a provider pi reads no key variable for, which is the caller's
37
- * cue to refuse rather than to guess.
56
+ * precedence order. Never the OAuth token and never the bearer token, whatever that precedence says: pi
57
+ * returns ANTHROPIC_AUTH_TOKEN then ANTHROPIC_OAUTH_TOKEN before ANTHROPIC_API_KEY, "set your subscription
58
+ * login" is wrong advice for an unattended service, and an api-key credential written under either name
59
+ * would be a value whose variable lies about what it is (under the bearer name pi would also send it in
60
+ * the wrong header). Falls back to the first candidate only for a provider with no API-key variable at
61
+ * all, which is no provider pi has today; `null` for a provider pi reads no key variable for, which is the
62
+ * caller's cue to refuse rather than to guess.
38
63
  */
39
64
  export function apiKeyVariable(candidates) {
40
- return candidates.find((name) => !OAUTH_KEY_RE.test(name)) ?? candidates[0] ?? null;
65
+ return candidates.find((name) => nonApiKeyKind(name) === null) ?? candidates[0] ?? null;
41
66
  }
@@ -7,7 +7,8 @@
7
7
  * nothing that says where the request goes, and that is the worse of the two: substitution spends the
8
8
  * trigger author's money, redirection sends the OPERATOR'S credential to a host the trigger chose.
9
9
  *
10
- * Measured against the 0.80.7 pin with a stubbed fetch and no real key, three providers, three routes:
10
+ * Measured against the 0.80.7 pin with a stubbed fetch and no real key, three providers, three routes (the
11
+ * derivation below was re-run at the 0.99.1 pin, issue #509):
11
12
  * AZURE_OPENAI_BASE_URL -> the request goes to the named host carrying `api-key`
12
13
  * GOOGLE_GEMINI_BASE_URL -> ... carrying `x-goog-api-key`
13
14
  * AWS_ENDPOINT_URL -> ... carrying a SigV4 signature over the operator's Bedrock credential
@@ -20,10 +21,11 @@
20
21
  * resolved provider, and the bound that gate keeps is deliberate and documented -- an `anthropic` job may
21
22
  * bind `OPENAI_API_KEY` for a flow that talks to OpenAI itself, because refusing it would be this project
22
23
  * claiming a namespace it does not own. Including them here would have broken that bound ARBITRARILY: only
23
- * four of pi's thirty-one key variables happen to appear as literals in a scanned artifact, so
24
- * `OPENAI_API_KEY` would refuse while `GROQ_API_KEY` and `HF_TOKEN` stayed bindable, and the documented
25
- * rule would be false for reasons no operator could predict. They are subtracted, and the bolt subtracts
26
- * them the same way rather than by hand.
24
+ * five of pi's thirty-eight key variables (at the 0.99.1 pin) happen to appear as literals in a scanned
25
+ * artifact, so `OPENAI_API_KEY` would refuse while `GROQ_API_KEY` and `HF_TOKEN` stayed bindable, and the
26
+ * documented rule would be false for reasons no operator could predict. They are subtracted, and the bolt
27
+ * subtracts them the same way rather than by hand. ONE is kept, by name and for a stated reason, in
28
+ * `RETAINED_KEY_VARIABLES` below.
27
29
  * The AWS credential variables STAY, and they are not an exception: pi's key table has no entry for
28
30
  * `amazon-bedrock` at all (`providerKeyCandidates("amazon-bedrock")` is empty), so nothing else reserves
29
31
  * them and they are read by the SDK as provider configuration, which is exactly what this set is.
@@ -54,6 +56,13 @@
54
56
  * They subvert any provider call, but they are properties of the RUNTIME rather than of a provider, they
55
57
  * predate this gate, and `NODE_OPTIONS` additionally needs a file the attacker can place. They belong to
56
58
  * whatever closes the runtime-hijack question, not to a set derived from what pi reads about providers.
59
+ * The same holds for `HOME`, `PATH`, `APPDATA`, `USERPROFILE` and `XDG_CONFIG_HOME`, which the scan DOES
60
+ * reach from the 0.99.1 pin on (issue #509): the Anthropic SDK reads the four directory variables only to
61
+ * locate its default config directory (`core/credentials`), and `PATH` inside its agent toolset, which pi
62
+ * does not use. The bolt subtracts them by name, with that reason, and asserts the scan still finds each,
63
+ * so a subtraction that stopped being needed is removed rather than carried. `HOME` is reserved anyway,
64
+ * by `reserved-env.mjs`, because the worker writes it (issue #341). The Anthropic-specific switch for the
65
+ * same directory, `ANTHROPIC_CONFIG_DIR`, IS in the set: it names a credential location outright.
57
66
  *
58
67
  * And a bound on the whole family, which no document here stated before: a trigger author picks the
59
68
  * variable NAME and a vault REFERENCE, never a value. Exploiting any of these needs the operator's own
@@ -86,6 +95,20 @@
86
95
  * `https_proxy` outranks the egress policy's own variable in pi's reader. Only the schemes a provider call
87
96
  * can use are listed: `ws_proxy` and the rest are reachable in `getProxyEnv` but not from an HTTPS request.
88
97
  */
98
+ /**
99
+ * Provider KEY variables that stay reserved here although the bolt subtracts key variables in general.
100
+ *
101
+ * `ANTHROPIC_AUTH_TOKEN` was in this set from issue #314 on, because at 0.80.7 it was read only by the
102
+ * Anthropic SDK and was no key variable of pi's. At the 0.99.1 pin pi lists it FIRST among anthropic's key
103
+ * variables and sends it as `Authorization: Bearer` ahead of the API key (issue #509), which would move it
104
+ * to the per-provider pre-spend gate and make it bindable again for every non-anthropic job. Kept instead,
105
+ * because a version bump must not widen what a trigger may bind without an operator deciding it, and no
106
+ * deployment can be relying on binding a name that has been refused at load since #314. The bolt asserts it
107
+ * is still both a key variable pi reads and a name the scan finds, so the day either stops holding this
108
+ * list is revisited rather than carried.
109
+ */
110
+ const RETAINED_KEY_VARIABLES = ["ANTHROPIC_AUTH_TOKEN"];
111
+
89
112
  const UNREACHABLE_BY_SCAN = [
90
113
  "AWS_CONFIG_FILE",
91
114
  "AWS_ENDPOINT_URL",
@@ -99,11 +122,27 @@ const UNREACHABLE_BY_SCAN = [
99
122
  ];
100
123
 
101
124
  export const PROVIDER_STEERING_VARS = new Set([
102
- "ANTHROPIC_AUTH_TOKEN",
103
125
  "ANTHROPIC_BASE_URL",
126
+ "ANTHROPIC_CONFIG_DIR",
127
+ "ANTHROPIC_CUSTOM_HEADERS",
128
+ "ANTHROPIC_ENVIRONMENT_ID",
129
+ "ANTHROPIC_ENVIRONMENT_KEY",
130
+ "ANTHROPIC_FEDERATION_RULE_ID",
131
+ "ANTHROPIC_IDENTITY_TOKEN",
132
+ "ANTHROPIC_IDENTITY_TOKEN_FILE",
104
133
  "ANTHROPIC_LOG",
134
+ "ANTHROPIC_ORGANIZATION_ID",
135
+ "ANTHROPIC_PROFILE",
136
+ "ANTHROPIC_SCOPE",
137
+ "ANTHROPIC_SERVICE_ACCOUNT_ID",
138
+ "ANTHROPIC_SESSION_ID",
139
+ "ANTHROPIC_WEBHOOK_SIGNING_KEY",
140
+ "ANTHROPIC_WORKSPACE_ID",
141
+ "ANTHROPIC_WORK_ID",
142
+ "ANTHROPIC_WORK_SECRET",
105
143
  "AWS_ACCESS_KEY_ID",
106
144
  "AWS_BEARER_TOKEN_BEDROCK",
145
+ "AWS_BEDROCK_BASE_URL",
107
146
  "AWS_BEDROCK_FORCE_CACHE",
108
147
  "AWS_BEDROCK_FORCE_HTTP1",
109
148
  "AWS_BEDROCK_SKIP_AUTH",
@@ -121,8 +160,6 @@ export const PROVIDER_STEERING_VARS = new Set([
121
160
  "AZURE_OPENAI_ENDPOINT",
122
161
  "AZURE_OPENAI_RESOURCE_NAME",
123
162
  "GCLOUD_PROJECT",
124
- "GEMINI_NEXT_GEN_API_BASE_URL",
125
- "GEMINI_NEXT_GEN_API_LOG",
126
163
  "GOOGLE_API_KEY",
127
164
  "GOOGLE_APPLICATION_CREDENTIALS",
128
165
  "GOOGLE_CLOUD_LOCATION",
@@ -131,14 +168,18 @@ export const PROVIDER_STEERING_VARS = new Set([
131
168
  "GOOGLE_GENAI_USE_ENTERPRISE",
132
169
  "GOOGLE_GENAI_USE_VERTEXAI",
133
170
  "GOOGLE_VERTEX_BASE_URL",
171
+ "KIMI_CODE_OAUTH_HOST",
172
+ "KIMI_OAUTH_HOST",
173
+ "OPENAI_ADMIN_KEY",
134
174
  "OPENAI_API_VERSION",
135
175
  "OPENAI_BASE_URL",
176
+ "OPENAI_CUSTOM_HEADERS",
136
177
  "OPENAI_LOG",
137
178
  "OPENAI_ORG_ID",
138
179
  "OPENAI_PROJECT_ID",
139
180
  "OPENAI_WEBHOOK_SECRET",
140
181
  "PI_CACHE_RETENTION",
141
- "PI_GATEWAY",
142
182
  "PI_OAUTH_CALLBACK_HOST",
183
+ ...RETAINED_KEY_VARIABLES,
143
184
  ...UNREACHABLE_BY_SCAN,
144
185
  ]);
package/src/secrets.mjs CHANGED
@@ -298,7 +298,8 @@ export function makeSecretsResolver({
298
298
  // prevent. Same conflated `undefined` as issue #286, one module over.
299
299
  //
300
300
  // It also closes a second hole that never needed auth.json. The worker never WRITES the OAuth variable
301
- // (`apiKeyVariable` skips it deliberately) and pi reads it BEFORE `ANTHROPIC_API_KEY`, so a presence
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
302
303
  // filter held it only on hosts that happened to export it, and a trigger binding it outranked the
303
304
  // operator's own key on the pure-env path too.
304
305
  //
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * Operator-declared subscription plans (issue #53): the one place a flat-rate plan's real price can be
3
- * stated. Subscription-backed providers ship all-zero rate tables (pi-ai's kimi-coding and zai-coding-cn
4
- * both do), so their runs record cost 0 and read as FREE when they are PREPAID -- and the env boundary
5
- * REFUSES OAuth/subscription logins on purpose (env-allowlist.mjs: an expiring token cannot power an
3
+ * stated. pi-ai's rate table for a subscription-backed provider does not state the plan's price: at 0.80.7
4
+ * kimi-coding and zai-coding-cn shipped all-zero tables, so their runs recorded cost 0 and read as FREE
5
+ * when they were PREPAID; at the 0.99.1 pin most of their models carry an implied API-equivalent rate
6
+ * instead (issue #509), so the same prepaid runs now record a POSITIVE cost and read as METERED spend.
7
+ * Either way the table is the wrong price, and a zero rate is no longer even a hint that a plan exists
8
+ * (the qwen-token-plan family is all-zero, and so are free models of metered providers). And the env
9
+ * boundary REFUSES OAuth/subscription logins on purpose (env-allowlist.mjs: an expiring token cannot power an
6
10
  * unattended service), so no credential ever reaches the worker that could name the plan. An operator-side
7
11
  * declaration is therefore the only honest price source, and `subscriptions.json` is that declaration.
8
12
  *
package/src/triggers.mjs CHANGED
@@ -167,7 +167,10 @@ export const REVIEW_STATES = new Set(["approved", "changes_requested", "commente
167
167
  const REVIEW_ACTION = "review_submitted";
168
168
 
169
169
  /**
170
- * The pi tool names a trigger may exclude (issue #291) -- the built-in set of the PINNED pi, 0.80.7.
170
+ * The pi tool names a trigger may exclude (issue #291) -- the built-in set of the PINNED pi, 0.99.1, in pi's
171
+ * own `allToolNames` order. `powershell` joined it at the 0.99.1 bump (issue #509): pi registers it as a
172
+ * built-in but does not activate it by default (`DEFAULT_TOOL_NAMES` is read, bash, edit, write), so a
173
+ * trigger that means "no shell" names it beside `bash`.
171
174
  * Hand-written because this validator is pure and pi-free (the worker does not depend on the agent
172
175
  * package), and therefore BOLTED twice to the artifact it restates: `worker/test/exclude-tools.pinned.test.mjs`
173
176
  * and `image/runner/test/pinned-api.test.mjs` both derive the set from the pinned package and fail with a
@@ -177,7 +180,7 @@ const REVIEW_ACTION = "review_submitted";
177
180
  * validation exists to close -- pi ignores unknown names in `excludeTools` without a diagnostic.
178
181
  */
179
182
  // EXPORTED for the two pinned-set bolts and for the admin, which states the vocabulary to an operator.
180
- export const EXCLUDABLE_TOOL_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
183
+ export const EXCLUDABLE_TOOL_NAMES = new Set(["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"]);
181
184
 
182
185
  // A cron id flows into BullMQ's deterministic `repeat:<id>:<nextMillis>` jobId, so a `:` corrupts that
183
186
  // parse; the charset also excludes `:` and the dedicated check names the reason.