Skip to content

fix(triggers): run.secrets refuses every steering name the pinned sources read (#511) - #513

Merged
edgehero merged 1 commit into
mainfrom
fix/511-steering-scan
Oct 2, 2026
Merged

edgehero merged 1 commit into
mainfrom
fix/511-steering-scan

Conversation

@edgehero

@edgehero edgehero commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Closes #511.

What changes

run.secrets refuses every provider steering name the pinned sources read. The set is still derived from the sources, never hand-typed, and a test compares it both ways.

The scan now:

  • reads both copies of pi-ai: the hoisted one and the one the runner loads under pi-coding-agent. Their google-auth-library copies differ: 10.9.1 hoisted, 10.6.2 for the runner.
  • follows each SDK's imports one hop further, but only those its package.json declares in dependencies. That reaches google-auth-library, @smithy/core, the AWS credential chain, debug and ws. It does not follow undici, which is only an optional peer of openai.
  • counts every occurrence that can reach the environment, instead of matching a list of accessor spellings. Comments are stripped first by a small tokenizer, checked against esbuild's parser on every scanned file. Then it counts 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), and every call of a helper. Helpers are derived, not listed: any named function one of whose own parameters is the key of an environment read (resolveEnvConfigValue(name, env)), so a new call of an existing helper is counted like a read.
  • each occurrence either names a variable (a member, a key, a call, an in test, a destructure, or a selector argument, with keys resolved through string constants) or is a site. Every file's sites are pinned with a count in worker/test/fixtures/provider-steering-sites.json. So a new occurrence anywhere either names something the equality sees or changes a count, and the failure prints the entries to review. The names pi reads outside its PI_* namespace are pinned too. Forms with no env token at all (Reflect.get(process, "env"), process["e" + "nv"]) are a stated limit; none occurs at the pin.
  • adds pi's own PI_* reads from pi-coding-agent's dist/ (not dist/bundle/) and from the pi packages it declares. It subtracts the names the worker writes and the ones the runner assigns before pi runs (PI_OFFLINE, PI_TELEMETRY).

Matching stays exact, so google_application_credentials is refused while openai_base_url stays bindable. The set grows from 68 to 132 names: 64 added, none removed. The hand-written residuals drop from nine to four.

A job queued before the set widened, a stored cron schedule, or an older receiver can still carry such a name. The worker now checks the same reserved set against the job at the call site, before the secrets resolver is called, and refuses it before anything spends as secret-name-reserved, naming the key.

Operator impact (release notes must say this)

A triggers file that binds any newly reserved name stops loading, and a queued job that binds one is refused. Some of these are ordinary names, such as DEBUG, HOSTNAME, K_CONFIGURATION, AWS_EXECUTION_ENV and PI_TUI_DEBUG. Put those on PI_FORWARD_ENV instead.

Tests

  • provider-steering.test.mjs goes from 9 to 16 tests, including a fixture test of every access form. triggers.test.mjs gains a refusal test and a still-bindable test. processor.test.mjs gains a test for the pre-spend re-check, with a resolver that fails if it is ever reached.
  • 34 mutations, each shown red then reverted. On the rule: uppercase-only capture, no hop 2, hoisted copy only, no dot form, no constant resolution, no config.js evaluation, no assignment check, no runner subtraction, scanning dist/bundle/, no alias receivers, counting only process.env, no selector form, no destructure form, no comment stripping, no env() result follow, no process["env"] count, no helper derivation, case-insensitive matching, a deleted name, and no pre-spend re-check. Added to the pinned sources: const { X } = process.env, an alias, process.env?.X, ctx.env("X"), env[CONST], a template key and a runtime key in openai/client.mjs, a new process.env[name] helper in pi-ai, const v = env(); v.X in @google/genai, process["env"]["X"], import { env as E } from "node:process", and two new calls of the existing resolveEnvConfigValue helper.
  • By hand: with google_application_credentials, and again with PI_CODING_AGENT_DIR, in run.secrets:
    • pi-dispatch doctor prints ✗ triggers file is refused at load;
    • pi-dispatch worker exits 2 with the same "is reserved" error.

@edgehero
edgehero force-pushed the fix/511-steering-scan branch 3 times, most recently from a88f574 to 1e75ed0 Compare October 2, 2026 08:07
…rces 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>
@edgehero
edgehero force-pushed the fix/511-steering-scan branch from 1e75ed0 to 2ddc704 Compare October 2, 2026 08:22
@edgehero
edgehero merged commit 9449e69 into main Oct 2, 2026
7 checks passed
edgehero added a commit that referenced this pull request Oct 2, 2026
…egress and endpoint changes (#529)

The three CLI transcripts were stale after the #508 and #503 round. init
now also writes model-endpoints.json and model-endpoints.conf. up starts
the egress proxy with a third mount for model-endpoints.conf and with
--add-host host.docker.internal:host-gateway. doctor has a third egress
probe, the plain HTTP refusal off port 80 (#508), and a Valkey listener
line.

Each transcript is real output of this tree's CLI, run on a Linux Docker
Engine 29.1.3 host as a throwaway account (uid 1000), in an empty folder,
with the home of that account shown as ~ (the sed is in launch/demo.md),
so the folder reads ~/pi-work. The key is a dummy and gh is not logged in, so
doctor shows the gh warning in place of the old scopes warning. In up the
25 doctor checks fold into one counted line (23 ok, 2 warnings, no
failures), as before.

The renderer that drew the old images was lost with a scratch folder. It
now lives in launch/transcript-svg.mjs. Fed the text of the 2.0.0 images
it gives them back byte for byte. launch/demo.md says how to capture and
render each image. cli-service-windows.svg is unchanged: the nssm render
did not change in this round. The panel images are unchanged too: no
admin renderer changed in #513 to #527.

The README cache busters for the three images move to 2026-10-02, and
the up and doctor alt texts name what the images now show.

No spec change: docs and maintainer tooling only. Spec IDs UNCHANGED,
checked: REQ-DEPLOYMENT-BOOTSTRAP and INT-EGRESS-POLICY-CONTRACT,
which these transcripts show, are not touched.

Signed-off-by: Rob Boerman <robboerman@live.nl>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

run.secrets can still bind a few steering names: lowercase Google twins, the billing project, and pi's own agent directory

1 participant