diff --git a/agents/researcher/work/queue-98/build.md b/agents/researcher/work/queue-98/build.md new file mode 100644 index 000000000..cbd77598c --- /dev/null +++ b/agents/researcher/work/queue-98/build.md @@ -0,0 +1,75 @@ +# Row 98 (#1592): Row 77 follow-up, four refusal cases in the service tests + +Owner: Researcher. Reviewer: Filbert. Brief: `docs/plans/2026-10-12_console-pinned-copy.md`, "Row 77 +follow-up". Built in a scratch worktree at 646e3f77; between 646e3f77 and HEAD nothing changed on the +candidate paths or on `scripts/webui-service.sh`. **No change to `scripts/webui-service.sh`**: no new +test found a defect in it. Tests only; nothing goes live; no Mos notice. + +## Candidate + +`candidate-manifest.sha256`: two files, repo-relative, hashed from the candidate worktree. +`build.patch` is `git diff` of them (71 insertions, 2 deletions; sha256 +c5127ba725f41bfa8cd255192839597f88803ae3decc53009a5453553c708cc3). + +- `packages/webui/tests/service.test.mjs`: the bus and status cases, and a `busOther` option on the + `standIn` systemctl so a second loaded unit can answer `show` apart from the first. +- `packages/webui/tests/pinned-copy.test.mjs`: the three rename cases. Placement: the brief names + `service.test.mjs`, but the rename cases need the fixture repository (`fixtureRepo`, `run`) that + only this file has, beside the existing rename-race test. Sage allowed it (note at rev 841); + Filbert confirms. + +## Cases and mutants + +`mutants.py` makes each edit to `scripts/webui-service.sh` alone, runs the test file that holds the +case, and restores the script (sha256 checked). Baseline worktree = 3bd3f85c; candidate = this build. + +| # | mutant | case | baseline | candidate | +|---|---|---|---|---| +| 1 | `LoadState` check refuses only `not-found` | N1 (Filbert): `bad-setting`, `error`, `masked` units, each with an `ExecStart` on the copy, refuse naming the state | ALIVE | killed | +| 2 | rename failure without a target not refused (the `[ -e "$target" ] \|\| die` line dropped) | N2 (Filbert): stand-in `mv` exits 1 and creates nothing: status 1, stderr "could not rename the copy into ", stdout empty, `copies/` empty | ALIVE | killed | +| 3 | `mv -T` becomes `mv` | N2 (Filbert), second half: an empty target directory exists at rename time and the real `mv` runs: `copied:` and the target holds the copy | ALIVE | killed | +| 4a | status copy id loses both anchors | N1 (Darkwing): `WorkingDirectory` ending `<40 hex>x`, `x<40 hex>` or 41 hex digits prints `copy: none` and no `refactor:` line | ALIVE | killed | +| 4b | loses its start anchor | same | ALIVE | killed | +| 4c | loses its end anchor | same (the 41-hex case) | ALIVE | killed | +| 5a | bus check reads only the first unit | N2 (Darkwing): two loaded units | killed | killed | +| 5b | path rule applied to the first unit only | N2 (Darkwing): the second unit runs from the checkout; install refuses naming `mosaic-bus@other.service` and its path, and both `show` calls are logged | ALIVE | killed | + +Baseline 1 of 8 killed; candidate 8 of 8 (`mut-base.txt`, `mut-candidate.txt`). The brief lists five +mutants (four edits plus dropping `-T`); 4 is split into three anchor variants and 5 into two, so the +table is eight. 5a was already killed in the baseline by the call-log assertion in the existing +two-unit case; it is Darkwing's gap (5b) the new case closes. + +## Mutant 3 needs care: what `-T` actually changes + +The brief's reasoning ("without `-T`, `mv` survives for the same reason") held for the stand-in `mv` +that ignores its flags. With a real `mv`, a non-empty target does not tell the two apart. The copy's +directories are `0555`, and renaming a directory into a new parent needs write permission on the +directory itself, so plain `mv` fails with Permission denied, where `mv -T` fails with Directory not +empty. Both end at `exists:`. Probed directly, as the user, on a `0555` source: + +| target | `mv -T` | `mv` | +|---|---|---| +| empty directory | rc 0, replaces it | rc 1, Permission denied, target stays empty | +| non-empty directory | rc 1, Directory not empty | rc 1, Permission denied | + +So only an empty target tells them apart: with `-T` the copy replaces it and prints `copied:`; +without, `copy` would print `exists:` over an empty directory, a copy that was never written. +`a copy that meets an empty target directory ...` pins that. It also fixes in a test the behaviour +that an empty directory at the target is replaced. If Filbert judges that incidental, mutant 3 is +equivalent under real inputs and that test can go; the non-empty test +(`a copy that meets a target holding files ...`) stays either way, since it holds the stand-in-free +outcome (`exists:`, nothing moved into the target, temp directory removed). + +## Suites + +- Both service test files on the unmodified script, candidate: 33 pass, 0 fail (32 before the + empty-target test). +- webui `node --test tests/`: 104 pass, 0 fail. +- Not run here: `scripts/test-*.sh` and the control-board suite (tests-only change in webui; Sage's + gate rerun covers them). +- After the runs no process had a cwd under my scratch (checked through /proc cwd links, 0 found). + +## Secrets scan + +`build.patch`, `mutants.py` and the mutant outputs: no token, secret, password, api key or private +key marker. diff --git a/agents/researcher/work/queue-98/build.patch b/agents/researcher/work/queue-98/build.patch new file mode 100644 index 000000000..ba82122ae --- /dev/null +++ b/agents/researcher/work/queue-98/build.patch @@ -0,0 +1,124 @@ +diff --git a/packages/webui/tests/pinned-copy.test.mjs b/packages/webui/tests/pinned-copy.test.mjs +index 4103f533..07589efc 100644 +--- a/packages/webui/tests/pinned-copy.test.mjs ++++ b/packages/webui/tests/pinned-copy.test.mjs +@@ -169,6 +169,53 @@ test('a copy that loses the rename race leaves no read-only temp directory behin + assert.deepEqual(readdirSync(copies), [head], 'the temp directory is removed'); + }); + ++// Row 98 (#1592). The rename's two other outcomes: it fails and no target ++// appears (a copy that was never written must not print exists:), it ++// meets a target that holds files (mv fails and nothing lands in the ++// target), and it meets an empty target directory. Only the last tells mv -T ++// from plain mv: the temp copy's directories are 0555, so plain mv cannot move ++// it into a target at all (Permission denied) and fails exactly as -T does on a ++// full one, but -T may replace an empty directory by rename. ++async function copyWithMv(t, mvBody) { ++ const { dir, script, head } = fixtureRepo(t); ++ const copies = join(dir, 'copies'); ++ const bin = join(dir, 'bin'); ++ mkdirSync(bin); ++ writeFileSync(join(bin, 'mv'), `#!/bin/sh\n${mvBody}\n`, { mode: 0o755 }); ++ const env = { PATH: `${bin}:${NODE_DIR}:/usr/bin:/bin`, HOME: dir, XDG_CONFIG_HOME: join(dir, 'config') }; ++ const child = spawn(script, ['copy', 'refactor', '--copies', copies], { env }); ++ let stdout = '', stderr = ''; ++ child.stdout.on('data', (d) => { stdout += d; }); ++ child.stderr.on('data', (d) => { stderr += d; }); ++ const [status] = await once(child, 'close'); ++ return { copies, target: join(copies, head), head, status, stdout, stderr }; ++} ++ ++test('a copy whose rename fails with no target refuses, writes no copy and leaves no temp directory', async (t) => { ++ const r = await copyWithMv(t, 'exit 1'); ++ assert.equal(r.status, 1); ++ assert.equal(r.stderr, `webui-service: could not rename the copy into ${r.target}\n`); ++ assert.equal(r.stdout, ''); ++ assert.deepEqual(readdirSync(r.copies), [], 'no target and no temp directory'); ++}); ++ ++test('a copy that meets a target holding files says exists: and moves nothing into it', async (t) => { ++ // The stand-in makes the target appear with a file in it, then runs the real mv with the script's flags. ++ const r = await copyWithMv(t, 'for a; do t="$a"; done\nmkdir "$t"\n: > "$t/other"\nexec /usr/bin/mv "$@"'); ++ assert.equal(r.status, 0, r.stderr); ++ assert.equal(r.stdout, `exists: ${r.target}\n`); ++ assert.deepEqual(readdirSync(r.copies), [r.head], 'the temp directory is removed'); ++ assert.deepEqual(readdirSync(r.target), ['other'], 'nothing was moved into the target'); ++}); ++ ++test('a copy that meets an empty target directory replaces it by rename, not by moving into it', async (t) => { ++ const r = await copyWithMv(t, 'for a; do t="$a"; done\nmkdir "$t"\nexec /usr/bin/mv "$@"'); ++ assert.equal(r.status, 0, r.stderr); ++ assert.equal(r.stdout, `copied: ${r.target}\ninstall it with: scripts/webui-service.sh install --copy ${r.head}, with the unit's flags\n`); ++ assert.deepEqual(readdirSync(r.copies), [r.head], 'the temp directory is gone'); ++ assert.ok(existsSync(join(r.target, 'packages', 'webui', 'README.md')), 'the copy is the target itself'); ++}); ++ + // serve from a copy: /healthz, the config check, roles and RELEASE from the + // copy, and the queue read from --checkout or, without it, from the copy. + async function serve(t, cli, args, env) { +diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs +index d1226b19..d5d55a1b 100644 +--- a/packages/webui/tests/service.test.mjs ++++ b/packages/webui/tests/service.test.mjs +@@ -71,19 +71,22 @@ const BUS_UNITS = 'mosaic-bus@acme.service loaded active running Mosaic bus host + // to stderr. The bus calls install makes are answered apart: `busUnits` for + // the mosaic-bus@ list-units, and `bus` for show, by default a bus started + // from the run's own copy ($HOME, as run() sets it). `busFails` makes both +-// bus calls fail. +-function standIn(dir, lines = '', { listFails = false, bus = null, busUnits = BUS_UNITS, busFails = false } = {}) { ++// bus calls fail. `busOther` answers show for mosaic-bus@other.service ++// apart from `bus`, so a second loaded unit can differ from the first. ++function standIn(dir, lines = '', { listFails = false, bus = null, busUnits = BUS_UNITS, busFails = false, busOther = null } = {}) { + const bin = join(dir, 'bin'); + mkdirSync(bin); + writeFileSync(join(dir, 'list-units.txt'), lines); + writeFileSync(join(dir, 'bus-units.txt'), busUnits); + if (bus !== null) writeFileSync(join(dir, 'bus-show.txt'), bus); ++ if (busOther !== null) writeFileSync(join(dir, 'bus-show-other.txt'), busOther); + const fail = 'echo "Failed to connect to bus" >&2; exit 1'; + const show = bus !== null ? `cat "${dir}/bus-show.txt"` : `r="$HOME/.local/share/mosaic-console/${COPY_ID}"; printf '%s' "${busShow('$r')}"`; + writeFileSync(join(bin, 'systemctl'), `#!/bin/sh + echo "$*" >> "${dir}/calls.txt" + case "$*" in + *list-units*mosaic-bus@*) ${busFails ? fail : `cat "${dir}/bus-units.txt"`} ;; ++ *show*mosaic-bus@other.service*) ${busFails ? fail : busOther !== null ? `cat "${dir}/bus-show-other.txt"` : show} ;; + *show*mosaic-bus@*) ${busFails ? fail : show} ;; + *list-units*) ${listFails ? fail : `cat "${dir}/list-units.txt"`} ;; + *is-enabled*) echo enabled ;; +@@ -538,10 +541,21 @@ test('install refuses unless the bus unit runs from the same copy, naming both p + assert.equal((await install(s.bin)).status, 0); + assert.match(readFileSync(join(s.d, 'calls.txt'), 'utf8'), /show mosaic-bus@other\.service -p LoadState -p ExecStart/); + rmSync(unitDir, { recursive: true }); ++ // The second loaded unit is off the copy: refused, naming the second unit and its path. ++ const two = `${BUS_UNITS}mosaic-bus@other.service loaded active running Mosaic bus host (other)\n`; ++ s = sys({ busUnits: two, bus: busShow(copy), busOther: busShow(REPO) }); ++ refused(await install(s.bin), `mosaic-bus@other.service runs from ${REPO}/scripts/mosaic, not the copy ${copy}; the bus accepts only its own human-cli.mjs, so every Console bus call would fail human-required until the bus runs from the same copy`); ++ assert.match(readFileSync(join(s.d, 'calls.txt'), 'utf8'), /show mosaic-bus@acme\.service -p LoadState -p ExecStart\n.*show mosaic-bus@other\.service -p LoadState -p ExecStart/s); + + // No bus unit installed for the business: refused. + s = sys({ bus: 'LoadState=not-found\nExecStart=\n' }); + refused(await install(s.bin, ['--business', 'acme']), `no bus unit mosaic-bus@acme.service is installed (LoadState not-found); the Console on the copy ${copy} needs its bus on that copy`); ++ // Only loaded counts: a unit systemd shows at any other LoadState is not the bus, even with an ++ // ExecStart on the copy. ++ for (const state of ['bad-setting', 'error', 'masked']) { ++ s = sys({ bus: busShow(copy).replace('LoadState=loaded', `LoadState=${state}`) }); ++ refused(await install(s.bin, ['--business', 'acme']), `no bus unit mosaic-bus@acme.service is installed (LoadState ${state}); the Console on the copy ${copy} needs its bus on that copy`); ++ } + // No --business and no mosaic-bus@ unit loaded: refused. + s = sys({ busUnits: '' }); + refused(await install(s.bin), `no mosaic-bus@ unit is loaded, and the Console on the copy ${copy} needs its bus on that copy; name the business with --business ID`); +@@ -644,6 +658,14 @@ test('status names the copy and whether refactor has moved past it, and says whe + r = await run(dir, ['status', '--dir', unitDir], bin); + assert.match(r.stdout, new RegExp(`^copy: none; the unit runs from ${esc(REPO)}$`, 'm')); + assert.ok(!/^refactor:/m.test(r.stdout)); ++ // Only a whole 40-hex last part is a copy: 40 hex plus a character, or 41 hex, is not. ++ for (const last of [`${COPY_ID}x`, `x${COPY_ID}`, `${COPY_ID}0`]) { ++ unit(join(dir, 'copies', last)); ++ r = await run(dir, ['status', '--dir', unitDir], bin); ++ assert.equal(r.status, 0, r.stderr); ++ assert.match(r.stdout, new RegExp(`^copy: none; the unit runs from ${esc(join(dir, 'copies', last))}$`, 'm'), last); ++ assert.ok(!/^refactor:/m.test(r.stdout), last); ++ } + // status reads; it calls nothing that changes the unit. + assert.deepEqual(readFileSync(join(dir, readdirSync(dir).find((d) => d.startsWith('si-')), 'calls.txt'), 'utf8').split('\n').filter(Boolean).filter((c) => !/^--user (is-enabled|is-active|show) mosaic-webui\.service( |$)/.test(c)), []); + }); diff --git a/agents/researcher/work/queue-98/candidate-manifest.sha256 b/agents/researcher/work/queue-98/candidate-manifest.sha256 new file mode 100644 index 000000000..beabc80da --- /dev/null +++ b/agents/researcher/work/queue-98/candidate-manifest.sha256 @@ -0,0 +1,2 @@ +df0222b622c6dba4242d158fd2cfc8df7afa71bbc68d2a5238f03603b50d6ac5 packages/webui/tests/pinned-copy.test.mjs +79dbaf4fee277da2cdfe55b863c267d6dd7df93acbca0adb0e0228ae58bf3196 packages/webui/tests/service.test.mjs diff --git a/agents/researcher/work/queue-98/mut-base.txt b/agents/researcher/work/queue-98/mut-base.txt new file mode 100644 index 000000000..a6f1ca45e --- /dev/null +++ b/agents/researcher/work/queue-98/mut-base.txt @@ -0,0 +1,11 @@ +worktree /mnt/storage/scratch/researcher/q97-base +start 2026-10-12T23:13:23Z +1 bus check refuses only not-found: ALIVE (exit 0, 2.9s, service.test.mjs) +2 rename failure without a target is not refused: ALIVE (exit 0, 2.1s, pinned-copy.test.mjs) +3 mv without -T: ALIVE (exit 0, 2.0s, pinned-copy.test.mjs) +4a status copy id loses both anchors: ALIVE (exit 0, 3.0s, service.test.mjs) +4b status copy id loses its start anchor: ALIVE (exit 0, 3.5s, service.test.mjs) +4c status copy id loses its end anchor: ALIVE (exit 0, 3.3s, service.test.mjs) +5a bus check reads only the first unit: killed (exit 1, 3.2s, service.test.mjs) +5b bus check applies the path rule to the first unit only: ALIVE (exit 0, 3.2s, service.test.mjs) +killed 1 of 8; script restored byte for byte: True diff --git a/agents/researcher/work/queue-98/mut-candidate.txt b/agents/researcher/work/queue-98/mut-candidate.txt new file mode 100644 index 000000000..a5307fb12 --- /dev/null +++ b/agents/researcher/work/queue-98/mut-candidate.txt @@ -0,0 +1,11 @@ +worktree /mnt/storage/scratch/researcher/q98 +start 2026-10-12T23:13:46Z +1 bus check refuses only not-found: killed (exit 1, 3.9s, service.test.mjs) +2 rename failure without a target is not refused: killed (exit 1, 3.4s, pinned-copy.test.mjs) +3 mv without -T: killed (exit 1, 3.2s, pinned-copy.test.mjs) +4a status copy id loses both anchors: killed (exit 1, 3.3s, service.test.mjs) +4b status copy id loses its start anchor: killed (exit 1, 3.0s, service.test.mjs) +4c status copy id loses its end anchor: killed (exit 1, 3.0s, service.test.mjs) +5a bus check reads only the first unit: killed (exit 1, 3.1s, service.test.mjs) +5b bus check applies the path rule to the first unit only: killed (exit 1, 2.8s, service.test.mjs) +killed 8 of 8; script restored byte for byte: True diff --git a/agents/researcher/work/queue-98/mutants.py b/agents/researcher/work/queue-98/mutants.py new file mode 100644 index 000000000..0d3e43515 --- /dev/null +++ b/agents/researcher/work/queue-98/mutants.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# Row 98 (#1592) mutants of scripts/webui-service.sh. Each edit must match exactly once and each runs +# alone against the test file that holds its case. Every run starts in its own process group and the +# group is killed when the run ends or times out. The script is restored from the bytes read before +# the edit, and its sha256 is checked against its start at the end. +# Usage: mutants.py [timeout-seconds] +import hashlib, os, signal, subprocess, sys, time + +WT, OUT = sys.argv[1], sys.argv[2] +TIMEOUT = int(sys.argv[3]) if len(sys.argv) > 3 else 300 +S = 'scripts/webui-service.sh' +SVC, PIN = 'tests/service.test.mjs', 'tests/pinned-copy.test.mjs' + +# (name, old, new, test file) +M = [ + ('1 bus check refuses only not-found', '[ "$load" = loaded ] ||', '[ "$load" != not-found ] ||', SVC), + ('2 rename failure without a target is not refused', ' [ -e "$target" ] || die "could not rename the copy into $target"\n', '', PIN), + ('3 mv without -T', 'mv -T "$COPY_TMP" "$target"', 'mv "$COPY_TMP" "$target"', PIN), + ('4a status copy id loses both anchors', '[[ "$copy" =~ ^[0-9a-f]{40}$ ]]', '[[ "$copy" =~ [0-9a-f]{40} ]]', SVC), + ('4b status copy id loses its start anchor', '[[ "$copy" =~ ^[0-9a-f]{40}$ ]]', '[[ "$copy" =~ [0-9a-f]{40}$ ]]', SVC), + ('4c status copy id loses its end anchor', '[[ "$copy" =~ ^[0-9a-f]{40}$ ]]', '[[ "$copy" =~ ^[0-9a-f]{40} ]]', SVC), + ('5a bus check reads only the first unit', ' for unit in $units; do\n out="$(systemctl --user show', ' for unit in $(printf \'%s\\n\' "$units" | head -n 1); do\n out="$(systemctl --user show', SVC), + ('5b bus check applies the path rule to the first unit only', ' for p in $paths; do\n', ' [ "$unit" = "$(printf \'%s\\n\' "$units" | head -n 1)" ] || continue\n for p in $paths; do\n', SVC), +] + +env = dict(os.environ, TMPDIR=os.environ.get('TMPDIR', '/mnt/storage/scratch/tmp')) +full = os.path.join(WT, S) +sha = lambda: hashlib.sha256(open(full, 'rb').read()).hexdigest() +start = sha() + +def run(test, limit): + t0 = time.time() + p = subprocess.Popen(['node', '--test', test], cwd=os.path.join(WT, 'packages/webui'), env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True, text=True) + try: + out, _ = p.communicate(timeout=limit) + code = p.returncode + except subprocess.TimeoutExpired: + code = 'timeout' + finally: + try: os.killpg(p.pid, signal.SIGKILL) + except ProcessLookupError: pass + try: p.communicate(timeout=5) + except Exception: pass + return code, round(time.time() - t0, 1) + +with open(OUT, 'w') as f: + def say(s): print(s); f.write(s + '\n'); f.flush() + say(f'worktree {WT}') + say(f'start {time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}') + killed = 0 + for name, old, new, test in M: + orig = open(full, 'rb').read() + src = orig.decode() + if src.count(old) != 1: + say(f'{name}: NO MATCH ({src.count(old)}), skipped'); continue + try: + open(full, 'w').write(src.replace(old, new)) + code, secs = run(test, TIMEOUT) + finally: + open(full, 'wb').write(orig) + dead = code not in (0, 'timeout') + killed += dead + verdict = 'killed' if dead else 'STALLED' if code == 'timeout' else 'ALIVE' + say(f'{name}: {verdict} (exit {code}, {secs}s, {test.split("/")[-1]})') + ok = sha() == start + say(f'killed {killed} of {len(M)}; script restored byte for byte: {ok}') + sys.exit(0 if ok else 1)