Skip to content

Commit 1e75ed0

Browse files
committed
fix(triggers): run.secrets refuses every steering name the pinned sources read (#511)
The steering set was derived from pi-ai and the SDKs it imports, one hop deep, with capital-only names and four accessor spellings. That left bindable: the lowercase twins google-auth-library reads beside the uppercase forms (google_application_credentials, measured planting a credential file), GOOGLE_CLOUD_QUOTA_PROJECT, every variable pi reads for its own configuration (PI_CODING_AGENT_DIR first), and every name read through a form the spelling list did not have. The derivation now: - reads both copies of pi-ai: the hoisted one and the one nested under pi-coding-agent that the runner dispatches through; - follows each SDK's imports that its package.json also declares in dependencies, one hop further (google-auth-library, @smithy/core, the AWS credential chain, debug, ws); - counts, after stripping comments with a small tokenizer (checked against esbuild's parser on every scanned file), every occurrence that can reach the environment instead of matching accessor spellings (worker/test/helpers/env-reads.mjs): every env token (process.env, ctx.env, a parameter named env, { env: e } = process), every process["env"], every use of an alias of one (const v = env(), const e = process.env), every call of a helper (derived: any named function one of whose own parameters is the key of a read). Each occurrence names a variable or is a site, and every file's sites are pinned with a count (worker/test/fixtures/provider-steering-sites.json), so a new occurrence anywhere names something or changes a count; - adds pi's own PI_* reads from pi-coding-agent's dist (not dist/bundle/) and the pi packages it declares, evaluating the agent and session directory keys through pi's config.js, minus CONTAINER_ENV_NAMES and the names the runner assigns before pi runs (PI_OFFLINE, PI_TELEMETRY, derived from image/runner/src). Every name found is reserved and matching stays exact, so the twins are literal members and openai_base_url stays bindable. The hand-written residuals shrink from nine to four. HOMEDRIVE and HOMEPATH join the runtime subtractions (the same home-directory helper as HOME). A job queued before the set widened, a stored scheduler template, or a receiver older than the worker can still carry such a name, and the container env would write it. The processor now asks the same RESERVED_ENV_NAMES of the job at the call site, before the secrets resolver is called (so no wiring of the resolver can skip it), and refuses it pre-spend as secret-name-reserved, naming the key. Specs: INT-TRIGGERS-FILE-CONTRACT AMENDED (the steering family, the pre-spend re-check), INT-CONTAINER-RUNTIME-CONTRACT AMENDED (the stale PI_CODING_AGENT_DIR claim, the steering-table note), REQ-TRIGGER-SECRETS AMENDED (load and pre-spend clauses), DES-TRIGGERS-UNIFIED-FILE AMENDED (#511 is another narrowing, the release notes must name it), CONST-PI-VERSION-PINNED evidence amended (the second upgrade gate covers pi's own reads). docs/secrets.md names the new families and the PI_FORWARD_ENV route. Checked unchanged: the per-provider pre-spend reservation, the key-variable bound, the closed container env map, admin/build.mjs inlining (the bundle carries the new members). Signed-off-by: Rob Boerman <robboerman@live.nl>
1 parent 97dab10 commit 1e75ed0

14 files changed

Lines changed: 2364 additions & 257 deletions

‎docs/secrets.md‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -409,16 +409,29 @@ failed, and never the reference, the resolver's path, or a byte of what it print
409409
`ANTHROPIC_AUTH_TOKEN` stays refused for every trigger, whichever provider the job runs on.
410410
The list is not maintained by hand: it is extracted from the pinned pi and from every package pi builds
411411
a client with, and a test compares the two in both directions, so an upgrade that starts reading a new
412-
variable fails the build. Seven names cannot be extracted, because the code builds the key at runtime
413-
(`AWS_ENDPOINT_URL` and the lowercase proxy spellings); those are listed by hand and a test checks they
414-
are still unfindable.
412+
variable fails the build. The scan also reads those packages' own dependencies, one level down. That
413+
adds the Google auth library's names (`GOOGLE_CLOUD_QUOTA_PROJECT`, `CLOUDSDK_CONFIG`, and the
414+
lowercase `google_application_credentials`, `gcloud_project` and `google_cloud_project`, which it reads
415+
beside the uppercase ones), the AWS SDK's (`AWS_CONFIG_FILE`, `AWS_SHARED_CREDENTIALS_FILE`, the EC2
416+
metadata settings, `AWS_USE_FIPS_ENDPOINT`, `AWS_MAX_ATTEMPTS` and the other client settings), the
417+
Google GenAI SDK's `GOOGLE_GENAI_ACCESS_TOKEN`, pi's Cloudflare `CLOUDFLARE_ACCOUNT_ID` and
418+
`CLOUDFLARE_GATEWAY_ID`, and a few from small helpers such as `DEBUG`. Names are matched exactly: only a
419+
lowercase spelling that some pinned package reads is refused.
420+
pi's own settings are refused too: every `PI_*` variable pi reads, such as `PI_CODING_AGENT_DIR`
421+
(another agent folder, with another `auth.json`), except the ones pi-dispatch sets itself.
422+
Four names cannot be extracted, because the code builds the key at runtime
423+
(`AWS_ENDPOINT_URL_BEDROCK_RUNTIME`, `http_proxy`, `https_proxy` and `ALL_PROXY`); those are listed by
424+
hand and a test checks they are still unfindable.
425+
A job that was queued before a name joined this list (or sent by an older receiver) is refused before
426+
it starts, as `secret-name-reserved`, and the refusal names the variable.
415427
This list does **not** include another provider's API key. Binding `OPENAI_API_KEY` on an Anthropic
416428
deployment is still fine, exactly as item 4 says: that is the previous rule and it is unchanged.
417429
**What it will cost you is carrying AWS or Google cloud credentials on a trigger for a step that has
418430
nothing to do with your model provider**, such as an S3 push or a `gcloud` call. `AWS_ACCESS_KEY_ID`,
419431
`AWS_SECRET_ACCESS_KEY` and `GOOGLE_APPLICATION_CREDENTIALS` are read by the provider SDKs as provider
420432
configuration, so they are refused on every deployment now, including one that uses neither Bedrock nor
421-
Vertex. The set also covers names that are harmless in themselves, such as `AWS_REGION` and `OPENAI_LOG`.
433+
Vertex. The set also covers names that are harmless in themselves, such as `AWS_REGION`, `OPENAI_LOG`
434+
and `DEBUG`.
422435
Put what you need on `PI_FORWARD_ENV` instead: nothing there refuses these names. It is not a
423436
like-for-like replacement, and the difference is the point of the refusal, `PI_FORWARD_ENV` is one host
424437
value for the whole deployment and it is the operator's list rather than the trigger author's.

‎specs/constitution.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -947,7 +947,10 @@ passing, on the record — issue #80.)
947947
it answers which variable a provider's key is read from, and `worker/test/env-allowlist.test.mjs` round
948948
trips it. The second is `PROVIDER_STEERING_VARS`, which answers which variables STEER a provider -- where
949949
the request goes, and with which credentials -- and is derived from the pinned artifacts in both
950-
directions by `worker/test/provider-steering.test.mjs`. Both exist because the same fact holds: a pi bump
950+
directions by `worker/test/provider-steering.test.mjs`. From issue #511 the second gate also covers
951+
pi's OWN reads in its `PI_*` namespace (pi-coding-agent and the pi packages it declares), and follows the
952+
SDKs' declared dependencies one hop further, so a bump that makes pi read a new configuration variable
953+
fails it too. Both exist because the same fact holds: a pi bump
951954
that quietly changes what the runtime reads from the environment is otherwise indistinguishable from
952955
nothing happening, and here the failure mode is a trigger choosing the host this deployment's own
953956
credential is sent to.
@@ -971,6 +974,7 @@ passing, on the record — issue #80.)
971974

972975
| Date | Change |
973976
|---|---|
977+
| 2026-09-30 | Issue #511 (the steering scan misses lowercase twins, a second SDK hop and pi's own reads). **No article changed.** **`CONST-PI-VERSION-PINNED`**: the evidence's second upgrade gate, `PROVIDER_STEERING_VARS`, now also covers pi's own `PI_*` reads and a second declared-dependency hop; the rule, the pin and the first gate UNCHANGED, checked. |
974978
| 2026-09-30 | Issue #509, the pi 0.80.7 -> 0.99.1 bump. **No article changed.** **`CONST-PI-VERSION-PINNED`**: the statement's version string reads **0.99.1**, and the evidence records that the `[Unreleased]` model/auth change it cited has shipped; the rule, and its two upgrade gates (`findEnvKeys` and `PROVIDER_STEERING_VARS`), UNCHANGED, checked: the steering gate named every new and stale name at this bump. The evidence convention gains one parenthetical: the pin moved and `ModelRuntime` now exists at it, the lesson did not move. **`CONST-NO-CONTEXT-FILES-MANDATORY` evidence re-verified at 0.99.1** (the trust branch, the `projectTrusted ?? true` default, the loader's two merge branches and `extensionsOverride`, at new line numbers; `SettingsManager.inMemory` now accepts options the runner does not pass), the 0.80.7 block kept as the record. **`CONST-RETRY-INFRA-ONLY` UNCHANGED, checked**: the new `retry-unresumable` exit `2` is a determinate stop after a paid turn and its tool effects, which a queue retry would repeat on a fresh budget, not an infrastructure failure; its #449 evidence block stays the 0.80.7 record. `CONST-MERGE-NEVER-AUTOMATIC`, `CONST-BUDGET-BEFORE-TOKENS` UNCHANGED, checked. |
975979
| 2026-09-29 | Issue #477, with PR #478's gate rounds 1 and 2. **No article changed.** **`CONST-ISOLATION-CONTAINER-PER-JOB` UNCHANGED, checked**: doctor now judges an unquoted `PI_BACKEND_FLOOR` from `.env` rather than calling it unread; the worker's boot refusal on the floor was already reading it. **`CONST-RETRY-INFRA-ONLY` UNCHANGED, checked**: cancel's wording changes no retry, removal or record, and a `VALKEY_URL` that names no database, or one the server does not have, is a configuration refusal (exit 2, not restarted into the same answer), as a refused owner is. **`CONST-TOKEN-SCOPED-PER-JOB` UNCHANGED, checked**: the foreground worker still reads no `.env`, and the `.env` writer hands no value to any new process. |
976980
| 2026-09-29 | Issue #476 (the netns keeper judged too young on every stack start). **No article changed.** **`CONST-RETRY-INFRA-ONLY` UNCHANGED, checked**: a keeper that is only young is held through the delayed set, which spends no attempt, like the pause, wait and podman.service holds; a keeper that does not hold, and the new `netns-keeper-crash-loop`, stay infrastructure throws that the queue retries. **`CONST-BUDGET-BEFORE-TOKENS` UNCHANGED, checked**: the hold sits where the keeper's check was, in the egress preflight before any reservation, and a held or crash-looped job reserves nothing. **`CONST-ISOLATION-CONTAINER-PER-JOB` UNCHANGED, checked**: no job runs any earlier than before; every pickup is judged by every keeper rule again. With PR #479's gate, an operator's cancel reaching a job the processor would hold or retry is a policy return, never a retry or a later run (`CONST-RETRY-INFRA-ONLY`). |

‎specs/design.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,15 @@ money with no upstream turn limit (`REQ-RUNNER-TURN-BUDGET`).
535535
secret of that name could otherwise have been meant to override it. No shipped trigger has a reason
536536
to bind any of these. The next release's notes must name it; the receiver image's `:latest` refuses
537537
such a file from the merge on.
538+
**Issue #511 narrows the same field again**, and the same two obligations apply. The steering set now
539+
also holds what google-auth-library and the AWS SDK's core read one dependency hop further in (the
540+
lowercase `google_application_credentials`, `GOOGLE_CLOUD_QUOTA_PROJECT`, `AWS_CONFIG_FILE` among them),
541+
and pi's own `PI_*` reads such as `PI_CODING_AGENT_DIR`. A file binding one of them used to load and now
542+
refuses at parse in all three loaders. The less obvious members are the reason to call it out:
543+
`DEBUG` and the Google and AWS runtime-detection names (`K_CONFIGURATION`, `AWS_EXECUTION_ENV`) are
544+
ordinary words an operator could have bound for a tool of their own. A job already queued, or a stored
545+
cron schedule, that binds such a name is refused pre-spend as `secret-name-reserved` rather than run
546+
with it. The next release's notes must name #511 and say that such a name moves to `PI_FORWARD_ENV`.
538547
- **Rejected**: a compat union accepting both old shapes (the repo bans backwards-compat shims,
539548
`.claude/rules/legacy-removal.md`) · two independent validators (they drift) · a third shared package
540549
(unnecessary — the one-way worker dependency already exists).
@@ -6433,4 +6442,5 @@ a tunnel.
64336442
| 2026-09-29 | Issue #492, with PR #493's review folded in. **`DES-COST-FOLD-BY-SCAN` AMENDED**: the aggregation sentence gains its one exception, a bucket every run of which ONE declared plan covers, with no run a floor, keeps `plan:<id>` instead of demoting to `estimated` (the review found the first cut dropped a floor's `≥` and joined two ids with a character an id may hold), so the fold agrees with `REQ-COST-ANALYTICS` (b) and with its own per-model bucket; mixed buckets still demote with coverage, and zero-rated and unknown buckets are unchanged (the issue is about plan coverage; widening the exception to them is a separate decision). The daily chart draws a plan day with the same 1px dashed sliver as before; only its tooltip now reads `plan:<id>`. **`DES-ADMIN-VIA-PI-EXTENSION` UNCHANGED, checked**: the topology's layout rules and the label-fit gate are untouched (the gate exits 0, with a new page of an armed and a spent one-shot and a long plan badge; the documented fixture's page probes clean). docs/costs.md gains the sentence saying what a wholly plan-covered bucket reads and what `dispatch_costs` returns for it. **Code evidence**: admin/src/costs.mjs; admin/test/costs.test.mjs. |
64346443
| 2026-09-30 | Issue #509, the pi 0.80.7 -> 0.99.1 bump, one row for the runner, worker and admin halves. **`DES-USAGE-METER-VIA-API-PROVIDER-REGISTRY` AMENDED in place** (ID kept, it is an address): at pi 0.99.1 the api-provider registry is off the session path, so the process-wide meter moved to ModelRuntime.prototype, with the registry kept as a second half for legacy extension calls and an AsyncLocalStorage deduplicating the composed-provider path where the two meet; a Rejected entry records why patching the prototype is not the rejected `dist/` patch; the Must-handle list is marked as the registry half's; compaction and branch summaries now land in `otherTotal`. The observe-don't-consume design, the fallback rule and the brake are UNCHANGED. **`DES-ADMIN-VIA-PI-EXTENSION` AMENDED**, the supported version (the pin, `0.99.1`), the evidence line numbers re-read at 0.99.1, and the column-width bullet: pi-tui 0.99.1 gives 472 spacing marks a column and walks a cluster from after its base, so the #417 doubling is gone (its pinned counts, 13,545 and 3,273 doubled forms and 6,446 breaking predecessors, are now zero, re-pinned rather than dropped) and the matched-leader rule is re-derived for the opposite correction: a spacing mark that LEADS a cluster draws nothing, so `panel.mjs` bundles the leading part of a run with its base as one step as wide as the base alone, and a cluster made only of non-printing code points as one zero step. The segmenter probe, the memo and the context stand-in are kept; the stand-in's safe direction flipped (a wrong join is now an under-count) and the predecessor sweep pins the new count (1,111,984 predecessors lead, fifteen join). New pinned arms: 472 spacing marks against 242 tails (1,888 and 124 narrower forms), and against the five emoji modifiers (exact at a string's start, 155 exact after a letter, the rest the declared skin-tone over-count). Residuals restated: the U+FFA0 under-count is gone, a joined piece now draws one column wider and is clipped by its frame, a Devanagari conjunct no longer over-counts. **`DES-SUBSCRIPTIONS-ARE-COUNTERFACTUAL-ONLY` AMENDED**, the first Why bullet: kimi-coding, zai and zai-coding-cn now carry implied API-equivalent rates, so prepaid runs record a positive cost rather than 0, and a zero rate is no longer a subscription signal; the declaration's role is unchanged. **The `run.excludeTools` decision AMENDED**: the known set and the runner's derivation are the eight built-ins of pi 0.99.1 (`powershell` added). The package-discovery Rejected entry re-checked: `collectPackageResources` still falls through to the convention dirs at 0.99.1. PR #510's review found two cuts the new rule left measuring text where it is not drawn, both fixed at the rule: a cut that strips a dangling joiner re-measures and re-cuts (a spacing mark and a joiner draw nothing together, the mark alone a column; thirty-one marks, swept), and every cutter takes an `afterSpace` context, used by both frame titles and the divider's label and meta (an 8-column box drew its top line at 9; the divider missed its width in 13,380 swept forms at the previous commit). **UNCHANGED, checked**: `OQ-035` (the substitution class is untouched; `interpreted` keeps every mark as composing), `REQ-TOPOLOGY-GRAPH`, `REQ-INSIGHTS-HTML-EXPORT` (the chip estimate is still one-sided; its exact-count pin moved from 185,319 to 185,583 for the 264 BMP spacing marks), `DES-COST-FOLD-BY-SCAN` (classification order unchanged: an owned plan first, so a declared plan reads `plan:<id>` across the rate change), counterfactual-only subscriptions. **Code evidence**: image/runner/src/usage-meter.mjs, model-runtime.mjs; admin/src/panel.mjs -> widthSteps, leaderSteps, planRun, contextOf, NON_PRINTING, SPACING; admin/src/style.mjs -> visibleLen (comments); admin/test/width-leaders.test.mjs, width-predecessors.test.mjs, width.test.mjs, graph-html.test.mjs, control-bytes.test.mjs, helpers/renderer.mjs (pin 0.99.1). |
64356444
| 2026-09-30 | Issue #508 (plain HTTP reached every port of a listed host). **`DES-EGRESS-DENY-ON-A-DEDICATED-NETWORK` AMENDED**, the Decision and one new bullet: the proxy passes a `CONNECT` to 443 or a plain request to 80, both by hostname to a listed host, by `http_access deny !Safe_ports !CONNECT` right before `http_access allow allowed`; the owner chose port 80 over HTTPS only because some package mirrors and redirects still use plain HTTP; a rule after the allow and a `dst` rule ahead of `allowed` are rejected; `doctor`'s canary gains a third probe that reads the rule back; the job image's git ignores `HTTP_PROXY` for an `http://` remote, so with the policy on a forge must be `https://` on 443. |
6445+
| 2026-09-30 | Issue #511 (the steering scan misses lowercase twins, a second SDK hop and pi's own reads). **`DES-TRIGGERS-UNIFIED-FILE` AMENDED**, the narrowing list: #511 reserves more names in `run.secrets` (the second SDK hop, the lowercase twins, pi's own `PI_*` reads), refusing files that used to load, and already queued jobs binding such a name refuse pre-spend; the release notes must name it and point at `PI_FORWARD_ENV`. Every other decision in the entry UNCHANGED, checked. |
64366446
| 2026-10-02 | Issue #503, part 3 (the measured host routes). **`DES-PODMAN-NATIVE-ROOTLESS-BACKEND` AMENDED**, one Why bullet: the routes from the egress proxy to a model server on the host, measured 2026-09-30 on rootless Podman 4.9.3 (slirp4netns), rootless 5.8.1 (pasta) and rootful 5.8.1, stating only what the issue's measurement comment and its addendum publish; the host's own LAN address is refuted under pasta only, and the issue's "LAN endpoints only for Podman 4.x" fallback is refuted. **`DES-CONTAINER-BACKEND-REGISTRY` AMENDED**, one bullet: `HOST_ROUTES` and `hostRouteFor` sit in the same leaf as a separate table, a reachability fact that is no declaration word, never read by the floor and refusing nothing. `hostRouteFor` answers `works` or `refuted` only for a host-local address (refuted everywhere), the two alias names and an IPv4 address in the host's own address list; another address or name is `lan`, an ordinary outbound route that is never `works`; anything else, a host not in a declared form, a bad host address entry, an IPv6 or link-local literal, an inexact version or a missing runtime field, is `unmeasured`. `PROXY_LOCAL_ADDRESSES` in the leaf is the one host-local set: `model-endpoints.mjs` builds `LOCAL_ADDRESSES` from it and refuses a declared host in it. Nothing reads the table yet (doctor and the proxy come in later parts). **`INT-CONTAINER-RUNTIME-CONTRACT` UNCHANGED, checked**: no job flag moves. **Code evidence**: worker/src/backends.mjs -> HOST_ROUTES, hostRouteFor, PROXY_LOCAL_ADDRESSES, isProxyLocalHost; worker/src/model-endpoints.mjs -> LOCAL_ADDRESSES; worker/test/backends.test.mjs, backends-doc.test.mjs, podman-doc.test.mjs, model-endpoints.test.mjs; docs/backends.md, docs/podman.md. |

0 commit comments

Comments
 (0)