Compare commits

...
8 changed files with 400 additions and 23 deletions
+230
View File
@@ -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)` |
+100
View File
@@ -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');
});
+3 -3
View 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'];
+13 -4
View File
@@ -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
View File
@@ -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=$?
+3 -1
View File
@@ -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
+5 -2
View File
@@ -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);
+12 -4
View File
@@ -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);}
}