Compare commits

...
2 changed files with 205 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
# Row 243: too-open mode refusal tests set their mode with chmod
Brief: `docs/plans/2026-10-21_gate-env-rows.md` § "The startup token refusal test sets its file mode"
(Sage, sage-1909). Reviewer Webby. Lands after row 197 (mos-1162). Design notes written before 197
lands, read at `origin/refactor` 01e1fe512. Recheck the line cites against 197's landing before code
starts.
## The defect
`writeFileSync(file, data, { mode })` and `mkdirSync(dir, { mode })` apply the process umask to the
mode. A test that creates a "too open" file this way and expects a refusal passes under `umask 022`
(0644 stays 0644) and fails under `umask 077` (0644 becomes 0600, and the source accepts it).
Row 197 r6 (df0c59608) runs each gate step under `umask 022` (`gate-lane.mjs:46`), so the lane no
longer hits this. Row 243 still makes the tests independent of the umask, as the brief asks.
## Sweep
Method: every source-side mode check in `packages/*/src` and `scripts/` (grep for `mode & 0o...`), then
the tests that reach each check's refusal branch, deciding whether the too-open object gets its mode at
creation or from an explicit chmod. Also a scan of every `writeFileSync`/`mkdirSync`/`openSync` in
`packages/*/tests` with a group or other bit in its mode (50 hits, 41 without a following chmod), and a
grep of the refusal messages for objects created with no mode or by a shell-out. Read at
`origin/refactor` with `git show` and `git grep`; nothing run.
Affected refusal tests, all three verified by reading the test (the fourth file, an accept test, follows the table):
| Creation | Test | Source check | Expected refusal |
|---|---|---|---|
| `packages/bus/tests/process.test.mjs:76` `writeFileSync(file, secret, { mode: 0o644 })` | startup token refusal returns safe code... | `bus/src/credentials.mjs:205` (must be 0600) | `credential-file`, exit 2 (:83-84) |
| `packages/webui/tests/proof-cli.test.mjs:28` `writeFileSync(file, value, { mode })`, mode 0o644 from the `mode` case at :27 | proof startup refuses partial flags... | `webui/src/serve.mjs:234` (0600 or 0400) | status 2 (:38) |
| `packages/webui/tests/sso.test.mjs:360` `writeFileSync(.../secrets/mosaic-stack/a, ..., { mode: 0o644 })` | 11: the CLI refuses --sso... | `webui/src/sso.mjs:45` (no group or other bits) | status 2 and the "no group or other permission bits" line (:361-363); the chmod to 0600 at :364 comes after |
Fix for each: keep the write, then `chmodSync(<path>, 0o644)` right after it. For `proof-cli.test.mjs`
the chmod goes inside the case map after the write, with the case's own mode, so the 0o600 cases are set
explicitly too.
Not affected (each sets the mode with a chmod or a chmodding helper before the code under test reads it):
bus credentials.test :52, credential-store.test :55-61; business access.test :225-226, business.test
:159/161/174, credentials.test :60, cli.test :151, helpers `writeJson`/`tokenFile`; cli cli.test :176,
notifier.test :318/:321, stack-release.test :1220/1239/1294/1313/1552, stack-upgrade.test :426-430,
case2-unit.test :340/:510/:635; control-board discord.test :79; discord binding.test :106/118/140/277,
notify.test :76, setspark.test :110, git.test :138/:360, offline-check.test :21; mosaic safety.test :80;
queue review.test :214/:228; ledger gitea-helper-raw.test :113; webui service.test :797, sso.test :310;
bus host-tools.test :182/:266. The 0o644/0o755 adapter and fake-binary writes (launcher, thread-launch,
session, sandbox, checkout, commit, etc.) aren't read by a mode check, or are only checked for the exec
bit, which 0700 keeps. `host-tools.test.mjs:205`, `:271` and the confDir at `:77` expect acceptance and
only need "not group-writable".
Mode checks with no refusal test at all (not this row; listed for the record): `bus/src/store.mjs:121`,
`bus/src/host-run.mjs:71`, `business/src/organization.mjs:28/45/109/141`,
`conversation/src/controller.mjs:299`, `mosaic/src/materialize-fixture.mjs:16`,
`mosaic/src/refresh-fixture.mjs:64`, `seat/src/session.mjs:72`, `scripts/git-credential-mosaic.mjs:49`,
and the mode branch of `scripts/stack-release.sh:1952`.
Fourth file, folded in by Sage (sage-1918): `packages/cli/tests/stack-instances.test.mjs:84` and `:89`
assert 0644 on the config and `instance.json`. `scripts/mosaic-config.mjs:337` creates them with
`openSync(file, "wx", 0o644)` and no chmod, so under `umask 077` they come out 0600 and the test fails.
This is an accept test of a source mode, not a refusal test.
| Creation | Test | Source | Ruling |
|---|---|---|---|
| `scripts/mosaic-config.mjs:337` via `bootstrap()` (`stack-instances.test.mjs:41`, a `spawnSync` of `scripts/bootstrap.sh`) | the `--instance` create test, :79-89 | `createExclusive` passes 0o644 and the umask narrows it | test-only (sage-1918) |
- No change to `mosaic-config.mjs`. Letting the operator's umask narrow the config is the source's
choice; a chmod to 0644 would make the config wider than that umask. No row asks for it.
- `bootstrap()` runs its child under an explicit `umask 022`, the pattern from `gate-lane.mjs:46`:
`run("/bin/bash", ["-c", 'umask 022; exec "$@"', "bash", BOOTSTRAP, ...args], homeEnv(home))`. Every
`bootstrap()` call goes through it (:79, :99, :102, :119, :127, :136, :140, :152, :165, :169), so the
0644 assertions still prove the mode the source asks for, whatever the gate's umask.
- New assertion: a `bootstrap --instance` run under `umask 077` makes the config and `instance.json`
0600. That pins that the source honors the umask, so a later chmod "fix" fails a test. The directories
stay 0700 in both runs (they're created with `mode: 0o700`, which 077 can't narrow).
- The `config()` helper (:42) has no mode assertion and stays as it is.
Tests outside `packages/*/tests`: the five `scripts/foundation/*.test.mjs` files have no open-mode
write. `cli.test.mjs:144/148` chmods to 0o000 and 0o600, which doesn't depend on the umask. The
`agents/*/work` probe tests are review evidence, not gate steps (`discoverSteps` doesn't run them), so
they're out of scope.
## Tests
- Each of the four files runs whole twice, once under `umask 022` and once under `umask 077`
(`bash -c 'umask 077; node --test <file>'`). Both pass. The brief requires this for process.test.mjs;
Sage extended it to all four (sage-1918).
- Proof that the change matters: before the change, the same four files under `umask 077` fail on the
named assertions. I record that run too, on the slot, so the 077 pass after the change is meaningful.
- Order (mos-1162): this lands after 197 r6, so r6's proof ran on the unchanged test.
## Files (expected)
`packages/bus/tests/process.test.mjs`, `packages/webui/tests/proof-cli.test.mjs`,
`packages/webui/tests/sso.test.mjs`, `packages/cli/tests/stack-instances.test.mjs`. Test-only. No
source change.
+111
View File
@@ -0,0 +1,111 @@
# Row 244: gate runs use a fixture pi auth file
Brief: `docs/plans/2026-10-21_gate-env-rows.md` § "Gate runs use a fixture pi auth file" (Sage, sage-1909).
Reviewer Webby. DEFERRED entry "Gate runs mount the host's pi auth file" (059577886). Design notes
written before row 197 lands, read against `origin/herb/row197-r6` (df0c59608). Recheck every line cite
against 197's landing before code starts.
## Where Compose is reached
`compose.yaml:43` mounts `${PI_AUTH_FILE:-/home/jwoltje/.pi/agent/auth.json}` read-only. The default is
a literal path, not `$HOME`, so keeping `HOME` in the lane env doesn't change it.
Gate steps come from `discoverSteps` (`gate-lane.mjs:32`): one `pkg-<name>` step per package with tests
(not queue) and one `script-<name>` step per `scripts/test-*.sh`. A grep of those for `run-task.sh`,
`docker compose`, `hello.sh`, `verify.sh`, `agent.sh` and `release.sh activate` finds two Compose
paths. The other hits (`cohort.test.mjs` "hello" shim requests, `test-auth.sh`) don't reach Compose.
| Step | Path to Compose | Adapter |
|---|---|---|
| `test-task.sh` :150-385 | `scripts/run-task.sh run` → `mosaic-task.mjs:396` `docker compose run` with `spawnEnv = {...process.env}` | mock (`mock_config`) |
| `test-release.sh` :69, :85, :93 | `release.sh activate` → `health_check` (:41-55) → `run-task.sh run tasks/hello-marker.json` | **pi** (config :61 sets no adapter; provider zai, model glm-5.3-flash) |
`mosaic-task.mjs` passes `process.env` through, so a `PI_AUTH_FILE` exported by a suite reaches Compose.
## The conflict: test-release's healthy activation is a live provider call
"healthy activation succeeds" (:85) and "repeat activation succeeds" (:93) need `health_check` to
pass. That runs the real pi adapter, asks glm-5.3-flash for `MOSAIC_HELLO_OK`, and authenticates through
the mounted auth file. The lane's `cleanEnv` drops `ZAI_API_KEY`, so the mount is the only credential.
With a fixture auth file the provider call fails, and four checks fail: :85, the pointer check after it,
:93 and "log is append-only". So every gate run today spends a real provider call on the host credential.
Options put to Sage (morgana-0037):
- **A (recommended).** test-release's config sets `"adapter":"mock"`, and the two healthy activations
run with `MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK`. The fault injection still refuses, because its task
expects `MOSAIC_FAULT_<random>`. The suite keeps proving the release state machine, the image and
Compose path, and the pointer and log. It stops proving that the pi image can reach the provider.
Real `release.sh activate` runs still do that, with the real credential. No credential and no spend
in the gate.
- **B.** Keep the live call. The lane passes the real auth file explicitly. That contradicts the brief
and DEFERRED, so I don't take it without a ruling.
**Ruling: A (sage-1914, 09:51Z).** B keeps a live billed call on every gate, which is what this row
stops. Sage's conditions:
1. test-release.sh's suite config sets `"adapter":"mock"`, with a comment beside it saying why: no
credential and no provider call in the gate (row 244). The healthy activations run with
`MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK`.
2. Fault injection still refuses. It keeps the random `MOSAIC_FAULT_<n>` expectation, and the mock's
fixed reply can't satisfy it. Mutant M-F forces the fault task's expected value to `MOSAIC_HELLO_OK`.
It must fail the suite.
3. The fake-docker test also asserts that test-release's activations ran with the mock adapter. The
fake docker records `MOSAIC_ADAPTER`, which `mosaic-task.mjs` sets in `spawnEnv` from the resolved
config. Then a suite drifting back to pi fails the test.
4. Coverage dropped: test-release.sh no longer proves that the pi image reaches the provider. A real
`release.sh activate` still proves it, with the real credential. Sage adds a DEFERRED line at landing
if Webby wants one.
guardDocker's new refusal also gets a mutant (M-G, below).
## Change
1. `scripts/test-task.sh` and `scripts/test-release.sh`: right after `SANDBOX` is made, write
`$SANDBOX/pi/auth.json` (`{}`, mode set by `chmod 600` after the write, so it doesn't depend on the
umask, row 243) and `export PI_AUTH_FILE`. It has to be a regular file that exists, because a missing
bind source makes Docker create a directory on the host. The suites set it themselves, so the legacy
gate (outside the lane) is covered too.
2. `scripts/test-release.sh`: option A above.
3. `scripts/testing/gate-lane.mjs` `cleanEnv`: set `PI_AUTH_FILE` to `join(o.tempRoot,'pi-auth.json')`,
and `run` writes that file (`{}`, `flag:'wx'`, then chmod 0600) next to where it writes `bin/docker`.
Any later step that reaches Compose without its own fixture then gets the lane's fixture.
4. `scripts/testing/lane-docker.mjs` `guardDocker`: `compose run` also refuses unless `PI_AUTH_FILE` is
set, resolves inside `run.tempRoot` and isn't the host default (refusal text
`Compose auth mount not a lane fixture`). The guard already sees every Docker call a lane step makes,
so this is where "no gate step mounts the host file" is enforced at run time. The CLI entry passes
`process.env`.
5. `compose.yaml` is unchanged (brief: not in scope).
6. At landing: the DEFERRED entry moves to Done with the landing commit.
## Tests
In `packages/cli/tests/gate-lane.test.mjs` (row 197's file; the exact file follows 197's landing):
- `guardDocker` refuses `compose run` with `PI_AUTH_FILE` unset, set to the host default, or set outside
`tempRoot`; it accepts the lane fixture and a suite fixture under `tempRoot`. Other verbs are unchanged.
- `cleanEnv` returns a `PI_AUTH_FILE` inside `tempRoot` that isn't the host default.
- The suites, read through their Compose environment: run `test-task.sh` and `test-release.sh` with a
fake `docker` first on `PATH`. It answers `info` and `image inspect` with 0, and for `compose run` it
appends `PI_AUTH_FILE` and whether that file is a regular 0600 file to a record. Then it prints the
mock response. The test ignores the suites' own exit code and asserts that the record has at least one
`compose run` from each suite and that every entry names an existing fixture file, never
`/home/jwoltje/.pi/agent/auth.json`. The test never reads the host file. If a full suite run with the
fake docker turns out too slow or brittle, I'll say so in the review request, with timings, and fall
back to the guard and `cleanEnv` tests plus a static check that each suite exports `PI_AUTH_FILE`
before its first Compose call. I won't drop it silently.
Mutants (each must fail a test; run on Sage's slot):
- M-F: the fault task's `expectExact` forced to `MOSAIC_HELLO_OK` in `release.sh` `health_check`. Must
fail `test-release.sh` ("fault-injected activation refuses").
- M-G: the `guardDocker` `PI_AUTH_FILE` check removed. Must fail the guard test.
- M-A: test-release's config back to no adapter (pi). Must fail the fake-docker test (condition 3).
- M-L: `cleanEnv` stops setting `PI_AUTH_FILE`. Must fail the `cleanEnv` test.
Gate: those tests, `test-task.sh` and `test-release.sh` whole, the 197 lane tests, and the four
mutants, all on Sage's slot.
## Files (expected)
`scripts/test-task.sh`, `scripts/test-release.sh`, `scripts/testing/gate-lane.mjs`,
`scripts/testing/lane-docker.mjs`, `packages/cli/tests/gate-lane.test.mjs` (or a new
`gate-lane-auth.test.mjs`), `docs/plans/DEFERRED.md` (at landing), `scripts/testing/README.md` if it
lists the guard's rules.