diff --git a/.github/scripts/openshell-bind-conformance.mjs b/.github/scripts/openshell-bind-conformance.mjs index d260390c..72c31a32 100644 --- a/.github/scripts/openshell-bind-conformance.mjs +++ b/.github/scripts/openshell-bind-conformance.mjs @@ -124,6 +124,16 @@ case "$c" in echo "probe uid=$(id -u) gid=$(id -g)" echo mine > /workspace/owned.txt && echo "probe home=$(touch "$HOME/.probe" 2>/dev/null && echo writable || echo denied)" exit 0 ;; + bundle) + # Issue #704 part 3: the copy pass's bundle case, through the bind stage (one stage for both modes). + echo "probe btoken=$(case "$BUNDLE_TOKEN" in openshell:resolve:env:*) echo placeholder ;; conformance-bundle-not-a-token) echo value ;; '') echo unset ;; *) echo other ;; esac)" + echo "probe bplain=$(if [ "$PI_BUNDLE_ENV" = 1 ] && grep -q conformance-bundle-plain-value /job/bundle-env.json 2>/dev/null; then echo file; else echo other; fi)" + # Readable means NON-EMPTY, read the way the count below reads it (a redirect, so the subshell's own environ, not a + # fresh child's): under OpenShell 0.1.4 that file opens and reads as nothing, as the credentials case measured. + echo "probe benviron-readable=$(if [ "$(tr '\\0' '\\n' < /proc/self/environ 2>/dev/null | wc -c)" -gt 0 ]; then echo yes; else echo no; fi)" + echo "probe benviron=$(tr '\\0' '\\n' < /proc/self/environ | grep -c -e '^BUNDLE_TOKEN=openshell:resolve:env:')" + echo "probe bown=$(node -e 'fetch("http://host.docker.internal:18080/bundle-bind",{headers:{authorization:"Bearer "+process.env.BUNDLE_TOKEN},signal:AbortSignal.timeout(10000)}).then(r=>console.log(r.status),e=>console.log("refused",e.cause?.code??e.name))')" + exit 0 ;; readback) echo "probe uid=$(id -u)" grep -E '^(CapEff|NoNewPrivs|Seccomp):' /proc/self/status | tr -d '\\t' | sed 's/^/probe /' @@ -311,6 +321,28 @@ if (!readings.readOnlyJobInputs?.ok) failed = true; if (!ok) failed = true; } +// Issue #704 part 3: a project secret bundle in bind mode: the placeholder in the env and /proc/self/environ, the plain +// value in the bound job directory's bundle-env.json (review round 1, H1: never the exec), the real value at the stand-in's host (the workflow's, still running across the gateway restart), and +// the providers gone after the run. +{ + const values = { BUNDLE_TOKEN: "conformance-bundle-not-a-token", BUNDLE_PLAIN: "conformance-bundle-plain-value" }; + const b = bundleFor("bind", probeImage, { readEndpoints: () => [{ id: "lab-b", host: "host.docker.internal", port: 18080, slots: 1 }] }); + const name = `pi-job-bind-conformance-${nonce}-bundle`; + output.set("last", ""); + const result = await b.runContainer({ job: JOB, prepared: jobDirFor("bundle"), name, signal: new AbortController().signal, secrets: values, bundleKeys: { endpoints: { BUNDLE_TOKEN: { host: "host.docker.internal", auth: "bearer" } }, plain: ["BUNDLE_PLAIN"] }, size: { memMiB: 1024, cpuCenti: 100 } }); + const got = said(output.get("last")); + let seen = []; + try { + seen = nodeFs.readFileSync(process.env.STANDIN_LOG ?? "", "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)); + } catch {} + const swapped = seen.some((e) => e.path === "/bundle-bind" && e.authorization === `Bearer ${values.BUNDLE_TOKEN}`); + const listed = await run("openshell", ["provider", "list", "--label-selector", `${OPENSHELL_OWNER_LABEL}=${OPENSHELL_OWNER}`, "-o", "json"], 30_000); + const left = listed.code === 0 ? (JSON.parse(listed.stdout).providers ?? []).map((p) => p.name).filter((p) => p.startsWith(sandboxNameOf(name))) : null; + const ok = result.code === 0 && got.btoken === "placeholder" && (got["benviron-readable"] === "no" || got.benviron === "1") && got.bplain === "file" && got.bown === "200" && swapped && Array.isArray(left) && left.length === 0; + console.log(`${ok ? "ok " : "FAIL "} a secret bundle in bind mode: ${JSON.stringify({ exit: result.code, ...got, swapped, providersLeft: left })}`); + if (!ok) failed = true; +} + // Review round 1 (#714): run_as_user other than the image's, exercised in CI. The job dir is handed to uid 2000 (sudo), // the bundle runs the job as "2000:2000", and the bound file it writes lands on the host as 2000. { diff --git a/.github/scripts/openshell-conformance.mjs b/.github/scripts/openshell-conformance.mjs index cd97f3e4..ef357615 100644 --- a/.github/scripts/openshell-conformance.mjs +++ b/.github/scripts/openshell-conformance.mjs @@ -29,7 +29,7 @@ * Exit 0 when everything held, 1 when anything failed or was not read, 2 when the host is not one this can run on. */ -import { execFile } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import * as nodeFs from "node:fs"; import { tmpdir } from "node:os"; @@ -39,7 +39,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const load = (path) => import(pathToFileURL(join(root, path)).href); -const { makeOpenshellBackend, OPENSHELL_OWNER, OPENSHELL_OWNER_LABEL } = await load("worker/src/backend-openshell.mjs"); +const { makeOpenshellBackend, OPENSHELL_OWNER, OPENSHELL_OWNER_LABEL, sandboxNameOf } = await load("worker/src/backend-openshell.mjs"); const { READ_BACK_BY_A_LIVE_PROBE, UNVERIFIED_BY_THIS_HARNESS, runBackendConformance } = await load("worker/src/backend-conformance.mjs"); const { makeLogSink } = await load("worker/src/run-history.mjs"); const { makeLiveTranscriptOpener } = await load("worker/src/live-transcript.mjs"); @@ -101,6 +101,25 @@ case "$c" in echo "probe push $u=$(node -e 'fetch(process.argv[1],{method:"POST",signal:AbortSignal.timeout(10000)}).then(async r=>console.log(r.status,/policy/i.test(await r.text())?"policy":"upstream"),e=>console.log("refused",e.cause?.code??e.name))' "$u")" done exit 0 ;; + bundle) + # Issue #704 part 3: a project secret bundle. BUNDLE_TOKEN is bound to host.docker.internal (its own provider) and + # is a placeholder here and in /proc/self/environ; BUNDLE_PLAIN is a value. The placeholder is swapped at its own + # host only: sent to the other alias (the job provider's endpoint) it is refused, and the job's own model key sent + # to the bundle's host is refused too, since an endpoint binds one provider. + echo "probe btoken=$(case "$BUNDLE_TOKEN" in openshell:resolve:env:*) echo placeholder ;; conformance-bundle-not-a-token) echo value ;; '') echo unset ;; *) echo other ;; esac)" + # Review round 1, H1: the plain one reaches the runner by FILE (this probe replaces the runner, so it reads the file + # itself; verify-image.sh proves the runner applies it), never the exec: no process's command line holds it. + echo "probe bplain=$(if [ "$PI_BUNDLE_ENV" = 1 ] && grep -q conformance-bundle-plain-value /sandbox/job/bundle-env.json 2>/dev/null; then echo file; elif [ -n "$BUNDLE_PLAIN" ]; then echo env; else echo other; fi)" + echo "probe bcmdline=$(cat /proc/[0-9]*/cmdline 2>/dev/null | tr '\\0' '\\n' | grep -c -e 'conformance-bundle-plain-valu[e]')" + # Readable means NON-EMPTY, read the way the count below reads it (a redirect, so the subshell's own environ, not a + # fresh child's): under OpenShell 0.1.4 that file opens and reads as nothing, as the credentials case measured. + echo "probe benviron-readable=$(if [ "$(tr '\\0' '\\n' < /proc/self/environ 2>/dev/null | wc -c)" -gt 0 ]; then echo yes; else echo no; fi)" + echo "probe benviron=$(tr '\\0' '\\n' < /proc/self/environ | grep -c -e '^BUNDLE_TOKEN=openshell:resolve:env:')" + echo "probe benviron-values=$(cat /proc/[0-9]*/environ 2>/dev/null | tr '\\0' '\\n' | grep -c -e conformance-bundle-not-a-token)" + echo "probe bown=$(node -e 'fetch("http://host.docker.internal:18080/bundle-own",{headers:{authorization:"Bearer "+process.env.BUNDLE_TOKEN},signal:AbortSignal.timeout(10000)}).then(r=>console.log(r.status),e=>console.log("refused",e.cause?.code??e.name))')" + echo "probe bother=$(node -e 'fetch("http://host.openshell.internal:18080/bundle-elsewhere",{headers:{authorization:"Bearer "+process.env.BUNDLE_TOKEN},signal:AbortSignal.timeout(10000)}).then(r=>console.log(r.status),e=>console.log("refused",e.cause?.code??e.name))')" + echo "probe bkey=$(node -e 'fetch("http://host.docker.internal:18080/key-at-bundle-host",{headers:{"x-api-key":process.env.ANTHROPIC_API_KEY},signal:AbortSignal.timeout(10000)}).then(r=>console.log(r.status),e=>console.log("refused",e.cause?.code??e.name))')" + exit 0 ;; *) exit "$c" ;; esac `, @@ -227,6 +246,95 @@ for (const f of r.findings) { console.log(`${ok ? "ok " : "FAIL "} the credentials: ${JSON.stringify({ envkey: said.envkey, envtoken: said.envtoken, environReadable: said["environ-readable"], environSelf: said["environ-self"], environValues: said["environ-values"], header: said.header, git: said.git, swapped, leaked, gitBasic: git })}`); if (!ok) failed = true; } +// Issue #704 part 3: a project secret bundle, through the bundle's own gate and stage as the processor drives them: one +// variable bound to host.docker.internal (the stand-in's second alias, a model endpoint of this policy) as a +// placeholder, one plain. The stand-in is reachable only as a model endpoint (a port other than 443), which the +// processor's credential-host rule (review round 1, H2) refuses as a bundle host, so the gate is asked here without the +// processor's hosts: what this case proves is the venue's binding and file channel beneath that rule. The plain value +// must reach the job directory's bundle-env.json and no command line, and neither value the gateway's own log (H1). Read from inside: the placeholder in the env and in /proc/self/environ and no process holding +// its value, the plain value visible, the real value received by the stand-in at its own host only, and the job's own +// model key refused at the bundle's host (an endpoint binds one provider). Then a KILLED job's providers: a worker +// process that dies with a bundled sandbox running leaves the sandbox and both its providers, and the reaper's sweep +// removes all three. +const BUNDLE_ENDPOINTS = { BUNDLE_TOKEN: { host: "host.docker.internal", auth: "bearer" } }; +const BUNDLE_VALUES = { BUNDLE_TOKEN: "conformance-bundle-not-a-token", BUNDLE_PLAIN: "conformance-bundle-plain-value" }; +const BUNDLE_KEYS = { endpoints: BUNDLE_ENDPOINTS, plain: ["BUNDLE_PLAIN"] }; +const providersListed = async () => { + const listed = await run("openshell", ["provider", "list", "--label-selector", `${OPENSHELL_OWNER_LABEL}=${OPENSHELL_OWNER}`, "-o", "json"], 30_000); + return listed.code === 0 ? (JSON.parse(listed.stdout).providers ?? []).map((p) => p.name) : null; +}; +{ + const gate = await backend.bundleGate(JOB, { endpoints: BUNDLE_ENDPOINTS, vars: BUNDLE_VALUES, shadowed: [], capabilities: ["bundleEnvFile"] }); + const refusedWide = await backend.bundleGate(JOB, { endpoints: { BUNDLE_TOKEN: { host: "example.com", auth: "bearer" } }, vars: null }); + output.set("last", ""); + const name = `pi-job-conformance-${nonce}-bundle`; + const result = await backend.runContainer({ job: JOB, token: "conformance-probe-not-a-token", prepared: jobDirFor("bundle"), name, signal: new AbortController().signal, secrets: BUNDLE_VALUES, bundleKeys: BUNDLE_KEYS, size: { memMiB: 1024, cpuCenti: 100 } }); + const said = Object.fromEntries([...String(output.get("last")).matchAll(/^probe ([A-Za-z.-]+)[=:][ \t]*(.*)$/gm)].map((m) => [m[1], m[2].trim()])); + let seen = []; + try { + seen = nodeFs.readFileSync(process.env.STANDIN_LOG ?? "", "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)); + } catch {} + const at = (path) => seen.filter((e) => e.path === path); + const swapped = at("/bundle-own").some((e) => e.authorization === `Bearer ${BUNDLE_VALUES.BUNDLE_TOKEN}`); + // Refused by the supervisor before it forwards (403 credential_endpoint_mismatch), so the stand-in never saw them. + const elsewhere = /^403$/.test(said.bother ?? "") && at("/bundle-elsewhere").length === 0; + const keyRefused = /^403$/.test(said.bkey ?? "") && at("/key-at-bundle-host").length === 0; + const leaked = seen.some((e) => /openshell:resolve/.test(e.authorization ?? "")); + let gatewayLog = ""; + try { + gatewayLog = nodeFs.readFileSync(process.env.GATEWAY_LOG ?? "", "utf8"); + } catch {} + const logged = Object.values(BUNDLE_VALUES).some((v) => gatewayLog.includes(v)); + const ok = !logged && said.bcmdline === "0" && gate?.ok === true && gate.placeholders === 1 && gate.plain === 1 && refusedWide?.venueRefused?.reason === "openshell-bundle-endpoint-not-allowed" && result.code === 0 && said.btoken === "placeholder" && (said["benviron-readable"] === "no" || said.benviron === "1") && said["benviron-values"] === "0" && said.bplain === "file" && said.bown === "200" && swapped && elsewhere && keyRefused && !leaked; + console.log(`${ok ? "ok " : "FAIL "} a secret bundle: ${JSON.stringify({ gate, wide: refusedWide?.venueRefused?.reason ?? null, exit: result.code, btoken: said.btoken, benvironReadable: said["benviron-readable"], benviron: said.benviron, benvironValues: said["benviron-values"], bplain: said.bplain, bcmdline: said.bcmdline, gatewayLogRead: gatewayLog.length > 0, logged, bown: said.bown, bother: said.bother, bkey: said.bkey, swapped, elsewhere, keyRefused, leaked })}`); + if (!ok) failed = true; + const left = await providersListed(); + const tidy = left !== null && !left.some((p) => p.startsWith(sandboxNameOf(name))); + console.log(`${tidy ? "ok " : "FAIL "} the bundle run's teardown deleted its providers: ${JSON.stringify(left)}`); + if (!tidy) failed = true; +} +{ + // The killed job: a worker of its own, SIGKILLed while its bundled sandbox runs, so nothing of its teardown runs. + const name = `pi-job-conformance-${nonce}-killed`; + const sandbox = sandboxNameOf(name); + const dirs = jobDirFor("sleep"); + const child = join(scratch, "killed-worker.mjs"); + nodeFs.writeFileSync( + child, + `import { pathToFileURL } from "node:url"; +const [root, image, engine, name, jobDir] = process.argv.slice(2); +const { makeOpenshellBackend } = await import(pathToFileURL(root + "/worker/src/backend-openshell.mjs").href); +const b = makeOpenshellBackend({ image, engine, hostEnv: { ANTHROPIC_API_KEY: "conformance-probe-not-a-key" }, onOutput: () => {}, readAllowlist: () => ["api.anthropic.com", ".github.com"], readEndpoints: () => [{ id: "lab-a", host: "host.openshell.internal", port: 18080, slots: 1 }, { id: "lab-b", host: "host.docker.internal", port: 18080, slots: 1 }] }); +await b.runContainer({ job: ${JSON.stringify(JOB)}, token: "conformance-probe-not-a-token", prepared: { jobDir, workspace: jobDir + "/workspace", live: { hostDir: jobDir + "/session" } }, name, signal: new AbortController().signal, secrets: ${JSON.stringify(BUNDLE_VALUES)}, bundleKeys: ${JSON.stringify(BUNDLE_KEYS)}, size: { memMiB: 1024, cpuCenti: 100 } }); +`, + ); + const worker = spawn(process.execPath, [child, root, probeImage, engine, name, dirs.jobDir], { stdio: "ignore" }); + let ready = false; + for (let i = 0; i < 180 && !ready; i++) { + const got = await run("openshell", ["sandbox", "get", sandbox, "-o", "json"], 15_000); + ready = got.code === 0 && JSON.parse(got.stdout).phase === "Ready"; + if (!ready) await new Promise((r) => setTimeout(r, 1000)); + } + // Running, not merely created: the exec has started once its sleep is in the sandbox's process list is not readable + // from here, so a few seconds past Ready, as the abort probe waits. + await new Promise((r) => setTimeout(r, 5000)); + const before = await providersListed(); + worker.kill("SIGKILL"); + const reaped = await backend.reap(); + let after = null; + let sandboxGone = false; + for (let i = 0; i < 30; i++) { + after = await providersListed(); + sandboxGone = (await run("openshell", ["sandbox", "get", sandbox, "-o", "json"], 15_000)).code !== 0; + if (after !== null && sandboxGone && !after.some((p) => p.startsWith(sandbox))) break; + await new Promise((r) => setTimeout(r, 2000)); + } + const held = before !== null && before.includes(sandbox) && before.includes(`${sandbox}-b0`); + const ok = ready && held && reaped?.reaped === true && sandboxGone && after !== null && !after.some((p) => p.startsWith(sandbox)); + console.log(`${ok ? "ok " : "FAIL "} a killed bundled job's sandbox and providers are gone after the sweep: ${JSON.stringify({ ready, before, reaped, sandboxGone, after })}`); + if (!ok) failed = true; +} + // readOnlyJobInputs is not in the harness's read-back list (the transfer check covers its word); this script reads it. console.log(`${readings.readOnlyJobInputs?.ok ? "ok " : "FAIL "} readOnlyJobInputs (live): ${readings.readOnlyJobInputs?.detail}`); if (!readings.readOnlyJobInputs?.ok) failed = true; diff --git a/.github/workflows/openshell-conformance.yml b/.github/workflows/openshell-conformance.yml index 3cbaae50..689f17b6 100644 --- a/.github/workflows/openshell-conformance.yml +++ b/.github/workflows/openshell-conformance.yml @@ -95,7 +95,8 @@ jobs: # Issue #684: a model server stand-in on the gateway host, which the generated policy reaches through OpenShell's # host aliases. It records what each request carried in its credential headers (only fake values reach it), so the - # script can tell a swapped value from a placeholder, and asks git for Basic auth on info/refs. + # script can tell a swapped value from a placeholder, and asks git for Basic auth on info/refs. Issue #704 part 3: + # its second alias (host.docker.internal) is the secret-bundle case's endpoint host, read off the same log. - name: A stand-in server on the gateway host run: | cat > "$RUNNER_TEMP/standin.mjs" <<'JS' @@ -114,7 +115,7 @@ jobs: STANDIN_LOG="$RUNNER_TEMP/standin.jsonl" nohup node "$RUNNER_TEMP/standin.mjs" > "$RUNNER_TEMP/standin.log" 2>&1 & - name: Conformance (the real bundle, the real runner) - run: STANDIN_LOG="$RUNNER_TEMP/standin.jsonl" PI_JOB_IMAGE=pi-job:ci node .github/scripts/openshell-conformance.mjs + run: STANDIN_LOG="$RUNNER_TEMP/standin.jsonl" GATEWAY_LOG="$RUNNER_TEMP/gateway.log" PI_JOB_IMAGE=pi-job:ci node .github/scripts/openshell-conformance.mjs # Issue #686 part 3: bind mode. First on the gateway as it is, whose default settings must refuse it before spend # (the probe create refused with upstream's allow_driver_config sentence). @@ -146,9 +147,10 @@ jobs: for i in $(seq 1 60); do (echo > /dev/tcp/127.0.0.1/17670) 2>/dev/null && break; sleep 1; done openshell status -o json + # Issue #704 part 3: its secret-bundle case reads the stand-in's log too (the stand-in outlives the restart). - name: Bind-mode conformance (the real bind bundle, the real runner) if: always() - run: PI_JOB_IMAGE=pi-job:ci node .github/scripts/openshell-bind-conformance.mjs + run: STANDIN_LOG="$RUNNER_TEMP/standin.jsonl" PI_JOB_IMAGE=pi-job:ci node .github/scripts/openshell-bind-conformance.mjs # Issue #707: the setup commands on the real host. `pi-dispatch up --yes` in a scratch deployment folder whose .env # blesses only the openshell venue: the gateway is already running and registered here, so the gate reads it and diff --git a/docs/backends.md b/docs/backends.md index 27218be4..228d8673 100644 --- a/docs/backends.md +++ b/docs/backends.md @@ -51,9 +51,10 @@ anywhere, but the declaration lands here. listJobContainers, // host budget, at boot: your job containers that may still run, with their sizes containerGone, // host budget: is this orphan's container gone, so its room can be given back? - // OPTIONAL. Leave either out and your venue gets the answer every non-local venue has always had. + // OPTIONAL. Leave any out and your venue gets the answer every non-local venue has always had. observationPreflight, // free: does this host still show what your `observedBy` words need? jobUserPreflight, // free: which uid this job's container runs as + bundleGate, // free: can this venue hold this job's project secret bundle as declared? (issue #704) } ``` @@ -71,9 +72,10 @@ worker starts, and whether a container whose stop did not take is gone before it can answer for your runtime. The worker used to guess from the venue's name and asked docker about every venue but `podman`, so another venue's container could be counted as gone while it still ran. -The two optional members are optional for a reason: an adapter written against the other required functions keeps -working, unchanged. Absent, `observationPreflight` admits every job and `jobUserPreflight` runs the image's own -`USER`. The one exception is a venue whose table entry names an `observedBy`: its words hold only while +The optional members are optional for a reason: an adapter written against the other required functions keeps +working, unchanged. Absent, `observationPreflight` admits every job, `jobUserPreflight` runs the image's own +`USER`, and `bundleGate` admits every secret bundle (right for a venue whose container holds its env as values, where a +bundle's variables simply join the env). The one exception is a venue whose table entry names an `observedBy`: its words hold only while something is observed, so the worker refuses to boot with that venue registered and no `observationPreflight` to observe it. A venue that carries one is dispatched to it per job, through the registry, exactly like the required functions. @@ -94,6 +96,7 @@ worse. | `listJobContainers` | nothing | `[{ name, memMiB, cpuCenti }]`: every job container of yours that may still run (leave out the ones in a state that runs nothing), with the size it was started at. **Throw when your runtime does not answer.** An empty array says you hold none, so the budget admits jobs on your venue as if nothing ran there. | | `containerGone` | the container name | `true` when it is gone (no such container, one in a state that runs nothing, or one not started and removed), `false` when it is listed in any other state, `null` when you could not ask. Only `true` gives the orphan's room back. | | `observationPreflight` (optional) | the job | `{ ok: true }` to admit, `{ refused: true, message, observations }` when a floor needs an observation this host does not show (`message` goes to the operator's log only; `observations` names which, from the closed list), `{ unavailable: true, reason }` when it could not be read yet (retried). Anything carried beside `ok` is handed to `jobUserPreflight` as `observed`. Beside `ok`, `jobUserRefused: { refused: "job-user-unmappable", cause }` refuses the job before the image preflight, for a venue that already knows no uid can run there whatever the image (the `podman` venue uses it: its image probe asks the same runtime and would otherwise misname the fault), and so does `podmanConfRefused: { reason, key, message }`, the `podman` venue's refusal of a widening containers.conf (`podman-conf-widens-job`, issue #428), which `local` also hands back, marked `rootful: true`, for rootful Podman's own containers.conf (issue #448). | +| `bundleGate` (optional, issue #704) | `(job, { endpoints, vars, shadowed })`, asked twice inside the bundle read: `vars` null before the resolver runs (judge the hosts), then the received variables | `{ ok: true }` to admit, optionally with `placeholders` and `plain` counts for the run record; `{ venueRefused: { reason, comment } }` to refuse before spend (the comment is fixed text); `{ unavailable: true }` when a source could not be read (retried). The call bag of `runContainer` then carries `bundleKeys: { endpoints, plain }`, the received variables' split, which a venue holding its env as values ignores. The ask also carries `credentialHosts` (the hosts the job's own model and MCP credentials go to) and the image's `capabilities`. | | `jobUserPreflight` (optional) | `(job, { capabilities, observed })` | `{ user, home }` (`user` null means the image's own `USER`), plus `relabel: true` where the job's own mounts must carry a private SELinux label (the processor hands it to `runContainer`), `{ refused: "job-user-unmappable", cause }` or `{ refused: "job-image-any-uid-unsupported" }` to refuse, `{ unavailable: true, reason }` to retry. | ## What a backend declares @@ -597,6 +600,21 @@ nothing, so no provider is made and its secrets go nowhere (logged `openshell_se the provider and its profile after the sandbox; a crashed job's are deleted by the boot sweep, by the owner label. Rotation does not reach a running job: a job is bounded at 30 minutes and an App token lives about an hour. +**A project secret bundle** (issue #704, [`docs/secrets.md`](secrets.md#on-an-openshell-venue-placeholders-for-the-variables-you-bind-to-a-host)). +A bundle variable the bundle's `endpoints` binds to a host goes into a provider of that host's own (`-b`, +one per host, created, labelled and deleted like the job's, swept after a crash), and only that host's endpoints of +the policy are bound to it: the agent sees its placeholder, and the value is swapped in only in a request to that host. +OpenShell 0.1.4 binds one provider per endpoint, so at that host nothing else of the job resolves (another credential +sent there is answered 403). Every other bundle variable is a value the agent reads as on every other venue, handed in +`bundle-env.json` in the job directory (0600, removed at teardown, or with the job directory by the next start's +salvage or orphan sweep after a worker crash) and read into the runner's environment by an image +declaring `bundleEnvFile`, never on the exec's command line, whose env the gateway logs. The host must already be +exactly a host of the job's policy and not one the job's own forge, model or MCP credentials go to, an `endpoints` name +the bundle lacks refuses the job, and so do a value OpenShell cannot carry and plain variables on an older image, each +before the sandbox exists and before spend (`openshell-bundle-endpoint-not-allowed`, `-endpoint-shared`, +`-endpoint-unknown`, `-name-taken`, `-unpassable`, `-image-too-old`). The read-back expects exactly one `_provider_*` +layer per provider the job attached. + **git push.** The clone's remote carries no token, so a forge job's exec gets a git credential helper (through `GIT_CONFIG_COUNT`) that prints the forge token's placeholder as the password, the supervisor rewriting it in a request to any host the policy admits (every credential of the job's provider is bound to every admitted host, as squid gives every diff --git a/docs/job-image.md b/docs/job-image.md index 6736f3c8..7785e0c2 100644 --- a/docs/job-image.md +++ b/docs/job-image.md @@ -73,7 +73,7 @@ list exists. | The loader flags in `image/runner/src/loader.mjs` | **Security posture is per-image.** A deployment that turned repo-file discovery off for multi-tenancy in one image **has not turned it off in another** | **silent** | | Label `dev.pi-dispatch.pi-version` = the pi the image actually carries | The worker reads it pre-spend and treats an absent one as **"never resume"**, the safe direction. Every `run.resume` job then cold-starts: correct, paid for in full, and invisible | **silent** (deliberately) | | Label `dev.pi-dispatch.forges` = the forges this image can serve | An **exclusion** list. A label that omits a forge refuses that forge's jobs pre-spend (`job-image-forge-unsupported`); a label naming a forge whose CLI is *not* installed is worse than none, turning that refusal into a paid container that fails at step 3 | loud, pre-spend | -| Label `dev.pi-dispatch.capabilities` = the optional features it honours (`replicas`, `commands`, `excludeTools`, `anyUid`, `costCap`, `modelPolicy`, `exitAuth`, `mcp`, `forgeMcp`, `awaitInput`, `reportProgress`, `recover`, `steer`, `sandboxLayout`, `transcriptStderr`, `steerStdin`) | An **inclusion** list. An image without the label is refused **every** replica job pre-spend (`job-image-replicas-unsupported`), because a floor that hard-codes `pi/issue-` would make both replicas converge on one branch; **every** `run.command` job pre-spend (`job-image-commands-unsupported`), because a runner that does not understand `PI_COMMAND` would feed `/name args` to the model as prose or die retryable on `no-terminal-message`; and **every** `run.excludeTools` job pre-spend (`job-image-exclude-tools-unsupported`), because a runner that does not read `PI_EXCLUDE_TOOLS` would run a "read-only" trigger with every tool the file says to remove and record a clean exit. `anyUid` says the image runs correctly as an arbitrary non-root uid with `HOME=/home/pi`, which is what a job needs on a daemon that enforces bind-mount ownership (native Linux Docker, rootful Podman): `chown -R pi:pi /home/pi && chmod 0777 /home/pi /home/pi/.pi /home/pi/.pi/agent` and no `ENV HOME`. On such a daemon a worker whose uid is not 1001 refuses a job on an image without it pre-spend (`job-image-any-uid-unsupported`); a uid-1001 worker runs the image as its own user and does not need it. `costCap` (issue #501) says the runner reads `PI_MAX_COST_MICROS` and stops a job before any provider call whose worst-case cost could take it past that cap; a runner without it would ignore the variable and spend with no cap, so the worker refuses **every** job with a dollar cap on an image without it, pre-spend (`job-image-cost-cap-unsupported`). `modelPolicy` (issue #502) says the runner reads `PI_ALLOWED_MODELS` and stops a job before any provider call to a model the list does not name; a runner without it would ignore the variable and call any model, so a job with an allowed-model list needs it (`job-image-model-policy-unsupported`). `mcp` says the runner reads `/job/mcp.json` and connects the MCP servers a trigger names ([mcp.md](mcp.md)); a runner without it would run such a job without them, so the worker refuses it pre-spend (`job-image-mcp-unsupported`). `forgeMcp` says the image carries the forge's own MCP servers at the versions [mcp.md](mcp.md#the-forges-own-server) names, behind the wrappers `/usr/local/bin/forge-mcp-github` and `/usr/local/bin/forge-mcp-gitlab` that withhold their merge and approval tools; a job whose `run.mcp` names `forge` needs it (`job-image-forge-mcp-unsupported`). `awaitInput` says the runner reads `PI_AWAIT_INPUT` and offers the `await_human_input` tool; a job armed with `run.awaitInput` needs it (`job-image-await-input-unsupported`). `reportProgress` (issue #644) says the runner reads `PI_REPORT_PROGRESS` and offers the `report_progress` tool; a job armed with `run.progress` needs it (`job-image-progress-unsupported`), because an older runner would ignore the variable and the trigger's phases would never appear. `recover` (issue #645) says the runner reads `PI_RECOVER` and `PI_RECOVERING`, runs an armed job's tools one at a time and answers a recovered run's interrupted tool calls ([durable-runs.md](durable-runs.md)); a job armed with `run.recover` needs it (`job-image-recover-unsupported`), because an older runner would resume a lost run into unanswered calls and the agent could repeat a push or a comment. `steer` (issue #646) says the runner reads `PI_STEER`, reads the messages the worker writes into the read-only `/inbox` mount and hands each to the running agent once, as framed data, with a delivery ack signed with the exit key ([steering.md](steering.md)); a job armed with `run.steer` needs it (`job-image-steer-unsupported`), because an older runner would never read the messages while every one of them was accepted. A derived image also needs `exitAuth` for steering to work: without it the job runs unsteered (`steerSkipped: "no-exit-key"`). `transcriptStderr` (issue #686) says the runner reads `PI_TRANSCRIPT=stderr` and mirrors its session file's lines over stderr as records signed with the exit key, which is how a venue that copies the job's files (`openshell`) serves the live transcript, run progress and the status comment. It refuses nothing: on that venue an image without it (or without `exitAuth`) runs without them, and the worker logs `live_transcript_skipped` with reason `no-capability` (or `no-exit-key`); a binding venue never reads it. `steerStdin` (issue #686) says the runner reads `PI_EXIT_AUTH=stdin-frames`: the exit key as frame 1 of a length-prefixed frame stream on stdin, read before pi loads, then each steer message as a frame signed with that key, the stream moved off fd 0 so a tool child gets `/dev/null` there ([steering.md](steering.md)). It is how a venue that copies the job's files (`openshell`) steers a running job. It refuses nothing: on that venue an image without it runs unsteered (`steerSkipped: "venue-copies"`), and a binding venue never reads it (it keeps the read-only `/inbox` mount). `exitAuth` (issue #545) says the runner signs its exit line with a key the worker writes to its stdin, writes a real exit line when it is stopped, and is started by the entrypoint under the exec-only `/opt/pi-dispatch/runner-node` with `--disable-sigusr1` (so a tool's SIGUSR1 cannot open the inspector of a process holding the key). It refuses nothing: on an image without it the worker reads the exit line as before, and a line the job's own tools forged can still be read as the runner's. Keep this repo's `ENTRYPOINT` in a derived image that keeps the label. Since issue #596 that entrypoint starts a small supervisor (`image/runner/supervise.mjs`) under the same node, which starts the runner: when the kernel kills the runner for memory, the supervisor writes the signed exit line saying `oom-killed`, and the worker stops the job without a retry. An image whose entrypoint runs the runner directly still works in the worker, and its out-of-memory kills are retried as infrastructure, as before, but `verify-image.sh` fails it while it keeps the label: its inspector probe finds every process running under `/opt/pi-dispatch/runner-node` and wants at least two (the supervisor and the runner), each with its inspector shut. The supervisor itself uses about 8 MB of anonymous memory inside the job's own memory limit (its RSS reads about 46 MB, but the node binary's text is shared with the runner), so a job has slightly less headroom than before it: on Docker Desktop the smallest limit at which the offline smoke job still reached its own exit moved up by 8 to 16 MB, from about 72 MB. The image runs that node as a separate copy, which adds about 120 MB | loud, pre-spend (`exitAuth` alone is **silent**: it refuses nothing, and a missing one only means the exit line is read unsigned) | +| Label `dev.pi-dispatch.capabilities` = the optional features it honours (`replicas`, `commands`, `excludeTools`, `anyUid`, `costCap`, `modelPolicy`, `exitAuth`, `mcp`, `forgeMcp`, `awaitInput`, `reportProgress`, `recover`, `steer`, `sandboxLayout`, `transcriptStderr`, `steerStdin`, `bundleEnvFile`) | An **inclusion** list. An image without the label is refused **every** replica job pre-spend (`job-image-replicas-unsupported`), because a floor that hard-codes `pi/issue-` would make both replicas converge on one branch; **every** `run.command` job pre-spend (`job-image-commands-unsupported`), because a runner that does not understand `PI_COMMAND` would feed `/name args` to the model as prose or die retryable on `no-terminal-message`; and **every** `run.excludeTools` job pre-spend (`job-image-exclude-tools-unsupported`), because a runner that does not read `PI_EXCLUDE_TOOLS` would run a "read-only" trigger with every tool the file says to remove and record a clean exit. `anyUid` says the image runs correctly as an arbitrary non-root uid with `HOME=/home/pi`, which is what a job needs on a daemon that enforces bind-mount ownership (native Linux Docker, rootful Podman): `chown -R pi:pi /home/pi && chmod 0777 /home/pi /home/pi/.pi /home/pi/.pi/agent` and no `ENV HOME`. On such a daemon a worker whose uid is not 1001 refuses a job on an image without it pre-spend (`job-image-any-uid-unsupported`); a uid-1001 worker runs the image as its own user and does not need it. `costCap` (issue #501) says the runner reads `PI_MAX_COST_MICROS` and stops a job before any provider call whose worst-case cost could take it past that cap; a runner without it would ignore the variable and spend with no cap, so the worker refuses **every** job with a dollar cap on an image without it, pre-spend (`job-image-cost-cap-unsupported`). `modelPolicy` (issue #502) says the runner reads `PI_ALLOWED_MODELS` and stops a job before any provider call to a model the list does not name; a runner without it would ignore the variable and call any model, so a job with an allowed-model list needs it (`job-image-model-policy-unsupported`). `mcp` says the runner reads `/job/mcp.json` and connects the MCP servers a trigger names ([mcp.md](mcp.md)); a runner without it would run such a job without them, so the worker refuses it pre-spend (`job-image-mcp-unsupported`). `forgeMcp` says the image carries the forge's own MCP servers at the versions [mcp.md](mcp.md#the-forges-own-server) names, behind the wrappers `/usr/local/bin/forge-mcp-github` and `/usr/local/bin/forge-mcp-gitlab` that withhold their merge and approval tools; a job whose `run.mcp` names `forge` needs it (`job-image-forge-mcp-unsupported`). `awaitInput` says the runner reads `PI_AWAIT_INPUT` and offers the `await_human_input` tool; a job armed with `run.awaitInput` needs it (`job-image-await-input-unsupported`). `reportProgress` (issue #644) says the runner reads `PI_REPORT_PROGRESS` and offers the `report_progress` tool; a job armed with `run.progress` needs it (`job-image-progress-unsupported`), because an older runner would ignore the variable and the trigger's phases would never appear. `recover` (issue #645) says the runner reads `PI_RECOVER` and `PI_RECOVERING`, runs an armed job's tools one at a time and answers a recovered run's interrupted tool calls ([durable-runs.md](durable-runs.md)); a job armed with `run.recover` needs it (`job-image-recover-unsupported`), because an older runner would resume a lost run into unanswered calls and the agent could repeat a push or a comment. `steer` (issue #646) says the runner reads `PI_STEER`, reads the messages the worker writes into the read-only `/inbox` mount and hands each to the running agent once, as framed data, with a delivery ack signed with the exit key ([steering.md](steering.md)); a job armed with `run.steer` needs it (`job-image-steer-unsupported`), because an older runner would never read the messages while every one of them was accepted. A derived image also needs `exitAuth` for steering to work: without it the job runs unsteered (`steerSkipped: "no-exit-key"`). `transcriptStderr` (issue #686) says the runner reads `PI_TRANSCRIPT=stderr` and mirrors its session file's lines over stderr as records signed with the exit key, which is how a venue that copies the job's files (`openshell`) serves the live transcript, run progress and the status comment. It refuses nothing: on that venue an image without it (or without `exitAuth`) runs without them, and the worker logs `live_transcript_skipped` with reason `no-capability` (or `no-exit-key`); a binding venue never reads it. `steerStdin` (issue #686) says the runner reads `PI_EXIT_AUTH=stdin-frames`: the exit key as frame 1 of a length-prefixed frame stream on stdin, read before pi loads, then each steer message as a frame signed with that key, the stream moved off fd 0 so a tool child gets `/dev/null` there ([steering.md](steering.md)). It is how a venue that copies the job's files (`openshell`) steers a running job. It refuses nothing: on that venue an image without it runs unsteered (`steerSkipped: "venue-copies"`), and a binding venue never reads it (it keeps the read-only `/inbox` mount). `bundleEnvFile` (issue #704) says the runner reads `PI_BUNDLE_ENV=1`: a project secret bundle's plain variables as one JSON object in `bundle-env.json` in the job directory, put into the runner's own environment (which pi and its tools inherit) before pi's runtime exists. A name under `PI_`/`OPENSHELL_`, one the runtime or the sandbox sets (`HOME`, `USER`, `SHELL`, `TERM`, `PATH`, the CA and proxy variables, the git helper's) or one holding a credential placeholder is refused (exit 2); any other name the image's own `ENV` set is replaced, as `-e` replaces it on `local`. It is how an OpenShell venue hands those values to a job without the exec's argv, which the gateway logs ([secrets.md](secrets.md#on-an-openshell-venue-placeholders-for-the-variables-you-bind-to-a-host)); on those venues a job that would carry plain bundle variables on an image without it is refused pre-spend (`openshell-bundle-image-too-old`), and every other venue never reads it. `exitAuth` (issue #545) says the runner signs its exit line with a key the worker writes to its stdin, writes a real exit line when it is stopped, and is started by the entrypoint under the exec-only `/opt/pi-dispatch/runner-node` with `--disable-sigusr1` (so a tool's SIGUSR1 cannot open the inspector of a process holding the key). It refuses nothing: on an image without it the worker reads the exit line as before, and a line the job's own tools forged can still be read as the runner's. Keep this repo's `ENTRYPOINT` in a derived image that keeps the label. Since issue #596 that entrypoint starts a small supervisor (`image/runner/supervise.mjs`) under the same node, which starts the runner: when the kernel kills the runner for memory, the supervisor writes the signed exit line saying `oom-killed`, and the worker stops the job without a retry. An image whose entrypoint runs the runner directly still works in the worker, and its out-of-memory kills are retried as infrastructure, as before, but `verify-image.sh` fails it while it keeps the label: its inspector probe finds every process running under `/opt/pi-dispatch/runner-node` and wants at least two (the supervisor and the runner), each with its inspector shut. The supervisor itself uses about 8 MB of anonymous memory inside the job's own memory limit (its RSS reads about 46 MB, but the node binary's text is shared with the runner), so a job has slightly less headroom than before it: on Docker Desktop the smallest limit at which the offline smoke job still reached its own exit moved up by 8 to 16 MB, from about 72 MB. The image runs that node as a separate copy, which adds about 120 MB | loud, pre-spend (`exitAuth` alone is **silent**: it refuses nothing, and a missing one only means the exit line is read unsigned) | | Label `dev.pi-dispatch.net-binaries` = the binaries that may need the network, at their real paths (issue #683) | Read by a venue whose egress policy names executables rather than hosts alone (OpenShell, issue #684). Each entry must be a regular file at its own real path: a policy naming a symlink or a moved binary matches nothing, and the job loses the network with no word why. `verify-image.sh` checks every entry. An image without it is refused on such a venue | loud | The two list labels have **opposite polarities**, deliberately: `forges` excludes, so no claim excludes diff --git a/docs/secrets.md b/docs/secrets.md index fe36bd44..e8d4c30c 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -626,7 +626,8 @@ human approval per connection, and its access expires after a TTL, after which t until someone approves again. The local `.env` named pipe and the CLI's desktop-app sign-in are interactive in the same way. None of those suits a service that starts jobs at 3 a.m. Whether a later OpenShell-side route (an operator-owned provider, or the gateway's credential driver) can serve unattended jobs is an open question, -recorded with what it would need; the resolver seam above already covers the service-account route. +recorded with what it would need (`OQ-048` in `specs/open-questions.md`); the resolver seam above already covers the +service-account route, and on an OpenShell venue its values can still be placeholders (below). ### Who gets the bundle, and who does not @@ -650,14 +651,81 @@ recorded with what it would need; the resolver seam above already covers the ser trigger's jobs to hold the bundle, delete the line by hand in `triggers.json`. For the same reason the project tools refuse to change the members of a project a bundle names (or to create one under a bundled id): change the bundle first, or edit `projects.json` by hand. -- **Not on an OpenShell venue, yet.** A job that would carry a bundle on `openshell` or `openshell-bind` is refused - before it spends (`openshell-bundle-unsupported`), until those venues can hold a bundle's variables. A trigger's own `run.secrets` **win** over a bundle variable of the same name, and the run record names the shadowed variables. The record also lists, by name, every variable a job received from its bundle (`bundle: { id, variables, shadowed }`), never a value: since a grant now changes outside review, each run says exactly what it held. +### On an OpenShell venue: placeholders for the variables you bind to a host + +On `openshell` and `openshell-bind` ([backends](backends.md)) the job's own credentials are already placeholders the +gateway swaps for the real value at the hosts the job may reach. A bundle can put its variables on that path too, one +host per variable, with `endpoints`: + +```jsonc +"secretBundles": { + "shop": { + "profile": "shop-env", "ref": "shop", "project": "shop", + "endpoints": { + "STRIPE_KEY": { "host": "api.stripe.com", "auth": "bearer" }, + "SENTRY_AUTH": { "host": "sentry.io", "auth": "header:X-Sentry-Token" } + } + } +} +``` + +What the agent sees: + +- **A variable `endpoints` names is a placeholder** (`openshell:resolve:env:...`) in the job's environment and in + `/proc/self/environ`. The gateway swaps in the real value only in an HTTP request to that host (a header, after + `Bearer `, inside Basic auth, in the path or the query); sent anywhere else it is refused with a 403 + (`credential_endpoint_mismatch`), never forwarded. The worker gives each host its own per-job provider, created + and deleted with the job's sandbox and swept after a crash. +- **Every other bundle variable is its value**, as on `local` and `podman`. That is the right place for a value the + agent must use itself (an HMAC key, a PEM, a database URL): a placeholder only works in an HTTP request. A multi-line + value works. The worker hands these values to the job in a file in the job's own directory (`bundle-env.json`, mode + 0600, removed when the job ends, or, if the worker crashes mid-job, when the next start's salvage or orphan sweep + removes the job directory), which the job image's runner reads into the agent's environment; they are on no + command line on this host and not in the OpenShell gateway's log (it logs the start of every exec command, which is + why the values do not travel there). The job image must declare `bundleEnvFile` ([job-image](job-image.md)); on an + older image such a job is refused before it spends (`openshell-bundle-image-too-old`). +- **`auth`** says how the value is sent (`bearer`, `basic`, or `header:`) and is recorded in the gateway's + provider profile. At OpenShell 0.1.4 it does not change where the swap happens: the agent's own request must carry + the placeholder where the service expects the key. +- The run record also counts them: `bundle.placeholders` and `bundle.plain`. + +What refuses the job, before the sandbox exists and before it spends (the worker's log names the variable and the +host, the comment names neither): + +- `openshell-bundle-endpoint-not-allowed`: the host is not exactly a host the job may already reach (an + `egress-allowlist.conf` line, a `model-endpoints.json` entry, the forge's own). A bundle never widens egress. A host + admitted only by a wildcard line (`.example.com`) does not count: add the host on a line of its own. +- `openshell-bundle-endpoint-shared`: the host is one the job's own credentials are sent to: the forge's + (`github.com`, `api.github.com`, `uploads.github.com`, `gitlab.com`, `dev.azure.com`, or your instance's), the model + provider's (`api.anthropic.com` and the like, for every model the job may call), any `model-endpoints.json` host, or + an HTTP MCP server the job names. OpenShell binds one provider per host, so the bundle would take the host from them. +- `openshell-bundle-endpoint-unknown`: `endpoints` names a variable the resolver did not print. A typo would otherwise + hand the real variable to the agent as a value while the file reads as holding it as a placeholder. +- `openshell-bundle-name-taken`: a bundle variable is named like one the sandbox sets itself (`OPENSHELL_*`, its CA + variables such as `NODE_EXTRA_CA_CERTS` and `SSL_CERT_FILE`, the proxy variables, `HOME`, `USER`, `SHELL`, `TERM`, + `LOGNAME`, `GIT_CONFIG_*`), or under `PI_`, which is pi-dispatch's own. +- `openshell-bundle-unpassable`: what OpenShell 0.1.4 cannot carry as a placeholder: a value in `endpoints` over + 8 KiB, more than 32 `endpoints` variables for one host, or more than 31 hosts. +- `openshell-bundle-image-too-old`: the bundle has variables outside `endpoints` and the job image does not declare + `bundleEnvFile`. Rebuild the image from a release that does. + +One thing to know: **a host in `endpoints` is the bundle's alone for that job.** A forwarded secret or another +`run.secrets` value sent to that host gets the 403 above (the hosts the worker knows the job's own credentials use are +refused up front). A trigger's `run.secrets` value for a name the bundle's `endpoints` declare wins as usual, and is +held as that host's placeholder. Two cases the worker cannot see before the job runs, both refused loudly by +OpenShell (a 403, nothing forwarded): an agent that switches to another model defined in the overlay's `models.json` +whose `baseUrl` is a bundle host, on a trigger with no allowed-model list (set `run.models` or `PI_ALLOWED_MODELS` to +close it), and a stdio MCP server's own HTTP calls to a bundle host carrying a secret. + +`endpoints` means nothing on `local` and `podman`: every variable goes in as its value there, and a name `endpoints` +lists that the bundle lacks is not refused. + ### The traps of a bundle 1. **The blast radius is the whole bundle.** A prompt injection in an issue or a pull request can exfiltrate @@ -670,7 +738,8 @@ exactly what it held. 3. **Fork reviews are off by default**, and `forkReviews: true` turns them on for the whole bundle. 4. **More values in the host argv.** On `local` and `podman` every variable goes into `docker run` / `podman run` as `-e NAME=VALUE`, so a bundle widens `OQ-028` by its count: a process on the host that can read - another's command line sees them while the container starts. + another's command line sees them while the container starts. On an OpenShell venue none is on a command line: the + ones `endpoints` names are placeholders, and the rest travel in a file in the job's directory. 5. **A project with folders.** A local job's `/workspace` is your own folder, so a value the agent writes to a `.env` there lands in your real repository. `doctor` warns per bundle whose project holds a folder. 6. **Declare the profile on every host that takes the project's jobs.** A bundle profile is a path on one diff --git a/image/Dockerfile b/image/Dockerfile index b96672de..e182c199 100644 --- a/image/Dockerfile +++ b/image/Dockerfile @@ -414,7 +414,13 @@ LABEL dev.pi-dispatch.forges="github,gitlab,forgejo" # verify-image.sh feeds an offline steered job a key frame and one message frame on a held-open stdin, and checks that # the key read took exactly frame 1, that pi's bash tool child and an inheriting child see /dev/null on fd 0, and that # the message came back acked under the key on the signed exit line. -LABEL dev.pi-dispatch.capabilities="replicas,commands,excludeTools,anyUid,costCap,modelPolicy,exitAuth,mcp,forgeMcp,awaitInput,reportProgress,recover,steer,sandboxLayout,transcriptStderr,steerStdin" +# `bundleEnvFile` (issue #704 part 3) tells the worker this runner reads PI_BUNDLE_ENV=1: a project secret bundle's plain +# variables as one JSON object in the job directory's bundle-env.json, put into the runner's own environment before pi's +# runtime exists (image/runner/src/bundle-env.mjs). It is how an OpenShell venue hands those values to a job without its +# exec's argv, which the gateway logs. A gate on those venues only: a job there that would carry plain bundle variables +# on an image without it is refused before spend (openshell-bundle-image-too-old). verify-image.sh runs an offline job +# with such a file and checks a workspace extension sees the variable, a multi-line one intact. +LABEL dev.pi-dispatch.capabilities="replicas,commands,excludeTools,anyUid,costCap,modelPolicy,exitAuth,mcp,forgeMcp,awaitInput,reportProgress,recover,steer,sandboxLayout,transcriptStderr,steerStdin,bundleEnvFile" # The binaries in this image that may need the network, at their real paths (issue #683). A venue whose egress policy # names binaries rather than hosts alone (OpenShell decides every connection per executable) needs the paths before it diff --git a/image/runner/run-job.mjs b/image/runner/run-job.mjs index b6f282dd..620da237 100644 --- a/image/runner/run-job.mjs +++ b/image/runner/run-job.mjs @@ -56,6 +56,7 @@ import { FRAME_FD, frameFdOpen, isolateStdin, openFrameReader } from "./src/stee import { RECOVERY_PROMPT, recoverSession } from "./src/recover.mjs"; import { attachTurnBudget } from "./src/turn-budget.mjs"; import { LAYOUT } from "./src/layout.mjs"; +import { applyBundleEnv } from "./src/bundle-env.mjs"; import { createTranscriptMirror, transcriptMirrorWanted } from "./src/transcript-mirror.mjs"; // NOTE: usage-meter.mjs meters at ModelRuntime.prototype, with the class handed over from the import above, // and reaches pi-ai (its compat half, the brake's stream factory) only through a copy it proves at runtime, @@ -179,6 +180,11 @@ async function main() { // UNWRITABLE stays advisory (mountAdvisories below): "a read-only review of a folder the job user cannot write // is a legitimate job", so only a workspace the job cannot even read is refused here. assertJobInputsReadable([JOB_DIR, GLOBAL_PI_DIR, WORKSPACE]); + // Issue #704 part 3: a project secret bundle's plain variables on a gateway venue, from the job's own directory into + // this process's environment (pi's), before any runtime or request exists. Same moment, same exit code: a flag + // without a usable file refuses before spend rather than running the job without a variable it was given. + const bundleVars = applyBundleEnv(); + if (bundleVars > 0) log("bundle_env_applied", { count: bundleVars }); // Offline is a property of the RUNNER, not of whoever started it. Set before the loader is built, // because the loader is what resolves package sources: with offline off, an unresolved source is a // live `npm install` at agent runtime, from inside the job, against a network the job's own input diff --git a/image/runner/src/bundle-env.mjs b/image/runner/src/bundle-env.mjs new file mode 100644 index 00000000..7b94e3db --- /dev/null +++ b/image/runner/src/bundle-env.mjs @@ -0,0 +1,77 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { LAYOUT } from "./layout.mjs"; +import { configError } from "./outcome.mjs"; + +/** + * A project secret bundle's PLAIN variables on a venue that copies or binds the job's files from a gateway (issue #704 + * part 3, the `bundleEnvFile` capability): the worker writes them as one JSON object to `bundle-env.json` in the job's + * own directory (`/sandbox/job` on the copy venue, the read-only `/job` bind on the bind venue) and sets + * PI_BUNDLE_ENV=1; this runner reads the file before pi's runtime exists and puts each variable into its OWN + * environment, which is pi's (pi runs in this process) and every tool pi starts inherits, exactly what `-e NAME=VALUE` + * gives a job on `local` and `podman`. + * + * WHY A FILE, AND NOT THE EXEC'S `--env`. OpenShell 0.1.4's exec takes env only as `KEY=VALUE` on the CLI's argv, and + * the gateway puts those pairs at the head of the remote command and logs its first 120 characters at INFO + * (openshell-server/src/grpc/sandbox.rs `build_remote_exec_command`, `stream_exec_over_relay`), so a value there lands + * on the host's argv AND in the gateway's journal. The file is in the job directory the worker already hands over, and + * the agent may read it: these values are the ones the operator hands the agent as values on every venue. + * + * STRICT, like every runner input: the flag is `1` or absent; the file is one object of non-empty strings of at most + * `BUNDLE_ENV_MAX_BYTES`; a name is an environment variable name, never PI_* or OPENSHELL_*, never one of + * `BUNDLE_ENV_PROTECTED` (what the worker, the sandbox or this runner sets: HOME, USER, SHELL, TERM, PATH, the + * sandbox's CA and proxy variables, the git helper's), and never one whose current value is an OpenShell placeholder + * (`openshell:resolve:env:`, a provider key: the model key, the forge token, a run.secrets or endpoint value); no value + * carries a NUL (an environment cannot hold one). Any OTHER name already set (an `ENV` the job image bakes in) is + * REPLACED, as `-e NAME=VALUE` replaces an image's ENV on `local` and `podman` (final review round: refusing every set + * name failed a job after spend for a name the worker had admitted). A closed list rather than "anything set" for that + * reason; the worker's own gate (backend-openshell.mjs `OPENSHELL_SET_ENV_NAMES`, held to be a subset of this list by + * its test) refuses the same names before spend, so this is the backstop, never the first answer. The worker refused every reserved + * name before spend; this is the line that holds if that refusal is ever bypassed. Anything else is a config failure + * (exit 2, never retried) before anything is spent, never a job run without a variable it was given. JSON carries a + * newline as `\n`, so a PEM or any multi-line value arrives intact. + */ +export const BUNDLE_ENV_FLAG = "PI_BUNDLE_ENV"; +export const BUNDLE_ENV_FILE = "bundle-env.json"; +/** The bundle's own output cap (worker/src/secret-bundles.mjs, 256 KiB) with room for JSON escaping. */ +export const BUNDLE_ENV_MAX_BYTES = 1024 * 1024; +const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; +/** The names a bundle variable may never replace here (above). */ +export const BUNDLE_ENV_PROTECTED = Object.freeze(["HOME", "USER", "SHELL", "TERM", "LOGNAME", "PATH", "GIT_CONFIG_COUNT", "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0", "NODE_EXTRA_CA_CERTS", "DENO_CERT", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO", "ALL_PROXY", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "all_proxy", "http_proxy", "https_proxy", "no_proxy", "grpc_proxy", "NODE_USE_ENV_PROXY", "NODE_OPTIONS"]); +const PLACEHOLDER = "openshell:resolve:env:"; + +/** + * Read the plain bundle variables into `env` (this process's own by default). Returns the count applied (0 when the + * worker set no flag). The flag leaves the environment either way, so no child sees it. + */ +export function applyBundleEnv({ env = process.env, path = join(LAYOUT.job, BUNDLE_ENV_FILE), read = readFileSync } = {}) { + // env-internal PI_BUNDLE_ENV: written by the worker on the container's closed env map, refused as a forwarded name. + const flag = env.PI_BUNDLE_ENV; + delete env.PI_BUNDLE_ENV; + if (flag === undefined) return 0; + if (flag !== "1") throw configError(`invalid ${BUNDLE_ENV_FLAG}: ${JSON.stringify(flag)} (want 1)`); + let text; + try { + text = read(path); + } catch { + throw configError(`${BUNDLE_ENV_FLAG}=1 but the job's ${BUNDLE_ENV_FILE} could not be read`); + } + if (text.length > BUNDLE_ENV_MAX_BYTES) throw configError(`the job's ${BUNDLE_ENV_FILE} is over ${BUNDLE_ENV_MAX_BYTES} bytes`); + let doc; + try { + doc = JSON.parse(String(text)); + } catch { + throw configError(`the job's ${BUNDLE_ENV_FILE} is not JSON`); + } + if (doc === null || typeof doc !== "object" || Array.isArray(doc)) throw configError(`the job's ${BUNDLE_ENV_FILE} is not one object`); + const entries = Object.entries(doc); + // Judged whole before any variable is set, so a refused file leaves the environment as it was. + for (const [name, value] of entries) { + // The NAME only, never a value: the exit line and the job log are read by the operator, not the agent alone. + if (!ENV_NAME.test(name) || name.startsWith("PI_") || name.startsWith("OPENSHELL_")) throw configError(`the job's ${BUNDLE_ENV_FILE} names ${JSON.stringify(name)}, which a bundle may not set`); + if (BUNDLE_ENV_PROTECTED.includes(name) || (typeof env[name] === "string" && env[name].startsWith(PLACEHOLDER))) throw configError(`the job's ${BUNDLE_ENV_FILE} names ${name}, which this job's runtime or its credential provider already sets`); + if (typeof value !== "string" || value === "" || value.includes("\0")) throw configError(`the job's ${BUNDLE_ENV_FILE} has an unusable value for ${name}`); + } + for (const [name, value] of entries) env[name] = value; + return entries.length; +} diff --git a/image/runner/test/bundle-env.test.mjs b/image/runner/test/bundle-env.test.mjs new file mode 100644 index 00000000..b21e4391 --- /dev/null +++ b/image/runner/test/bundle-env.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { applyBundleEnv, BUNDLE_ENV_FILE, BUNDLE_ENV_FLAG } from "../src/bundle-env.mjs"; + +/** + * Issue #704 part 3: a project secret bundle's plain variables, read from the job's own directory into the runner's + * environment (pi's) on a gateway venue, instead of the exec's argv, which the gateway logs. + */ + +const reader = (text) => (path) => { + if (!path.endsWith(`/${BUNDLE_ENV_FILE}`)) throw new Error(`unexpected ${path}`); + if (text === null) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + return text; +}; + +test("no flag, nothing read, nothing set", () => { + const env = { PATH: "/bin" }; + assert.equal(applyBundleEnv({ env, read: () => assert.fail("never read") }), 0); + assert.deepEqual(env, { PATH: "/bin" }); +}); + +test("the flag reads the file into the environment, a multi-line value intact, and leaves the environment itself", () => { + const pem = "-----BEGIN KEY-----\nabc\ndef\n-----END KEY-----\n"; + const env = { [BUNDLE_ENV_FLAG]: "1", PATH: "/bin" }; + assert.equal(applyBundleEnv({ env, read: reader(JSON.stringify({ DB_URL: "postgres://u:p@db/x", SIGNING_PEM: pem })) }), 2); + assert.deepEqual(env, { PATH: "/bin", DB_URL: "postgres://u:p@db/x", SIGNING_PEM: pem }); + assert.equal(BUNDLE_ENV_FLAG in env, false, "the flag reaches no child"); +}); + +test("every refusal is a config failure (exit 2) before any variable is set, and names no value", () => { + const cases = [ + [{ [BUNDLE_ENV_FLAG]: "yes" }, "{}"], + [{ [BUNDLE_ENV_FLAG]: "1" }, null], + [{ [BUNDLE_ENV_FLAG]: "1" }, "not json"], + [{ [BUNDLE_ENV_FLAG]: "1" }, "[]"], + [{ [BUNDLE_ENV_FLAG]: "1" }, JSON.stringify({ OK: "v", "bad-name": "secretvalue" })], + [{ [BUNDLE_ENV_FLAG]: "1" }, JSON.stringify({ OK: "v", PI_MODEL: "secretvalue" })], + [{ [BUNDLE_ENV_FLAG]: "1" }, JSON.stringify({ OK: "v", OPENSHELL_X: "secretvalue" })], + [{ [BUNDLE_ENV_FLAG]: "1", HOME: "/home/pi" }, JSON.stringify({ OK: "v", HOME: "secretvalue" })], + [{ [BUNDLE_ENV_FLAG]: "1" }, JSON.stringify({ OK: "v", TERM: "secretvalue" })], + [{ [BUNDLE_ENV_FLAG]: "1", SSL_CERT_FILE: "/etc/openshell-tls/ca-bundle.pem" }, JSON.stringify({ OK: "v", SSL_CERT_FILE: "secretvalue" })], + [{ [BUNDLE_ENV_FLAG]: "1", ANTHROPIC_API_KEY: "openshell:resolve:env:v1_ANTHROPIC_API_KEY" }, JSON.stringify({ ANTHROPIC_API_KEY: "secretvalue" })], + [{ [BUNDLE_ENV_FLAG]: "1" }, JSON.stringify({ OK: "v", N: 1 })], + [{ [BUNDLE_ENV_FLAG]: "1" }, JSON.stringify({ OK: "v", E: "" })], + [{ [BUNDLE_ENV_FLAG]: "1" }, JSON.stringify({ OK: "v", Z: "a\u0000b" })], + [{ [BUNDLE_ENV_FLAG]: "1" }, "x".repeat(1024 * 1024 + 1)], + ]; + for (const [start, text] of cases) { + const env = { ...start }; + assert.throws(() => applyBundleEnv({ env, read: reader(text) }), (e) => e.piDispatchExit === 2 && !e.message.includes("secretvalue"), JSON.stringify(start) + String(text).slice(0, 60)); + assert.equal(env.OK, undefined, "nothing applied from a refused file"); + } +}); + +test("a name the job image's ENV set is replaced, as -e replaces it on local; a protected or placeholder name is not (final review round)", () => { + const env = { [BUNDLE_ENV_FLAG]: "1", LANG: "C.UTF-8", APP_MODE: "image-default" }; + assert.equal(applyBundleEnv({ env, read: reader(JSON.stringify({ APP_MODE: "from-bundle" })) }), 1); + assert.equal(env.APP_MODE, "from-bundle"); + assert.equal(env.LANG, "C.UTF-8"); +}); diff --git a/image/verify-image.sh b/image/verify-image.sh index 6387d24e..8567d6ac 100755 --- a/image/verify-image.sh +++ b/image/verify-image.sh @@ -610,6 +610,34 @@ STPROBE [ "$st_probe" = "steer-stdin-probe bash=/dev/null,no-fd3 inherit=/dev/null,no-fd3" ] && ! echo "$st_out" | grep -q '"event":"steer_stdin_shared"' \ || fail "the image declares 'steerStdin' but a tool child of the steered runner does not see /dev/null on stdin, or holds an fd 3 (${st_probe:-no probe line}): the frames are reachable from it" ;; + bundleEnvFile) + # Issue #704 part 3. The runner reads a project secret bundle's plain variables from /job/bundle-env.json on + # PI_BUNDLE_ENV=1, into its own environment, which pi and its tools inherit. Proved by running it: the offline + # job under a cap of 0 (stopped before any model call, after pi started the agent), a JSON file with one plain + # and one multi-line variable, and a workspace extension printing what it sees at agent_start. + be_dir=$(mktemp -d) + mkdir -p "$be_dir/job" "$be_dir/ws/.pi/extensions" + echo "Reply with the single word ok." >"$be_dir/job/prompt.md" + printf '%s' '{"VERIFY_PLAIN":"plain-value","VERIFY_PEM":"line1\nline2"}' >"$be_dir/job/bundle-env.json" + cat >"$be_dir/ws/.pi/extensions/bundle-env-probe.ts" <<'BEPROBE' +export default function (pi) { + pi.on("agent_start", async () => { + const pem = String(process.env.VERIFY_PEM ?? "").split("\n").join("|"); + console.log(`bundle-env-probe plain=${process.env.VERIFY_PLAIN ?? "unset"} pem=${pem} flag=${process.env.PI_BUNDLE_ENV ?? "gone"}`); + }); +} +BEPROBE + chmod -R a+rX "$be_dir/job" + chmod -R a+rwX "$be_dir/ws" + be_out=$(docker run --rm --init --network none --cap-drop=ALL --security-opt no-new-privileges \ + -v "$be_dir/job:/job:ro" -v "$be_dir/ws:/workspace" --workdir /workspace \ + -e PI_PROVIDER=anthropic -e PI_MODEL=claude-sonnet-4-5-20250929 -e PI_MAX_TURNS=1 \ + -e ANTHROPIC_API_KEY=sk-ant-not-a-real-key -e PI_MAX_COST_MICROS=0 -e PI_BUNDLE_ENV=1 \ + "$IMAGE_REF" 2>&1) || true + rm -rf "$be_dir" + echo "$be_out" | grep -q '^bundle-env-probe plain=plain-value pem=line1|line2 flag=gone$' \ + || fail "the image declares 'bundleEnvFile' but a job with PI_BUNDLE_ENV=1 did not see the bundle's variables from /job/bundle-env.json in its environment ($(echo "$be_out" | grep '^bundle-env-probe' | tail -1))" + ;; *) fail "dev.pi-dispatch.capabilities names an unknown capability '$capability'" ;; esac done diff --git a/specs/design.md b/specs/design.md index c936f457..08df7dda 100644 --- a/specs/design.md +++ b/specs/design.md @@ -1643,7 +1643,10 @@ money with no upstream turn limit (`REQ-RUNNER-TURN-BUDGET`). the skip reasons, `secret-bundle-trigger-changed`, `secret-bundle-resume`, the profile lookup (`secret-bundle-profile-unknown` / `-ambiguous`) and `openshell-bundle-unsupported`, ahead of the `run.secrets` resolver, the one earlier gate that spawns. The READ comes after that resolver and BEFORE the mint for every job - that needs no fork check, so its determinate refusals cost not even a token. Only a pull request without + that needs no fork check, so its determinate refusals cost not even a token. **AMENDED 2026-10-20 (#704)**: the + venue's judgement of the bundle (`bundleGate`, the openshell venues' only) moved INTO the read, the hosts before + the resolver runs and the received variables after it, so it is behind the fork check and before every spend; + `openshell-bundle-unsupported` is retired. Only a pull request without `forkReviews` needs the job's own token for the fork check, so its check and read come right AFTER the mint, both BEFORE branch protection, the clone, the token-cap read and every reserve. A refusal there leaves the minted token to expire, the unprotected-branch refusal's existing path: it is scoped and short-lived @@ -1683,11 +1686,24 @@ money with no upstream turn limit (`REQ-RUNNER-TURN-BUDGET`). treat them as they treat `run.secrets` values. - **The record names, never values**: `bundle: { id, variables, shadowed }` or `bundleSkipped` (`INT-RUN-HISTORY-FILE-CONTRACT`). Since a grant now changes outside review, each run says what it held. + **AMENDED 2026-10-20 (#704)**: on the openshell venues also `placeholders` and `plain`, two counts. - **Per venue**: on `local` and `podman` the values are `-e NAME=VALUE` in the host argv like every other (`OQ-028`, widened by the count). On the openshell venues a job that would carry a bundle is REFUSED before spend (`openshell-bundle-unsupported`, review round 1): the `run.secrets` provider path binds every key to every endpoint, and to nothing without a network rule, while the record would say the job received it. The per-endpoint - provider keys `endpoints` declares are #704 part 3; the field is shape-validated now. + provider keys `endpoints` declares are #704 part 3; the field is shape-validated now. **AMENDED 2026-10-20 + (#704): built, the refusal retired.** The processor hands every venue the received variables' split + (`bundleKeys`: `{ endpoints, plain }`; a variable a run.secrets value shadowed is in `plain` never, and in + `endpoints` only when `endpoints` declares it, carrying the trigger's value, which wins, to that host), and every + venue but the openshell ones ignores it. There a variable `endpoints` names becomes a key of + a per-job provider of its own host, bound to that host's endpoints only, its auth scheme documented in the profile, + and every other variable reaches the runner by a file in the job directory, never the exec's argv (review round 1: + the gateway logs an exec's env; an image declaring `bundleEnvFile` is needed); the host must already be an exact + endpoint of the job's policy and not one the job's own credentials use (a forge's, a callable model's, a model + endpoint's, an HTTP MCP server's), and an `endpoints` name the bundle did not + deliver refuses, each before the sandbox and before spend (`DES-OPENSHELL-VENUE`, "Project secret bundles"). An + `endpoints` name stays meaningless on `local` and `podman`: a variable it names goes in plain there like the rest, + and one it names that the bundle lacks is not refused there (nothing would be held as a placeholder). - **Residuals, stated**: - an MCP server cannot reference a bundle variable (the MCP check runs before the read and knows only `run.secrets` and `PI_FORWARD_ENV` names, so it refuses `mcp-secret-unmapped`; bind that one value in @@ -1697,9 +1713,11 @@ money with no upstream turn limit (`REQ-RUNNER-TURN-BUDGET`). there (a fleet's shared queue) runs without its environment and records no `bundleSkipped`. Keep the same `triggers.json` on every host; doctor warns on a fleet worker (`PI_WORKER_NAME`) with projects and no file; - the openshell refusal precedes the fork check, so a fork's pull request that would have run WITHOUT the bundle - is refused on an OpenShell venue until #704 part 3; + is refused on an OpenShell venue until #704 part 3; **AMENDED 2026-10-20 (#704): GONE**, checked by a test: the + venue's judgement now runs inside the read, which a fork's job never reaches; - a shared-queue job naming no backend is judged against THIS host's default venue, so a host whose default is an - OpenShell venue refuses a bundled job another host would have run; + OpenShell venue refuses a bundled job another host would have run; **AMENDED 2026-10-20 (#704): narrowed** to a + bundle that venue cannot hold as declared (`bundleGate`'s refusals), since every other bundle now runs there; - `dispatch_trigger_edit` can still change the flow, backend, provider and model of an operator-written trigger in a bundled project, exactly as it can for a trigger with `run.secrets` today; freezing bundled triggers against the tools would be a new rule, not this one; @@ -10888,7 +10906,9 @@ a tunnel. is refused on both OpenShell venues before spend, `openshell-bundle-unsupported` (`OPENSHELL_BUNDLE_UNSUPPORTED`, one sentence shared with `OPENSHELL_REFUSALS`), because the per-job provider would bind every bundle variable to every endpoint, or to nothing without a network rule, while the record said the job received it. #704 part 3 lifts it - with per-endpoint bundle keys. `run.secrets` keep today's path unchanged. + with per-endpoint bundle keys. `run.secrets` keep today's path unchanged. **AMENDED 2026-10-20 (#704): SUPERSEDED** + by "Project secret bundles (#704 part 3)" below: both venues hold a bundle, and `openshell-bundle-unsupported` is + retired (it shipped in no release). - **Why**: the custody. On `local` and `podman` the provider key and the forge token sit in the job's environment, where the agent reads them, and in the host's argv (`OQ-028`); on this venue they never enter the agent's container, the provider is to be created with no value on any command line (#684, so `OQ-028` is not reproduced on @@ -11034,7 +11054,9 @@ a tunnel. above, as built and measured on OpenShell 0.1.4. Per job, `secretNamesOf` takes every env name the redactor does not list as non-secret (`NON_SECRET_ENV_KEYS`, the one list), and the adapter imports an ENDPOINTLESS profile for them (`providerProfileFor`, no endpoints and no binaries of its own, written 0600 at `.openshell-profile.json` - beside the job dir, never uploaded; `provider profile import -f`), then creates a provider named like the sandbox + beside the job dir, never uploaded; `provider profile import -f`; AMENDED 2026-10-20 (#704): one file per provider, + `.openshell-profile-.json`, deleted as soon as its import answers, either way, the teardown's + removal kept as the backstop), then creates a provider named like the sandbox with `--credential NAME` per secret and the owner label. `--credential NAME` reads `$NAME` from the CLI's own environment (measured), so the values ride the CLI child's environment (PATH, HOME, XDG_CONFIG_HOME and the values, nothing else of the worker's), never an argv and never a file: `OQ-028` is not reproduced on the CLI. The provider is @@ -11232,7 +11254,10 @@ a tunnel. leaves alone), a path the engine sees; doctor's stays under the worker's tmp dir, since its target is refused anyway. Each probe logs `openshell_bind_probed` with its answer; doctor's probe takes the adapter's 60 s bound. - `BESIDE_JOB_DIR` gains `.openshell-profile.json`, so a salvage and the orphan sweep remove the provider profile - beside a job dir too. + beside a job dir too. **AMENDED 2026-10-20 (#704)**: since #704 part 3 the file is named per provider + (`.openshell-profile-.json`), which no fixed suffix matches, so it is deleted right after its import + answers instead (nothing reads it later); the window a crash can leave one in is the import call alone, and the + teardown still removes it. The `.openshell-profile.json` suffix stays, for a job dir an older worker left. - The lane adds: the gate's probe must have RUN (`openshell_bind_probed: admitted`), every leftover check polls the listing until empty, `/inbox` and the overlay refuse a write (EROFS), a backgrounded `setsid` writer leaves the host file unchanged after the run returned, and one run as `run_as_user` 2000 writes its bound file as uid 2000. @@ -11344,6 +11369,148 @@ a tunnel. openshellUnitOrderChecks, CLI_SERVICE_KEYS, valkeyVenues; worker/test/up.test.mjs, service.test.mjs, podman-stack.test.mjs, backends.test.mjs, init.test.mjs, doctor-openshell.test.mjs; .github/workflows/openshell-conformance.yml. +- **Project secret bundles (#704 part 3, AMENDED 2026-10-20 (#704))**: both venues hold a project's secret bundle + (`DES-PROJECT-SECRET-BUNDLES`), replacing part 2's refusal. Read at the pin (v0.1.4, `5a7728c`), the model is: + - **One provider per endpoint, every key of a provider on every endpoint naming it.** A policy endpoint's + `credential_binding` names one provider and nothing else (proto/sandbox.proto `NetworkCredentialBinding`); the + gateway binds EVERY static key of that provider to the union of the endpoints naming it + (crates/openshell-server/src/grpc/policy.rs `policy_static_credential_endpoint_bindings`, provider.rs + `resolve_provider_environment_from_records_with_policy_bindings_and_credentials`); a key's placeholder is resolved + only where it is bound (crates/openshell-core/src/provider_credentials.rs `resolver_and_body_classifier_for_endpoint`) + and, sent anywhere else, answered 403 `credential_endpoint_mismatch` and never forwarded + (crates/openshell-supervisor-network/src/l7/relay.rs `reject_credential_resolution`); two attached providers may not + expose one env key (provider.rs `validate_provider_environment_key_ownership`); and two endpoints that can match one + request may not name different providers (crates/openshell-policy/src/ambiguity.rs, the + `credential_binding.provider` conflict). So a variable bound to ONE host needs a provider of its own. + - **So, per job**: the job's own provider (named as the sandbox, #684's, its profile unchanged) holds every secret + but the bundle's, `run.secrets`, the forge token, the model key and the forwarded secrets; each distinct bundle + HOST gets a provider `-b` (sorted by host, so every attempt names them alike) holding the variables the + bundle's `endpoints` bind to it, attached with a second `--provider` and labelled with the owner label; the + endpoints whose host is that host are bound to it, every other endpoint to the job's provider as before + (`bundleProviderPlan`). The values travel as #684's do, `--credential NAME` from the CLI child's environment. + **What that costs, stated**: at a bundle host only that host's bundle variables resolve, so a run.secrets value, + a forwarded secret or the model key sent there is refused 403 by the supervisor; loud, before any spend at the + provider, never a leak. So every host the job's own credentials are known to go to is refused as a bundle host + (below). **CORRECTED in review round 1 (H2)**: this sentence first said a model's host was not judged because the + catalog host could not be known; it can (`outputCapView` composes it), and a bundle endpoint on `api.anthropic.com` + took the model key's binding and failed the run after spend. + - **`auth` documents, the binding narrows.** The `endpoints` entry's `auth` becomes the profile credential's + `auth_style` and `header_name` (`bearer` -> `bearer` + `authorization`, `basic` -> `basic`, `header:` -> + `header` + the name lowercased; `bundleAuthFields`). At the pin these change nothing a static credential does: the + supervisor swaps a placeholder wherever a request carries it (a whole header value, after a `Bearer `-style + prefix, inside Basic's decoded `user:pass`, the path or the query; crates/openshell-core/src/secrets.rs + `rewrite_header_value`), and no supervisor code reads either field for a static credential (only token grants, + crates/openshell-supervisor-network/src/l7/token_grant_injection.rs). The gateway validates them at import + (crates/openshell-providers/src/profiles.rs: empty or `basic` free, `bearer` and `header` need a `header_name`) and + `provider profile get` prints them, so the reviewed file's intent is visible in the gateway's own view. Rejected: + leaving them out because they do nothing. + - **The plain variables** (every bundle variable `endpoints` does not name). **SUPERSEDED in review round 1 (H1)** by + the file channel below: as first built they reached the exec as `--env NAME=VALUE`, values the agent reads, as on + every other venue. Measured at the pin: `sandbox exec --env` takes `KEY=VALUE` only + (crates/openshell-cli/src/main.rs `SandboxCommands::Exec`, commands/common.rs `parse_key_value_pairs`: "--env + expects KEY=VALUE"), with no bare `KEY` that reads the CLI's environment (as `provider create --credential KEY` + has) and no file; `sandbox create --env` is the same flag and would keep the values in the gateway's sandbox spec. + So those values were on the host's argv for the exec's lifetime, and (found in review round 1) in the gateway's + log: crates/openshell-server/src/grpc/sandbox.rs `build_remote_exec_command` puts the env pairs first, sorted by + key, and `stream_exec_over_relay` and the interactive path log the command's first 120 characters at INFO as + `command_preview`, so `DB_URL=postgres://u:secret@...` reached the journal. + The exec's other `--env` names are unchanged (`NON_SECRET_ENV_KEYS`, the git helper, `HOME`). Rejected: a provider + for them (the agent would hold placeholders, useless for a value it must use itself: an HMAC key, a PEM, a + database URL). The env file, rejected at first as a job-image change, is what review round 1 chose: + - **The file channel (review round 1, H1)**: the stage writes the plain variables as one JSON object to + `/bundle-env.json` (`BUNDLE_ENV_FILE`, 0600, before the upload in copy mode and before the exec in bind + mode, so `/sandbox/job/bundle-env.json` or the read-only `/job/bundle-env.json`), run-container (told + `bundleEnvFile: true` by this venue alone) puts `PI_BUNDLE_ENV=1` in the closed env map (reserved, non-secret, so + it is the only bundle trace on the exec), the runner of an image declaring the new capability `bundleEnvFile` reads + the file before pi's runtime exists into its own environment, which pi and its tools inherit + (image/runner/src/bundle-env.mjs: a flag without a readable file, a non-object, a non-string or empty value, a name + that is not an env name, starts `PI_` or `OPENSHELL_`, is in the closed `BUNDLE_ENV_PROTECTED` list (what the + runtime and the sandbox set) or holds an OpenShell placeholder each refuse, exit 2, before spend; any other name + an image `ENV` set is replaced, as `-e` replaces it on `local`, the final review round's narrowing of an earlier + "already set" rule that failed admitted jobs), and the + teardown removes the host file. JSON carries a newline, so a PEM is a plain variable now. Copy mode's upload is tar + over the sandbox's SSH extracted by the sandbox's own user (crates/openshell-cli/src/ssh.rs + `sandbox_sync_up_files`), so the 0600 file is the job's to read, and it has no size cap at the pin: the bundle's + own caps (64 variables, 256 KiB) are its only bounds (review round 1, M1). An image without the capability + refuses a job that would need the file before spend (`openshell-bundle-image-too-old`). The agent can read the + file, as it reads the values in its env. After a worker crash the host file stays in the job directory until the + next start's salvage or the orphan sweep removes that directory (`BESIDE_JOB_DIR`'s sweep, recovery-queue.mjs), + the window every job input has. **RELEASE OBLIGATION**: a job image release carrying `bundleEnvFile` is + owed (beside #695's) before plain bundle variables can run on an OpenShell venue; until then such a job is refused, + never run without them. + - **Judged before the sandbox and before spend** (`bundleGate`, `judgeBundle`, refusals in `OPENSHELL_REFUSALS`, + fixed text, the names and host in `refused_openshell_bundle`), asked twice by the processor inside its bundle read: + with the hosts alone before the resolver runs, then with the received variables. + `openshell-bundle-endpoint-not-allowed`: the host is not EXACTLY an endpoint host of the generated policy (a reviewed file may not widen egress), + or a wildcard endpoint covers it (an allowlist `.example.com` is `*.example.com`, bound to the job's provider: the + ambiguity OpenShell refuses). `openshell-bundle-endpoint-shared`: a host the job's own credentials go to + (`credentialHostsOf`): every forge's (GitHub's three with `uploads.github.com`, gitlab.com, dev.azure.com, each + configured instance URL's), and, from the processor (review round 1, H2), the composed `baseUrl` host of every model + the job may call (`outputCapView` over the overlay's models.json and pi's catalog through `builtinModel`, as the + output cap and the zero-rated check compose it), every model endpoint's host keyed or not (the simple rule), and + every HTTP MCP server's host the job names (its headers may carry a run.secrets value). Gaps left, each refused + loudly at run time by the supervisor's 403 and never a leak: a model whose `baseUrl` cannot be composed (no + catalog entry, no overlay definition) adds no host; an agent switching to another overlay model whose `baseUrl` + is a bundle host, on a trigger with no allowed-model list (the callable set is then open, so it cannot be listed; + `run.models` or `PI_ALLOWED_MODELS` closes it); and a stdio MCP server's own HTTP calls with a secret to a bundle + host (its destinations are its own, not in its configuration). + `openshell-bundle-endpoint-unknown`: an `endpoints` name the bundle did not deliver (and no run.secrets value + shadowed): a typo would otherwise send the real variable in PLAIN while the file reads as holding it as a + placeholder. `openshell-bundle-name-taken`: the `PI_` prefix (the worker's and the runner's own, final review round), a + name the sandbox sets itself (`USER`, `SHELL` and `TERM` for every exec, crates/openshell-sandbox/src/process.rs and + boundary_exec.rs, and `LOGNAME` beside them), the `OPENSHELL_` prefix (common.rs + `parse_env_pairs`; crates/openshell-server/src/grpc/validation.rs `validate_env_entries_inner`; + crates/openshell-sandbox/src/process.rs `RESERVED_ENV_PREFIX`), the CA variables + (crates/openshell-sandbox/src/child_env.rs `tls_env_vars`), the proxy variables (process.rs `PROXY_ENV_VARS`), `HOME` and the git helper's + `GIT_CONFIG_*` (`OPENSHELL_SET_ENV_NAMES`). `openshell-bundle-unpassable`: what the gateway would refuse + (`OPENSHELL_LIMITS`, from crates/openshell-server/src/grpc/mod.rs and validation.rs): a placeholder value over 8 KiB + (`MAX_MAP_VALUE_LEN`), more than 32 placeholders for one host (`MAX_PROVIDER_CREDENTIALS_ENTRIES`), more than 31 + bundle hosts (`MAX_PROVIDERS` 32 per sandbox, one is the job's); the plain-value limits it first had (32 KiB, no + line break, the exec's) are gone with the exec path. `openshell-bundle-image-too-old`: plain variables on an image + without `bundleEnvFile`. The wildcard check ignores ports (an allowlist wildcard is 443, a model endpoint may not + be) and errs toward refusing, as its any-depth suffix match does (review round 1, L3). **A shadowed endpoint name** + (review round 1, M2): a trigger's run.secrets value for a name the bundle's `endpoints` declare goes into THAT host's + provider, since it is the value that wins and the reviewed file binds the name to the host; it counts among the + placeholders. A + source the gate cannot read retries. A plan the stage still cannot build (a key in two places, a bundle host + without an endpoint) is a defect refused as infrastructure (`openshell_bundle_collision`, + `openshell_bundle_host_unbound`), never run around. + - **Read-back**: `expectedPolicies(policy, { providers })` adds one `_provider_` layer per attached provider and + nothing else, read at the pin (crates/openshell-policy/src/compose.rs `provider_rule_name`, `compose_effective_policy`: + each layer under its own key, a numeric suffix only on a clash these names cannot have; + policy.rs `provider_policy_context_from_records`: an endpointless profile's layer is the measured `{ name }`). + `--full` is kept rather than `--base`: the base alone would no longer see a layer this job did not attach, the one + difference a read-back exists for. A missing or an extra layer never starts the job (unit-tested). + - **Lifecycle**: teardown deletes every provider the attempt created, after the sandbox, by #684's bounded retry, + and every profile it imported; an attempt removes its earlier attempt's leftovers by name first; the boot sweep + deletes owner-labelled providers named like a job's or a bundle host's (`isJobProviderName`, `BUNDLE_PROVIDER_NAME` + `pi-job-s<11 hex>-b`, sandboxes first since a provider still attached is refused deletion), then (review round + 1, L4) every profile named in the job namespace, which a provider never created for (an import, then a crash) + would otherwise leave: `provider profile list -o json` is the whole catalog as one array + (crates/openshell-cli/src/commands/provider.rs `provider_list_profiles_text`, + crates/openshell-providers/src/profiles.rs `profiles_to_json`), and a failed listing leaves the sweep's answer as it is; the gone-check deletes a + gone sandbox's bundle providers too. Still one set per job, never shared (`OQ-047`). + - **The record** gains `bundle.placeholders` and `bundle.plain` on both venues (`INT-RUN-HISTORY-FILE-CONTRACT`). + The redactor still holds every placeholder variable's real value (it is in the job's env as every secret is), so a + value that came back some other way is still masked. + - **CI** (advisory lane): the copy pass binds a bundle variable to the stand-in's second alias (`host.docker.internal`) + and reads from inside: the placeholder in the env and `/proc/self/environ` and no process holding the value, the + plain value in `/sandbox/job/bundle-env.json` with `PI_BUNDLE_ENV=1` and on no process's command line, neither value + in the gateway's log (the probe replaces the runner, so the runner's own read is `verify-image.sh`'s `bundleEnvFile` + check), the real value received by the stand-in at that host, the same placeholder refused at the + first alias (the job provider's endpoint) and the job's model key refused at the bundle's host; then a worker + process SIGKILLed while its bundled sandbox runs, and the reaper's sweep leaving neither the sandbox nor its two + providers. The bind pass repeats the inside reads and the swap. Only that lane, on Linux with a gateway, can prove + them; the unit tests drive the adapter against a fake CLI. + - **Code evidence**: worker/src/backend-openshell.mjs -> judgeBundle, bundleProviderPlan, bundleAuthFields, + providerProfileFor, credentialHostsOf, OPENSHELL_LIMITS, OPENSHELL_SET_ENV_NAMES, BUNDLE_PROVIDER_NAME, + isJobProviderName, expectedPolicies, openshellExecArgs, BUNDLE_ENV_FILE, the bundle's `bundleGate`; + worker/src/processor.mjs -> readBundle, askBundleGate, credentialHosts; image/runner/src/bundle-env.mjs -> + applyBundleEnv; worker/src/env-allowlist.mjs (`PI_BUNDLE_ENV`); image/Dockerfile and image/verify-image.sh + (`bundleEnvFile`); worker/src/backend-registry.mjs -> OPTIONAL_PREFLIGHT_DEFAULTS.bundleGate; + worker/src/run-container.mjs (`bundleKeys`); worker/src/run-history.mjs -> recordedBundle; + worker/test/backend-openshell.test.mjs, backend-openshell-bind.test.mjs, processor-bundles.test.mjs; + .github/scripts/openshell-conformance.mjs, openshell-bind-conformance.mjs. ## DES-OOM-CONFIRMED-BY-AN-IN-IMAGE-SUPERVISOR @@ -13165,3 +13332,4 @@ a tunnel. | 2026-10-19 | Issue #704, part 2: project secret bundles on the worker, `local` and `podman`. **NEW `DES-PROJECT-SECRET-BUNDLES`**: a project's environment granted once in `triggers.json`'s `secretBundles`, read by one resolver call per job start (`PI_SECRET_BUNDLES`, `name:/abs/path:dotenv\|json`, the overlay's operator-only `secretBundles`), the worker reading the binding and the `run.secretBundle: false` opt-out from its own file, trigger-made jobs only (`bundleSkipped` `manual`, `chained`, `opted-out`, `fork`), the free refusals before the mint and the fork check and read after it, strict declared formats, reserved names refused by name, `run.secrets` winning a clash, names-only record; seven rejected alternatives (projects.json as home, per-reference reads, dropping reserved names, sniffing formats, receiver-carried binding, `b:` routing, several bundles per project). **`DES-PER-TRIGGER-SECRET-PROFILE` REVERSED in part**: its rejected bullet "A vault name as the unit of grant" is reversed for bundles only, with the trade stated (the reviewed file no longer lists the fields; the record does, after the fact); it stands for `run.secrets`. **`DES-SERVICE-ENV-SETUP-SEAM` UNCHANGED, checked**: it stays boot time only; a bundle profile is a name selecting among execs the operator declared, the same argument `DES-PER-TRIGGER-SECRET-PROFILE` makes, and a trigger file names no path. **`DES-TRIGGERS-UNIFIED-FILE` UNCHANGED, checked**: still one file read by both services, refused whole when any part is malformed. **Code evidence**: worker/src/secret-bundles.mjs -> parseBundleOutput, makeBundleResolver, mergeBundleSecrets; worker/src/secret-profiles.mjs -> parseSecretBundleProfiles; worker/src/triggers.mjs -> parseTriggersDocument, validateSecretBundles; worker/src/triggers-file.mjs -> makeFindSecretBundle; worker/src/processor.mjs -> the two bundle gates; worker/src/doctor-bundles.mjs -> secretBundleChecks. | | 2026-10-19 | Issue #704, part 2, review round 1. **`DES-PROJECT-SECRET-BUNDLES` AMENDED**: no model-callable tool widens a grant (an entry `dispatch_trigger_add` writes carries `secretBundle: false`, no trigger tool call may carry the field, and the project tools refuse a membership change of a bundled project); the worker keeps its last good triggers read and retries a project job only while it has never had one (replacing the `secret-bundles-unreadable` refusal, which lost deliveries and was silent on hosts with no profile); a profile this host lacks is deferred on a multi-host shared queue (`SecretBundleProfileHold`); the read moves before the mint for every job that needs no fork check; the fork comparison is case-insensitive on GitHub and Forgejo and a 401/403/404 head read is `head-unverified`, not a retry; bundle-only loader and steering names refuse; bundle ids are capped at 128; two residuals stated (MCP servers cannot reference a bundle variable; values under 8 characters are not redacted). **`DES-OPENSHELL-VENUE` AMENDED**: a bundled job is refused on both OpenShell venues before spend (`openshell-bundle-unsupported`) until #704 part 3. **Code evidence**: admin/src/write-plans.mjs -> secretBundleFieldRefusal, planTriggerAdd; admin/src/read-model.mjs -> planProjectsWrite; worker/src/triggers-file.mjs -> makeFindSecretBundle; worker/src/processor.mjs -> the bundle gates, SecretBundleProfileHold, headReadStatus; worker/src/index.mjs -> the hold's hand-back; worker/src/secret-bundles.mjs -> bundleLoaderName; worker/src/backends.mjs -> OPENSHELL_BUNDLE_UNSUPPORTED. | | 2026-10-19 | Issue #704, part 2, review round 2. **`DES-PROJECT-SECRET-BUNDLES` AMENDED**: a missing triggers file is a failure (last good read, else retry) on a host that declares a bundle profile and "no bundles" only on one that declares none; the free profile lookup stays in the processor, with the reason; `run.secretBundle`, the top-level `secretBundles` and their spellings get the loader's near-miss sweep; a rate-limited head read retries; the bundle-only reserved names gain the remaining TLS-trust, template, interpreter and `npm_config_*` names, held to `docs/secrets.md` by a test; five residuals named (no profile and no file on a fleet host, the openshell refusal before the fork check, a shared-queue job judged against this host's default venue, `dispatch_trigger_edit` on a bundled project's operator trigger, the save-by-rename instant). **Code evidence**: worker/src/triggers.mjs -> isSecretBundleNearMiss; worker/src/triggers-file.mjs -> makeFindSecretBundle; worker/src/processor.mjs -> headReadRateLimited; worker/src/secret-bundles.mjs -> BUNDLE_LOADER_NAMES; worker/src/doctor.mjs -> the fleet warning. | +| 2026-10-20 | Issue #704, part 3: project secret bundles on both OpenShell venues. **`DES-OPENSHELL-VENUE` AMENDED**, a dated "Project secret bundles (#704 part 3)" bullet, and part 2's refusal paragraph marked SUPERSEDED: read at the pin (v0.1.4, `5a7728c`), a policy endpoint's `credential_binding` names ONE provider, a provider binds every key to every endpoint naming it, two providers may not expose one key and two overlapping endpoints may not name different providers, so each bundle HOST gets a per-job provider `-b` bound to that host's endpoints only, and the job's own provider keeps every other secret and every other endpoint; at a bundle host only its bundle variables resolve (anything else sent there is refused 403 `credential_endpoint_mismatch`, stated). `auth` documents (`auth_style`, `header_name`, validated and printed by the gateway, read by no supervisor code for a static credential), the binding narrows. Plain variables reach the exec as `--env NAME=VALUE`, since 0.1.4's exec takes nothing else (measured), so `OQ-028` re-opens on the venue for exactly them. Five refusals before the sandbox and before spend (`openshell-bundle-endpoint-not-allowed`, `-endpoint-shared`, `-endpoint-unknown`, `-name-taken`, `-unpassable`, each with the pinned source it rests on); the read-back accepts exactly the attached providers' layers (`--full` kept, `--base` rejected); teardown, the boot sweep and the gone-check delete the bundle providers; the record counts placeholders and plain variables. **`DES-PROJECT-SECRET-BUNDLES` AMENDED**: the venue's judgement runs inside the read (hosts before the resolver, variables after), behind the fork check, so part 2's "refused before the fork check" residual is GONE (a test) and the shared-queue residual narrows to a bundle the venue cannot hold; `openshell-bundle-unsupported` retired; the record's counts. **Review round 1** (same PR): H1, plain values no longer travel as `exec --env` (the gateway logs an exec's env: `build_remote_exec_command`, `stream_exec_over_relay`) but in `/bundle-env.json`, read by a runner declaring the new capability `bundleEnvFile` on `PI_BUNDLE_ENV=1`, an image without it refused before spend (`openshell-bundle-image-too-old`), a job image release owed; H2, the credential hosts a bundle may not claim gain every callable model's composed `baseUrl` host, every model endpoint's and every HTTP MCP server's, and `uploads.github.com` (the earlier "a model's host is not judged" CORRECTED); M1, no exec limit is left on a plain value (the upload has none at the pin, the bundle caps bound the file); M2, a run.secrets value shadowing a declared endpoint name goes into that host's provider; L3, the wildcard check ignores ports and errs safe, said; L4, the boot sweep deletes job-namespace profiles left without a provider. **Final review round**: the `PI_` prefix and `USER`, `SHELL`, `TERM`, `LOGNAME` (the sandbox's session names) refused before spend, and the runner's backstop narrowed from "already set" to a closed list plus placeholders, so an image `ENV` name is replaced as on local; each per-provider profile file deleted right after its import (no `BESIDE_JOB_DIR` suffix matches it); the crash window of `bundle-env.json` and the remaining run-time 403 cases (an unlisted overlay model, a stdio MCP server's own calls) stated. Rejected: a provider for plain values (useless placeholders), `policy get --base` (blind to a layer this job did not attach), refusing a model endpoint's host (the catalog host cannot be known, so the rule would read as protecting the model key and not). **`CONST-VENUE-EGRESS-LAYER` UNCHANGED, checked**: the providers are still per job and the layer still the deployment's, and a bundle may not widen the generated policy by a host. **`CONST-BUDGET-BEFORE-TOKENS` UNCHANGED, checked**: every refusal is before the clone, the token-cap read and the reservation (a pull request's after the mint, as its read already was). **`CONST-RETRY-INFRA-ONLY` UNCHANGED, checked**: the refusals return, an unreadable source and an unbuildable plan retry. **`CONST-TOKEN-SCOPED-PER-JOB`, `CONST-TRIGGER-AUTHOR-GATE` UNCHANGED, checked.** **Code evidence**: worker/src/backend-openshell.mjs -> judgeBundle, bundleProviderPlan, bundleAuthFields, expectedPolicies, openshellExecArgs, isJobProviderName, the bundle's bundleGate; worker/src/processor.mjs -> readBundle, askBundleGate; worker/src/backend-registry.mjs -> OPTIONAL_PREFLIGHT_DEFAULTS; worker/src/run-history.mjs -> recordedBundle; .github/scripts/openshell-conformance.mjs, openshell-bind-conformance.mjs. | diff --git a/specs/interfaces.md b/specs/interfaces.md index e7215bb4..39c0cb7b 100644 --- a/specs/interfaces.md +++ b/specs/interfaces.md @@ -1616,6 +1616,21 @@ refactor apart. burned, and the refusal is not retried. Given a portfolio job (issue #505), `/job/portfolio.json` exists, `0444`, holding no issue title, plan reason or path; given any other job, or a portfolio job whose flag was removed from the live file before prepare, it does not exist and the portfolio protocol is not in the prompt. +- **AMENDED 2026-10-20 (#704 part 3, review round 1): `bundle-env.json` on the OpenShell venues.** A job there whose + project secret bundle has PLAIN variables (ones the bundle's `endpoints` do not bind to a host) gets them as one JSON + object of strings in `/bundle-env.json`, `0600`, written by the venue's stage (`/sandbox/job/bundle-env.json` + in copy mode, uploaded with the job's other inputs; `/job/bundle-env.json` through the read-only bind in bind mode) and + removed at teardown (after a worker crash it stays in the job directory until the salvage or the orphan sweep + removes the directory), and the closed env map carries `PI_BUNDLE_ENV=1` (reserved, so `run.secrets`, `PI_FORWARD_ENV` + and a bundle cannot set it; non-secret, so it rides the exec's `--env`). A runner of an image declaring the capability + **`bundleEnvFile`** reads the file before pi's runtime exists and sets each variable in its own environment, refusing + as a config error (exit 2, before spend) a flag without a readable file, anything but one object of non-empty strings, + a name that is not an environment name, starts `PI_` or `OPENSHELL_`, is one the runtime or the sandbox sets + (`BUNDLE_ENV_PROTECTED`, a superset of the worker's pre-spend `OPENSHELL_SET_ENV_NAMES`, held so by a test) or holds + an OpenShell placeholder; any other name an image `ENV` set is replaced, as on `local` (final review round) + (`image/runner/src/bundle-env.mjs`). The worker refuses a job that needs the file on an image without the capability + before spend (`openshell-bundle-image-too-old`). Every other venue writes no file and no flag: the values are env there + as before. The agent can read the file; its values are ones the operator hands the agent on every venue. ## INT-CONTAINER-RUNTIME-CONTRACT @@ -1643,7 +1658,8 @@ a `finally`. The stop is `sandbox delete`, the listing `sandbox list --selector **AMENDED 2026-10-19 (#684 part 2): blessable, and the credentials.** Since #684 the venue is blessable (`NOT_YET_BLESSABLE` is empty). For a job whose env carries a secret (every name `NON_SECRET_ENV_KEYS` does not list) and whose policy has at least one rule, before the create: `provider profile import -f .openshell-profile.json` -(an endpointless profile naming the secrets, 0600, beside the job dir and never uploaded), then `provider create --name +(an endpointless profile naming the secrets, 0600, beside the job dir and never uploaded; AMENDED 2026-10-20 (#704): +`.openshell-profile-.json`, one per provider, deleted as soon as its import answers), then `provider create --name --type --credential NAME... --label pi.dispatch.owner=pi-dispatch`, its child's environment PATH, HOME, XDG_CONFIG_HOME and the secret values (the CLI reads `$NAME`), never an argv; the create gains `--provider `, every endpoint of the policy file `credential_binding: {"provider": ""}`, and the read-back's @@ -4126,7 +4142,12 @@ and selects the `on.type` it owns (worker: `cron`; receiver: `label`, `comment`, project, a non-boolean `forkReviews`, and an `endpoints` entry whose name is not an env-var name or is reserved, whose `host` is not a bare host name, whose `auth` is not `bearer`, `basic` or `header:`, or that carries an unknown field. `endpoints` is shape-checked on every venue and USED only on the openshell venues (a later part of - #704); elsewhere a named variable goes in plain like the rest. `run.secretBundle` is accepted on every trigger + #704); elsewhere a named variable goes in plain like the rest. **AMENDED 2026-10-20 (#704 part 3)**: used there now: + each named variable is held as a provider placeholder bound to its `host`'s endpoints only (a trigger's run.secrets + value that shadows the name keeps that binding), the host must be an exact endpoint of the job's generated policy and + not one the job's own credentials are sent to (a forge's, a callable model's composed `baseUrl`, a model endpoint, an + HTTP MCP server), and a named variable the bundle does not deliver refuses the job there (`DES-OPENSHELL-VENUE`, "Project secret bundles"); `auth` is recorded in the provider profile + and narrows nothing at OpenShell 0.1.4. `run.secretBundle` is accepted on every trigger type, strictly boolean. What the loader cannot answer is refused per job: whether the profile is declared (env `PI_SECRET_BUNDLES` or the overlay's `secretBundles`), the variables themselves (names, caps, reserved names), and whether the job's trigger is still the entry it came from. **The WORKER reads both from its own copy of this @@ -5478,7 +5499,7 @@ validator rather than a second copy of it. "flow": "" | null, "startedAt": "", "endedAt": "", "outcome": "completed" | "policy" | "failed", - "reason": "" | null, + "reason": "" | null, "exitCode": | null, "turns": | null, "tokens": { "input": , "output": , "total": , "cost": , // per-job usage totals; null when the container died before the exit line @@ -5775,7 +5796,10 @@ validator rather than a second copy of it. record is byte-identical to before. `bundle` is `{ id, variables, shadowed }`: the bundle's id and the variable NAMES the job received from it, and those a trigger's own `run.secrets` shadowed, each sorted and REBUILT by the worker (`recordedBundle`), every name held to the environment-name charset and at most 64, the whole key absent on - any violation. Names, never a value or a reference: the operator's own names from their own manager, and the + any violation. **AMENDED 2026-10-20 (#704 part 3)**: on both OpenShell venues `bundle` also carries `placeholders` + and `plain`, how many of the bundle's variables the agent held as provider placeholders (the ones the bundle's `endpoints` + bind to a host, a name a run.secrets value shadowed included, since that value is held at the declared host) and how many as values: a PAIR of integers, each 0 to 64 and summing to at most + the variable count, or neither key (a record of any other venue, or one written before part 3, has neither). Names, never a value or a reference: the operator's own names from their own manager, and the answer to "a grant now changes outside review", since a run says exactly what it held. A refused bundle job carries no `bundle` (its `reason` says why). `bundleSkipped`: why the job ran WITHOUT its project's bundle, a closed enum: `manual` (no trigger made it: `pi-dispatch run `, `/dispatch run`, `dispatch_run`), `chained` @@ -5784,7 +5808,14 @@ validator rather than a second copy of it. to the head read, so the job is treated as a fork). **Five reasons** come with them, all outcome `policy`, pre-spend and before the mint, `budgetReserved: false`: `secret-bundle-profile-unknown`, `secret-bundle-profile-ambiguous` (both deferred instead, with no record, on a multi-host worker's shared queue), - `secret-bundle-resume`, `secret-bundle-trigger-changed`, `openshell-bundle-unsupported`. A bundle read's refusals + `secret-bundle-resume`, `secret-bundle-trigger-changed`, `openshell-bundle-unsupported`. **AMENDED 2026-10-20 (#704 + part 3)**: `openshell-bundle-unsupported` is RETIRED (it shipped in no release, so no record holds it), and six + reasons replace it, outcome `policy`, `budgetReserved: false`, refused by the OpenShell venues' bundle gate inside + the read (before the mint for a job that needs no fork check, right after it for a pull request, before the clone and + every reserve either way): `openshell-bundle-endpoint-not-allowed`, `openshell-bundle-endpoint-shared`, + `openshell-bundle-endpoint-unknown`, `openshell-bundle-name-taken`, `openshell-bundle-unpassable`, + `openshell-bundle-image-too-old` (`DES-OPENSHELL-VENUE`, "Project secret bundles"); a source that gate could not read retries as + `container-never-started`. A bundle read's refusals reuse `secret-unresolved` and `secret-name-reserved` and its infrastructure failure `secret-resolver-unreachable`; a fork check whose forge read failed for any reason but 401, 403 or 404, and a triggers file never read in this process, retry as `container-never-started`. The runner's @@ -10699,3 +10730,4 @@ pull request when its container starts, edited as the run goes and a last time w | 2026-10-19 | Issue #704, part 2: project secret bundles (`DES-PROJECT-SECRET-BUNDLES`). **`INT-TRIGGERS-FILE-CONTRACT` AMENDED**: the top-level `secretBundles` block in the contract (`profile`, `ref`, `project`, optional `forkReviews` and `endpoints`), validated strictly with every load-time refusal listed, a malformed block refusing the whole file in every loader; `run.secretBundle` (optional boolean) on every trigger type; both read by the WORKER from its own copy of the file at pickup, never carried by a receiver. **`INT-RUN-HISTORY-FILE-CONTRACT` AMENDED**: the conditional tail keys `bundle: { id, variables, shadowed }` (names only, rebuilt, charset-checked) and `bundleSkipped` (`manual` \| `chained` \| `opted-out` \| `fork` \| `head-unverified`), and five new `reason` tokens (`secret-bundle-profile-unknown`, `secret-bundle-profile-ambiguous`, `secret-bundle-resume`, `secret-bundle-trigger-changed`, `openshell-bundle-unsupported`), checked against the nested `session.reason` enum for the collision rule. **`INT-CONFIG-OVERLAY-CONTRACT` AMENDED**: the overlay accepts `secretBundles` (`{ name: { path, format } }`, unknown fields and a missing format refused), operator-only (`OPERATOR_ONLY_KEYS`), outside `KNOWN_KEYS` like `secretProfiles`. **`INT-RUNNER-EXIT-CODE-PROTOCOL` UNCHANGED, checked**: the bundle resolver speaks its three codes through the same `runResolver`. **`INT-OUTBOX-CONTRACT` UNCHANGED, checked**: a chained child gets no bundle, recorded as `bundleSkipped: "chained"`. **`INT-PROJECTS-FILE-CONTRACT` AMENDED** (review round 1): the project tools refuse any change to the membership of a project a bundle names, a creation under a bundled id included, judged on the triggers file before the confirm and again by `writeProjects`; an unreadable or invalid triggers file refuses a membership change it cannot rule out. **Code evidence**: worker/src/triggers.mjs -> validateSecretBundles, validateSecretBundleFlag, parseTriggersDocument; worker/src/triggers-file.mjs -> makeFindSecretBundle; worker/src/run-history.mjs -> recordedBundle, BUNDLE_SKIPPED_REASONS; worker/src/runtime-settings.mjs -> the `secretBundles` case. | | 2026-10-19 | Issue #704, part 2, review round 1. **`INT-RUN-HISTORY-FILE-CONTRACT` AMENDED**: `bundleSkipped` gains `head-unverified`; `secret-bundles-unreadable` is dropped (never released) and `openshell-bundle-unsupported` joins the reason enum; the two profile reasons are deferred, with no record, on a multi-host worker's shared queue. **`INT-TRIGGERS-FILE-CONTRACT` AMENDED**: the worker answers from its last good read while the file does not validate, a missing file reads as no bundles, and an entry `dispatch_trigger_add` writes carries `"secretBundle": false`. **`INT-PROJECTS-FILE-CONTRACT` AMENDED**: the "Secret bundles" bullet, the project tools' refusal of a membership change of a bundled project. **Code evidence**: admin/src/read-model.mjs -> planProjectsWrite, writeProjects; worker/src/triggers-file.mjs -> makeFindSecretBundle; worker/src/run-history.mjs -> BUNDLE_SKIPPED_REASONS. | | 2026-10-19 | Issue #704, part 2, review round 2. **`INT-TRIGGERS-FILE-CONTRACT` AMENDED**: the near-miss refusal of `run.secretBundle` and the top-level `secretBundles` spellings; a missing file on a host that declares a bundle profile is a failure, not "no bundles". **Code evidence**: worker/src/triggers.mjs -> isSecretBundleNearMiss, validateSecretBundleFlag, parseTriggersDocument; worker/src/triggers-file.mjs -> makeFindSecretBundle. | +| 2026-10-20 | Issue #704, part 3: project secret bundles on both OpenShell venues. **`INT-RUN-HISTORY-FILE-CONTRACT` AMENDED**: `bundle` gains `placeholders` and `plain` on the OpenShell venues, a pair of bounded integers or neither (rebuilt by `recordedBundle`, so every other record is byte-identical); the reason enum loses `openshell-bundle-unsupported` (retired, in no release) and gains `openshell-bundle-endpoint-not-allowed`, `openshell-bundle-endpoint-shared`, `openshell-bundle-endpoint-unknown`, `openshell-bundle-name-taken`, `openshell-bundle-unpassable` and (review round 1) `openshell-bundle-image-too-old`, all outcome `policy`, pre-spend, held to `OPENSHELL_REFUSALS` by the existing test. **`INT-CONTAINER-JOB-INPUTS` AMENDED** (review round 1, H1): on the OpenShell venues a bundle's plain variables are `/bundle-env.json` (0600, removed at teardown) beside the reserved, non-secret `PI_BUNDLE_ENV=1`, read by a runner declaring the new image capability `bundleEnvFile`, which refuses a bad file as a config error; never the exec's `--env`, which the gateway logs. RELEASE OBLIGATION: a job image release carrying `bundleEnvFile` is owed before plain bundle variables run on an OpenShell venue (beside #695's). **`INT-TRIGGERS-FILE-CONTRACT` AMENDED**: `endpoints` keeps its shape and its load-time rules; the "a later part of #704" sentence now says what it does on the OpenShell venues (a placeholder bound to its host only, a shadowing run.secrets value kept on that binding, the host an exact policy endpoint and not one the job's own credentials use, an undelivered name refused there, `auth` recorded and narrowing nothing at 0.1.4). **Code evidence**: worker/src/run-history.mjs -> recordedBundle; worker/src/backend-openshell.mjs -> OPENSHELL_REFUSALS. | diff --git a/specs/open-questions.md b/specs/open-questions.md index e0b21dd8..dd49faf8 100644 --- a/specs/open-questions.md +++ b/specs/open-questions.md @@ -1504,6 +1504,38 @@ adversarial passes did. readers and what bounds it are unchanged; the quantity is not, and `docs/secrets.md` (Recipe D's traps) and `SECURITY.md` say so. On the openshell venues the bundle's values take the `run.secrets` path, so the closure above holds for them. **Status: ACCEPTED RISK for Docker and Podman, UNCHANGED.** +- **AMENDED 2026-10-20 (#704 part 3): RE-OPENED on the `openshell` venues for a bundle's PLAIN variables, and only + them.** The sentence just above ("the bundle's values take the `run.secrets` path") is CORRECTED: part 2 refused a + bundled job there instead, and part 3 gives a bundle two paths. A variable the bundle's `endpoints` binds to a host + is a provider key, by `--credential NAME` from the CLI child's environment, so the closure holds for it. Every other + bundle variable is a value the agent reads by design, and it reaches the sandbox as `openshell sandbox exec --env + NAME=VALUE`, on that CLI's argv for the exec's lifetime, readable from `/proc//cmdline` by any local user under + a default `hidepid`, as `-e` is on docker's. **Measured at the pin** (OpenShell v0.1.4, `5a7728c`): the exec's + `--env` takes `KEY=VALUE` only, refusing a bare `KEY` ("--env expects KEY=VALUE", + crates/openshell-cli/src/commands/common.rs `parse_key_value_pairs`, through `parse_env_pairs`), and the exec has no + env-file flag (crates/openshell-cli/src/main.rs `SandboxCommands::Exec`); the bare form that reads the CLI's own + environment exists only for `provider create --credential KEY` (main.rs: "Provider credential pair (`KEY=VALUE`) or + env lookup key (`KEY`)"); `sandbox create --env` is the same `KEY=VALUE` flag and would also keep the values in the + gateway's sandbox spec. The gateway further refuses an exec value with a NUL, LF or CR or over 32 KiB + (crates/openshell-server/src/grpc/validation.rs `validate_exec_request_fields`), which the venue turns into a + pre-spend refusal (`openshell-bundle-unpassable`) rather than a failed exec. **What bounds it**: the same as on + docker (a trusted worker host), plus a narrower set: only the variables the operator left out of `endpoints`, and + the operator can move any of them into it to take it off the argv. **What would close it**: an exec that reads env + from its own environment or a file (an upstream change, checked at each OpenShell bump), or the runner reading the + plain values from a file uploaded beside `/job` (a job-image change). **Status: ACCEPTED RISK for Docker and Podman, + UNCHANGED; on `openshell` and `openshell-bind` CLOSED for every credential the provider path carries and ACCEPTED + RISK for a bundle's plain variables.** +- **AMENDED 2026-10-20 (#704 part 3, review round 1): CLOSED again on `openshell` and `openshell-bind`.** The bullet + above is SUPERSEDED. Review found the exec's `--env` worse than argv exposure: the gateway puts an exec's env pairs at + the head of the remote command and logs its first 120 characters at INFO (crates/openshell-server/src/grpc/sandbox.rs + `build_remote_exec_command`, `stream_exec_over_relay`, the interactive path), so a plain value reached the gateway's + journal. Plain bundle variables now travel in a 0600 file in the job directory (`bundle-env.json`), uploaded with the + job's inputs in copy mode (tar over the sandbox's SSH, crates/openshell-cli/src/ssh.rs) or bound read-only in bind mode, + read by a runner declaring `bundleEnvFile` into its own environment, and removed from the host at teardown; the exec + carries only `PI_BUNDLE_ENV=1`. So no bundle value is on any host argv or in the gateway's log on these venues. What + remains: the file on the worker host for the job's lifetime, in the worker's own 0700 job directory, readable by the + worker's uid (which holds every value anyway) and root, the exposure the job directory's other inputs already have. + **Status: ACCEPTED RISK for Docker and Podman, UNCHANGED; CLOSED for `openshell` and `openshell-bind`.** - **Raised by**: issue #225. ## OQ-029 — A held job's WAKE has no authorizing actor, and the moment picks the commit @@ -2326,14 +2358,73 @@ adversarial passes did. within the probe's ten-minute cache can still refuse a job's create (one infrastructure retry, then refused before spend). A worker uid other than 1001 is MEASURED to work (`run_as_user` 2000 ran as 2000 and wrote a bind as 2000); the Podman driver is refused for bind mode outright. The other residuals UNCHANGED, checked. +- **Added in #704 part 3** (2026-10-20, AMENDED 2026-10-20 (#704), read at the pin v0.1.4): **more values in the + supervisor, still no shared provider.** A project secret bundle's `endpoints` variables become keys of per-job + providers, one per bundle host (`-b`, `DES-OPENSHELL-VENUE`, "Project secret bundles"), so the + supervisor holds up to 64 more values than a trigger's `run.secrets` could put there, whatever the operator's + manager held at the job's start: the "Supervisor compromise" residual's quantity widens, its kind does not (every one + is a value the job would have held on any venue). Each such provider is created, labelled, attached and deleted per + job exactly as the job's own is, and swept by the boot reaper; none is shared across jobs, so this row's "What would + reopen or widen it" (a layer holding a credential the job would not have held) is NOT reached. NEW residual, stated: + at a bundle host only that host's bundle variables resolve, because the pinned model binds one provider per + endpoint (proto/sandbox.proto `NetworkCredentialBinding`), so another credential sent there is refused 403 + `credential_endpoint_mismatch` by the supervisor (crates/openshell-supervisor-network/src/l7/relay.rs) rather than + swapped: loud, never a leak; every host the job's own credentials are known to go to (a forge's, a callable model's + composed `baseUrl`, a model endpoint's, an HTTP MCP server's) is refused as a bundle host for it (review round 1). The + other residuals UNCHANGED, checked. - **Related**: `OQ-011` (the custody half of its close condition is admitted on this venue, the accounting half stays open), `OQ-012` (a vendor runtime builds the box itself, so its isolation words are `asserted`), `OQ-028` - (closes on this venue once #684 ships; **closed there since #684 part 2, AMENDED 2026-10-19**). `OQ-004` is not related in the sense its warning names: this is the + (closes on this venue once #684 ships; **closed there since #684 part 2, AMENDED 2026-10-19**; a secret bundle's plain + variables travel by file there, so it stays closed, **AMENDED 2026-10-20 (#704)**), `OQ-048` (a manager through OpenShell itself). `OQ-004` is not related in the sense its warning names: this is the secrets proxy, and the allowlist proxy stays squid on Docker and Podman. - **Needs**: maintainer ratification that a vendor supervisor may hold the job's credentials and see its provider and forge plaintext, bounded by token scope and the provider spend limit rather than by trusting its code, given that the venue is opt-in and Docker and Podman keep the agent-held key. +## OQ-048: 1Password (or any manager) through OpenShell itself + +- **Status**: **OPEN**, not built (2026-10-20, issue #704 part 3). Recorded so both later routes stay possible and + neither is mistaken for something this repository ships. +- **Position**: a project secret bundle (`DES-PROJECT-SECRET-BUNDLES`) reads the operator's manager through the + RESOLVER SEAM, a command the operator writes that runs on the worker host once per job start and prints the + environment. That seam already covers 1Password's unattended route, a read-only, Environment-scoped service + account whose token stays in a host file the job cannot see (`docs/secrets.md`'s recipe, placeholder paths only), + and on the OpenShell venues the values it prints then take the per-job provider path (`DES-OPENSHELL-VENUE`). What + is NOT built is a route where OpenShell itself, rather than the worker, talks to the manager. Two exist at the pin + (v0.1.4, `5a7728c`), and each needs something this project does not have: + - **An operator-owned provider attached per job.** 1Password's OpenShell integration (Beta, 1password.dev, + "nvidia-openshell") creates a provider from the desktop app; the worker would attach that one provider to every + job's sandbox (`sandbox create --provider`, repeatable; `sandbox provider attach` works on a running sandbox). It + collides with `OQ-047`'s rule that no provider is shared across jobs (a long-lived provider is "a layer that holds + a credential the job would not have held", and its endpoints would be the vendor's profile, not the worker's + generated policy, `CONST-VENUE-EGRESS-LAYER`'s second condition), and with the integration's own terms: an + approval per connection in the desktop app and access that lasts a TTL, after which "OpenShell stops resolving + the placeholders" (the declined route of #704's 2026-10-19 decision). Unattended jobs cannot wait on either. + - **The gateway's external credential driver.** A gateway can store provider secrets through one external + credential driver over a Unix socket (gateway.toml `[openshell.gateway] credential_drivers = [""]` and a + `[openshell.credential_drivers.]` table with `transport = "uds"`, an absolute `socket_path` and an optional + absolute `command`; at most one enabled driver per gateway: crates/openshell-core/src/config.rs, + crates/openshell-server/src/credentials.rs). The contract is proto/credential_driver.proto (service + `CredentialDriver`: `GetCapabilities` with protocol 1.0 metadata and the `openshell.credentials.contract` + capability, `StoreCredential`, `DeleteCredential`, `ResolveCredentials` by gateway-issued handle, an optional + `ListCredentials`). Handles are resolved when a sandbox's provider environment is built + (crates/openshell-server/src/grpc/policy.rs `load_sandbox_provider_environment`, through + `resolve_provider_handles`), so a value would be fetched per sandbox, not per worker read. What it would need: a + driver process on the gateway host holding the manager's credential (for 1Password, the service-account token); + a mapping from the gateway's `StoreCredential` (which hands the driver the VALUE, at `provider create`) to a + read-only manager, which has nothing to store into, so the driver would have to keep per-job values itself and + resolve only those, or a provider created from handles rather than values, which the 0.1.4 CLI does not offer; + and acceptance that the driver is gateway-WIDE, replacing the default encrypted storage for every provider, + the worker's per-job ones included. A driver is code that would live outside this repository, per manager. +- **Decision recorded, not reversed here**: nothing 1Password-specific is in this repository, by decision (the + repository is public: no keys, no account, no lab). The seam stays a command (`#206`, `#209`), and the recipe in + `docs/secrets.md` uses placeholder paths only. +- **What would move it**: a manager integration that works headless with a per-job scope (no per-connection approval, + no TTL shorter than a job), or an OpenShell release whose credential driver can resolve a reference the worker names + per job without being handed the value; either is measured at its pin before any code. +- **Related**: `OQ-047` (no shared provider), `OQ-028` (what the resolver seam's values cost on each venue), + `DES-PROJECT-SECRET-BUNDLES`, `DES-OPENSHELL-VENUE`. + ## Revision History | Date | Change | @@ -2459,3 +2550,4 @@ adversarial passes did. | 2026-10-19 | Issue #686, part 3: bind mode, the `openshell-bind` venue. **`OQ-047` AMENDED**: a dated "Added in #686 part 3" bullet: the bind residual is real and measured (upstream's warning quoted; what holds is the worker's read-only binds and the policy entries, each measured), and NEW residuals: `--driver-config-json` is experimental upstream; the gateway's bind settings are learned from a probe create (the check order measured) whose admitting answer leaves a sandbox in phase `Error` (measured), deleted on every answer and by the boot reaper after a crash; a gateway reconfigured within the probe's cache costs one infrastructure retry, then refused before spend. A worker uid other than 1001 is measured to work; the Podman driver is refused for bind mode. The other residuals UNCHANGED, checked. **Review round 1 (#714)**: UNCHANGED, checked: the residuals stand; a sandbox that outlives its exec is now ended before its tree is read (60 s at most, then infrastructure), so that is no residual. **Review round 2 (#714)**: UNCHANGED, checked: the lingering-sandbox path is now a named result or a retry by exit, with no exit checkpoint; the session tail's final read now follows the settle, so no residual remains there. | | 2026-10-19 | Issue #704, the routing fixes found while researching it. **`OQ-032` AMENDED** (it stays CLOSED): a job with `run.secrets` and no `run.secretsProfile` now needs `s:default`, by `secretsArmed`'s rule, and a host advertises each overlay-declared profile it would resolve (resolver realpath an executable inside `PI_SECRET_RESOLVER_ROOTS`, read each beat) and no secret profile while one name is declared in both env and the overlay. The rule routes only to a host that advertises every need, but an older worker serving `default` from its overlay does not advertise it, so a job that also needs a profile only that host declares loses its route on a mixed fleet: the workers upgrade before the receiver and the console. Such jobs are now routed, so the stopped-host residual covers them too. **`OQ-031` UNCHANGED, checked**: folder identity is untouched. **`OQ-008` UNCHANGED, checked**: `caps` stays a per-beat self-description, and deleting the `host:*` keyspace still sends every delivery to the shared queue. **Code evidence**: worker/src/capabilities.mjs -> jobNeeds, IMPLICIT_SECRET_PROFILE; worker/src/secret-profiles.mjs -> servableSecretProfiles; worker/src/start.mjs -> the registry `caps` thunk. | | 2026-10-19 | Issue #704, part 2: project secret bundles. **`OQ-028` AMENDED** (status stays ACCEPTED RISK for Docker and Podman): a bundled job's host argv carries up to 64 more `-e NAME=VALUE` elements, whatever the manager holds at the job's start; the mechanism and its bounds are unchanged, the quantity is not, and the docs say so; on the openshell venues the closure holds, since bundle values take the `run.secrets` path there. **`OQ-032` UNCHANGED, checked** (it stays CLOSED): a bundle is deliberately not routed, because the need depends on the job's project, which only the worker resolves; the worker's pre-spend `secret-bundle-profile-unknown` refusal is the backstop, and doctor fails on a host that lacks a bound profile. **`OQ-027` UNCHANGED, checked**: a bundle resolver's exit code is the same convention, and the recipes say what each must exit. **`OQ-047` UNCHANGED, checked**: no shared provider is introduced; the openshell bundle endpoints are #704 part 3. | +| 2026-10-20 | Issue #704, part 3: project secret bundles on both OpenShell venues. **`OQ-028` AMENDED twice in this PR** (review round 1 closed it again on the OpenShell venues: plain values moved off the exec, whose env the gateway logs, into a job-directory file a `bundleEnvFile` runner reads), its part 2 bullet's "the bundle's values take the `run.secrets` path" CORRECTED: it RE-OPENS on `openshell` and `openshell-bind` for a bundle's plain variables only, which reach the sandbox as `sandbox exec --env NAME=VALUE` on the CLI's argv; measured at the pin (v0.1.4) that the exec takes `KEY=VALUE` only, with no bare form and no env file (the bare form is `provider create --credential KEY`'s), and that the gateway refuses an exec value with a line break or over 32 KiB (turned into a pre-spend refusal); what would close it named. Status: closed there for every credential the provider path carries, ACCEPTED RISK for plain bundle variables; Docker and Podman UNCHANGED. **`OQ-047` AMENDED**, a dated "Added in #704 part 3" bullet: the supervisor holds up to 64 more values (per-job providers, one per bundle host), the kind of the custody residual unchanged and no provider shared, so its widening condition is not reached; NEW residual, one provider per endpoint at the pin, so another credential sent to a bundle host is refused 403 by the supervisor; the other residuals UNCHANGED, checked. **NEW `OQ-048`**: a manager through OpenShell itself, OPEN and not built: an operator-owned provider attached per job (collides with `OQ-047` and with 1Password's per-connection approval and TTL) or the gateway's external credential driver (proto/credential_driver.proto, one per gateway, handed values at `provider create`, so a read-only manager needs a driver that keeps per-job values or a handle-based create the 0.1.4 CLI lacks); the resolver seam already covers the read-only service account; nothing 1Password-specific in the repository, by decision. | diff --git a/specs/requirements.md b/specs/requirements.md index eb017953..54817560 100644 --- a/specs/requirements.md +++ b/specs/requirements.md @@ -4484,6 +4484,18 @@ and nothing about the box itself (`INT-CONTAINER-RUNTIME-CONTRACT`). carries `bundle: { id, variables, shadowed }` or `bundleSkipped`, names only; no value reaches a record, a log line or a comment. `doctor` fails on an undeclared bundle profile and an unknown project, and `doctor --secrets` prints each bundle's variable names and count. + AMENDED 2026-10-20 (#704 part 3): `openshell-bundle-unsupported` is retired; both OpenShell venues hold a bundle. + Inside the read (before the resolver for its hosts, after it for its variables, so behind the fork check and before + the clone and every reserve), the venue refuses as `policy` a bundle it cannot hold as declared: + `openshell-bundle-endpoint-not-allowed` (an `endpoints` host that is not exactly an endpoint of the job's generated + policy, or sits under a wildcard entry), `openshell-bundle-endpoint-shared` (a forge's host), + `openshell-bundle-endpoint-unknown` (an `endpoints` name the bundle did not deliver), `openshell-bundle-name-taken` + (a name the sandbox sets itself), `openshell-bundle-unpassable` (a value or a count OpenShell 0.1.4 cannot carry) and + `openshell-bundle-image-too-old` (plain variables on a job image without `bundleEnvFile`); a source it cannot read + retries. On those venues a variable `endpoints` names is a placeholder in the job, its value used only at its own + host (a trigger's run.secrets value for that name too), the host never one the job's own forge, model or MCP + credentials go to; every other bundle variable is a value, handed by a file in the job directory and never on the + exec's command line or in the gateway's log; and the record's `bundle` also counts the two (`placeholders`, `plain`). ## REQ-DEPLOYMENT-BOOTSTRAP @@ -5173,6 +5185,7 @@ instead of drifting. | Date | Change | |---|---| +| 2026-10-20 | Issue #704, part 3: project secret bundles on both OpenShell venues. **`REQ-TRIGGER-SECRETS` AMENDED**, its bundle acceptance: `openshell-bundle-unsupported` retired; six venue refusals inside the read (`openshell-bundle-endpoint-not-allowed`, `-endpoint-shared`, `-endpoint-unknown`, `-name-taken`, `-unpassable`, `-image-too-old`, the last and the model and MCP hosts of `-endpoint-shared` from review round 1), plain values by a job-directory file and never the exec, behind the fork check and before every reserve, an unreadable source retried; an `endpoints` variable a placeholder used only at its host, the rest values, and the record's two counts. The statement, the per-trigger `run.secrets` rules and every other acceptance line UNCHANGED, checked. | | 2026-10-19 | Issue #704, part 2, review round 1. **`REQ-TRIGGER-SECRETS` AMENDED**, its bundle scope and acceptance: no model-callable tool may widen a grant (an added trigger carries `secretBundle: false`, no trigger tool call may carry the field, the project tools refuse a bundled project's membership change); `openshell-bundle-unsupported`; a profile this host lacks defers a multi-host shared-queue job; the last good triggers read, and a retry only before the first; the read before the mint unless a fork check is needed; a 401, 403 or 404 head read is `head-unverified`; bundle-only loader and steering names refuse. | | 2026-10-19 | Issue #704, part 2: project secret bundles. **`REQ-TRIGGER-SECRETS` AMENDED**, three dated paragraphs: the scope gains bundles (trigger-made jobs of the bound project, one resolver call per job start, the same exclusions: no `dispatch_run` or manual run, no chained child, no model-callable writer; the `run.secretBundle: false` opt-out; no fork pull request without `forkReviews`; `run.secrets` win a clash); "names FIELDS" stays true for `run.secrets` and is explicitly not true for bundles; the acceptance gains the load-time refusals, the pre-mint and post-mint refusals and retries, the names-only record and doctor's lines. **`REQ-MULTI-HOST-COORDINATION` UNCHANGED, checked**: a bundle is not routed (`DES-PROJECT-SECRET-BUNDLES` says why); the worker's pre-spend refusal is the backstop. **`REQ-DEPLOYMENT-BOOTSTRAP` UNCHANGED, checked**: `doctor --secrets` reads and never writes. **Code evidence**: worker/src/processor.mjs -> the bundle gates; worker/src/secret-bundles.mjs; worker/src/triggers.mjs -> validateSecretBundles; worker/src/run-history.mjs -> recordedBundle. | | 2026-10-19 | Issue #704, the standalone fixes found while researching project secret bundles. **`REQ-TRIGGER-SECRETS` AMENDED**, its acceptance: a deployment entry is one in `PI_SECRET_PROFILES` or an overlay `secretProfiles` entry whose resolver is an executable inside `PI_SECRET_RESOLVER_ROOTS`; `doctor` fails on every name declared in both and on a named overlay profile the worker would not use; a forge delivery with `run.secrets` and no profile is routed as needing `default`, and hosts advertise the overlay profiles they would resolve. **`REQ-MULTI-HOST-COORDINATION` UNCHANGED, checked**: the registry and the routing rule keep their shape; only the tokens read and written are wider. **`REQ-DEPLOYMENT-BOOTSTRAP` UNCHANGED, checked**: doctor still reads and never writes. **Code evidence**: worker/src/capabilities.mjs -> jobNeeds; worker/src/secret-profiles.mjs -> servableSecretProfiles; worker/src/doctor.mjs -> the secrets section; worker/src/triggers-file.mjs -> serialize. | diff --git a/worker/src/backend-openshell.mjs b/worker/src/backend-openshell.mjs index f273c238..f121f62d 100644 --- a/worker/src/backend-openshell.mjs +++ b/worker/src/backend-openshell.mjs @@ -6,9 +6,9 @@ import { basename, dirname, join } from "node:path"; import { isJobNamespace, jobContainerName, JOB_NAME_PREFIX, reaperExec } from "./backend-local.mjs"; import { FORGES } from "./forges.mjs"; import { NON_SECRET_ENV_KEYS } from "./redact-secrets.mjs"; -import { BACKENDS, OPENSHELL_BACKEND, OPENSHELL_BIND_BACKEND, OPENSHELL_BIND_LINGERING, OPENSHELL_BUNDLE_UNSUPPORTED } from "./backends.mjs"; +import { BACKENDS, OPENSHELL_BACKEND, OPENSHELL_BIND_BACKEND, OPENSHELL_BIND_LINGERING } from "./backends.mjs"; import { containerSpec, SHIPPED_IMAGE_UID, SIZE_LABEL_CPU, SIZE_LABEL_MEM, transfersFromSpec } from "./container-spec.mjs"; -import { makeImagePreflight, normalizeImageId, resolveJobImage } from "./image-preflight.mjs"; +import { BUNDLE_ENV_FILE_CAPABILITY, makeImagePreflight, normalizeImageId, resolveJobImage } from "./image-preflight.mjs"; import { LAYOUTS } from "./job-layout.mjs"; import { DAEMON_FACTS_MAX_BUFFER, DAEMON_FACTS_TIMEOUT_MS } from "./daemon-facts.mjs"; import { JOB_USER_FACTS_MAX_AGE_MS, decideJobUser, makeDaemonFactsReader, relabelsPrivateMounts, resolveImageUser } from "./job-user.mjs"; @@ -38,7 +38,9 @@ import { makeRunContainer } from "./run-container.mjs"; * * BLESSABLE SINCE ISSUE #684: the sandbox's network policy is generated per job (openshell-policy.mjs) and the job's * credentials live in a per-job provider, placeholders in the agent (`CONST-VENUE-EGRESS-LAYER`); only names the - * redactor treats as not secret reach the exec's `--env`. + * redactor treats as not secret reach the exec's `--env`. A project secret bundle's PLAIN variables (issue #704 part 3), + * which the operator hands the agent as values, reach it in a file in the job directory (`BUNDLE_ENV_FILE`), never the + * exec, whose env the gateway logs (`bundleProviderPlan` below says which variable goes where). * * THE LIVE TRANSCRIPT (issue #686): the session file is in the sandbox until the download, so no host tail can read it. * The runner mirrors its lines over the exec's STDERR as records signed with the exit key (transcript-mirror.mjs), and @@ -170,14 +172,24 @@ export function expectedPolicy(policy) { * baseline path only where the image HAS it (an operator-built image without `/var/log` reads back as written). * Nothing else: any other difference never starts the job. */ -export function expectedPolicies(policy, { provider = null } = {}) { +export function expectedPolicies(policy, { providers = [] } = {}) { const withBaseline = expectedPolicy(policy); const bodies = withBaseline === policy ? [policy] : [withBaseline, policy]; - if (provider === null) return bodies; + if (providers.length === 0) return bodies; // Issue #684: an attached provider adds one layer of its own to the full read-back (measured: a rule named - // `_provider_` holding only its name, for an endpointless profile). - const key = `_provider_${provider.replace(/-/g, "_")}`; - return bodies.map((b) => ({ ...b, network_policies: { ...b.network_policies, [key]: { name: key } } })); + // `_provider_` holding only its name, for an endpointless profile). Issue #704 part + // 3: one layer per attached provider, EXACTLY the providers this job attached and nothing else. Read at the pin + // (openshell-policy/src/compose.rs `provider_rule_name`: lowercased, every character but [a-z0-9_] an underscore; + // `compose_effective_policy` adds each under its own key, a numeric suffix only on a clash, which names of the shape + // `pi-job-s<11 hex>` and `pi-job-s<11 hex>-b` cannot have; openshell-server/src/grpc/policy.rs + // `provider_policy_context_from_records` pushes a layer for an endpointless profile with an empty rule, which is the + // measured `{ name }`). `--full` is kept rather than `--base`: comparing the base alone would no longer see a layer + // this job did not attach, which is the one difference a read-back exists to catch. + const layers = Object.fromEntries(providers.map((p) => { + const key = `_provider_${p.replace(/-/g, "_")}`; + return [key, { name: key }]; + })); + return bodies.map((b) => ({ ...b, network_policies: { ...b.network_policies, ...layers } })); } /** How often, and how far apart, a provider's delete is asked again while its sandbox's cleanup finishes. */ @@ -255,16 +267,227 @@ export function unbindableSecretNamesOf(env = {}) { * no endpoints and no binaries of its own, so the credentials resolve only where the job's own policy binds them * (`credential_binding.provider`). Named by the sandbox, so the reaper finds a crashed job's. */ -export function providerProfileFor(name, secretNames) { +export function providerProfileFor(name, secretNames, { auth = {} } = {}) { return { id: name, display_name: name, description: "pi-dispatch per-job credentials", category: "other", - credentials: secretNames.map((env, i) => ({ name: `c${i}`, env_vars: [env], required: true })), + credentials: secretNames.map((env, i) => ({ name: `c${i}`, env_vars: [env], required: true, ...bundleAuthFields(auth[env]) })), }; } +/** + * A bundle endpoint's `auth` (triggers.mjs `BUNDLE_ENDPOINT_AUTH`) in the profile's own words (issue #704 part 3), read + * at the pin. On OpenShell 0.1.4 these fields DOCUMENT the scheme and change nothing a static credential does: the + * supervisor swaps a placeholder wherever a request carries it (a header value whole, after a `Bearer `-style prefix, + * inside Basic's decoded `user:pass`, in the path or query; openshell-core/src/secrets.rs `rewrite_header_value`), and + * no supervisor code reads `auth_style` or `header_name` for a static credential (they drive only token grants, + * openshell-supervisor-network/src/l7/token_grant_injection.rs). The gateway validates them at import + * (openshell-providers/src/profiles.rs, the `auth_style` match: empty or `basic` free, `bearer` and `header` need a + * `header_name`) and `provider profile get` prints them ("Authentication: bearer"), so the operator's declaration is + * visible in the gateway's own view of the credential. Rejected: leaving them out because they do nothing; the profile + * is the one place an operator reading the gateway sees what the reviewed file meant. The binding, which DOES narrow, + * is per endpoint (`credential_binding`), below. + */ +export function bundleAuthFields(auth) { + if (auth === "bearer") return { auth_style: "bearer", header_name: "authorization" }; + if (auth === "basic") return { auth_style: "basic" }; + const header = /^header:([A-Za-z0-9-]{1,64})$/.exec(String(auth ?? "")); + return header ? { auth_style: "header", header_name: header[1].toLowerCase() } : {}; +} + +/** + * A bundle provider's name (issue #704 part 3): the sandbox's name and `-b`, one per bundle host, so the reaper's + * namespace match (`isJobProviderName`) finds a crashed job's, and every attempt of one job uses the same names (an + * earlier attempt's leftover is removed first, as the job provider's is). A valid profile id at the pin (lowercase + * kebab-case, openshell-providers/src/profiles.rs `is_valid_profile_id`), 22 to 23 characters, inside the 64 a provider + * type may have (openshell-server/src/grpc/mod.rs `MAX_PROVIDER_TYPE_LEN`). + */ +export const BUNDLE_PROVIDER_NAME = /^pi-job-s[0-9a-f]{11}-b[0-9]{1,2}$/; +export const bundleProviderNameOf = (sandbox, index) => `${sandbox}-b${index}`; +/** Whether a provider is one this worker makes per job: the job's own (named as its sandbox) or a bundle host's. */ +export const isJobProviderName = (name) => typeof name === "string" && (SANDBOX_NAME.test(name) || BUNDLE_PROVIDER_NAME.test(name)); + +/** + * What OpenShell 0.1.4 can carry, read at the pin, and what a bundle is judged against before the sandbox exists + * (issue #704 part 3), so a value the gateway would refuse is a determinate refusal before spend rather than a create + * that fails as infrastructure and is retried into the same answer: a provider credential value is at most 8 KiB, a + * provider holds at most 32, a sandbox at most 32 providers (openshell-server/src/grpc/mod.rs `MAX_MAP_VALUE_LEN`, + * `MAX_PROVIDER_CREDENTIALS_ENTRIES`, `MAX_PROVIDERS`; validation.rs `validate_provider_mutable_fields`, sandbox.rs's + * attach check). A PLAIN variable meets none of these (review round 1, M1): it travels in `BUNDLE_ENV_FILE`, which + * copy mode uploads with the job directory as tar over the sandbox's SSH, extracted by the sandbox's own user + * (openshell-cli/src/ssh.rs `sandbox_sync_up_files`, no size cap at the pin) and bind mode binds, so its only bounds + * are the bundle's own (64 variables, 256 KiB, secret-bundles.mjs), judged before the read is accepted. What stays on + * the exec is the closed set of non-secret names (`NON_SECRET_ENV_KEYS`), which no bundle can grow. + */ +export const OPENSHELL_LIMITS = Object.freeze({ credentialBytes: 8192, credentialsPerProvider: 32, providersPerSandbox: 32 }); + +/** + * The job-directory file a bundle's plain variables reach the runner by on this venue (issue #704 part 3, review round + * 1, H1): one JSON object, written 0600 by the stage beside the job's other inputs (so `/sandbox/job/bundle-env.json` in + * copy mode, the read-only `/job/bundle-env.json` bind in bind mode), read by an image declaring `bundleEnvFile` on + * PI_BUNDLE_ENV=1 (image/runner/src/bundle-env.mjs), and removed at teardown. JSON carries a newline as `\n`, so a PEM + * arrives intact. The agent can read it: these are values the operator hands the agent on every venue. + */ +export const BUNDLE_ENV_FILE = "bundle-env.json"; + +/** + * The variable names a bundle variable may not take on this venue (issue #704 part 3), because the sandbox already + * sets them and two writers of one name means one is silently lost: the `OPENSHELL_` prefix, which the CLI and the + * gateway refuse on an exec (openshell-cli/src/commands/common.rs `parse_env_pairs`; openshell-server + * validation.rs `validate_env_entries_inner`) and the sandbox reserves (openshell-sandbox/src/process.rs + * `RESERVED_ENV_PREFIX`); the CA variables the sandbox writes for its TLS-terminating proxy + * (openshell-sandbox/src/child_env.rs `tls_env_vars`) and the proxy variables it strips (process.rs `PROXY_ENV_VARS`); + * the session names the sandbox sets for every exec, `USER`, `SHELL` and `TERM` (openshell-sandbox/src/process.rs, the + * `HOME`/`USER`/`SHELL`/`TERM` loop; boundary_exec.rs, the `env_clear` builder), with `LOGNAME`, which 0.1.4 does not + * set but a login shell does, beside them; and the names this adapter's own exec sets (`HOME`, the git helper's + * `GIT_CONFIG_*`, `gitCredentialEnv`). The `PI_` prefix is refused here too (final review round): it is the worker's and + * the runner's own namespace, which the runner refuses at start, so a bundle name in it would otherwise pass this gate + * and fail after spend. Refused + * by NAME before spend, never dropped. Most are reserved on every venue already (HOME, the proxy names); listed whole + * so this venue's answer does not lean on another module's list staying what it is. The runner refuses any name already + * set in its environment besides (bundle-env.mjs), the backstop for a name this list misses. + */ +export const OPENSHELL_RESERVED_ENV_PREFIX = "OPENSHELL_"; +export const OPENSHELL_SET_ENV_NAMES = Object.freeze(["HOME", "USER", "SHELL", "TERM", "LOGNAME", "GIT_CONFIG_COUNT", "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0", "NODE_EXTRA_CA_CERTS", "DENO_CERT", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO", "ALL_PROXY", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "all_proxy", "http_proxy", "https_proxy", "no_proxy", "grpc_proxy", "NODE_USE_ENV_PROXY"]); +const isSandboxSetName = (name) => name.startsWith(OPENSHELL_RESERVED_ENV_PREFIX) || name.startsWith("PI_") || OPENSHELL_SET_ENV_NAMES.includes(name); + +/** + * The hosts a job's own credentials are sent to, which a bundle endpoint may not claim (issue #704 part 3, review round + * 1, H2), since at a bundle host only that host's bundle variables resolve (`judgeBundle`): + * - every forge's API, upload and git hosts this deployment can serve (GitHub's three, GitLab's and Azure's public + * ones, and each configured instance URL's host), which every forge job's git, CLI and API use; + * - `extra`, the processor's: the composed `baseUrl` host of every model the job may call (the overlay's models.json + * over pi's catalog, output-cap.mjs `outputCapView`), every declared model endpoint's host whether keyed or not (the + * simple rule: a keyless one costs the operator nothing to keep off a bundle), and every HTTP MCP server's host the + * job names (its headers may carry a run.secrets value). + * Before review round 1 the model's hosts were left out on the reasoning that the catalog host could not be known; it + * can (`outputCapView` composes it, as the output cap and the zero-rated check already do), and leaving it out let a + * bundle endpoint on `api.anthropic.com` take the model key's binding, so the run failed `provider-auth-refused` after + * spend. + */ +export const FORGE_CREDENTIAL_HOSTS = Object.freeze(["github.com", "api.github.com", "uploads.github.com", "gitlab.com", "dev.azure.com"]); +export function credentialHostsOf({ forgeHosts = {}, extra = [] } = {}) { + const hosts = new Set(FORGE_CREDENTIAL_HOSTS); + for (const url of Object.values(forgeHosts ?? {})) { + try { + if (typeof url === "string" && url !== "") hosts.add(new URL(url).hostname.toLowerCase()); + } catch {} + } + for (const host of Array.isArray(extra) ? extra : []) if (typeof host === "string" && host !== "") hosts.add(host.toLowerCase()); + return hosts; +} + +/** + * Judge a job's bundle against this venue (issue #704 part 3, `DES-OPENSHELL-VENUE`), pure: `{ ok, placeholders, plain }` + * or `{ refused: reason, name?, host? }`. `network` is the job's generated `{ network_policies }`; `vars` the bundle + * variables the job receives (NOT the ones its own run.secrets shadowed), null before the bundle is read, when only + * the hosts can be judged; `shadowed` the names run.secrets took; `capabilities` the job image's. + * + * WHY A HOST IS CLAIMED WHOLE. At the pin a policy endpoint names ONE provider (sandbox.proto `NetworkCredentialBinding` + * has a `provider` and nothing else), a provider binds EVERY one of its keys to every endpoint naming it + * (openshell-server/src/grpc/policy.rs `policy_static_credential_endpoint_bindings`, then provider.rs's + * `resolve_provider_environment_from_records_with_policy_bindings_and_credentials`), two attached providers may not + * expose one env key (provider.rs `validate_provider_environment_key_ownership`: "is provided by both provider"), and + * two endpoints that can match one request may not name different providers (openshell-policy/src/ambiguity.rs, the + * `credential_binding.provider` conflict). So a variable bound to ONE host needs a provider of its own whose binding is + * on that host's endpoints, and those endpoints then carry no binding to the job's own provider: at a bundle host only + * that host's bundle variables resolve. A placeholder of any other credential sent there is refused by the supervisor, + * 403 `credential_endpoint_mismatch`, never forwarded (openshell-supervisor-network/src/l7/relay.rs + * `reject_credential_resolution`): loud, never a leak. Hence the refusals: + * - `openshell-bundle-endpoint-not-allowed`: the host is not EXACTLY an endpoint host of the generated policy (a + * reviewed file may not widen egress), or a wildcard endpoint of the policy covers it (`.example.com` in the + * allowlist admits `*.example.com`, which would match the host's requests too and is bound to the job's provider: + * the ambiguity OpenShell refuses at create); + * - `openshell-bundle-endpoint-shared`: the host is one the job's own credentials are sent to (`credentialHostsOf`), + * which the claim would take from them; + * - `openshell-bundle-endpoint-unknown`: an `endpoints` key names no variable the bundle delivered (a typo, or one + * removed in the manager) and no run.secrets value shadowed. The named variable would otherwise be missing and a + * misspelt one would go in PLAIN, visible to the agent, while the reviewed file reads as holding it as a placeholder; + * - `openshell-bundle-name-taken`: a bundle variable is named as one the sandbox sets itself (`OPENSHELL_SET_ENV_NAMES`); + * - `openshell-bundle-unpassable`: what the gateway would refuse (`OPENSHELL_LIMITS`): a placeholder value over 8 KiB, + * more than 32 placeholders for one host, more bundle hosts than a sandbox's providers leave room for, or a name no + * provider can carry; + * - `openshell-bundle-image-too-old`: plain variables, and a job image that does not declare `bundleEnvFile`, whose + * runner would ignore `BUNDLE_ENV_FILE` and run the job without them. + * A shadowed endpoint name (review round 1, M2) is still a placeholder at its host: the run.secrets value, which is the + * one that wins, goes into that host's provider (`bundleProviderPlan`), so it counts among the placeholders here. + */ +export function judgeBundle({ endpoints = {}, vars = null, shadowed = [], network = { network_policies: {} }, credentialHosts = new Set(), capabilities = [] } = {}) { + const policyHosts = Object.values(network?.network_policies ?? {}).flatMap((rule) => (rule?.endpoints ?? []).map((e) => String(e?.host ?? "").toLowerCase())); + const exact = new Set(policyHosts.filter((h) => !h.startsWith("*."))); + const wildcards = policyHosts.filter((h) => h.startsWith("*.")).map((h) => h.slice(1)); + const names = Object.keys(endpoints ?? {}).sort(); + for (const name of names) { + const host = String(endpoints[name]?.host ?? "").toLowerCase(); + // Conservative on the wildcard twice over (review round 1, L3): any `*.base` the host ends under, though a single + // star crosses no dot at the pin, and whatever its PORT, though OpenShell's ambiguity check compares ports too + // (an allowlist wildcard is 443, a model endpoint may be another): both err toward refusing, never toward a create + // the gateway refuses after the read. + if (!exact.has(host) || wildcards.some((w) => host.endsWith(w))) return { refused: "openshell-bundle-endpoint-not-allowed", name, host }; + if (credentialHosts.has(host)) return { refused: "openshell-bundle-endpoint-shared", name, host }; + } + const hosts = new Set(names.map((n) => String(endpoints[n].host).toLowerCase())); + // One provider per bundle host, beside the job's own. + if (hosts.size > OPENSHELL_LIMITS.providersPerSandbox - 1) return { refused: "openshell-bundle-unpassable", name: null, host: null }; + if (vars === null) return { ok: true }; + const delivered = new Set(Object.keys(vars)); + const unknown = names.find((n) => !delivered.has(n) && !shadowed.includes(n)); + if (unknown !== undefined) return { refused: "openshell-bundle-endpoint-unknown", name: unknown }; + const placeholders = names.filter((n) => delivered.has(n) || shadowed.includes(n)); + const plain = [...delivered].filter((n) => !Object.hasOwn(endpoints ?? {}, n)).sort(); + const taken = [...placeholders, ...plain].find(isSandboxSetName); + if (taken !== undefined) return { refused: "openshell-bundle-name-taken", name: taken }; + for (const n of placeholders) { + // A shadowed name's value is the trigger's own, which #684's path already carried at any size; judged as before. + if (!PROVIDER_ENV_NAME.test(n) || (delivered.has(n) && Buffer.byteLength(vars[n]) > OPENSHELL_LIMITS.credentialBytes)) return { refused: "openshell-bundle-unpassable", name: n }; + } + for (const host of hosts) { + if (placeholders.filter((n) => String(endpoints[n].host).toLowerCase() === host).length > OPENSHELL_LIMITS.credentialsPerProvider) return { refused: "openshell-bundle-unpassable", name: null, host }; + } + if (plain.length > 0 && !(Array.isArray(capabilities) && capabilities.includes(BUNDLE_ENV_FILE_CAPABILITY))) return { refused: "openshell-bundle-image-too-old", name: null }; + return { ok: true, placeholders: placeholders.length, plain: plain.length }; +} + +/** + * Which provider holds which of a job's secrets, and which bundle variables go in plain (issue #704 part 3), pure: + * `{ job: { name, keys }, bundles: [{ name, host, keys, auth }], plain }` or `{ collision: name }`. + * - the job's own provider (named as its sandbox, #684's, unchanged): every secret of the env (`secretNamesOf`) that is + * not a bundle variable, so run.secrets, the forge token, the model key and the forwarded secrets keep their path; + * - one provider per bundle HOST (`bundleProviderNameOf`, sorted by host so every attempt names them alike): the + * variables the bundle's `endpoints` declare for that host, each with its `auth` documented in the profile. The + * processor includes a declared name a run.secrets value shadowed (review round 1, M2), so that value, the one that + * wins, resolves at the host the reviewed file binds the name to, and not in the job's provider bound elsewhere; + * - `plain`: every other bundle variable, which reaches the runner in `BUNDLE_ENV_FILE`, its value visible to the agent + * as on every other venue. + * `bundleKeys` is the processor's `{ endpoints, plain }`, null for a job without a bundle, which plans exactly #684's + * one provider. An env key in two places is a `collision`, named, never a guess at which wins: OpenShell refuses two + * providers exposing one key, and a plain variable beside a placeholder of the same name would shadow one of them. The + * processor's split and the reserved names make it unreachable; checked anyway. + */ +export function bundleProviderPlan({ name, env = {}, bundleKeys = null } = {}) { + const endpoints = bundleKeys?.endpoints ?? {}; + const plainNames = Array.isArray(bundleKeys?.plain) ? bundleKeys.plain : []; + const has = (k) => typeof env?.[k] === "string" && env[k] !== ""; + const bundleNames = new Set([...Object.keys(endpoints), ...plainNames]); + const jobKeys = secretNamesOf(env).filter((k) => !bundleNames.has(k)); + const endpointNames = Object.keys(endpoints).filter(has).sort(); + const hostOf = (k) => String(endpoints[k].host).toLowerCase(); + const hosts = [...new Set(endpointNames.map(hostOf))].sort(); + const bundles = hosts.map((host, i) => { + const keys = endpointNames.filter((k) => hostOf(k) === host); + return { name: bundleProviderNameOf(name, i), host, keys, auth: Object.fromEntries(keys.map((k) => [k, endpoints[k].auth])) }; + }); + const plain = [...new Set(plainNames)].filter(has).sort(); + const seen = new Set(); + for (const k of [...jobKeys, ...bundles.flatMap((b) => b.keys), ...plain]) { + if (seen.has(k)) return { collision: k }; + seen.add(k); + } + return { job: { name, keys: jobKeys }, bundles, plain }; +} + /** * The git credential helper a forge job's exec gets on this venue (issue #684), as `GIT_CONFIG_COUNT`, `_KEY_0` and * `_VALUE_0`: it prints the forge token's PLACEHOLDER as the password, and OpenShell's supervisor rewrites it into the @@ -371,6 +594,14 @@ export function openshellExecArgs({ name, env = {}, gateway = null, mode = "copy // could not carry (a name no shell can spell) would otherwise fall through to this argv as a value (review H1). Every // other name is the provider's, its placeholder in the sandbox's env already; the git helper names the forge token's // placeholder, never its value. + // + // Issue #704 part 3: a project secret bundle's PLAIN variables never travel here either (review round 1, H1). The + // gateway puts an exec's env pairs at the head of the remote command and logs its first 120 characters at INFO + // (openshell-server/src/grpc/sandbox.rs `build_remote_exec_command`, `stream_exec_over_relay` and the interactive + // path), so a value here would be on the host's argv AND in the gateway's journal. They go by file instead + // (`BUNDLE_ENV_FILE` in the job directory, read by the runner on PI_BUNDLE_ENV=1, which rides here as a non-secret + // name). Rejected: `--env` as built first (the log), a provider (the agent would hold useless placeholders for a + // value it must use itself), `sandbox create --env` (the same flag, kept in the gateway's sandbox spec besides). const envArgs = []; const carried = Object.fromEntries(Object.entries(env ?? {}).filter(([key]) => NON_SECRET_ENV_KEYS.has(key))); for (const [key, value] of Object.entries({ ...carried, ...(gitCredentialEnv(env) ?? {}), HOME: "/home/pi" })) { @@ -467,9 +698,23 @@ function reapWith(run, deleteProvider, log) { const owned = (await listOwned(run)).filter((s) => isJobNamespace(s.name)); for (const s of owned) await run(["sandbox", "delete", s.name]); if (owned.length > 0) log("reaped_sandboxes", { count: owned.length }); - // Issue #684: a crashed job's provider and profile, by the owner label and the job namespace. + // Issue #684: a crashed job's provider and profile, by the owner label and the job namespace. Issue #704 part 3: + // its bundle hosts' providers too (`-b`, `isJobProviderName`), after the sandboxes above, since a + // provider still attached is refused deletion. const providers = parseJson((await run(["provider", "list", "--label-selector", `${OPENSHELL_OWNER_LABEL}=${OPENSHELL_OWNER}`, "-o", "json"])).stdout, "provider list"); - for (const p of (providers?.providers ?? []).filter((x) => typeof x?.name === "string" && SANDBOX_NAME.test(x.name))) await deleteProvider(p.name); + for (const p of (providers?.providers ?? []).filter((x) => isJobProviderName(x?.name))) await deleteProvider(p.name); + // Review round 1, L4: a profile whose provider never existed (an import, then a crash or a refused create) is + // not reached through the providers, so the profiles are swept too, by the same names: a profile carries no + // label, and only a name of this worker's job namespace (`isJobProviderName`) is ever deleted. `-o json` is every + // page of the catalog as one array of profiles (openshell-cli/src/commands/provider.rs + // `provider_list_profiles_text`, openshell-providers/src/profiles.rs `profiles_to_json`). A listing that fails + // leaves the answer as it is: the sandboxes are what the tri-state is about, and the next sweep asks again. + try { + const profiles = parseJson((await run(["provider", "profile", "list", "-o", "json"])).stdout, "provider profile list"); + for (const prof of (Array.isArray(profiles) ? profiles : []).filter((x) => isJobProviderName(x?.id))) await run(["provider", "profile", "delete", prof.id]).catch(() => {}); + } catch { + log("openshell_profiles_unswept", {}); + } return { reaped: true }; } catch { return { reaped: false }; @@ -552,8 +797,15 @@ export const OPENSHELL_REFUSALS = Object.freeze({ "openshell-egress-untranslatable": "Refused: this deployment's egress settings hold an entry the OpenShell venue cannot turn into its network policy without widening it, so the operator must change it before jobs run there. Not run.", // Issue #686 part 3, review round 2 of #714: the bind venue's named result for a sandbox not confirmed gone (backends.mjs). [OPENSHELL_BIND_LINGERING.reason]: OPENSHELL_BIND_LINGERING.comment, - // Issue #704 part 2: refused by the processor's bundle gate, before spend, until part 3 gives this venue bundle keys. - [OPENSHELL_BUNDLE_UNSUPPORTED.reason]: OPENSHELL_BUNDLE_UNSUPPORTED.comment, + // Issue #704 part 3: a project secret bundle this venue cannot hold as declared (`judgeBundle` says why for each), each + // refused before the sandbox exists and before spend. The variable's name and the host go to the operator's log + // (`refused_openshell_bundle`), never to a forge comment: a bundle's names are its manager's contents. + "openshell-bundle-endpoint-not-allowed": "Refused: this job's project has a secret bundle whose endpoints name a host the job's network policy does not admit by that exact name, and a bundle may not widen what the job reaches. Add the host to egress-allowlist.conf (as itself, not under a wildcard entry), or remove it from the bundle's endpoints. Not run.", + "openshell-bundle-endpoint-shared": "Refused: this job's project has a secret bundle whose endpoints name a host the job's own forge, model or MCP credentials are sent to, and on the OpenShell venue a host is bound to one credential provider, so the job's own would stop working there. Remove that host from the bundle's endpoints. Not run.", + "openshell-bundle-endpoint-unknown": "Refused: this job's project has a secret bundle whose endpoints name a variable the bundle's resolver did not deliver, so a variable meant to be held as a placeholder could be missing or reach the agent as a value. Fix the name in the bundle's endpoints, or add the variable in the manager. Not run.", + "openshell-bundle-name-taken": "Refused: this job's project has a secret bundle with a variable the OpenShell sandbox already sets itself, so one of the two would be silently lost. Rename the variable in the manager. Not run.", + "openshell-bundle-unpassable": "Refused: this job's project has a secret bundle with a variable the OpenShell venue cannot hold as a placeholder (a value over 8 KiB, or more endpoint variables or hosts than a sandbox's providers hold). Run the project on another venue, or change the bundle's endpoints. Not run.", + "openshell-bundle-image-too-old": "Refused: this job's project has a secret bundle whose variables reach the job as values, and on the OpenShell venue they reach it by a file only a newer job image reads, so this image would run the job without them. Rebuild the job image from a version that declares bundleEnvFile. Not run.", // Issue #686 part 3, bind mode only. The setting goes to the operator's log (`openshell_bind_mounts_disabled`). "openshell-bind-mounts-disabled": "Refused: this job's venue mounts its files into an OpenShell sandbox, and the OpenShell gateway is not configured to allow that (its gateway.toml must allow driver config and bind mounts, and turn resource admission off), so the operator must change it before jobs run there. Not run.", }); @@ -760,7 +1012,9 @@ export function makeOpenshellBackend(opts = {}) { const stageRun = async ({ opts: built, signal = null, job = null, modelEndpoints = null }) => { const { jobDir, size = DEFAULT_JOB_SIZE } = built; const name = sandboxNameOf(built.name); - const at = { name, jobDir, policyFile: `${jobDir}.openshell-policy.json`, profileFile: `${jobDir}.openshell-profile.json`, uploadDir: `${jobDir}.openshell-up`, downloadDir: `${jobDir}.openshell-down`, transfers: [], provider: null }; + // `providers` and `profiles`: what this attempt created and imported (issue #704 part 3: the job's own and one per + // bundle host), so the teardown deletes exactly those; `profileFiles`, the files the imports read. + const at = { name, jobDir, policyFile: `${jobDir}.openshell-policy.json`, uploadDir: `${jobDir}.openshell-up`, downloadDir: `${jobDir}.openshell-down`, transfers: [], providers: [], profiles: [], profileFiles: [] }; attempts.set(built, at); at.signal = signal; live.set(name, (live.get(name) ?? 0) + 1); @@ -815,28 +1069,77 @@ export function makeOpenshellBackend(opts = {}) { // ENVIRONMENT (`--credential NAME` reads `$NAME`, measured), never its argv, and every endpoint of the policy // bound to it, so a placeholder resolves only in a request to a host the policy admits. With no rule the job // reaches nothing, so no provider is made and the secrets go nowhere. - const secrets = secretNamesOf(built.env ?? {}); - const unbindable = unbindableSecretNamesOf(built.env ?? {}); + // + // Issue #704 part 3: a project secret bundle splits that (`bundleProviderPlan`). The job's own provider keeps every + // secret but the bundle's; each bundle HOST gets a provider of its own holding the variables the bundle binds to + // it, and only that host's endpoints are bound to it (one provider per endpoint at the pin, `judgeBundle` says + // why); every other bundle variable goes in plain, by file (`BUNDLE_ENV_FILE`). The bundle was judged before + // spend (`bundleGate`), so a plan this cannot build here is a defect, refused as infrastructure, never run around. + const plan = bundleProviderPlan({ name, env: built.env ?? {}, bundleKeys: built.bundleKeys ?? null }); + if (plan.collision) { + log("openshell_bundle_collision", { container: name, name: plan.collision }); + throw new Error(`openshell: ${plan.collision} would be exposed twice in the sandbox`); + } + const secrets = plan.job.keys; + // Review round 1, L5: a bundle's variables are judged by name before spend (`judgeBundle`), and a plain one is never + // a provider key, so none is said here as a secret this venue could not carry. + const bundleNames = new Set([...Object.keys(built.bundleKeys?.endpoints ?? {}), ...(built.bundleKeys?.plain ?? [])]); + const unbindable = unbindableSecretNamesOf(built.env ?? {}).filter((k) => !bundleNames.has(k)); + // Review round 1, H1: the plain variables, by file in the job directory, never on the exec. Written before the + // upload (copy mode carries the job directory) or the create (bind mode binds it), 0600, removed at teardown. + if (plan.plain.length > 0) { + at.bundleEnvFile = join(jobDir, BUNDLE_ENV_FILE); + fs.writeFileSync(at.bundleEnvFile, `${JSON.stringify(Object.fromEntries(plan.plain.map((k) => [k, built.env[k]])))}\n`, { mode: 0o600 }); + } if (unbindable.length > 0) log("openshell_secret_unbindable", { container: name, names: unbindable }); const rules = policy.network_policies ?? {}; - if (secrets.length > 0 && Object.keys(rules).length > 0) { - // Named by the sandbox, so every attempt of one job uses the same name: an earlier attempt's leftover (a delete - // refused while its sandbox lingered, a crash between import and create) is removed first, once, or this create - // would collide with it until the next boot's reap (review M2). - await run(["provider", "delete", name]).catch(() => {}); - await run(["provider", "profile", "delete", name]).catch(() => {}); - fs.writeFileSync(at.profileFile, `${JSON.stringify(providerProfileFor(name, secrets))}\n`, { mode: 0o600 }); - await run(["provider", "profile", "import", "-f", at.profileFile]); - at.profile = name; + const hasRules = Object.keys(rules).length > 0; + const endpointHosts = new Set(Object.values(rules).flatMap((rule) => rule.endpoints.map((e) => String(e.host).toLowerCase()))); + const unbound = plan.bundles.find((b) => !endpointHosts.has(b.host)); + if (unbound) { + log("openshell_bundle_host_unbound", { container: name, host: unbound.host }); + throw new Error("openshell: a bundle host is not an endpoint of the job's policy"); + } + // Named by the sandbox, so every attempt of one job uses the same names: an earlier attempt's leftover (a delete + // refused while its sandbox lingered, a crash between import and create) is removed first, once, or this create + // would collide with it until the next boot's reap (review M2). + const makeProvider = async ({ name: provider, keys, auth = {} }) => { + await run(["provider", "delete", provider]).catch(() => {}); + await run(["provider", "profile", "delete", provider]).catch(() => {}); + // Final review round: the file is deleted as soon as the import has answered, either way, since nothing reads it + // after the import. A name per provider (`.openshell-profile-.json`) is not one `BESIDE_JOB_DIR`'s fixed + // suffixes match, so a worker that died between the write and the import would leave it for good; deleting it at + // once makes that window the import's alone, and the teardown's removal stays the backstop. Names only. + const file = `${jobDir}.openshell-profile-${provider}.json`; + at.profileFiles.push(file); + fs.writeFileSync(file, `${JSON.stringify(providerProfileFor(provider, keys, { auth }))}\n`, { mode: 0o600 }); + try { + await run(["provider", "profile", "import", "-f", file]); + } finally { + try { + fs.rmSync(file, { force: true }); + } catch {} + } + at.profiles.push(provider); // The CLI child's environment is the worker's own, as on every other step (so it reaches the same gateway with // the same settings), plus the values; never its argv. - const valueEnv = { ...process.env, ...Object.fromEntries(secrets.map((k) => [k, built.env[k]])) }; - await run(["provider", "create", "--name", name, "--type", name, ...secrets.flatMap((k) => ["--credential", k]), "--label", `${OPENSHELL_OWNER_LABEL}=${OPENSHELL_OWNER}`], { env: valueEnv }); - at.provider = name; - for (const rule of Object.values(rules)) for (const endpoint of rule.endpoints) endpoint.credential_binding = { provider: name }; - } else if (secrets.length > 0) { - log("openshell_secrets_unbound", { container: name, n: secrets.length }); + const valueEnv = { ...process.env, ...Object.fromEntries(keys.map((k) => [k, built.env[k]])) }; + await run(["provider", "create", "--name", provider, "--type", provider, ...keys.flatMap((k) => ["--credential", k]), "--label", `${OPENSHELL_OWNER_LABEL}=${OPENSHELL_OWNER}`], { env: valueEnv }); + at.providers.push(provider); + }; + const jobProvider = secrets.length > 0 && hasRules ? name : null; + if (jobProvider !== null) await makeProvider({ name, keys: secrets }); + else if (secrets.length > 0) log("openshell_secrets_unbound", { container: name, n: secrets.length }); + for (const b of plan.bundles) await makeProvider(b); + // The bindings: a bundle host's endpoints to its own provider, every other endpoint to the job's, as before. + const byHost = new Map(plan.bundles.map((b) => [b.host, b.name])); + for (const rule of Object.values(rules)) { + for (const endpoint of rule.endpoints) { + const provider = byHost.get(String(endpoint.host).toLowerCase()) ?? jobProvider; + if (provider !== null) endpoint.credential_binding = { provider }; + } } + if (plan.bundles.length > 0 || plan.plain.length > 0) log("openshell_bundle_bound", { container: name, providers: plan.bundles.length, placeholders: plan.bundles.reduce((n, b) => n + b.keys.length, 0), plain: plan.plain.length }); if (stopped()) return; fs.writeFileSync(at.policyFile, `${JSON.stringify(policy)}\n`, { mode: 0o600 }); // Issue #686 part 3: the mode label and the binds, bind mode only, so copy mode's create is unchanged. @@ -845,7 +1148,7 @@ export function makeOpenshellBackend(opts = {}) { const bindArgs = binding ? ["--label", `${OPENSHELL_MODE_LABEL}=bind`, "--label", `${OPENSHELL_JOB_DIR_LABEL}=${basename(jobDir)}`, "--driver-config-json", driverConfigFor(engine, binds)] : []; let createdOut; try { - createdOut = await run(["sandbox", "create", "--detach", "--name", name, "--from", imageId, "--label", `${OPENSHELL_OWNER_LABEL}=${OPENSHELL_OWNER}`, "--label", `${SIZE_LABEL_MEM}=${size.memMiB}`, "--label", `${SIZE_LABEL_CPU}=${size.cpuCenti}`, ...bindArgs, ...sizeArgs(size), "--policy", at.policyFile, ...(at.provider ? ["--provider", at.provider] : []), "--no-auto-providers", "-o", "json"], { timeoutMs: 360_000 }); + createdOut = await run(["sandbox", "create", "--detach", "--name", name, "--from", imageId, "--label", `${OPENSHELL_OWNER_LABEL}=${OPENSHELL_OWNER}`, "--label", `${SIZE_LABEL_MEM}=${size.memMiB}`, "--label", `${SIZE_LABEL_CPU}=${size.cpuCenti}`, ...bindArgs, ...sizeArgs(size), "--policy", at.policyFile, ...at.providers.flatMap((p) => ["--provider", p]), "--no-auto-providers", "-o", "json"], { timeoutMs: 360_000 }); } catch (err) { // The backstop for a probe that admitted: the gateway refused the binds at the create, with one of its settings // sentences. This attempt is infrastructure (nothing ran, refunded), and the verdict is held so the next attempt's @@ -865,7 +1168,7 @@ export function makeOpenshellBackend(opts = {}) { // plus exactly that (`expectedPolicy`), never to "close enough". const effective = parseJson((await run(["policy", "get", name, "--full", "-o", "json"])).stdout, "policy get"); const readBack = JSON.stringify(canonical(effective?.policy)); - if (effective?.policy_source !== "sandbox" || !expectedPolicies(policy, { provider: at.provider }).some((p) => JSON.stringify(canonical(p)) === readBack)) { + if (effective?.policy_source !== "sandbox" || !expectedPolicies(policy, { providers: at.providers }).some((p) => JSON.stringify(canonical(p)) === readBack)) { log("openshell_policy_mismatch", { container: name, source: typeof effective?.policy_source === "string" ? effective.policy_source : null }); throw new Error("openshell: the sandbox's policy read back differs from the one the worker wrote"); } @@ -928,6 +1231,14 @@ export function makeOpenshellBackend(opts = {}) { }; const deleteProvider = providerDeleter(run, { log, delay }); + // The bundle hosts' providers of one sandbox, by the owner label and their names, each once (the gone-check's). + const deleteBundleProvidersOf = async (sandbox) => { + const listed = parseJson((await run(["provider", "list", "--label-selector", `${OPENSHELL_OWNER_LABEL}=${OPENSHELL_OWNER}`, "-o", "json"])).stdout, "provider list"); + for (const p of (listed?.providers ?? []).filter((x) => BUNDLE_PROVIDER_NAME.test(x?.name ?? "") && x.name.startsWith(`${sandbox}-b`))) { + await run(["provider", "delete", p.name]).catch(() => {}); + await run(["provider", "profile", "delete", p.name]).catch(() => {}); + } + }; const teardownRun = async ({ opts: built }) => { const name = sandboxNameOf(built.name); @@ -952,9 +1263,11 @@ export function makeOpenshellBackend(opts = {}) { // Issue #684: the provider and its profile, after the sandbox. A provider still attached is refused (measured: // "is attached to sandbox(es)"), and a sandbox delete is accepted before its cleanup ends, so the provider's // delete is asked again for a bounded while; what is left, the reaper deletes by the owner label. - if (at?.provider) await deleteProvider(at.provider); - else if (at?.profile) await run(["provider", "profile", "delete", at.profile]).catch(() => {}); - for (const path of at ? [at.policyFile, at.profileFile, at.uploadDir, at.downloadDir] : []) { + // Issue #704 part 3: every provider this attempt made (the job's and each bundle host's), then any profile imported + // for a provider whose create never answered. + for (const provider of at?.providers ?? []) await deleteProvider(provider); + for (const profile of (at?.profiles ?? []).filter((p) => !at.providers.includes(p))) await run(["provider", "profile", "delete", profile]).catch(() => {}); + for (const path of at ? [at.policyFile, ...at.profileFiles, at.uploadDir, at.downloadDir, ...(at.bundleEnvFile ? [at.bundleEnvFile] : [])] : []) { try { fs.rmSync(path, { recursive: true, force: true }); } catch {} @@ -987,6 +1300,8 @@ export function makeOpenshellBackend(opts = {}) { bin: "openshell", // The sandbox's own name; PI_JOB_ID in the env stays the container name, which the exit line and its MAC carry. buildArgs: (built) => openshellExecArgs({ name: sandboxNameOf(built.name), env: built.env, gateway, mode }), + // Issue #704 part 3: a bundle's plain variables reach the runner by file (`BUNDLE_ENV_FILE`), never the exec. + bundleEnvFile: true, memoryLimitOf: (_args, built) => (built.size?.memMiB ?? DEFAULT_JOB_SIZE.memMiB) * 1024 * 1024, // Copy mode: no recovery staging, since the session file is in the sandbox until the download, so a tail on the host // reads nothing. The live transcript comes over the exec's stderr instead, and steering over its stdin (issue #686). @@ -1087,6 +1402,29 @@ export function makeOpenshellBackend(opts = {}) { return { observations: { openshellGatewayLocal: true } }; }; + // Issue #704 part 3: a project secret bundle judged against this venue before the sandbox exists and before spend + // (`judgeBundle`), from the same sources the policy is generated from. The processor asks it twice, inside its bundle + // read: with the hosts alone before the resolver runs (a file that names a host this job cannot bind costs no read of + // the operator's manager), then with the variables. `{ ok, placeholders?, plain? }`, `{ venueRefused }`, or + // `{ unavailable }` when a source cannot be read (retried, as the observation preflight's own read is). + const bundleGate = async (job, { endpoints = {}, vars = null, shadowed = [], credentialHosts = [], capabilities = [] } = {}) => { + let network; + try { + network = networkFor(job, readEndpoints(), []); + } catch { + log("openshell_unavailable", { reason: "egress-sources-unread" }); + return { unavailable: true }; + } + if (network.refused) return { venueRefused: { reason: "openshell-egress-untranslatable", comment: OPENSHELL_REFUSALS["openshell-egress-untranslatable"] } }; + const judged = judgeBundle({ endpoints, vars, shadowed, network, credentialHosts: credentialHostsOf({ forgeHosts, extra: credentialHosts }), capabilities }); + if (judged.refused) { + // Names and the host only: operator config and the manager's names, never a value. + log("refused_openshell_bundle", { reason: judged.refused, name: judged.name ?? null, host: judged.host ?? null }); + return { venueRefused: { reason: judged.refused, comment: OPENSHELL_REFUSALS[judged.refused] } }; + } + return judged; + }; + // Issue #686 part 3: who a bind-mode job runs as, after the image probe (its `anyUid`). Copy mode carries none, so the // registry's default (the image's own user) stands, exactly as before. // Review round 1 (#714), M2: the engine's `docker info`, read through this bundle's exec, cached like `local`'s (an @@ -1177,8 +1515,11 @@ export function makeOpenshellBackend(opts = {}) { // Issue #684 (review M3): the gone sandbox's provider goes too, so a teardown that could not delete it does // not leave the job's credentials in the gateway until the next boot's reap. Once, not awaited: the // budget's question is about the sandbox, and the boot reap is the backstop. + // Issue #704 part 3: and its bundle hosts' providers, found by the owner label and named `-b`. run(["provider", "delete", sandbox]) .then(() => run(["provider", "profile", "delete", sandbox])) + .catch(() => {}) + .then(() => deleteBundleProvidersOf(sandbox)) .catch(() => {}); return true; } @@ -1193,5 +1534,6 @@ export function makeOpenshellBackend(opts = {}) { } }, observationPreflight, + bundleGate, }; } diff --git a/worker/src/backend-registry.mjs b/worker/src/backend-registry.mjs index f90a33fc..a13539f0 100644 --- a/worker/src/backend-registry.mjs +++ b/worker/src/backend-registry.mjs @@ -66,6 +66,11 @@ export const BACKEND_NOT_REGISTERED = "BACKEND_NOT_REGISTERED"; export const OPTIONAL_PREFLIGHT_DEFAULTS = Object.freeze({ observationPreflight: () => ({ ok: true }), jobUserPreflight: () => ({ user: null, home: null }), + // Issue #704 part 3: a project secret bundle judged against the venue (`(job, { endpoints, vars, shadowed })`). A + // venue that carries none admits every bundle, which is what a venue whose container holds its env as values does: + // the variables join the env, and `endpoints` means nothing there. Only a venue whose own layer holds credentials + // (both OpenShell venues, which carry one) has a narrower answer. + bundleGate: () => ({ ok: true }), }); /** The freezer verbs a bundle MAY carry (issue #645, the `pausable` row), always as a pair. */ @@ -247,11 +252,12 @@ export function makeBackendRegistry({ bundles = [], defaultName, blessed = null, }, imagePreflight: (job) => backendFor(job).imagePreflight(job), egressPreflight: (job) => backendFor(job).egressPreflight(job), - // The two OPTIONAL per-venue preflights (issue #354), dispatched through the same resolution as every - // required one, so an unregistered name still throws BACKEND_NOT_REGISTERED rather than being waved + // The OPTIONAL per-venue preflights (issue #354; `bundleGate`, #704 part 3), dispatched through the same + // resolution as every required one, so an unregistered name still throws BACKEND_NOT_REGISTERED rather than being waved // through by a default. A venue that carries none gets the answer the processor's own defaults give. observationPreflight: (job) => optionalPreflight(backendFor(job), "observationPreflight", job), jobUserPreflight: (job, opts) => optionalPreflight(backendFor(job), "jobUserPreflight", job, opts), + bundleGate: (job, opts) => optionalPreflight(backendFor(job), "bundleGate", job, opts), stopContainer: (name, job) => backendFor(job).stopContainer(name, job), // Issue #645: whether this job's venue can freeze it, and the verbs themselves, dispatched like every per-job // function. A venue without verbs answers `{ ok: false, reason: "not-pausable" }`, never a throw: the caller is a diff --git a/worker/src/backends.mjs b/worker/src/backends.mjs index 1a43849b..5d653a21 100644 --- a/worker/src/backends.mjs +++ b/worker/src/backends.mjs @@ -941,19 +941,6 @@ export const OPENSHELL_BIND_LINGERING = Object.freeze({ comment: "Stopped: this job ran in an OpenShell sandbox with its files bound from this machine, and the sandbox could not be confirmed gone after the run, so something in it may still be changing those files. The run's result is not used and it is not run again; what it already changed stays as it is. The operator must check the sandbox on the gateway.", }); -/** - * Issue #704 part 2: a job that would carry its project's secret bundle on a venue whose own layer holds the credentials - * (`credentialCustody: "layer"`, both OpenShell venues). There every secret becomes a key of one per-job provider bound - * to every endpoint of the policy, and with no network rule it reaches nothing, while the record would say the job - * received the bundle. The per-endpoint bundle keys that venue needs are #704 part 3; until then such a job is refused - * before spend rather than run with an environment it may not hold. Shared by the processor's gate and the adapter's - * `OPENSHELL_REFUSALS`, `OPENSHELL_BIND_LINGERING`'s pattern, so the two say one sentence. - */ -export const OPENSHELL_BUNDLE_UNSUPPORTED = Object.freeze({ - reason: "openshell-bundle-unsupported", - comment: "Refused: this job's project has a secret bundle, and the OpenShell venue this job runs on cannot hold a bundle's variables yet. Run the project's jobs on another venue, or set \"secretBundle\": false on this trigger. Not run.", -}); - /** Both OpenShell venues, the copy mode first: what "is any OpenShell venue blessed" asks of a list. */ export const OPENSHELL_BACKENDS = Object.freeze([OPENSHELL_BACKEND, OPENSHELL_BIND_BACKEND]); diff --git a/worker/src/env-allowlist.mjs b/worker/src/env-allowlist.mjs index d0832137..56ba3959 100644 --- a/worker/src/env-allowlist.mjs +++ b/worker/src/env-allowlist.mjs @@ -374,7 +374,7 @@ function layoutEnv(layout) { * `allowGlobalExtensions` defaults to TRUE here, matching loadConfig's default (REQ-GLOBAL-PI-OVERLAY): a * caller that says nothing gets the operator's staged setup, and only an explicit `false` withholds it. */ -export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], mcpServers = [], awaitInput = false, reportProgress = false, recover = false, recovering = false, steer = false, layout = "mounts", allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false, transcriptStderr = false, steerStdin = false }) { +export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], mcpServers = [], awaitInput = false, reportProgress = false, recover = false, recovering = false, steer = false, layout = "mounts", allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false, transcriptStderr = false, steerStdin = false, bundleEnvFile = false }) { // The provider credential(s), by pi's expected variable name(s) -- from the worker env, or (when // PI_AUTH_FROM_PI is set and the env has none) host-side from pi's auth.json. Throws (config) if // neither source yields one, which the processor turns into a policy refusal that refunds any reserve @@ -584,6 +584,13 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos if (transcriptStderr === true && exitAuth === true) env.PI_TRANSCRIPT = "stderr"; else delete env.PI_TRANSCRIPT; + // Issue #704 part 3: the runner reads a project secret bundle's plain variables from the job directory's + // bundle-env.json, for an attempt run-container armed (a venue that hands them by file, `openshell`, and a bundle + // with plain variables). Settled after both loops like the two above, so no forwarded or secret value can set it. + // env-internal PI_BUNDLE_ENV: written into the job's closed env map here, never read from the worker's environment. + if (bundleEnvFile === true) env.PI_BUNDLE_ENV = "1"; + else delete env.PI_BUNDLE_ENV; + // Issue #500: the runner sets these in its own environment, for its child processes, and no value from outside // may arrive first. Both lists refuse them upstream (config.mjs for PI_FORWARD_ENV, the triggers loader for // run.secrets, through RUNNER_ENV_NAMES); this is the line that holds if either refusal is ever bypassed. diff --git a/worker/src/image-preflight.mjs b/worker/src/image-preflight.mjs index eeb5424a..fa05cdd1 100644 --- a/worker/src/image-preflight.mjs +++ b/worker/src/image-preflight.mjs @@ -305,6 +305,17 @@ export const TRANSCRIPT_STDERR_CAPABILITY = "transcriptStderr"; */ export const STEER_STDIN_CAPABILITY = "steerStdin"; +/** + * The capability an image declares when its runner reads PI_BUNDLE_ENV=1: a project secret bundle's plain variables + * from `bundle-env.json` in the job directory into its own environment (issue #704 part 3, image/runner/src/ + * bundle-env.mjs). A gate on the OpenShell venues only, judged by their bundle gate rather than a CAPABILITY_GATES row + * (the need is known only once the bundle is read, which is after those rows run): a job there that would carry plain + * bundle variables on an image without it is refused before spend (`openshell-bundle-image-too-old`), since an older + * runner would ignore the file and the job would run without them. Every other venue hands the values as env and never + * reads it. + */ +export const BUNDLE_ENV_FILE_CAPABILITY = "bundleEnvFile"; + /** * Exported so the test cannot drift from the format string above. `|` because an image id is * `sha256:` and a version is a version, so neither can contain one -- and because a literal control diff --git a/worker/src/processor.mjs b/worker/src/processor.mjs index 1563e211..cfd023ee 100644 --- a/worker/src/processor.mjs +++ b/worker/src/processor.mjs @@ -1,4 +1,4 @@ -import { ABSENT, OPENSHELL_BIND_LINGERING, OPENSHELL_BUNDLE_UNSUPPORTED, backendFor, DAEMON_APPLIES_BOUNDS, DEFAULT_BACKEND, DOCKER_ENDPOINT_LOCAL, DOCKER_NEVER_STARTED_EXITS, PODMAN_ADDS_NO_MOUNTS, PODMAN_BACKEND, PODMAN_BOUNDS_DELEGATED, PODMAN_CONF_WIDENS_JOB, OPENSHELL_GATEWAY_LOCAL, PODMAN_SERVICE_LOCAL, RUNTIME_ADDS_NO_MOUNTS } from "./backends.mjs"; +import { ABSENT, OPENSHELL_BIND_LINGERING, backendFor, DAEMON_APPLIES_BOUNDS, DEFAULT_BACKEND, DOCKER_ENDPOINT_LOCAL, DOCKER_NEVER_STARTED_EXITS, PODMAN_ADDS_NO_MOUNTS, PODMAN_BACKEND, PODMAN_BOUNDS_DELEGATED, PODMAN_CONF_WIDENS_JOB, OPENSHELL_GATEWAY_LOCAL, PODMAN_SERVICE_LOCAL, RUNTIME_ADDS_NO_MOUNTS } from "./backends.mjs"; import { resolveBackendName } from "./backend-registry.mjs"; import { lstatSync } from "node:fs"; import { checkTokenCap, recordTokenSpend, releaseLedgers, reserveLedgers } from "./budget.mjs"; @@ -24,6 +24,7 @@ export const LOCAL_FOLDER_PROJECT_CHANGED = "local-folder-project-changed"; export const PORTFOLIO_NO_ENVELOPE = "portfolio-no-envelope"; import { DOLLAR_CAP_REASON, DOLLAR_KEY_PREFIX, dollarLedgers, dollarSettlement, dollarsRecord, holdPart, meteredMicros, modelDollarSettlement, releaseDollars, reserveDollars, settleDollars } from "./dollar-budget.mjs"; import { callableModelRefs, zeroRatedFor } from "./reserve-estimate.mjs"; +import { outputCapView } from "./output-cap.mjs"; import { answerTrailComment, consoleAnswerBy } from "./console-answer.mjs"; import { forgeDeliveryJobId } from "./job-id.mjs"; @@ -560,6 +561,11 @@ async function runAttempt(job, deps, durable, bundleFacts = { bundle: null, bund // on a multi-host worker's shared queue (index.mjs, the never-fits gate's predicate), where another host may serve // it. False by default, so a single host refuses, as it always did. bundleProfileDeferrable = false, + // Issue #704 part 3: the job's venue's judgement of its project secret bundle (the registry's `bundleGate`, + // backend-openshell.mjs's for both OpenShell venues), `(job, { endpoints, vars, shadowed })` => `{ ok, placeholders?, + // plain? }`, `{ venueRefused: { reason, comment } }` or `{ unavailable }`. The default admits, which is every venue + // that holds its env as values: there a bundle's variables simply join the env. + bundleGate = async () => ({ ok: true }), // (job) => { ok, document } | { refused, name, why } | { unavailable: code } (REQ-MCP-OPERATOR-SERVERS, // mcp-preflight.mjs). The default passes a job that names no server and refuses one that does, so a wiring that // forgot the check can never run a job without the servers its trigger names. @@ -1452,10 +1458,10 @@ async function runAttempt(job, deps, durable, bundleFacts = { bundle: null, bund // Every refusal RETURNS (CONST-RETRY-INFRA-ONLY): an undeclared or doubly declared profile, a resumed job // (`run.resume` would replay a transcript that may hold an echoed value into every later job on its key, the // rule that refuses `run.secrets` beside it at load, which cannot be applied at load here because the file - // cannot know a job's project), a trigger the file no longer holds, and a bundle on a venue that cannot hold one - // yet (`openshell-bundle-unsupported`). A file that has never been read and validated in this process RETRIES a - // job with a project (the finder answers from its last good read otherwise), and a profile this host lacks is - // DEFERRED on a multi-host worker's shared queue, the never-fits gate's rule, since another host may serve it. + // cannot know a job's project), and a trigger the file no longer holds. A file that has never been read and + // validated in this process RETRIES a job with a project (the finder answers from its last good read otherwise), + // and a profile this host lacks is DEFERRED on a multi-host worker's shared queue, the never-fits gate's rule, + // since another host may serve it. A venue's refusal of the bundle itself comes with the read (`readBundle`). const bundleChained = job.parentJobId !== undefined || job.chainDepth !== undefined; const bundleManual = job.trigger === undefined || job.trigger === null; let bundleBinding = null; @@ -1501,14 +1507,10 @@ async function runAttempt(job, deps, durable, bundleFacts = { bundle: null, bund log(reason === "secret-bundle-profile-ambiguous" ? "refused_secret_bundle_profile_ambiguous" : "refused_secret_bundle_profile_unknown", { kind: job.kind ?? null, bundle: bundleFound.id, profile: bundleFound.bundle.profile }); return { outcome: "policy", reason, exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false }; // return => not retried } - // Issue #704 review: a venue whose own layer holds the credentials (both OpenShell venues) would take the - // bundle down the run.secrets provider path, bound to every endpoint, or nowhere at all without a network - // rule, while the record said the job received it. Refused before spend until #704 part 3 gives it bundle keys. - if (backendFor(resolveBackendName(job, blessedBackends[0]))?.credentialCustody === "layer") { - await comment(job, OPENSHELL_BUNDLE_UNSUPPORTED.comment); - log("refused_openshell_bundle_unsupported", { kind: job.kind ?? null, bundle: bundleFound.id }); - return { outcome: "policy", reason: OPENSHELL_BUNDLE_UNSUPPORTED.reason, exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false }; // return => not retried - } + // Issue #704 part 3: no venue refusal here any more. Part 2 refused a bundled job on both OpenShell venues at + // this point, which was AHEAD of the fork check, so a fork's job that would have run without the bundle was + // refused for it. The venue's own judgement of the bundle (`bundleGate`) now runs inside the read below, + // which a fork's job never reaches. bundleBinding = { id: bundleFound.id, ...bundleFound.bundle }; } } @@ -1619,7 +1621,46 @@ async function runAttempt(job, deps, durable, bundleFacts = { bundle: null, bund } // `let`: a project bundle's variables join it after the mint (below), under the trigger's own values. let secrets = resolved.secrets ?? {}; - + // Issue #704 part 3: the bundle's `{ endpoints, plain }` split, for the venue (null: no bundle reached this job). + let bundleKeys = null; + + // Issue #704 part 3: the venue's answer, the same handling for both of its asks below. A refusal RETURNS with the + // venue's fixed sentence (its log names the variable and host, never the comment); a source it could not read + // RETRIES, as the observation preflight's does. + // The hosts this job's OWN credentials are sent to beyond the forge's (issue #704 part 3, review round 1, H2), which a + // bundle endpoint may not claim on a venue that binds one provider per host: the composed `baseUrl` of every model + // the job may call (the overlay's models.json over pi's catalog, `outputCapView`, the composition the output cap and + // the zero-rated check read), every declared model endpoint's host (keyed or not: the simple rule), and every + // HTTP MCP server's host (its headers may carry a run.secrets value). A model whose baseUrl cannot be composed adds + // nothing, and is said so in DES-OPENSHELL-VENUE. + const credentialHosts = () => { + const hosts = new Set(); + const add = (url) => { + try { + if (typeof url === "string" && url !== "") hosts.add(new URL(url).hostname.toLowerCase()); + } catch {} + }; + for (const ref of callableModelRefs(job)) { + if (ref === null) continue; + try { + add(outputCapView({ models: modelEndpoints?.models ?? null, provider: ref.provider, modelId: ref.id, builtinModel })?.baseUrl); + } catch {} + } + for (const ep of modelEndpoints?.endpoints ?? []) if (typeof ep?.host === "string") hosts.add(ep.host.toLowerCase()); + for (const server of Object.values(mcpChecked?.document?.servers ?? {})) add(server?.url); + return [...hosts].sort(); + }; + const askBundleGate = async (ask) => { + const judged = await bundleGate(job, { ...ask, credentialHosts: credentialHosts(), capabilities: img.capabilities ?? [] }); + if (judged?.venueRefused && typeof judged.venueRefused.reason === "string") { + const { reason, comment: text } = judged.venueRefused; + await comment(job, typeof text === "string" && text !== "" ? text : "Refused: this job's venue cannot hold its project's secret bundle as declared. Not run."); + log("refused_venue", { reason, bundle: bundleBinding.id }); + return { refused: { outcome: "policy", reason, exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false } }; // return => not retried + } + if (judged?.unavailable) throw new InfraRetry("the job's venue could not judge its secret bundle", { reason: "container-never-started", provider: job.provider ?? null, model: job.model ?? null }); + return { judged }; + }; // PROJECT SECRET BUNDLES, the read (issue #704). ONE resolver call, then the strict parse, the reserved names and // the merge. BEFORE the mint for every job that needs no fork check (CONST-BUDGET-BEFORE-TOKENS: its determinate // refusals spend nothing, not even a token), and right after the mint for a pull request whose head must be asked @@ -1627,6 +1668,15 @@ async function runAttempt(job, deps, durable, bundleFacts = { bundle: null, bund // Returns null, or the result to return. const readBundle = async () => { if (bundleBinding === null) return null; + // Issue #704 part 3: the hosts first, before the resolver runs, so a file naming a host this job's venue cannot + // bind costs no read of the operator's manager. Here rather than among the free gates above (where part 2 + // refused every OpenShell bundle), because only here is the fork check behind it: a fork's job runs without + // the bundle, so nothing about the bundle may refuse it. + const endpoints = bundleBinding.endpoints ?? {}; + if (Object.keys(endpoints).length > 0) { + const pre = await askBundleGate({ endpoints, vars: null }); + if (pre.refused) return pre.refused; + } const bundleRead = await secretBundles.resolve(job, bundleBinding); if (bundleRead?.ok !== true) { if (bundleRead?.unreachable) { @@ -1661,8 +1711,28 @@ async function runAttempt(job, deps, durable, bundleFacts = { bundle: null, bund // value as a secret treats these alike: buildContainerEnv injects them, the redaction covers every // non-NON_SECRET_ENV_KEYS name (run-container.mjs), and a checkpoint refuses to store any of them. const merged = mergeBundleSecrets(bundleRead.vars, secrets); + // Issue #704 part 3: which received variables the bundle binds to a host (`endpoints`) and which go in plain; a + // variable a run.secrets value shadowed is in neither unless `endpoints` names it (below). Said to every + // venue (`bundleKeys` on the call bag); only a venue whose layer holds credentials reads it, and it judges the + // split first, now with the values (`bundleGate`): a value its gateway would refuse is a refusal here, before + // spend, never a create that fails as infrastructure and is retried into the same answer. + const received = Object.fromEntries(Object.entries(bundleRead.vars).filter(([name]) => !merged.shadowed.includes(name))); + const post = await askBundleGate({ endpoints, vars: received, shadowed: merged.shadowed }); + if (post.refused) return post.refused; + // Review round 1, M2: a declared endpoint name a run.secrets value shadowed stays on that host's provider, now with + // the trigger's value (the one that wins), so it resolves at the host the reviewed file binds the name to. + bundleKeys = { + endpoints: Object.fromEntries([...Object.keys(received), ...merged.shadowed].filter((name) => Object.hasOwn(endpoints, name)).map((name) => [name, endpoints[name]])), + plain: Object.keys(received).filter((name) => !Object.hasOwn(endpoints, name)).sort(), + }; secrets = merged.secrets; bundleFacts.bundle = { id: bundleBinding.id, variables: Object.keys(bundleRead.vars).sort(), shadowed: merged.shadowed }; + // On a venue whose own layer holds the credentials, the record says how many reached the agent as placeholders + // and how many as values (INT-RUN-HISTORY-FILE-CONTRACT), the venue's own counts. + if (backendFor(resolveBackendName(job, blessedBackends[0]))?.credentialCustody === "layer" && Number.isSafeInteger(post.judged?.placeholders) && Number.isSafeInteger(post.judged?.plain)) { + bundleFacts.bundle.placeholders = post.judged.placeholders; + bundleFacts.bundle.plain = post.judged.plain; + } log("secret_bundle_applied", { kind: job.kind ?? null, bundle: bundleBinding.id, count: bundleFacts.bundle.variables.length, shadowed: merged.shadowed.length }); return null; }; @@ -2123,7 +2193,7 @@ async function runAttempt(job, deps, durable, bundleFacts = { bundle: null, bund const secretValues = [token, ...Object.values(secrets ?? {})].filter((v) => typeof v === "string" && v !== ""); checkpointArgs = { runId, attempt: durable.seq, baseSha, branch: null, secretValues, meta: { piVersion, imageDigest: img.imageDigest ?? null, venue: resolveBackendName(job, blessedBackends[0]), provider: job.provider ?? null, model: job.model ?? null } }; } - const { code, aborted, abortReason, turns, tokens, session, usage, context, detached, exitReason, exitWhy = null, exitLineCode = null, exitAuth: exitAuthResult = null, exitOomKilled = false, memoryLimit = null, resources: ranResources = null, progress: ranProgress = null, checkpoint: ranCheckpoint = null, recovery: ranRecovery = null, lingering = false } = await runContainer({ job: containerJob, token, prepared, secrets, user: jobUser?.user ?? null, home: jobUser?.home ?? null, relabel: jobUser?.relabel === true, ...(modelEndpoints?.endpoints?.length > 0 ? { modelEndpoints } : {}), ...(exitAuth ? { exitAuth: true } : {}), ...(transcriptStderr ? { transcriptStderr: true } : {}), ...(steerStdin ? { steerStdin: true } : {}), ...(jobSize ? { size: jobSize } : {}), ...(Number.isSafeInteger(jobUser?.hostCpus) ? { hostCpus: jobUser.hostCpus } : {}), ...(Number.isSafeInteger(cpuBudgetCenti) ? { cpuBudgetCenti } : {}), ...(Array.isArray(jobUser?.unenforced) && jobUser.unenforced.length > 0 ? { unenforced: jobUser.unenforced } : {}), ...(checkpointArgs !== null ? { recover: true, recovering: isRecovery, checkpoint: checkpointArgs } : {}) }); + const { code, aborted, abortReason, turns, tokens, session, usage, context, detached, exitReason, exitWhy = null, exitLineCode = null, exitAuth: exitAuthResult = null, exitOomKilled = false, memoryLimit = null, resources: ranResources = null, progress: ranProgress = null, checkpoint: ranCheckpoint = null, recovery: ranRecovery = null, lingering = false } = await runContainer({ job: containerJob, token, prepared, secrets, ...(bundleKeys !== null ? { bundleKeys } : {}), user: jobUser?.user ?? null, home: jobUser?.home ?? null, relabel: jobUser?.relabel === true, ...(modelEndpoints?.endpoints?.length > 0 ? { modelEndpoints } : {}), ...(exitAuth ? { exitAuth: true } : {}), ...(transcriptStderr ? { transcriptStderr: true } : {}), ...(steerStdin ? { steerStdin: true } : {}), ...(jobSize ? { size: jobSize } : {}), ...(Number.isSafeInteger(jobUser?.hostCpus) ? { hostCpus: jobUser.hostCpus } : {}), ...(Number.isSafeInteger(cpuBudgetCenti) ? { cpuBudgetCenti } : {}), ...(Array.isArray(jobUser?.unenforced) && jobUser.unenforced.length > 0 ? { unenforced: jobUser.unenforced } : {}), ...(checkpointArgs !== null ? { recover: true, recovering: isRecovery, checkpoint: checkpointArgs } : {}) }); containerRan = true; // Issue #596: what the container used, off its exit line, rebuilt by the sink (null from a runContainer that predates // the field). Every result and every throw below carries it, so a retried attempt's record says what it used too. diff --git a/worker/src/redact-secrets.mjs b/worker/src/redact-secrets.mjs index bc63305a..2307b2e8 100644 --- a/worker/src/redact-secrets.mjs +++ b/worker/src/redact-secrets.mjs @@ -159,6 +159,8 @@ export const NON_SECRET_ENV_KEYS = new Set([ "PI_EXIT_AUTH", // Issue #686: names the transcript channel (`stderr`), never a secret; on openshell it must ride the exec's --env. "PI_TRANSCRIPT", + // Issue #704 part 3: `1`, the bundle env file's flag, never a secret; on openshell it must ride the exec's --env. + "PI_BUNDLE_ENV", // Issue #683: the runner's layout name, `sandbox` on the openshell venue. "PI_JOB_LAYOUT", "HOME", diff --git a/worker/src/reserved-env.mjs b/worker/src/reserved-env.mjs index ea8cb1bd..8d9faee6 100644 --- a/worker/src/reserved-env.mjs +++ b/worker/src/reserved-env.mjs @@ -68,6 +68,9 @@ export const CONTAINER_ENV_NAMES = new Set([ // Issue #686: tells the runner to mirror its session file over stderr. A trigger setting it would send records no // worker reads into the job log, or, with the image's key, stream a transcript the operator did not arm. "PI_TRANSCRIPT", + // Issue #704 part 3: tells the runner to read a project secret bundle's plain variables from the job directory's + // bundle-env.json. A trigger setting it would make a runner refuse on a file nobody wrote, or read one it should not. + "PI_BUNDLE_ENV", "PI_OFFLINE", "PLAYWRIGHT_BROWSERS_PATH", "PLAYWRIGHT_MCP_BROWSER", diff --git a/worker/src/run-container.mjs b/worker/src/run-container.mjs index 14d64626..36622b97 100644 --- a/worker/src/run-container.mjs +++ b/worker/src/run-container.mjs @@ -132,6 +132,11 @@ export function makeRunContainer({ // `lingering: true` on the result, which the processor reads (no exit checkpoint; the named result where a retry // would be wrong). Absent, every venue's run is exactly what it was. settleRun = null, + // Issue #704 part 3: this venue hands a project secret bundle's plain variables to the runner by FILE (the job + // directory's bundle-env.json, which its `stageRun` writes), so an attempt with plain bundle variables gets + // PI_BUNDLE_ENV=1. A construction option, the venue's own statement like `transcript`: every binding venue passes + // them as env and leaves it off, so its env is byte-identical. + bundleEnvFile = false, }) { // Refused at construction rather than written into a job's env: a guessed layout reads nothing. const paths = layoutFor(layout); @@ -170,7 +175,10 @@ export function makeRunContainer({ // `steerStdin` (issue #686, part 2) is the processor's too: true when the job image declares `steerStdin`, so its // runner reads steer messages as MAC'd frames on stdin. Read only for a steer job on a venue that copies (`binds` not // true); a binding venue keeps the read-only mount whatever the image declares. - return async function runContainer({ job, token, prepared, secrets = {}, name, signal, user = null, home = null, relabel = false, modelEndpoints = null, exitAuth = false, transcriptStderr = false, steerStdin = false, size = DEFAULT_JOB_SIZE, hostCpus = null, cpuBudgetCenti = null, unenforced = [], jobId = null, onLive = null, recover = false, recovering = false, checkpoint = null, pause = null, unpause = null, binds = false }) { + // `bundleKeys` (issue #704 part 3) is the processor's `{ endpoints, plain }` split of a project secret bundle's received + // variables: handed on in the build options for a venue whose own layer holds credentials (openshell), which binds the + // first kind to their hosts and passes the second as values. Every other venue ignores it: the values are in `secrets`. + return async function runContainer({ job, token, prepared, secrets = {}, bundleKeys = null, name, signal, user = null, home = null, relabel = false, modelEndpoints = null, exitAuth = false, transcriptStderr = false, steerStdin = false, size = DEFAULT_JOB_SIZE, hostCpus = null, cpuBudgetCenti = null, unenforced = [], jobId = null, onLive = null, recover = false, recovering = false, checkpoint = null, pause = null, unpause = null, binds = false }) { if (signal?.aborted) return { code: 137, aborted: true, turns: null, tokens: null, session: null, usage: null, context: null, exitReason: null }; // killed before it could start // Minted per attempt, held in this closure and the sink's, and handed to the container on its stdin only: never in // the argv (a host `ps` shows it), never in the env (the container's /proc/1/environ shows it), never logged. @@ -380,6 +388,7 @@ export function makeRunContainer({ transcriptStderr: stdioArmed, // Issue #686: PI_EXIT_AUTH=stdin-frames, only for an attempt armed above for stdin steering (beside PI_STEER=1). steerStdin: stdinSteered, + bundleEnvFile: bundleEnvFile === true && Array.isArray(bundleKeys?.plain) && bundleKeys.plain.length > 0, })); // `-net` on this container's own name (egress.mjs). null when no policy is armed, and docker-run's @@ -429,6 +438,8 @@ export function makeRunContainer({ // Issue #545: `-i`, so the container's stdin is the pipe the key is written to. Absent otherwise, and the argv is // byte-identical to one built before the signed exit line. ...(exitKey !== null ? { extraFlags: ["-i"] } : {}), + // Issue #704 part 3: absent without a bundle, so every other build is byte-identical. + ...(bundleKeys !== null && typeof bundleKeys === "object" ? { bundleKeys } : {}), }; const args = beforeSpawn(() => buildArgs(buildOpts)); diff --git a/worker/src/run-history.mjs b/worker/src/run-history.mjs index f1bccd9d..11243c7a 100644 --- a/worker/src/run-history.mjs +++ b/worker/src/run-history.mjs @@ -492,8 +492,9 @@ const BUNDLE_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; const BUNDLE_ID = /^[A-Za-z0-9._-]{1,128}$/; /** - * The record's `bundle` (issue #704): `{ id, variables, shadowed }`, the bundle's id and the variable NAMES the job - * received from it and the ones the trigger's own run.secrets shadowed, sorted, or null on any violation. REBUILT, + * The record's `bundle` (issue #704): `{ id, variables, shadowed, placeholders?, plain? }`, the bundle's id and the + * variable NAMES the job received from it and the ones the trigger's own run.secrets shadowed, sorted, and on an + * OpenShell venue two counts (below), or null on any violation. REBUILT, * never the source's object, and NAMES ONLY: each is held to the environment-name charset, so no value, no * reference and no free text can reach a record by this key. The names are the operator's own, from their own * manager, the class the record already holds in `project` and the run.secrets names doctor prints; a record says @@ -506,7 +507,13 @@ export function recordedBundle(b) { const variables = names(b.variables); const shadowed = names(b.shadowed ?? []); if (variables === null || shadowed === null) return null; - return { id: b.id, variables, shadowed }; + // Issue #704 part 3: on a venue whose own layer holds the credentials (both OpenShell venues), how many of the + // received variables the agent saw as placeholders and how many as values. A PAIR of integers, each 0 to the + // variable cap, or neither key: a record never says one without the other, and every other venue's record is + // byte-identical to before. + const count = (n) => Number.isSafeInteger(n) && n >= 0 && n <= BUNDLE_NAMES_MAX; + const counted = count(b.placeholders) && count(b.plain) && b.placeholders + b.plain <= variables.length; + return { id: b.id, variables, shadowed, ...(counted ? { placeholders: b.placeholders, plain: b.plain } : {}) }; } /** @@ -1047,7 +1054,8 @@ export function buildRecord({ job, result, error, startedAt, endedAt, host = nul ...(steerSkipped !== null ? { steerSkipped } : {}), // Project secret bundles (issue #704, INT-RUN-HISTORY-FILE-CONTRACT), TAIL position after the steering keys, // CONDITIONAL like them, so a record of a job whose project has no bundle is byte-identical to before: - // - `bundle`: `{ id, variables, shadowed }`, NAMES only, REBUILT (`recordedBundle`); + // - `bundle`: `{ id, variables, shadowed }`, NAMES only, REBUILT (`recordedBundle`), and on an OpenShell venue + // `placeholders` and `plain`, two counts (issue #704 part 3); // - `bundleSkipped`: why the job ran without its project's bundle (BUNDLE_SKIPPED_REASONS), a fixed enum. ...(bundle !== null ? { bundle } : {}), ...(bundleSkipped !== null ? { bundleSkipped } : {}), diff --git a/worker/src/start.mjs b/worker/src/start.mjs index 5e0e42b0..a54b09ae 100644 --- a/worker/src/start.mjs +++ b/worker/src/start.mjs @@ -2748,6 +2748,8 @@ export async function startWorker( // `local`'s are the closures built beside its bundle below, unchanged. observationPreflight: backends.observationPreflight, jobUserPreflight: backends.jobUserPreflight, + // Issue #704 part 3: the venue's judgement of a project secret bundle, dispatched like the two above. + bundleGate: backends.bundleGate, // Completed-only, so a policy or infra exit leaves the canonical transcript byte-identical and a // retry starts from what the first attempt did (CONST-RETRY-INFRA-ONLY). promoteSession: sessionStore.promoteSession, diff --git a/worker/test/backend-openshell-bind.test.mjs b/worker/test/backend-openshell-bind.test.mjs index c6c0e2e7..9e3567cb 100644 --- a/worker/test/backend-openshell-bind.test.mjs +++ b/worker/test/backend-openshell-bind.test.mjs @@ -81,12 +81,14 @@ function fakeOpenshell({ probe = "admitted", createRefusal = null, imageMissing state.creates.push({ args: rest, config }); if (createRefusal) return reject(SENTENCES[createRefusal]); state.policy = JSON.parse(readFileSync(rest[rest.indexOf("--policy") + 1], "utf8")); + // Issue #704 part 3: what the read-back adds, one layer per attached provider (measured in #684 for one). + state.layers = Object.fromEntries(rest.flatMap((x, i) => (x === "--provider" ? [`_provider_${rest[i + 1].replace(/-/g, "_")}`] : [])).map((k) => [k, { name: k }])); const labels = Object.fromEntries(rest.flatMap((x, i) => (x === "--label" ? [rest[i + 1].split(/=(.*)/s).slice(0, 2)] : []))); const created = rest[rest.indexOf("--name") + 1]; state.live.set(created, { name: created, phase: "Ready", labels, lag: null }); return { stdout: JSON.stringify({ phase: "Ready" }), stderr: "" }; } - if (a === "policy" && b === "get") return { stdout: JSON.stringify({ policy_source: "sandbox", policy: state.policy }), stderr: "" }; + if (a === "policy" && b === "get") return { stdout: JSON.stringify({ policy_source: "sandbox", policy: state.layers && Object.keys(state.layers).length > 0 ? { ...state.policy, network_policies: { ...state.policy.network_policies, ...state.layers } } : state.policy }), stderr: "" }; if (a === "sandbox" && b === "delete") { state.leftover.delete(rest[2]); state.deleted.push(rest[2]); @@ -105,7 +107,10 @@ function fakeOpenshell({ probe = "admitted", createRefusal = null, imageMissing } return { stdout: JSON.stringify({ sandboxes: out, next_page_token: "" }), stderr: "" }; } - if (a === "provider") return { stdout: JSON.stringify({ providers: [] }), stderr: "" }; + if (a === "provider") { + if (b === "create") (state.made ??= []).push({ args: rest }); + return { stdout: JSON.stringify({ providers: [] }), stderr: "" }; + } return reject(`unexpected ${args.join(" ")}`); }; const spawnFn = (cmd, args) => { @@ -580,3 +585,28 @@ test("the bind-settings reader and the gateway.toml: the three sentences, the ad assert.match(gatewayTomlFor("podman"), /\[openshell\.drivers\.podman\]/); assert.equal(UPSTREAM_BIND_WARNING, "Bind mounts expose gateway-host files to the sandbox and can bypass workspace isolation and filesystem policy."); }); + +test("a bundle on the bind venue (#704 part 3): the same per-host provider as copy mode, and the plain variable in the bound job dir's file, never the exec", { skip }, async () => { + const fake = fakeOpenshell(); + const dirs = jobDirs(); + // What the bound /job holds when the exec starts (review round 1, H1). + const spawnFn = (cmd, args) => { + const file = join(dirs.jobDir, "bundle-env.json"); + fake.state.bundleEnv = existsSync(file) ? readFileSync(file, "utf8") : null; + return fake.spawnFn(cmd, args); + }; + const b = bundle({ ...fake, spawnFn }, { readAllowlist: () => ["api.anthropic.com", "api.stripe.com"] }); + const result = await b.runContainer({ job: JOB, prepared: { jobDir: dirs.jobDir, workspace: dirs.workspace, live: dirs.live }, name: "pi-job-j1", signal: new AbortController().signal, user: "1234:1235", home: "/home/pi", secrets: { STRIPE_KEY: "sk_bind_0005", DB_URL: "plain_bind_0006" }, bundleKeys: { endpoints: { STRIPE_KEY: { host: "api.stripe.com", auth: "bearer" } }, plain: ["DB_URL"] } }); + assert.equal(result.code, 0); + const sb = mod.sandboxNameOf("pi-job-j1"); + assert.deepEqual(fake.state.made.map((m) => m.args[m.args.indexOf("--name") + 1]), [sb, `${sb}-b0`]); + const [{ args: create }] = fake.state.creates; + assert.deepEqual(create.flatMap((a, i) => (a === "--provider" ? [create[i + 1]] : [])), [sb, `${sb}-b0`]); + for (const rule of Object.values(fake.state.policy.network_policies)) for (const e of rule.endpoints) assert.equal(e.credential_binding.provider, e.host === "api.stripe.com" ? `${sb}-b0` : sb); + const execCall = fake.state.calls.find((c) => c[2] === "exec"); + assert.equal(fake.state.bundleEnv, `${JSON.stringify({ DB_URL: "plain_bind_0006" })}\n`); + assert.ok(execCall.includes("PI_BUNDLE_ENV=1")); + assert.ok(!execCall.join(" ").includes("sk_bind_0005") && !execCall.join(" ").includes("plain_bind_0006")); + assert.equal(existsSync(join(dirs.jobDir, "bundle-env.json")), false, "removed at teardown"); + assert.equal(typeof b.bundleGate, "function", "the bind bundle judges bundles as the copy one does"); +}); diff --git a/worker/test/backend-openshell.test.mjs b/worker/test/backend-openshell.test.mjs index 001ca840..b07f26ea 100644 --- a/worker/test/backend-openshell.test.mjs +++ b/worker/test/backend-openshell.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { test } from "node:test"; import { BACKENDS, OPENSHELL_BACKEND } from "../src/backends.mjs"; @@ -40,26 +40,38 @@ function fakeOpenshell({ phase = "Ready", policyBack = null, sandboxes = [], fai if (a === "status") return { stdout: JSON.stringify(status), stderr: "" }; if (a === "policy" && rest.includes("--global")) return globalPolicy ? { stdout: JSON.stringify({ policy: {} }), stderr: "" } : reject("no global", "Error: × code: 'Some requested entity was not found', message: \"no global policy\n │ revision found\""); if (a === "provider" && b === "profile") { - if (rest[2] === "import") state.written.profile = JSON.parse(readFileSync(rest[rest.indexOf("-f") + 1], "utf8")); + if (rest[2] === "import") { + (state.profileFilesSeen ??= []).push(rest[rest.indexOf("-f") + 1]); + if (state.failImport) return reject("import failed"); + state.written.profile = JSON.parse(readFileSync(rest[rest.indexOf("-f") + 1], "utf8")); + (state.written.profiles ??= []).push(state.written.profile); + } + // Issue #704 part 3, review round 1 (L4): every page of the catalog as one array, the reaper's profile sweep. + if (rest[2] === "list") return { stdout: JSON.stringify(state.profiles ?? []), stderr: "" }; return { stdout: "", stderr: "" }; } if (a === "provider" && b === "create") { state.provider = { args: rest, env: opts.env ?? null }; + (state.made ??= []).push(state.provider); return { stdout: "", stderr: "" }; } if (a === "provider" && b === "list") return { stdout: JSON.stringify({ providers: state.providers ?? [], next_page_token: "" }), stderr: "" }; if (a === "provider" && b === "delete") return { stdout: "", stderr: "" }; if (a === "sandbox" && b === "create") { + // Final review round: whether every profile file this run wrote was already gone when the sandbox was created. + state.profileGoneAtCreate = (state.profileFilesSeen ?? []).every((f) => !existsSync(f)); if (failCreate) return reject("create failed", "ImagePullFailed"); const file = rest[rest.indexOf("--policy") + 1]; state.written.policy = JSON.parse(readFileSync(file, "utf8")); state.attached = rest.includes("--provider") ? rest[rest.indexOf("--provider") + 1] : null; + state.attachedAll = rest.flatMap((a, i) => (a === "--provider" ? [rest[i + 1]] : [])); return { stdout: JSON.stringify({ name: rest[rest.indexOf("--name") + 1], phase }), stderr: "" }; } if (a === "policy" && b === "get") { // What OpenShell reads back: the body, plus an attached provider's own layer (measured). const body = policyBack ?? JSON.parse(JSON.stringify(state.written.policy)); - if (!policyBack && state.attached) { const key = `_provider_${state.attached.replace(/-/g, "_")}`; body.network_policies = { ...body.network_policies, [key]: { name: key } }; } + // Issue #704 part 3: one layer per attached provider. + if (!policyBack) for (const p of state.attachedAll ?? []) { const key = `_provider_${p.replace(/-/g, "_")}`; body.network_policies = { ...body.network_policies, [key]: { name: key } }; } return { stdout: JSON.stringify({ policy_source: "sandbox", policy: body }), stderr: "" }; } if (a === "sandbox" && b === "upload") return { stdout: "", stderr: "" }; @@ -484,7 +496,10 @@ test("credentials (#684): every secret goes into a per-job provider by the CLI's const order = fake.state.calls.map((c) => c.slice(1, 4).join(" ")).filter((s) => /delete/.test(s)); assert.deepEqual(order, [`provider delete ${sb}`, `provider profile delete`, `sandbox delete ${sb}`, `provider delete ${sb}`, `provider profile delete`]); assert.ok(fake.state.calls.findIndex((c) => c[1] === "provider" && c[2] === "profile" && c[3] === "import") > fake.state.calls.findIndex((c) => c[1] === "provider" && c[2] === "profile" && c[3] === "delete"), "the stale removal comes before the import"); - for (const suffix of [".openshell-profile.json", ".openshell-policy.json"]) assert.equal(existsSync(`${dirs.jobDir}${suffix}`), false, suffix); + for (const suffix of [`.openshell-profile-${sb}.json`, ".openshell-policy.json"]) assert.equal(existsSync(`${dirs.jobDir}${suffix}`), false, suffix); + // Final review round: the profile file is gone once its import answered, before the sandbox even exists (the fake read + // it at the import), so a crash after that leaves nothing beside the job dir that BESIDE_JOB_DIR does not sweep. + assert.ok(fake.state.profileGoneAtCreate, "deleted right after the import, not only at teardown"); }); test("no rule, no provider: a deny-all job's secrets go nowhere, said in the log (#684)", { skip }, async () => { @@ -640,3 +655,228 @@ test("steering over the exec's stdin (#686 part 2): the relay opener handed thro assert.ok(!execOf(fake2.state).some((a) => a === "PI_STEER=1")); assert.equal(opened.length, 1); }); + +// Issue #704 part 3: a project secret bundle on this venue. The processor hands the venue its `bundleKeys` +// (`{ endpoints, plain }`), and its values ride `secrets` as every secret's do. +const BUNDLE_KEYS = { endpoints: { STRIPE_KEY: { host: "api.stripe.com", auth: "bearer" } }, plain: ["DB_URL"] }; +const BUNDLE_SECRETS = { STRIPE_KEY: "sk_bundle_value_0003", DB_URL: "postgres://plain_value_0004" }; +const runBundled = (fake, over = {}, call = {}) => { + const dirs = jobDirs(); + // What the job directory held when it was uploaded: the bundle env file, read at that moment (review round 1, H1). + const inner = fake.exec; + const exec = async (bin, args, opts) => { + const rest = args[0] === "--gateway" ? args.slice(2) : args; + if (rest[0] === "sandbox" && rest[1] === "upload" && rest[4] === "/sandbox") { + const file = join(rest[3], "bundle-env.json"); + if (existsSync(file)) fake.state.bundleEnv = { text: readFileSync(file, "utf8"), mode: statSync(file).mode & 0o777 }; + } + return inner(bin, args, opts); + }; + fake = { ...fake, exec }; + const b = bundle(fake, { readAllowlist: () => ["api.anthropic.com", "api.stripe.com", ".github.com"], hostEnv: { ANTHROPIC_API_KEY: "sk-ant-real-provider-key-0001" }, delay: async () => {}, ...over }); + return { dirs, run: b.runContainer({ job: { ...JOB, repo: "acme/widget" }, token: "ghs_real_forge_token_0002", prepared: { jobDir: dirs.jobDir, workspace: dirs.workspace, live: dirs.live }, name: "pi-job-j1", signal: new AbortController().signal, exitAuth: true, secrets: BUNDLE_SECRETS, bundleKeys: BUNDLE_KEYS, ...call }) }; +}; + +test("bundle (#704 part 3): an endpoint variable is its host's own provider key, bound to that host's endpoints only; a plain one goes by file, never the exec; the job's provider keeps the rest", { skip }, async () => { + const fake = fakeOpenshell(); + const { dirs, run } = runBundled(fake); + assert.equal((await run).code, 0); + const sb = mod.sandboxNameOf("pi-job-j1"); + const hostProvider = `${sb}-b0`; + // Two profiles: the job's own, unchanged in shape, without either bundle variable; the host's, with its auth said. + assert.deepEqual(fake.state.written.profiles.map((p) => [p.id, p.credentials.map((c) => c.env_vars[0]).sort()]), [ + [sb, ["ANTHROPIC_API_KEY", "GH_TOKEN", "GITHUB_TOKEN"]], + [hostProvider, ["STRIPE_KEY"]], + ]); + assert.deepEqual(fake.state.written.profiles[1].credentials[0], { name: "c0", env_vars: ["STRIPE_KEY"], required: true, auth_style: "bearer", header_name: "authorization" }); + assert.deepEqual(fake.state.made.map((p) => p.args[p.args.indexOf("--name") + 1]), [sb, hostProvider]); + assert.equal(fake.state.made[1].env.STRIPE_KEY, "sk_bundle_value_0003", "the placeholder's value travels in the CLI's env"); + assert.equal(fake.state.made[0].env.STRIPE_KEY, undefined, "and only to its host's provider"); + assert.ok(fake.state.made[1].args.includes("pi.dispatch.owner=pi-dispatch"), "labelled, so the reaper finds it"); + // The bindings: api.stripe.com to the host's provider, every other endpoint to the job's. + for (const rule of Object.values(fake.state.written.policy.network_policies)) { + for (const e of rule.endpoints) assert.deepEqual(e.credential_binding, { provider: e.host === "api.stripe.com" ? hostProvider : sb }, e.host); + } + const create = fake.state.calls.find((c) => c[1] === "sandbox" && c[2] === "create"); + assert.deepEqual(create.flatMap((a, i) => (a === "--provider" ? [create[i + 1]] : [])), [sb, hostProvider]); + // Review round 1, H1: the plain variable is in the job directory's bundle-env.json (0600) when it is uploaded, and the + // exec carries only the flag: no bundle value, and no other value, on any argv (the gateway logs an exec's env). + assert.deepEqual(fake.state.bundleEnv, { text: `${JSON.stringify({ DB_URL: "postgres://plain_value_0004" })}\n`, mode: 0o600 }); + const execCall = fake.state.calls.find((c) => c[2] === "exec"); + const env = Object.fromEntries(execCall.flatMap((a, i) => (a === "--env" ? [execCall[i + 1].split(/=(.*)/s).slice(0, 2)] : []))); + assert.equal(env.PI_BUNDLE_ENV, "1"); + assert.equal(env.DB_URL, undefined); + assert.equal(env.STRIPE_KEY, undefined); + for (const call of fake.state.calls) { + const flat = call.join(" "); + for (const v of ["postgres://plain_value_0004", "sk_bundle_value_0003", "sk-ant-real-provider-key-0001", "ghs_real_forge_token_0002"]) assert.ok(!flat.includes(v), `${v} on no argv: ${call.slice(0, 4).join(" ")}`); + } + assert.equal(existsSync(join(dirs.jobDir, "bundle-env.json")), false, "removed at teardown"); + // Teardown: the sandbox, then both providers and their profiles; no profile file left. + const deletes = fake.state.calls.map((c) => c.slice(1, 4).join(" ")).filter((s) => / delete/.test(s)); + assert.deepEqual(deletes.slice(-5), [`sandbox delete ${sb}`, `provider delete ${sb}`, "provider profile delete", `provider delete ${hostProvider}`, "provider profile delete"]); + assert.ok(deletes.slice(0, 4).join(" ").includes(hostProvider), "an earlier attempt's leftover of the host's provider is removed first too"); + for (const p of [sb, hostProvider]) assert.equal(existsSync(`${dirs.jobDir}.openshell-profile-${p}.json`), false, p); +}); + +test("bundle read-back (#704 part 3): exactly the layers this job attached, a missing or an extra one never starts the job", { skip }, async () => { + const policy = { version: 1, network_policies: { a: { name: "a", endpoints: [{ host: "x" }] } }, filesystem_policy: { read_only: ["/var/log"], read_write: [] } }; + const [only] = mod.expectedPolicies(policy, { providers: ["pi-job-s0123456789a", "pi-job-s0123456789a-b0"] }); + assert.deepEqual(Object.keys(only.network_policies).sort(), ["_provider_pi_job_s0123456789a", "_provider_pi_job_s0123456789a_b0", "a"]); + for (const tamper of [(body) => delete body.network_policies[Object.keys(body.network_policies).find((k) => k.endsWith("_b0"))], (body) => (body.network_policies._provider_someone = { name: "_provider_someone" })]) { + const fake = fakeOpenshell(); + const exec = async (bin, args, opts) => { + const out = await fake.exec(bin, args, opts); + if (args[0] === "policy" && args[1] === "get" && !args.includes("--global")) { + const doc = JSON.parse(out.stdout); + tamper(doc.policy); + return { stdout: JSON.stringify(doc), stderr: "" }; + } + return out; + }; + const { run } = runBundled({ ...fake, exec }); + await assert.rejects(run, (e) => e.reason === "container-never-started"); + assert.equal(fake.state.calls.some((c) => c[2] === "exec"), false, "never exec'd"); + } +}); + +test("judgeBundle (#704 part 3): the host must be exactly a policy endpoint, not under a wildcard, not the forge's", { skip }, () => { + const network_policies = { a: { endpoints: [{ host: "api.stripe.com" }, { host: "github.com" }, { host: "api.github.com" }] }, w: { endpoints: [{ host: "example.org" }, { host: "*.example.org" }] }, m: { endpoints: [{ host: "llm.internal", port: 8080 }] } }; + const judge = (host, extra = {}) => mod.judgeBundle({ endpoints: { K: { host, auth: "bearer" } }, network: { network_policies }, credentialHosts: mod.credentialHostsOf({}), ...extra }); + assert.deepEqual(judge("api.stripe.com"), { ok: true }); + assert.deepEqual(judge("api.unlisted.com"), { refused: "openshell-bundle-endpoint-not-allowed", name: "K", host: "api.unlisted.com" }); + assert.deepEqual(judge("api.example.org"), { refused: "openshell-bundle-endpoint-not-allowed", name: "K", host: "api.example.org" }, "admitted only by a wildcard"); + assert.equal(judge("example.org").refused, undefined, "the apex is its own endpoint, which a single-label wildcard does not cover"); + for (const host of ["github.com", "api.github.com"]) assert.equal(judge(host).refused, "openshell-bundle-endpoint-shared", host); + assert.deepEqual(judge("llm.internal"), { ok: true }, "a model endpoint's host is not the forge's: claimable (credentialHostsOf says why)"); + assert.ok(mod.credentialHostsOf({ forgeHosts: { gitlab: "https://git.acme.test/api/v4", forgejo: null } }).has("git.acme.test"), "a configured instance's host"); + assert.deepEqual(mod.judgeBundle({ endpoints: {}, network: { network_policies: {} } }), { ok: true }, "no endpoints, nothing to bind"); +}); + +test("judgeBundle (#704 part 3): with the variables, an undelivered endpoint name, a name the sandbox sets, and what the gateway cannot carry each refuse by name", { skip }, () => { + const network = { network_policies: { a: { endpoints: [{ host: "api.stripe.com" }, { host: "api.other.com" }] } } }; + const endpoints = { STRIPE_KEY: { host: "api.stripe.com", auth: "bearer" } }; + const judge = (vars, extra = {}) => mod.judgeBundle({ endpoints, vars, network, ...extra }); + const capable = { capabilities: ["bundleEnvFile"] }; + assert.deepEqual(judge({ STRIPE_KEY: "s", DB_URL: "d", FLAG: "on" }, capable), { ok: true, placeholders: 1, plain: 2 }); + assert.deepEqual(judge({ DB_URL: "d" }), { refused: "openshell-bundle-endpoint-unknown", name: "STRIPE_KEY" }); + // Review round 1, M2: a shadowed endpoint name is still its host's placeholder, with the trigger's value. + assert.deepEqual(judge({ DB_URL: "d" }, { shadowed: ["STRIPE_KEY"], ...capable }), { ok: true, placeholders: 1, plain: 1 }, "shadowed by run.secrets is not unknown"); + // Review round 1, H1: plain variables go by file, which only an image declaring bundleEnvFile reads. + assert.deepEqual(judge({ STRIPE_KEY: "s", DB_URL: "d" }), { refused: "openshell-bundle-image-too-old", name: null }); + assert.deepEqual(judge({ STRIPE_KEY: "s", DB_URL: "d" }, { capabilities: ["steerStdin"] }), { refused: "openshell-bundle-image-too-old", name: null }); + assert.deepEqual(judge({ STRIPE_KEY: "s" }), { ok: true, placeholders: 1, plain: 0 }, "no plain variable, no file, any image"); + for (const taken of ["OPENSHELL_THING", "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "GIT_CONFIG_COUNT", "grpc_proxy"]) { + assert.deepEqual(judge({ STRIPE_KEY: "s", [taken]: "v" }), { refused: "openshell-bundle-name-taken", name: taken }, taken); + } + // Review round 1, M1 and L1: a plain value meets no exec limit any more, a PEM and a large value included. + assert.equal(judge({ STRIPE_KEY: "s", PEM: "-----BEGIN\nx\n-----END" }, capable).ok, true); + assert.equal(judge({ STRIPE_KEY: "s", BIG: "x".repeat(64 * 1024) }, capable).ok, true); + assert.deepEqual(judge({ STRIPE_KEY: "x".repeat(8193) }), { refused: "openshell-bundle-unpassable", name: "STRIPE_KEY" }, "a placeholder over a provider value's 8 KiB"); + // A multi-line value is fine as a placeholder: it never reaches an argv. + assert.equal(judge({ STRIPE_KEY: "line1\nline2" }).ok, true); + const many = Object.fromEntries(Array.from({ length: 33 }, (_, i) => [`K${i}`, { host: "api.stripe.com", auth: "basic" }])); + assert.equal(mod.judgeBundle({ endpoints: many, vars: Object.fromEntries(Object.keys(many).map((k) => [k, "v"])), network }).refused, "openshell-bundle-unpassable", "33 placeholders for one host"); + const hosts = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`H${i}`, { host: `h${i}.test`, auth: "bearer" }])); + const wide = { network_policies: { a: { endpoints: Object.values(hosts).map((e) => ({ host: e.host })) } } }; + assert.equal(mod.judgeBundle({ endpoints: hosts, network: wide }).refused, "openshell-bundle-unpassable", "32 hosts and the job's own provider is 33"); + assert.equal(mod.judgeBundle({ endpoints: Object.fromEntries(Object.entries(hosts).slice(0, 31)), network: wide }).ok, true, "31 hosts fit"); +}); + +test("bundleGate (#704 part 3): the venue's own refusal with its fixed sentence and the names in the log, before any sandbox; an unreadable source retries", { skip }, async () => { + const fake = fakeOpenshell(); + const logs = []; + const b = bundle(fake, { readAllowlist: () => ["api.anthropic.com"], log: (e, f) => logs.push([e, f]) }); + const job = { ...JOB, repo: "acme/widget" }; + const r = await b.bundleGate(job, { endpoints: { STRIPE_KEY: { host: "api.stripe.com", auth: "bearer" } }, vars: null }); + assert.equal(r.venueRefused.reason, "openshell-bundle-endpoint-not-allowed"); + assert.equal(r.venueRefused.comment, mod.OPENSHELL_REFUSALS["openshell-bundle-endpoint-not-allowed"]); + assert.ok(!r.venueRefused.comment.includes("STRIPE_KEY") && !r.venueRefused.comment.includes("api.stripe.com"), "the comment names neither"); + assert.deepEqual(logs.find(([e]) => e === "refused_openshell_bundle")[1], { reason: "openshell-bundle-endpoint-not-allowed", name: "STRIPE_KEY", host: "api.stripe.com" }); + assert.equal(fake.state.calls.length, 0, "a pure read of the deployment's sources: no gateway call, no sandbox"); + // A configured forge instance's host is the forge token's. + const forge = bundle(fake, { readAllowlist: () => ["git.acme.test"], forgeHosts: { gitlab: "https://git.acme.test" } }); + assert.equal((await forge.bundleGate(job, { endpoints: { K: { host: "git.acme.test", auth: "bearer" } } })).venueRefused.reason, "openshell-bundle-endpoint-shared"); + assert.deepEqual(await bundle(fake, { readAllowlist: () => ["api.stripe.com"] }).bundleGate(job, { endpoints: BUNDLE_KEYS.endpoints, vars: BUNDLE_SECRETS, capabilities: ["bundleEnvFile"] }), { ok: true, placeholders: 1, plain: 1 }); + // Review round 1, H2: the processor's credential hosts (a model's composed baseUrl, a model endpoint, an MCP server). + const modelHost = bundle(fake, { readAllowlist: () => ["api.anthropic.com"] }); + assert.equal((await modelHost.bundleGate(job, { endpoints: { K: { host: "api.anthropic.com", auth: "bearer" } }, credentialHosts: ["api.anthropic.com"] })).venueRefused.reason, "openshell-bundle-endpoint-shared"); + const down = bundle(fake, { readAllowlist: () => { throw new Error("EACCES"); } }); + assert.deepEqual(await down.bundleGate(job, { endpoints: BUNDLE_KEYS.endpoints }), { unavailable: true }); + assert.equal(mod.OPENSHELL_REFUSALS["openshell-bundle-unsupported"], undefined, "part 2's refusal is retired"); +}); + +test("bundleProviderPlan (#704 part 3): one provider per bundle host, sorted, the job's keeps the rest, and a key in two places is a collision by name", { skip }, async () => { + const env = { PI_PROVIDER: "anthropic", ANTHROPIC_API_KEY: "k", A: "1", B: "2", C: "3", D: "4" }; + const plan = mod.bundleProviderPlan({ name: "pi-job-s0123456789a", env, bundleKeys: { endpoints: { B: { host: "z.test", auth: "basic" }, A: { host: "a.test", auth: "header:X-Api-Key" }, C: { host: "a.test", auth: "bearer" } }, plain: ["D"] } }); + assert.deepEqual(plan, { + job: { name: "pi-job-s0123456789a", keys: ["ANTHROPIC_API_KEY"] }, + bundles: [ + { name: "pi-job-s0123456789a-b0", host: "a.test", keys: ["A", "C"], auth: { A: "header:X-Api-Key", C: "bearer" } }, + { name: "pi-job-s0123456789a-b1", host: "z.test", keys: ["B"], auth: { B: "basic" } }, + ], + plain: ["D"], + }); + assert.deepEqual(mod.providerProfileFor("p", ["A"], { auth: { A: "header:X-Api-Key" } }).credentials[0], { name: "c0", env_vars: ["A"], required: true, auth_style: "header", header_name: "x-api-key" }); + assert.deepEqual(mod.bundleAuthFields("basic"), { auth_style: "basic" }); + assert.deepEqual(mod.bundleProviderPlan({ name: "pi-job-s0123456789a", env, bundleKeys: { endpoints: { A: { host: "a.test", auth: "bearer" } }, plain: ["A"] } }), { collision: "A" }); + // The stage refuses such a plan as infrastructure, before any sandbox or provider exists. + const fake = fakeOpenshell(); + const { run } = runBundled(fake, {}, { bundleKeys: { endpoints: BUNDLE_KEYS.endpoints, plain: ["STRIPE_KEY"] } }); + await assert.rejects(run, (e) => e.reason === "container-never-started"); + assert.equal(fake.state.calls.some((c) => (c[1] === "sandbox" && c[2] === "create") || (c[1] === "provider" && c[2] === "create")), false); + // A job with no bundle plans #684's one provider, exactly. + assert.deepEqual(mod.bundleProviderPlan({ name: "n", env }), { job: { name: "n", keys: ["ANTHROPIC_API_KEY", "A", "B", "C", "D"] }, bundles: [], plain: [] }); + // Review round 1, M2: a declared name whose env value is a run.secrets one (it shadowed the bundle's) still goes into + // its host's provider, so the winning value resolves at the host the reviewed file binds the name to. + const shadowedPlan = mod.bundleProviderPlan({ name: "n", env: { STRIPE_KEY: "sk_trigger_value" }, bundleKeys: { endpoints: { STRIPE_KEY: { host: "api.stripe.com", auth: "bearer" } }, plain: [] } }); + assert.deepEqual([shadowedPlan.job.keys, shadowedPlan.bundles.map((b) => [b.host, b.keys])], [[], [["api.stripe.com", ["STRIPE_KEY"]]]]); +}); + +test("bundle providers are reaped (#704 part 3): the boot sweep and the gone-check find -b by the owner label, nobody else's", { skip }, async () => { + const sb = mod.sandboxNameOf("pi-job-old"); + const reaping = fakeOpenshell(); + reaping.state.providers = [{ name: sb }, { name: `${sb}-b0` }, { name: `${sb}-b12` }, { name: "someone-elses-b0" }, { name: `${sb}-bx` }]; + assert.deepEqual(await bundle(reaping, { delay: async () => {} }).reap(), { reaped: true }); + assert.deepEqual(reaping.state.calls.filter((c) => c[1] === "provider" && c[2] === "delete").map((c) => c[3]), [sb, `${sb}-b0`, `${sb}-b12`]); + assert.ok(mod.isJobProviderName(`${sb}-b3`) && !mod.isJobProviderName("pi-job-old-b3") && !mod.isJobProviderName(null)); + // Review round 1, L4: a profile left without a provider (an import, then a crash) is swept by its name too, never + // a profile outside the job namespace. + const profiled = fakeOpenshell(); + profiled.state.profiles = [{ id: `${sb}-b1` }, { id: sb }, { id: "anthropic" }, { id: "someone-elses-b0" }]; + assert.deepEqual(await bundle(profiled, { delay: async () => {} }).reap(), { reaped: true }); + assert.deepEqual(profiled.state.calls.filter((c) => c[1] === "provider" && c[2] === "profile" && c[3] === "delete").map((c) => c[4]), [`${sb}-b1`, sb]); + const gone = fakeOpenshell({ sandboxes: [] }); + const other = mod.sandboxNameOf("pi-job-other"); + gone.state.providers = [{ name: `${mod.sandboxNameOf("pi-job-gone")}-b0` }, { name: `${other}-b0` }]; + assert.equal(await bundle(gone).containerGone("pi-job-gone"), true); + for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r)); + const deleted = gone.state.calls.filter((c) => c[1] === "provider" && c[2] === "delete").map((c) => c[3]); + assert.deepEqual(deleted, [mod.sandboxNameOf("pi-job-gone"), `${mod.sandboxNameOf("pi-job-gone")}-b0`], "its own bundle provider, not another sandbox's"); +}); + +test("bundle: the redactor still holds a placeholder variable's real value, and no bundle value rides the exec (#704 part 3, review round 1)", { skip }, async () => { + const args = mod.openshellExecArgs({ name: "pi-job-sx", env: { PI_PROVIDER: "anthropic", PI_BUNDLE_ENV: "1", STRIPE_KEY: "s3cret", DB_URL: "plainval", OTHER_SECRET: "o" } }); + assert.ok(args.includes("PI_BUNDLE_ENV=1"), "the flag is a non-secret name"); + assert.ok(!args.join(" ").includes("plainval") && !args.join(" ").includes("s3cret") && !args.join(" ").includes("=o")); + const { secretValuesOf } = await import("../src/redact-secrets.mjs"); + assert.ok(secretValuesOf({ STRIPE_KEY: "s3cret-value-long", DB_URL: "plainval-long" }, []).includes("s3cret-value-long"), "a placeholder variable's value is in the env the redactor is built from"); +}); + +test("final review round: the PI_ prefix and the sandbox's session names are refused before spend, and the runner's backstop list covers every name this gate refuses", { skip }, async () => { + const network = { network_policies: { a: { endpoints: [{ host: "api.stripe.com" }] } } }; + for (const name of ["PI_ANYTHING", "USER", "SHELL", "TERM", "LOGNAME"]) { + assert.deepEqual(mod.judgeBundle({ endpoints: {}, vars: { [name]: "v" }, network, capabilities: ["bundleEnvFile"] }), { refused: "openshell-bundle-name-taken", name }, name); + } + const { BUNDLE_ENV_PROTECTED } = await import("../../image/runner/src/bundle-env.mjs"); + for (const name of mod.OPENSHELL_SET_ENV_NAMES) assert.ok(BUNDLE_ENV_PROTECTED.includes(name), `${name}: refused by the worker's gate, so the runner's backstop must protect it too`); +}); + +test("final review round: a refused profile import leaves no profile file beside the job dir", { skip }, async () => { + const fake = fakeOpenshell(); + fake.state.failImport = true; + const dirs = jobDirs(); + await assert.rejects(bundle(fake, { readAllowlist: () => ["api.anthropic.com"] }).runContainer({ job: JOB, prepared: { jobDir: dirs.jobDir, workspace: dirs.workspace, live: dirs.live }, name: "pi-job-j1", signal: new AbortController().signal }), (e) => e.reason === "container-never-started"); + assert.equal(fake.state.profileFilesSeen.length, 1); + assert.equal(existsSync(fake.state.profileFilesSeen[0]), false); +}); diff --git a/worker/test/backend-registry.test.mjs b/worker/test/backend-registry.test.mjs index d41725cd..0b0fa355 100644 --- a/worker/test/backend-registry.test.mjs +++ b/worker/test/backend-registry.test.mjs @@ -315,6 +315,9 @@ test("the two OPTIONAL preflights: absent gives the non-local answers, present i // The defaults are the processor's own, so an omitted registry and an omitted member agree. assert.deepEqual(OPTIONAL_PREFLIGHT_DEFAULTS.observationPreflight(), { ok: true }); assert.deepEqual(OPTIONAL_PREFLIGHT_DEFAULTS.jobUserPreflight(), { user: null, home: null }); + // Issue #704 part 3: a venue without a bundle gate admits every bundle (its container holds the env as values). + assert.deepEqual(OPTIONAL_PREFLIGHT_DEFAULTS.bundleGate(), { ok: true }); + assert.deepEqual(await reg.bundleGate({ id: "a" }, { endpoints: {} }), { ok: true }); // A fresh object each call: a caller that decorates the answer must not decorate every later venue's. assert.notEqual(OPTIONAL_PREFLIGHT_DEFAULTS.jobUserPreflight(), OPTIONAL_PREFLIGHT_DEFAULTS.jobUserPreflight()); // An unregistered name is still the registry's refusal, never waved through by the absent answer. diff --git a/worker/test/env-allowlist.test.mjs b/worker/test/env-allowlist.test.mjs index a9e4f48b..f4f8173a 100644 --- a/worker/test/env-allowlist.test.mjs +++ b/worker/test/env-allowlist.test.mjs @@ -1107,8 +1107,9 @@ test("the reserved-name list triggers.mjs refuses covers every STATIC name this // The drift guard. reserved-env.mjs is a hand-written list in a module with no imports (so the shared // validator and the admin bundle can have it for free), and a variable added to the closed map without // being added there would open a hole a trigger could drive through. This is the test that closes it. - const env = mod.buildContainerEnv({ ...secretsBase, maxTokens: 100, packagePaths: ["/opt/pi-global/packages/x"], sessionFile: "/session/current.jsonl", flow: "fix", excludeTools: ["bash"], allowGlobalExtensions: false, home: "/home/pi", exitAuth: true, transcriptStderr: true, reportProgress: true, recover: true, recovering: true, steer: true }); + const env = mod.buildContainerEnv({ ...secretsBase, maxTokens: 100, packagePaths: ["/opt/pi-global/packages/x"], sessionFile: "/session/current.jsonl", flow: "fix", excludeTools: ["bash"], allowGlobalExtensions: false, home: "/home/pi", exitAuth: true, transcriptStderr: true, reportProgress: true, recover: true, recovering: true, steer: true, bundleEnvFile: true }); assert.equal(env.PI_STEER, "1", "the drift check must see PI_STEER (issue #646)"); + assert.equal(env.PI_BUNDLE_ENV, "1", "the drift check must see PI_BUNDLE_ENV (issue #704 part 3)"); assert.equal(env.PI_TRANSCRIPT, "stderr", "the drift check must see PI_TRANSCRIPT (issue #686)"); assert.equal(env.PI_REPORT_PROGRESS, "1", "the drift check must see PI_REPORT_PROGRESS (issue #644)"); assert.equal(env.PI_RECOVER, "1", "the drift check must see PI_RECOVER (issue #645)"); diff --git a/worker/test/processor-bundles.test.mjs b/worker/test/processor-bundles.test.mjs index c4c4dc34..bac33cbe 100644 --- a/worker/test/processor-bundles.test.mjs +++ b/worker/test/processor-bundles.test.mjs @@ -194,7 +194,7 @@ test("no bundle value reaches the record, a log line or a comment, on success or for (const v of values) assert.ok(!surfaces.includes(v), v); }); -test("the free refusals come before the mint: an undeclared profile, run.resume, a changed trigger, an openshell venue", async () => { +test("the free refusals come before the mint: an undeclared profile, run.resume, a changed trigger", async () => { const undeclared = deployment({ declared: false }); const r1 = await runJob(prJob, undeclared.deps); assert.deepEqual([r1.reason, undeclared.calls], ["secret-bundle-profile-unknown", []]); @@ -204,13 +204,78 @@ test("the free refusals come before the mint: an undeclared profile, run.resume, const changed = deployment({ triggers: [cron] }); const r3 = await runJob(prJob, changed.deps); assert.deepEqual([r3.reason, changed.calls], ["secret-bundle-trigger-changed", []]); - // Both OpenShell venues (a layer holds the credentials) refuse a bundled job until #704 part 3. +}); + +// Issue #704 part 3: both OpenShell venues hold a bundle now. The venue judges it twice inside the read (`bundleGate`): +// the hosts before the resolver runs, then the received variables, and its counts reach the record. +// A forge job that needs no fork check (an issue), since the copy venue refuses a local folder job before any of this. +const issueOn = { ...prJob, target: { type: "issue", number: 7 }, trigger: { deliveryId: "d3", matched: { index: 1, type: "pull_request", action: "opened" } } }; +const endpointBundles = { shop: { profile: "shop-env", ref: "shop", project: "shop", endpoints: { STRIPE_KEY: { host: "api.stripe.com", auth: "bearer" } } } }; +function gated(answer = () => ({ ok: true, placeholders: 1, plain: 1 }), over = {}) { + const asks = []; + const kept = []; + const d = deployment({ bundles: endpointBundles, over: { blessedBackends: ["local", "openshell", "openshell-bind"], ...over } }); + // The venue's own facts ride every ask (the job's credential hosts and the image's capabilities), kept apart here. + d.deps.bundleGate = async (job, ask) => { + const { credentialHosts, capabilities, ...rest } = ask; + asks.push({ ask: JSON.parse(JSON.stringify(rest)), calls: [...d.calls], credentialHosts, capabilities }); + return answer(ask); + }; + const run = d.deps.runContainer; + d.deps.runContainer = async (args) => (kept.push(args.bundleKeys ?? null), run(args)); + return { ...d, asks, kept }; +} + +test("on both OpenShell venues a bundled job runs: hosts judged before the read, variables after, the split handed on and counted", async () => { for (const backend of ["openshell", "openshell-bind"]) { - const venue = deployment({ over: { blessedBackends: ["local", backend] } }); - const r = await runJob({ ...prJob, backend }, venue.deps); - assert.deepEqual([r.reason, r.budgetReserved, venue.calls], ["openshell-bundle-unsupported", false, []], backend); - assert.match(venue.comments.at(-1), /cannot hold a bundle's variables yet/); + const g = gated(); + const job = { ...issueOn, backend }; + const r = await runJob(job, g.deps); + assert.equal(r.outcome, "completed", backend); + assert.deepEqual(g.asks.map((a) => a.ask), [ + { endpoints: endpointBundles.shop.endpoints, vars: null }, + { endpoints: endpointBundles.shop.endpoints, vars: { STRIPE_KEY: "sk_live_value", DB_URL: "postgres://u:pw@db/shop" }, shadowed: [] }, + ], backend); + assert.deepEqual(g.asks.map((a) => a.calls), [[], []], "both asks come before the mint for a job that needs no fork check"); + assert.deepEqual(g.kept[0], { endpoints: { STRIPE_KEY: { host: "api.stripe.com", auth: "bearer" } }, plain: ["DB_URL"] }); + assert.deepEqual(record(job, r).bundle, { id: "shop", variables: ["DB_URL", "STRIPE_KEY"], shadowed: [], placeholders: 1, plain: 1 }); } + // On local the same gate admits (a venue without one), the split still rides the call bag, and the record has no counts. + const local = gated(() => ({ ok: true })); + const r = await runJob(cronJob, local.deps); + assert.deepEqual(record(cronJob, r).bundle, { id: "shop", variables: ["DB_URL", "STRIPE_KEY"], shadowed: [] }); +}); + +test("an OpenShell venue's refusal of the bundle returns before the resolver, the mint and every reserve, with its fixed sentence", async () => { + const g = gated((ask) => (ask.vars === null ? { venueRefused: { reason: "openshell-bundle-endpoint-not-allowed", comment: "Refused: fixed. Not run." } } : assert.fail("never read"))); + const r = await runJob({ ...issueOn, backend: "openshell" }, g.deps); + assert.deepEqual([r.outcome, r.reason, r.budgetReserved, g.calls], ["policy", "openshell-bundle-endpoint-not-allowed", false, []]); + assert.equal(g.comments.at(-1), "Refused: fixed. Not run."); + assert.equal(g.redis.incrCalls, 0); + assert.equal(g.logs.some((l) => l.event === "secret_bundle_applied"), false, "the resolver never ran"); + // The second ask's refusal: after the read, still before the mint. + const after = gated((ask) => (ask.vars === null ? { ok: true } : { venueRefused: { reason: "openshell-bundle-endpoint-unknown", comment: "Refused: fixed. Not run." } })); + const r2 = await runJob({ ...issueOn, backend: "openshell" }, after.deps); + assert.deepEqual([r2.reason, after.calls, after.ran], ["openshell-bundle-endpoint-unknown", [], []]); + // A source the venue could not read retries. + const down = gated(() => ({ unavailable: true })); + await assert.rejects(runJob({ ...issueOn, backend: "openshell" }, down.deps), (e) => e instanceof InfraRetry); + assert.deepEqual(down.calls, []); +}); + +test("part 2's residual is gone: a fork's pull request on OpenShell runs without the bundle, never refused for it", async () => { + const g = gated(() => assert.fail("a fork's job never reaches the bundle gate"), { resolvePullRequestHead: async () => ({ headRef: "x", headRepo: "stranger/shop" }) }); + const r = await runJob({ ...prJob, backend: "openshell" }, g.deps); + assert.deepEqual([r.outcome, r.bundleSkipped, g.kept], ["completed", "fork", [null]]); +}); + +test("a run.secrets value that shadows an endpoint variable keeps the declared host binding with the trigger's value, and the venue is told which", async () => { + const g = gated(() => ({ ok: true, placeholders: 0, plain: 1 }), { resolveSecrets: async () => ({ ok: true, secrets: { STRIPE_KEY: "sk_trigger_value" } }) }); + const job = { ...issueOn, backend: "openshell", secrets: { STRIPE_KEY: "op://ci/stripe" } }; + const r = await runJob(job, g.deps); + assert.deepEqual(g.asks[1].ask, { endpoints: endpointBundles.shop.endpoints, vars: { DB_URL: "postgres://u:pw@db/shop" }, shadowed: ["STRIPE_KEY"] }); + assert.deepEqual(g.kept[0], { endpoints: { STRIPE_KEY: { host: "api.stripe.com", auth: "bearer" } }, plain: ["DB_URL"] }, "review round 1, M2: the trigger's STRIPE_KEY stays on the host the bundle binds the name to"); + assert.deepEqual(record(job, r).bundle, { id: "shop", variables: ["DB_URL", "STRIPE_KEY"], shadowed: ["STRIPE_KEY"], placeholders: 0, plain: 1 }); }); test("on a multi-host worker's shared queue a profile this host lacks defers the job; a single host refuses it", async () => { @@ -323,3 +388,17 @@ test("makeProcessor hands a shared-queue job back for the never-fits recheck whe assert.equal(single.outcome?.reason, "secret-bundle-profile-unknown"); assert.deepEqual(single.moves, []); }); + +test("review round 1, H2: the bundle gate is told every host the job's own credentials go to, and the image's capabilities", async () => { + const g = gated(() => ({ ok: true, placeholders: 1, plain: 1 }), { + modelEndpoints: { models: null, endpoints: [{ id: "lab-a", host: "llm.lan", port: 8000 }, { id: "other", host: "Other.Lan", port: 9000 }] }, + builtinModel: (provider) => (provider === "anthropic" ? { api: "anthropic-messages", baseUrl: "https://api.anthropic.com" } : null), + imagePreflight: async () => ({ ok: true, capabilities: ["bundleEnvFile"] }), + }); + const r = await runJob({ ...issueOn, backend: "openshell" }, g.deps); + assert.equal(r.outcome, "completed", r.reason); + for (const ask of g.asks) { + assert.deepEqual(ask.credentialHosts, ["api.anthropic.com", "llm.lan", "other.lan"]); + assert.deepEqual(ask.capabilities, ["bundleEnvFile"]); + } +}); diff --git a/worker/test/secret-bundles.test.mjs b/worker/test/secret-bundles.test.mjs index 86145877..3dc7a5ca 100644 --- a/worker/test/secret-bundles.test.mjs +++ b/worker/test/secret-bundles.test.mjs @@ -231,6 +231,11 @@ test("the record's bundle is rebuilt from names only: anything that is not an en assert.equal(recordedBundle({ id: "shop prod", variables: ["A"] }), null); assert.equal(recordedBundle({ id: "shop", variables: Array.from({ length: 65 }, (_, i) => `V${i}`) }), null); assert.equal(recordedBundle(null), null); + // Issue #704 part 3: the OpenShell counts, as a pair of bounded integers or not at all. + assert.deepEqual(recordedBundle({ id: "shop", variables: ["A", "B"], shadowed: [], placeholders: 1, plain: 1 }), { id: "shop", variables: ["A", "B"], shadowed: [], placeholders: 1, plain: 1 }); + for (const bad of [{ placeholders: 1 }, { placeholders: -1, plain: 1 }, { placeholders: 1.5, plain: 0 }, { placeholders: "1", plain: 1 }, { placeholders: 2, plain: 1 }]) { + assert.deepEqual(recordedBundle({ id: "shop", variables: ["A", "B"], shadowed: [], ...bad }), { id: "shop", variables: ["A", "B"], shadowed: [] }, JSON.stringify(bad)); + } }); test("docs/secrets.md's list of names a bundle may not carry is the worker's table, both directions", async () => {