Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94ab4c2fe6 | ||
|
|
81e037d20d | ||
|
|
47148991b4 |
@@ -1,128 +0,0 @@
|
||||
# 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.
|
||||
|
||||
## Build (sage-2056: start on row 197's landing a36523713)
|
||||
|
||||
Branch `morgana/row243`, one commit on a36523713. Cites rechecked there: all hold except proof-cli's
|
||||
write, which is now `proof-cli.test.mjs:30` (was :28).
|
||||
|
||||
- `bus/tests/process.test.mjs`: imports `chmodSync`; `chmodSync(file, 0o644)` after the write at :76.
|
||||
- `webui/tests/proof-cli.test.mjs:30`: `chmodSync(file, mode)` after the write, with each case's own
|
||||
mode, so the 0o600 cases are set explicitly too.
|
||||
- `webui/tests/sso.test.mjs:360`: `chmodSync(..., 0o644)` after the write, before the `--sso` start
|
||||
that must refuse.
|
||||
- `cli/tests/stack-instances.test.mjs`: `underUmask(mask, home, args)` runs `scripts/bootstrap.sh`
|
||||
through `/bin/bash -c 'umask <mask>; exec "$@"'`; `bootstrap()` is `underUmask("022", ...)`. New
|
||||
subtest in T-I1: under umask 077 the config dir and root are 0700, and the config and `instance.json`
|
||||
are 0600.
|
||||
- Hash pins: no test or script names any of the four files (a grep for the basenames; positive control
|
||||
`stack-release.test` found in two files). `stack-release.test.mjs:2122` mentions
|
||||
`stack-instances.test.mjs` only in a comment.
|
||||
|
||||
Tests: on Sage's slot (sage-2062, plan approved in sage-2059), 18:55Z. My busy check read CLEAR before
|
||||
and after. Each file ran whole with `node --test --test-concurrency=2` from its package, under
|
||||
`bash -c 'umask <mask>; exec ...'`, with TMPDIR from `mktemp -d /mnt/storage/scratch/tmp/g243XXXX`.
|
||||
Script `/mnt/storage/scratch/morgana-r243/slot243.sh`, logs in `logs/` next to it.
|
||||
|
||||
| Tree | umask | process | proof-cli | sso | stack-instances |
|
||||
|---|---|---|---|---|---|
|
||||
| base a36523713 | 077 | 7/1 | 2/1 | 27/1 | 21/2 |
|
||||
| candidate 137bda3af | 022 | 8/0 | 3/0 | 28/0 | 24/0 |
|
||||
| candidate 137bda3af | 077 | 8/0 | 3/0 | 28/0 | 24/0 |
|
||||
|
||||
(pass/fail.) The base fails are the predicted ones: "startup token refusal returns safe code...",
|
||||
"proof startup refuses partial flags...", "11: the CLI refuses --sso...", and in stack-instances the
|
||||
0644 subtest "alpha and beta get a 0700 config dir..." plus its parent T-I1 (so 2). stack-instances has
|
||||
one more test on the candidate (the umask 077 subtest). No unpredicted failure.
|
||||
@@ -0,0 +1,230 @@
|
||||
# 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.
|
||||
|
||||
## Correction (morgana-0040, 18:50Z)
|
||||
|
||||
The table above says `test-task.sh` uses only the mock adapter. That was wrong. On a36523713 two
|
||||
sections run the pi adapter through `$CONFIG` (provider zai) and authenticate through the host mount:
|
||||
`:443-501` (live hello, result.json written and correct, wrong expectExact, distinct run dirs, list,
|
||||
fork base, fork child, fork recall, ancestor untouched, child branch file) and `:518-530` (user recall
|
||||
run, recalled the user name). That's 13 checks and about 6 billed provider calls per gate run. I missed
|
||||
them because I read the mock section and stopped there.
|
||||
|
||||
**Ruling C (sage-2061, 18:50Z).** D (keep them live) refused.
|
||||
1. The run-record checks (hello, result.json, wrong expectExact, distinct run dirs, list) move to the
|
||||
mock adapter. Every assertion is kept. Only the response source changes.
|
||||
2. Fork recall and user recall run only under `MOSAIC_LIVE_PROVIDER=1`. Otherwise the suite prints
|
||||
`skip live provider cases (row 244: no provider call in the gate)`, which the lane's requiredSkips
|
||||
regex doesn't match, on purpose. A test asserts the line prints when the variable is unset and that
|
||||
the live checks run, and fail under a fixture, when it's 1 (M-T).
|
||||
3. The dropped coverage and the DEFERRED wording are below. Sage writes the entry at landing.
|
||||
|
||||
## Build (on a36523713, sage-2056)
|
||||
|
||||
Rechecked against 197's landing: `lane-docker.mjs:30` guardDocker, `gate-lane.mjs:109` cleanEnv and
|
||||
`:181` (bin/docker written), `compose.yaml:43`, `release.sh:41-55` health_check, `mosaic-task.mjs:321-322`
|
||||
spawnEnv and MOSAIC_ADAPTER, `test-release.sh:61` config. All hold.
|
||||
|
||||
- `scripts/testing/lane-docker.mjs`: `guardDocker(argv,run,env=process.env)`. After the option check, a
|
||||
`compose run` calls `laneAuthFile`. It refuses (`Compose auth mount not a lane fixture`) unless
|
||||
`PI_AUTH_FILE` is absolute, isn't the host default, resolves (realpath) inside the realpath of
|
||||
`run.tempRoot`, and is a regular file. Symlinks out of the root and relative paths refuse. `info` and
|
||||
`image inspect` need no auth file. The CLI entry passes `process.env`.
|
||||
- `scripts/testing/gate-lane.mjs`: `cleanEnv` sets `PI_AUTH_FILE=join(o.tempRoot,'pi-auth.json')`.
|
||||
`runGate` writes it (`{}`, flag `wx`, then `chmodSync` 0600) right after `bin/docker`.
|
||||
`MOSAIC_LIVE_PROVIDER` isn't on cleanEnv's keep list, so the lane can't request live cases.
|
||||
- `scripts/test-release.sh`: exports a `$SANDBOX/pi/auth.json` fixture (`{}`, `chmod 600` after the
|
||||
write, exits 1 if the write fails). The config adds `"adapter":"mock"` with the ruling A comment. The
|
||||
fault, healthy and repeat activations all run with `MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK`, so the
|
||||
fault activation refuses only because its `MOSAIC_FAULT_<n>` expectation is wrong. (Correction,
|
||||
before any run: the first build gave the fault activation no mock response. The mock then answered
|
||||
empty, which fails any expectation, so M-F would have survived. Fixed in the follow-up commit.)
|
||||
- `scripts/test-task.sh`: same fixture, exported only when `MOSAIC_LIVE_PROVIDER` isn't 1, so an
|
||||
operator's live run keeps their own `PI_AUTH_FILE` (or Compose's default). Ruling C (a) and (b) as
|
||||
above. The section header changes from "live: real runs" to "run records through the mock adapter",
|
||||
the check name "live hello task..." becomes "hello task...", and the docker-less branch of the new
|
||||
split prints `skip run-record cases (docker unavailable)`.
|
||||
- `packages/cli/tests/gate-lane-image.test.mjs` (:21, :106, :119): the compose-run calls pass
|
||||
`run.env`, the composeFixture env, which already holds a 0600 `PI_AUTH_FILE` inside tempRoot (accepted
|
||||
by sage-2061).
|
||||
- `scripts/testing/README.md`: one sentence on the lane fixture and the refusal.
|
||||
- New `packages/cli/tests/gate-lane-auth.test.mjs`, five tests:
|
||||
1. guardDocker accepts the lane fixture and a suite fixture under tempRoot. It refuses unset, empty,
|
||||
the host default, a file outside, a symlink inside that points outside, a missing path, a directory
|
||||
and a relative path. The option check still comes first. `info` and `image inspect` pass with no env.
|
||||
2. cleanEnv sets the lane fixture path and drops `MOSAIC_LIVE_PROVIDER` and the caller's
|
||||
`PI_AUTH_FILE`.
|
||||
3. test-release.sh in a private copy of the checkout, with a fake `docker` first on PATH. The fake
|
||||
answers every verb with 0. For `compose run` it records `PI_AUTH_FILE`, `stat -c '%F %a'` of it
|
||||
(never for the host path) and `MOSAIC_ADAPTER`, and it prints `MOSAIC_MOCK_RESPONSE` when the
|
||||
adapter is mock. Asserts: at least 3 compose runs, every one on a regular 0600 file under the
|
||||
suite's TMPDIR, and every adapter mock (condition 3). The healthy and fault lines print OK.
|
||||
4. test-task.sh, same harness: every compose run on its fixture and the mock adapter, the skip line
|
||||
printed exactly twice, and the hello check OK.
|
||||
5. M-T: test-task.sh with `MOSAIC_LIVE_PROVIDER=1` and an operator-style fixture. No skip line,
|
||||
`FAIL fork base: teach succeeds` and `FAIL recalled user name`, at least one pi run, and every run
|
||||
on the operator's file.
|
||||
The copy leaves out `.git`, `node_modules` (symlinked), `v1`, `agents`, `docs`, `BUILD-LOG.md` and
|
||||
`scripts/foundation`, about 14 MB. Copying is needed because `test-release.sh` rewrites `RELEASE`
|
||||
while it runs, which would race other cli tests that read it. The suites ignore their own exit codes
|
||||
here: the fake doesn't emit the mock adapter's env lines, so some seam checks fail by design.
|
||||
- Readers: `gate-lane.mjs` is imported by gate-lane.test, gate-lane-state.test,
|
||||
gate-lane-fixtures.test, gate-lane-image.test and fixtures/gate-lane-child.mjs (unchanged exports).
|
||||
`lane-docker.mjs` by gate-lane-image.test. No test hash-pins any changed file. vars.test.mjs names
|
||||
`scripts/test-task.sh` only as a string value.
|
||||
|
||||
Coverage dropped from the gate (for Webby, and for the DEFERRED entry):
|
||||
- `test-release.sh` no longer proves that the pi image reaches the provider (ruling A).
|
||||
- `test-task.sh` no longer proves session fork recall or onboarding user recall against a real model
|
||||
(ruling C).
|
||||
|
||||
DEFERRED wording for Sage (sage-2061 condition 3): "**Real-model suite cases run outside the gate.**
|
||||
Row 244 moved `test-task.sh` fork recall (M11) and user recall (M16) behind `MOSAIC_LIVE_PROVIDER=1`,
|
||||
and `test-release.sh` activations to the mock adapter. Before any re-pin whose tree changes
|
||||
`packages/harness` or the session fork code (`scripts/mosaic-task.mjs` session and `sessionForkFrom`
|
||||
handling, the pi adapter), an operator runs `MOSAIC_LIVE_PROVIDER=1 bash scripts/test-task.sh` with the
|
||||
real pi credential and a real `release.sh activate`, and records both results on the re-pin row."
|
||||
|
||||
Mutants (each must fail a named test; on Sage's slot):
|
||||
- M-F: `release.sh` health_check fault task `expectExact` forced to `MOSAIC_HELLO_OK`. Fails
|
||||
test-release.sh "fault-injected activation refuses", and test 3's OK match. This needs the fault
|
||||
activation's `MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK` (see the correction above).
|
||||
- M-G: the `laneAuthFile(env,run)` call removed from guardDocker. Fails test 1.
|
||||
- M-A: `,"adapter":"mock"` removed from test-release's config. Fails test 3 (adapter set is pi).
|
||||
- M-L: the `PI_AUTH_FILE` key removed from cleanEnv. Fails test 2.
|
||||
- M-T: test 5 itself (sage-2061). As a mutant check too: the `MOSAIC_LIVE_PROVIDER` gate in test-task
|
||||
forced on fails test 4 (skip line count and pi adapter).
|
||||
|
||||
Results, Sage's slot (sage-2147), candidate 81e037d20, 2026-10-21 20:46:16Z to 20:46:59Z. Busy check
|
||||
CLEAR before and after. Image `mosaic-poc-agent:0.85.1-r0.0.12` present, so nothing was built. No
|
||||
unexpected failure. Singles ran under umask 022 with TMPDIR under /mnt/storage/scratch/tmp.
|
||||
|
||||
| Run | rc | Result | Time |
|
||||
|---|---|---|---|
|
||||
| gate-lane-auth.test.mjs (new) | 0 | 5 pass, 0 fail | 14 s |
|
||||
| gate-lane-image.test.mjs | 0 | 11 pass, 0 fail | 0 s |
|
||||
| gate-lane.test.mjs | 0 | 13 pass, 0 fail | 0 s |
|
||||
| gate-lane-state.test.mjs | 0 | 14 pass, 0 fail | 2 s |
|
||||
| gate-lane-fixtures.test.mjs | 0 | 8 pass, 0 fail | 0 s |
|
||||
| test-task.sh | 0 | OK 91, FAIL 0, the two live-skip lines | 12 s |
|
||||
| test-release.sh | 0 | OK 14, FAIL 0, Docker section on the mock adapter | 2 s |
|
||||
|
||||
Mutants, each run on its named gate-lane-auth test and restored after:
|
||||
|
||||
| Mutant | Target | Result |
|
||||
|---|---|---|
|
||||
| M-G | test 1 | killed (0 pass, 1 fail) |
|
||||
| M-L | test 2 | killed (0 pass, 1 fail) |
|
||||
| M-A | test 3 | killed (0 pass, 1 fail) |
|
||||
| M-T | test 4 | killed (0 pass, 1 fail), 6 s |
|
||||
| M-F | test 3 | killed (0 pass, 1 fail) |
|
||||
| M-F | real test-release.sh | killed: rc 1, OK 11, FAIL 3, the first `fault-injected activation refuses (exit 0, expected 1)` |
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fork } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync, readFileSync, chmodSync } from 'node:fs';
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { once } from 'node:events';
|
||||
@@ -74,8 +74,6 @@ test('startup token refusal returns safe code without value or partial listening
|
||||
const secret = 'fixture-token-never-in-db-948723';
|
||||
const file = join(root, 'secret');
|
||||
writeFileSync(file, secret, { mode: 0o644 });
|
||||
// writeFileSync applies the umask; chmod sets the too-open mode under any umask (row 243).
|
||||
chmodSync(file, 0o644);
|
||||
const config = structuredClone(businesses);
|
||||
config.demo.roles.cto.credentials = { gitea: { file, rotateBy: '2099-01-01' } };
|
||||
const { child, logs } = launch(t);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Row 244: no gate step mounts the host's pi auth file or makes a provider call.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, symlinkSync, rmSync, cpSync, readFileSync, existsSync, realpathSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { guardDocker } from '../../../scripts/testing/lane-docker.mjs';
|
||||
import { cleanEnv } from '../../../scripts/testing/gate-lane.mjs';
|
||||
|
||||
const REPO = realpathSync(fileURLToPath(new URL('../../../', import.meta.url)));
|
||||
const HOST_AUTH = '/home/jwoltje/.pi/agent/auth.json';
|
||||
const LIVE_SKIP = 'skip live provider cases (row 244: no provider call in the gate)';
|
||||
const scratch = t => { const root = mkdtempSync('/mnt/storage/scratch/gt-'); t.after(() => rmSync(root, { recursive: true, force: true })); return root; };
|
||||
const fixture = (dir, name = 'auth.json') => { const file = join(dir, name); writeFileSync(file, '{}'); chmodSync(file, 0o600); return file; };
|
||||
const RUN = ['compose', 'run', '--rm', '-T', 'mosaic-agent', 'prompt'];
|
||||
|
||||
test('guardDocker refuses a compose run unless PI_AUTH_FILE is a regular file inside the lane temp root', t => {
|
||||
const tempRoot = scratch(t), outside = scratch(t);
|
||||
const run = { tempRoot, checkout: join(tempRoot, 'checkout'), imageId: 'sha256:' + 'a'.repeat(64), tag: 'mosaic-poc-agent:fixture' };
|
||||
const lane = fixture(tempRoot, 'pi-auth.json');
|
||||
mkdirSync(join(tempRoot, 'suite', 'pi'), { recursive: true });
|
||||
const suite = fixture(join(tempRoot, 'suite', 'pi'));
|
||||
for (const file of [lane, suite]) assert.equal(guardDocker(RUN, run, { PI_AUTH_FILE: file })[0], 'compose');
|
||||
const link = join(tempRoot, 'escape.json'); symlinkSync(fixture(outside), link);
|
||||
mkdirSync(join(tempRoot, 'dir.json'));
|
||||
for (const env of [{}, { PI_AUTH_FILE: '' }, { PI_AUTH_FILE: HOST_AUTH }, { PI_AUTH_FILE: fixture(outside) }, { PI_AUTH_FILE: link },
|
||||
{ PI_AUTH_FILE: join(tempRoot, 'missing.json') }, { PI_AUTH_FILE: join(tempRoot, 'dir.json') }, { PI_AUTH_FILE: relative(process.cwd(), lane) }])
|
||||
assert.throws(() => guardDocker(RUN, run, env), /Compose auth mount not a lane fixture/, JSON.stringify(env));
|
||||
// The option check still comes first, and the other allowed verbs need no auth file.
|
||||
assert.throws(() => guardDocker(['compose', 'run', '--build', 'mosaic-agent'], run, {}), /options not allowed/);
|
||||
assert.deepEqual(guardDocker(['info'], run, {}), ['info']);
|
||||
assert.deepEqual(guardDocker(['image', 'inspect', run.tag], run, {}), ['image', 'inspect', run.tag]);
|
||||
});
|
||||
|
||||
test('cleanEnv points PI_AUTH_FILE at the lane fixture and drops MOSAIC_LIVE_PROVIDER', t => {
|
||||
const tempRoot = scratch(t), saved = { PI_AUTH_FILE: process.env.PI_AUTH_FILE, MOSAIC_LIVE_PROVIDER: process.env.MOSAIC_LIVE_PROVIDER };
|
||||
process.env.PI_AUTH_FILE = HOST_AUTH; process.env.MOSAIC_LIVE_PROVIDER = '1';
|
||||
let env;
|
||||
try { env = cleanEnv({ tempRoot, lane: 'a', stateRoot: join(tempRoot, 'state'), out: join(tempRoot, 'out') }, { project: 'fixture' }, '/fake/docker'); }
|
||||
finally { for (const [k, v] of Object.entries(saved)) if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
||||
assert.equal(env.PI_AUTH_FILE, join(tempRoot, 'pi-auth.json'));
|
||||
assert.equal(env.MOSAIC_LIVE_PROVIDER, undefined);
|
||||
});
|
||||
|
||||
// Both suites run in a private copy of the checkout: test-release.sh rewrites RELEASE while it runs.
|
||||
// A fake docker records each compose run's PI_AUTH_FILE and MOSAIC_ADAPTER, and answers as the mock adapter.
|
||||
// It never reads or stats the host auth file.
|
||||
const SKIP_COPY = new Set(['.git', 'node_modules', 'v1', 'agents', 'docs', 'BUILD-LOG.md', join('scripts', 'foundation')]);
|
||||
function suiteLane(t) {
|
||||
const root = scratch(t), checkout = join(root, 'c'), bin = join(root, 'bin'), tmp = join(root, 'tmp');
|
||||
cpSync(REPO, checkout, { recursive: true, filter: src => !SKIP_COPY.has(relative(REPO, src)) });
|
||||
symlinkSync(realpathSync(join(REPO, 'node_modules')), join(checkout, 'node_modules'));
|
||||
mkdirSync(bin); mkdirSync(tmp, { mode: 0o700 });
|
||||
writeFileSync(join(bin, 'docker'), `#!/bin/bash
|
||||
[ "$1" = compose ] || exit 0
|
||||
f="\${PI_AUTH_FILE-}"
|
||||
if [ -z "\${PI_AUTH_FILE+x}" ]; then kind=unset; elif [ "$f" = ${HOST_AUTH} ]; then kind=host; else kind="$(stat -c '%F %a' -- "$f" 2>/dev/null || echo missing)"; fi
|
||||
printf '%s\\t%s\\t%s\\n' "$f" "$kind" "\${MOSAIC_ADAPTER-}" >> "$FAKE_DOCKER_RECORD"
|
||||
[ "\${MOSAIC_ADAPTER-}" = mock ] && printf '%s\\n' "\${MOSAIC_MOCK_RESPONSE-}"
|
||||
exit 0
|
||||
`);
|
||||
chmodSync(join(bin, 'docker'), 0o700);
|
||||
return (script, extra = {}) => {
|
||||
const record = join(root, `${script}-${Object.keys(extra).length}.tsv`), begin = performance.now();
|
||||
const env = { PATH: `${bin}:${process.env.PATH}`, HOME: process.env.HOME, TMPDIR: tmp, LANG: 'C.UTF-8', FAKE_DOCKER_RECORD: record, ...extra };
|
||||
const r = spawnSync('bash', [join(checkout, 'scripts', script)], { env, encoding: 'utf8', timeout: 600000 });
|
||||
t.diagnostic(JSON.stringify({ script, extra: Object.keys(extra), wallMs: Math.round(performance.now() - begin), status: r.status }));
|
||||
const runs = existsSync(record) ? readFileSync(record, 'utf8').trim().split('\n').filter(Boolean).map(l => { const [file, kind, adapter] = l.split('\t'); return { file, kind, adapter }; }) : [];
|
||||
return { out: r.stdout + r.stderr, runs, tmp };
|
||||
};
|
||||
}
|
||||
const fixtureRuns = (runs, tmp) => runs.every(r => r.file !== HOST_AUTH && r.file.startsWith(tmp + '/') && r.kind === 'regular file 600');
|
||||
|
||||
test('test-release.sh activates through the mock adapter with a 0600 fixture auth file', t => {
|
||||
const { out, runs, tmp } = suiteLane(t)('test-release.sh');
|
||||
assert.ok(runs.length >= 3, `fault, healthy and repeat activations reach Compose\n${out}`);
|
||||
assert.ok(fixtureRuns(runs, tmp), JSON.stringify(runs));
|
||||
assert.deepEqual([...new Set(runs.map(r => r.adapter))], ['mock'], 'ruling A: no pi adapter in the gate');
|
||||
assert.match(out, /OK +healthy activation succeeds/); assert.match(out, /OK +fault-injected activation refuses/);
|
||||
});
|
||||
|
||||
test('test-task.sh mounts only its fixture, uses only the mock adapter and prints the live skip twice', t => {
|
||||
const { out, runs, tmp } = suiteLane(t)('test-task.sh');
|
||||
assert.ok(runs.length >= 1, out);
|
||||
assert.ok(fixtureRuns(runs, tmp), JSON.stringify(runs));
|
||||
assert.deepEqual([...new Set(runs.map(r => r.adapter))], ['mock']);
|
||||
assert.equal(out.split('\n').filter(l => l === LIVE_SKIP).length, 2, out);
|
||||
assert.match(out, /OK +hello task succeeds with exact marker/);
|
||||
});
|
||||
|
||||
test('M-T: MOSAIC_LIVE_PROVIDER=1 runs the real-model cases, which fail under a fixture auth file', t => {
|
||||
const lane = suiteLane(t), auth = join(scratch(t), 'operator-auth.json'); writeFileSync(auth, '{}'); chmodSync(auth, 0o600);
|
||||
const { out, runs } = lane('test-task.sh', { MOSAIC_LIVE_PROVIDER: '1', PI_AUTH_FILE: auth });
|
||||
assert.ok(!out.includes(LIVE_SKIP), out);
|
||||
// The fake answers a pi run with nothing: the fork base misses REMEMBERED, recall misses the name.
|
||||
assert.match(out, /FAIL fork base: teach succeeds/); assert.match(out, /FAIL recalled user name/);
|
||||
assert.ok(runs.some(r => r.adapter === 'pi'), JSON.stringify(runs));
|
||||
assert.ok(runs.every(r => r.file === auth), 'live mode keeps the operator\'s PI_AUTH_FILE');
|
||||
});
|
||||
@@ -18,7 +18,7 @@ test('a changed COPY input or actual image ID refuses compatible image sharing',
|
||||
test('warm Docker guard disables implicit build and pull and refuses image mutation',t=>{
|
||||
assert.equal(typeof docker.guardDocker,'function','Docker guard required');
|
||||
const run=composeFixture(t);
|
||||
assert.deepEqual(docker.guardDocker(['compose','run','--rm','-T','mosaic-agent','prompt'],run),['compose','-f',join(run.checkout,'compose.yaml'),'-f',join(run.tempRoot,'compose-no-build.yaml'),'run','--pull','never','--no-deps','--rm','-T','mosaic-agent','prompt']);
|
||||
assert.deepEqual(docker.guardDocker(['compose','run','--rm','-T','mosaic-agent','prompt'],run,run.env),['compose','-f',join(run.checkout,'compose.yaml'),'-f',join(run.tempRoot,'compose-no-build.yaml'),'run','--pull','never','--no-deps','--rm','-T','mosaic-agent','prompt']);
|
||||
for(const args of [['build','.'],['image','prune'],['compose','build'],['tag','a','b'],['network','rm','foreign']])assert.throws(()=>docker.guardDocker(args,run),/refused/);
|
||||
});
|
||||
test('exclusive image preparation records actual image ID and build wall time and requires a grant',t=>{
|
||||
@@ -103,7 +103,7 @@ function composeFixture(t) {
|
||||
test('installed Compose accepts rewritten run argv without starting a container',t=>{
|
||||
const run=composeFixture(t);
|
||||
if(docker.writeComposeOverride)docker.writeComposeOverride(run);
|
||||
const argv=docker.guardDocker(['compose','run','--rm','-T','mosaic-agent','fixture'],run);
|
||||
const argv=docker.guardDocker(['compose','run','--rm','-T','mosaic-agent','fixture'],run,run.env);
|
||||
const command=argv.indexOf('run');assert.ok(command>0);
|
||||
// --help is before SERVICE, so parsing exits before any service operation.
|
||||
const parsed=spawnSync(installedDocker(),[...argv.slice(0,command+1),'--help',...argv.slice(command+1)],{cwd:run.checkout,env:run.env,encoding:'utf8',timeout:10000});
|
||||
@@ -116,7 +116,7 @@ test('installed Compose resolves the private override with no build and the pinn
|
||||
assert.equal(override,join(run.tempRoot,'compose-no-build.yaml'));
|
||||
assert.equal(statSync(override).mode&0o777,0o600);
|
||||
assert.equal(docker.writeComposeOverride(run),override,'parallel callers reuse identical private content');
|
||||
const argv=docker.guardDocker(['compose','run','--rm','mosaic-agent'],run),command=argv.indexOf('run');
|
||||
const argv=docker.guardDocker(['compose','run','--rm','mosaic-agent'],run,run.env),command=argv.indexOf('run');
|
||||
const resolved=spawnSync(installedDocker(),[...argv.slice(0,command),'config','--format','json'],{cwd:run.checkout,env:run.env,encoding:'utf8',timeout:10000});
|
||||
assert.equal(resolved.status,0,resolved.stdout+resolved.stderr);
|
||||
const service=JSON.parse(resolved.stdout).services['mosaic-agent'];
|
||||
|
||||
@@ -37,10 +37,7 @@ function run(cmd, args, env) {
|
||||
return { code: r.status, out: r.stdout, err: r.stderr };
|
||||
}
|
||||
|
||||
// The child runs under an explicit umask, so the mode assertions hold under any gate umask (row 243,
|
||||
// sage-1918). mosaic-config.mjs creates with 0644 and lets the umask narrow it.
|
||||
const underUmask = (mask, home, args) => run("/bin/bash", ["-c", `umask ${mask}; exec "$@"`, "bash", BOOTSTRAP, ...args], homeEnv(home));
|
||||
const bootstrap = (home, ...args) => underUmask("022", home, args);
|
||||
const bootstrap = (home, ...args) => run(BOOTSTRAP, args, homeEnv(home));
|
||||
const config = (home, extra, ...args) => run(process.execPath, [CONFIG, ...args], homeEnv(home, extra));
|
||||
const cfgOf = (home, name) => join(home, ".config", `mosaic-${name}`, "config.json");
|
||||
|
||||
@@ -97,16 +94,6 @@ test("T-I1 bootstrap --instance creates each instance's config, root and marker
|
||||
assert.equal(existsSync(join(home, ".mosaic-dev")), false);
|
||||
});
|
||||
|
||||
await t.test("under umask 077 the config and marker are 0600: the source honors the operator's umask", (t) => {
|
||||
const home = tmp(t, "si-");
|
||||
const r = underUmask("077", home, ["--instance", "alpha"]);
|
||||
assert.equal(r.code, 0, r.err);
|
||||
assert.equal(mode(join(home, ".config", "mosaic-alpha")), 0o700);
|
||||
assert.equal(mode(cfgOf(home, "alpha")), 0o600);
|
||||
assert.equal(mode(join(home, ".mosaic-alpha")), 0o700);
|
||||
assert.equal(mode(join(home, ".mosaic-alpha", "instance.json")), 0o600);
|
||||
});
|
||||
|
||||
await t.test("a second bootstrap of each changes no byte, mode or mtime", (t) => {
|
||||
const home = tmp(t, "si-");
|
||||
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
|
||||
|
||||
@@ -27,7 +27,7 @@ test('proof startup refuses partial flags non-preview and invalid fixture file m
|
||||
const cases = [
|
||||
...sets.map(flags => ({ flags })),
|
||||
...[['mode', VALUE, 0o644], ['short', 'short_fixture', 0o600], ['newline', VALUE.slice(0, 20) + '\n' + VALUE.slice(20), 0o600], ['bad', VALUE + '!', 0o600], ['two-final-lines', VALUE + '\n\n', 0o600]].map(([name, value, mode]) => {
|
||||
const file = join(f.root, name); writeFileSync(file, value, { mode }); chmodSync(file, mode); // umask-proof (row 243)
|
||||
const file = join(f.root, name); writeFileSync(file, value, { mode });
|
||||
return { flags: [...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], value };
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -358,7 +358,6 @@ test('11: the CLI refuses --sso with the old flags and without console.sso, and
|
||||
assert.match(noKey.stderr, /^refused: --sso needs console\.sso in the system config$/m);
|
||||
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify({ ...base, console: { sso: { publicOrigin: ORIGIN, providers: [providerFor(a)] } } }));
|
||||
writeFileSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), `${a.clientSecret}\n`, { mode: 0o644 });
|
||||
chmodSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), 0o644); // writeFileSync applies the umask (row 243)
|
||||
const wide = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
|
||||
assert.equal(wide.status, 2);
|
||||
assert.match(wide.stderr, /^refused: console\.sso provider a client secret slot must have no group or other permission bits$/m);
|
||||
|
||||
+13
-4
@@ -13,6 +13,11 @@ cp RELEASE "$RELEASE_BACKUP"
|
||||
# One exit trap: the repo RELEASE is ALWAYS restored from the backup,
|
||||
# regardless of how the test run ends.
|
||||
trap 'cp "$RELEASE_BACKUP" RELEASE 2>/dev/null; rm -rf "$SANDBOX" "$RELEASE_BACKUP"' EXIT
|
||||
# Compose mounts PI_AUTH_FILE. Point it at a fixture so no activation reaches
|
||||
# the host's pi credential (row 244). chmod after the write: umask-proof (row 243).
|
||||
{ mkdir -p "$SANDBOX/pi" && printf '{}' > "$SANDBOX/pi/auth.json" && chmod 600 "$SANDBOX/pi/auth.json"; } \
|
||||
|| { echo "cannot write the pi auth fixture" >&2; exit 1; }
|
||||
export PI_AUTH_FILE="$SANDBOX/pi/auth.json"
|
||||
|
||||
PASS=0
|
||||
# Status colors: terminal-only, NO_COLOR-respecting; plain when piped.
|
||||
@@ -57,8 +62,11 @@ check "valid RELEASE leaves image tag consistent with version" $?
|
||||
# ---------- sandboxed state machine (Docker required) ----------
|
||||
if docker info >/dev/null 2>&1; then
|
||||
mkdir -p "$SANDBOX/data"
|
||||
# The mock adapter: no credential and no provider call in the gate (row 244,
|
||||
# ruling A, sage-1914). This suite proves the release state machine, image and
|
||||
# Compose path, not that the pi image reaches the provider.
|
||||
cat > "$SANDBOX/config.json" <<EOF
|
||||
{"configVersion":1,"environment":"development","dataRoot":"$SANDBOX/data","execution":{"backend":"docker","provider":"zai","model":"glm-5.3-flash"}}
|
||||
{"configVersion":1,"environment":"development","dataRoot":"$SANDBOX/data","execution":{"backend":"docker","provider":"zai","model":"glm-5.3-flash","adapter":"mock"}}
|
||||
EOF
|
||||
export MOSAIC_CONFIG="$SANDBOX/config.json"
|
||||
|
||||
@@ -66,7 +74,8 @@ EOF
|
||||
[ ! -e "$SANDBOX/data/state/active.json" ] \
|
||||
&& check "status created no pointer" 0 || check "status created no pointer" 1
|
||||
|
||||
expect_exit "fault-injected activation refuses" 1 -- scripts/release.sh activate --fault-injection
|
||||
# The mock gives the right marker, so only the fault's wrong expectation refuses.
|
||||
expect_exit "fault-injected activation refuses" 1 -- env MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK scripts/release.sh activate --fault-injection
|
||||
[ ! -e "$SANDBOX/data/state/active.json" ] \
|
||||
&& check "refused activation wrote no pointer" 0 || check "refused activation wrote no pointer" 1
|
||||
if [ -f "$SANDBOX/data/state/activation-log.jsonl" ]; then
|
||||
@@ -82,7 +91,7 @@ process.exit(e.event === "refused" && e.release && e.imageTag && e.at ? 0 : 1);
|
||||
check "refusal logged exactly once with valid fields" 1
|
||||
fi
|
||||
|
||||
expect_exit "healthy activation succeeds" 0 -- scripts/release.sh activate
|
||||
expect_exit "healthy activation succeeds" 0 -- env MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK scripts/release.sh activate
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
@@ -90,7 +99,7 @@ process.exit(p.pointerVersion === 1 && p.release && p.imageTag && p.activatedAt
|
||||
' "$SANDBOX/data/state/active.json"
|
||||
check "pointer written with valid fields" $?
|
||||
|
||||
expect_exit "repeat activation succeeds (log grows)" 0 -- scripts/release.sh activate
|
||||
expect_exit "repeat activation succeeds (log grows)" 0 -- env MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK scripts/release.sh activate
|
||||
LINES=$(grep -c '' "$SANDBOX/data/state/activation-log.jsonl")
|
||||
[ "$LINES" -ge 3 ] && check "log is append-only across activations" 0 || check "log is append-only across activations" 1
|
||||
|
||||
|
||||
+34
-9
@@ -9,6 +9,18 @@ cd "$(dirname "$0")/.."
|
||||
|
||||
SANDBOX="$(mktemp -d)"
|
||||
trap 'rm -rf "$SANDBOX"' EXIT
|
||||
# Real-model cases need the operator's credential and a billed provider call,
|
||||
# so they run only on request (row 244, sage-2061). The gate lane never passes
|
||||
# MOSAIC_LIVE_PROVIDER through.
|
||||
LIVE_SKIP="skip live provider cases (row 244: no provider call in the gate)"
|
||||
# Compose mounts PI_AUTH_FILE. Unless live cases were requested, point it at a
|
||||
# fixture so no run reaches the host's pi credential (row 244). chmod after the
|
||||
# write: umask-proof (row 243).
|
||||
if [ "${MOSAIC_LIVE_PROVIDER:-}" != "1" ]; then
|
||||
{ mkdir -p "$SANDBOX/pi" && printf '{}' > "$SANDBOX/pi/auth.json" && chmod 600 "$SANDBOX/pi/auth.json"; } \
|
||||
|| { echo "cannot write the pi auth fixture" >&2; exit 1; }
|
||||
export PI_AUTH_FILE="$SANDBOX/pi/auth.json"
|
||||
fi
|
||||
|
||||
PASS=0
|
||||
# Status colors: terminal-only, NO_COLOR-respecting; plain when piped.
|
||||
@@ -423,7 +435,8 @@ else
|
||||
echo "skip workspace/capability cases (docker daemon unavailable)"
|
||||
fi
|
||||
|
||||
# ---------- live: real runs (Docker + credentials required) ----------
|
||||
# ---------- run records through the mock adapter (Docker required) ----------
|
||||
# These prove the run-record path, not the model, so the mock answers (row 244).
|
||||
# On failure, surface the run record + agent stderr BEFORE the sandbox
|
||||
# cleanup destroys them. Never let a wrong-exit mask the real reason.
|
||||
dump_latest_run() {
|
||||
@@ -441,14 +454,15 @@ dump_latest_run() {
|
||||
|
||||
if docker info >/dev/null 2>&1; then
|
||||
RUNS1=$(ls "$DATA_ROOT/runs" 2>/dev/null | wc -l)
|
||||
if scripts/run-task.sh run "$SANDBOX/ok.json" >/dev/null 2>&1; then
|
||||
PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} live hello task succeeds with exact marker"
|
||||
if env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK \
|
||||
scripts/run-task.sh run "$SANDBOX/ok.json" >/dev/null 2>&1; then
|
||||
PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} hello task succeeds with exact marker"
|
||||
else
|
||||
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} live hello task succeeds with exact marker" >&2
|
||||
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} hello task succeeds with exact marker" >&2
|
||||
dump_latest_run
|
||||
fi
|
||||
|
||||
R1="$(ls -t "$DATA_ROOT/runs" | head -1)" # newest = the live run above
|
||||
R1="$(ls -t "$DATA_ROOT/runs" | head -1)" # newest = the hello run above
|
||||
[ -f "$DATA_ROOT/runs/$R1/result.json" ] && check "result.json written in run dir" 0 || check "result.json written in run dir" 1
|
||||
node -e '
|
||||
const r = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
|
||||
@@ -457,7 +471,8 @@ process.exit(r.status === "succeeded" && r.response === "MOSAIC_HELLO_OK" && r.e
|
||||
check "result.json contents are correct" $?
|
||||
|
||||
printf '{"taskVersion":1,"id":"t-wrong","prompt":"Return your startup marker and nothing else.","expectExact":"MOSAIC_NOT_OK"}' > "$SANDBOX/wrong.json"
|
||||
scripts/run-task.sh run "$SANDBOX/wrong.json" >/dev/null 2>&1
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK \
|
||||
scripts/run-task.sh run "$SANDBOX/wrong.json" >/dev/null 2>&1
|
||||
RC=$?
|
||||
WRONG_REASON="$(latest_reason)"
|
||||
if [ "$RC" -eq 1 ] && [ "$WRONG_REASON" = "expect-mismatch" ]; then
|
||||
@@ -473,6 +488,14 @@ process.exit(r.status === "succeeded" && r.response === "MOSAIC_HELLO_OK" && r.e
|
||||
|
||||
COUNT=$($TASK list | wc -l)
|
||||
[ "$COUNT" -ge 2 ] && check "list shows both runs" 0 || check "list shows both runs" 1
|
||||
else
|
||||
echo "skip run-record cases (docker unavailable)"
|
||||
fi
|
||||
|
||||
# ---------- live: real model (Docker + operator credential, on request) ----------
|
||||
if [ "${MOSAIC_LIVE_PROVIDER:-}" != "1" ]; then
|
||||
echo "$LIVE_SKIP"
|
||||
elif docker info >/dev/null 2>&1; then
|
||||
|
||||
# session fork (M11): teach in base, fork into child, child recalls;
|
||||
# ancestor file count must be unchanged by the fork
|
||||
@@ -512,9 +535,11 @@ grep -q "## Skillset" "$ONB/user/USER.md" && grep -q "## Pets" "$ONB/user/USER.m
|
||||
&& check "canon sections present" 0 || check "canon sections present" 1
|
||||
|
||||
# Live recall: real pi, real phrasing - assert containment, not exactness.
|
||||
# Needs Docker like the live task cases above. Without it the pair prints
|
||||
# a skip and counts as neither a pass nor a failure.
|
||||
if docker info >/dev/null 2>&1; then
|
||||
# Needs Docker and MOSAIC_LIVE_PROVIDER=1 like the live fork cases above.
|
||||
# Without either the pair prints a skip and counts as neither a pass nor a failure.
|
||||
if [ "${MOSAIC_LIVE_PROVIDER:-}" != "1" ]; then
|
||||
echo "$LIVE_SKIP"
|
||||
elif docker info >/dev/null 2>&1; then
|
||||
printf '{"taskVersion":1,"id":"t-user","prompt":"What is the user name? Reply with only the name.","session":"onb-check","timeoutSeconds":180}' > "$SANDBOX/recall-task.json"
|
||||
RC=0
|
||||
scripts/run-task.sh run "$SANDBOX/recall-task.json" >/dev/null 2>&1 || RC=$?
|
||||
|
||||
@@ -21,7 +21,9 @@ scripts/testing/gate-lane.sh run --lane a --candidate FULL_SHA \
|
||||
Each run creates a private root like `/mnt/storage/scratch/ga-ABCDEF` (30 bytes).
|
||||
An explicit `--temp-root` must already be private, outside `/tmp`, and no longer
|
||||
than 30 UTF-8 bytes after resolving symlinks. The sandbox's own `/tmp` and HOME
|
||||
are unchanged. Host HOME stays real. A listener covering loopback port 9, an
|
||||
are unchanged. Host HOME stays real. Steps get `PI_AUTH_FILE` naming a private `{}`
|
||||
fixture in the temp root, and the Docker guard refuses a Compose run whose
|
||||
`PI_AUTH_FILE` isn't a regular file inside the temp root (row 244). A listener covering loopback port 9, an
|
||||
unsafe privileged-port threshold or unreadable host inventory refuses startup.
|
||||
|
||||
Lane a leases ports 61000..61127, lane b 61128..61255. Refuse any band overlapping
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function executePinnedSteps(steps,options,{receipt,checkout,inspect
|
||||
verifyImage(receipt,checkout,inspect);return {rc:0,imageBuilds:0};
|
||||
}
|
||||
|
||||
import { mkdirSync, lstatSync, fstatSync, mkdtempSync, writeFileSync, readFileSync, openSync, readlinkSync, rmdirSync } from 'node:fs';
|
||||
import { mkdirSync, lstatSync, fstatSync, mkdtempSync, writeFileSync, readFileSync, openSync, readlinkSync, rmdirSync, chmodSync } from 'node:fs';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { status, appendRecord, runRecords, processInventory, sameProcess, nonce } from './lane-state.mjs';
|
||||
@@ -109,7 +109,9 @@ export function activeConflicts(o,observations={}){
|
||||
export function cleanEnv(o,run,docker){
|
||||
const kept=['PATH','HOME','USER','LOGNAME','SHELL','LANG','LC_ALL','LC_CTYPE','TZ','TERM','XDG_RUNTIME_DIR','DBUS_SESSION_BUS_ADDRESS','DOCKER_HOST','CHROMIUM'];
|
||||
const env=Object.fromEntries(kept.filter(k=>typeof process.env[k]==='string').map(k=>[k,process.env[k]]));
|
||||
Object.assign(env,{TMPDIR:o.tempRoot,TMP:o.tempRoot,TEMP:o.tempRoot,MOSAIC_GATE_LANE:o.lane,MOSAIC_GATE_STATE:o.stateRoot,MOSAIC_TEST_CONCURRENCY:'2',COMPOSE_PROJECT_NAME:run.project,JITI_FS_CACHE:join(o.tempRoot,'jiti'),XDG_CACHE_HOME:join(o.tempRoot,'cache'),MOSAIC_CONFIG:join(o.tempRoot,'missing-config.json'),MOSAIC_GATE_DOCKER_RECORD:join(o.out,'docker.json')});
|
||||
Object.assign(env,{TMPDIR:o.tempRoot,TMP:o.tempRoot,TEMP:o.tempRoot,MOSAIC_GATE_LANE:o.lane,MOSAIC_GATE_STATE:o.stateRoot,MOSAIC_TEST_CONCURRENCY:'2',COMPOSE_PROJECT_NAME:run.project,JITI_FS_CACHE:join(o.tempRoot,'jiti'),XDG_CACHE_HOME:join(o.tempRoot,'cache'),MOSAIC_CONFIG:join(o.tempRoot,'missing-config.json'),MOSAIC_GATE_DOCKER_RECORD:join(o.out,'docker.json'),
|
||||
// A step reaching Compose mounts this fixture, never the host's pi auth file (row 244).
|
||||
PI_AUTH_FILE:join(o.tempRoot,'pi-auth.json')});
|
||||
env.PATH=`${join(o.out,'bin')}:${env.PATH??'/usr/bin:/bin'}`;
|
||||
return env;
|
||||
}
|
||||
@@ -179,6 +181,7 @@ async function runGate(input){
|
||||
const guard=join(BUILDER,'scripts/testing/lane-docker.mjs');
|
||||
// Launch the guard directly so it validates every real Docker invocation.
|
||||
writeFileSync(join(o.out,'bin','docker'),`#!/bin/sh\nexec '${process.execPath.replaceAll("'","'\\''")}' '${guard.replaceAll("'","'\\''")}' "$@"\n`,{mode:0o700,flag:'wx'});
|
||||
const auth=join(o.tempRoot,'pi-auth.json');writeFileSync(auth,'{}',{mode:0o600,flag:'wx'});chmodSync(auth,0o600);
|
||||
const steps=discoverSteps(o.checkout);record({kind:'plan',steps});
|
||||
const before=homeMetadata(process.env.HOME);record({kind:'home-before',entries:before});
|
||||
const env=cleanEnv(o,run,docker);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, linkSync, unlinkSync, openSync, closeSync, fstatSync, constants } from 'node:fs';
|
||||
import { readFileSync, writeFileSync, linkSync, unlinkSync, openSync, closeSync, fstatSync, realpathSync, statSync, constants } from 'node:fs';
|
||||
import { join, isAbsolute } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { refuse, shortRoot, nonce } from './lane-state.mjs';
|
||||
import { refuse, shortRoot, nonce, within } from './lane-state.mjs';
|
||||
const HOST_PI_AUTH='/home/jwoltje/.pi/agent/auth.json';
|
||||
function composeFiles(run){
|
||||
if(!/^sha256:[a-f0-9]{64}$/.test(run.imageId??''))refuse('invalid pinned image ID');
|
||||
if(typeof run.checkout!=='string'||!isAbsolute(run.checkout))refuse('invalid candidate checkout');
|
||||
@@ -27,12 +28,19 @@ export function writeComposeOverride(run){
|
||||
finally{if(fd!==undefined)closeSync(fd);}
|
||||
return file;
|
||||
}
|
||||
export function guardDocker(argv,run){
|
||||
// compose.yaml mounts PI_AUTH_FILE, defaulting to the host's real pi auth file (row 244).
|
||||
function laneAuthFile(env,run){
|
||||
const file=env.PI_AUTH_FILE;let real,root;
|
||||
try{real=realpathSync(file);root=realpathSync(run.tempRoot);}catch{refuse('Compose auth mount not a lane fixture');}
|
||||
if(!isAbsolute(file)||file===HOST_PI_AUTH||real===HOST_PI_AUTH||!within(root,real)||!statSync(real).isFile())refuse('Compose auth mount not a lane fixture');
|
||||
}
|
||||
export function guardDocker(argv,run,env=process.env){
|
||||
if(argv[0]==='info'&&argv.length===1)return argv;
|
||||
if(argv[0]==='image'&&argv[1]==='inspect'&&argv[2]===run.tag&&argv.length===3)return argv;
|
||||
if(argv[0]==='compose'&&argv[1]==='run'){
|
||||
const service=argv.indexOf('mosaic-agent',2);
|
||||
if(service<0||argv.slice(2,service).some(a=>!['--rm','-T','--no-deps','--interactive','-i'].includes(a)))refuse('Docker Compose run options not allowed');
|
||||
laneAuthFile(env,run);
|
||||
const [base,override]=composeFiles(run);
|
||||
return ['compose','-f',base,'-f',override,'run','--pull','never','--no-deps',...argv.slice(2)];
|
||||
}
|
||||
@@ -55,6 +63,6 @@ export function cleanupProject(run,docker='docker'){
|
||||
}
|
||||
if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href){
|
||||
try{const run=JSON.parse(readFileSync(process.env.MOSAIC_GATE_DOCKER_RECORD,'utf8'));if(process.env.COMPOSE_PROJECT_NAME!==run.project)refuse('Compose project mismatch');
|
||||
const argv=guardDocker(process.argv.slice(2),run);if(argv[0]==='compose')writeComposeOverride(run);const result=spawnSync(run.docker,argv,{stdio:'inherit'});process.exit(result.status??1);
|
||||
const argv=guardDocker(process.argv.slice(2),run,process.env);if(argv[0]==='compose')writeComposeOverride(run);const result=spawnSync(run.docker,argv,{stdio:'inherit'});process.exit(result.status??1);
|
||||
}catch(e){process.stderr.write(`${e.message}\n`);process.exit(2);}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user