From e3fd9f3a080ddd324406c63fca324487e603c391 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 20 Oct 2026 15:25:04 -0500 Subject: [PATCH] stack-release: round 2, slot and proof checks resolve REPO, config_checks refuses a config that doesn't parse (row 200, #1698, sage-1432) --- agents/launchpad/work/queue-200/BUILD.md | 41 +++++++++++++++---- .../work/queue-200/candidate-manifest.sha256 | 4 +- agents/launchpad/work/queue-200/mutants.py | 5 ++- packages/cli/tests/stack-release.test.mjs | 27 ++++++++++++ scripts/stack-release.sh | 25 +++++++---- 5 files changed, 85 insertions(+), 17 deletions(-) diff --git a/agents/launchpad/work/queue-200/BUILD.md b/agents/launchpad/work/queue-200/BUILD.md index b556a94d9..a4c47cbbe 100644 --- a/agents/launchpad/work/queue-200/BUILD.md +++ b/agents/launchpad/work/queue-200/BUILD.md @@ -3,13 +3,13 @@ Launchpad, 2026-10-20. Author Launchpad, reviewer Webby, gate owner Sage. Issue #1698. Brief `docs/plans/2026-10-20_stack-release-sso.md` (blob 426336b177f6). Rulings sage-1389 (R1 uses the activation onto the current copy, plus a same-copy test and a latest-set mutant), sage-1403 and sage-1407 (N4), mos-1056 item 5 -(N4 is a window acceptance item). +(N4 is a window acceptance item), sage-1432 (round 2 for webby-0028 N1 and N2). Base 4d487dfdc (`base.txt`): aa1bd209e plus row 192's two commits picked. 48359dbfa is 83b32d8d8 (patch-id 8d11f48a9e03) and 4d487dfdc is r2 37582e820 (patch-id 0e32af8aa2a4 on both). The webui tree at the base matches -`launchpad/row192` at 37582e820 path for path. Candidate: commit 5dc0b5534 on `launchpad/row200`. +`launchpad/row192` at 37582e820 path for path. Candidate: r1 5dc0b5534 and the round 2 commit on `launchpad/row200`. `candidate-manifest.sha256` lists its 3 paths; the packet files are not in it. Drift check at commit time: -`git log aa1bd209e..origin/refactor` (origin/refactor ae3c36f24) touches none of the 3 paths. +`git log aa1bd209e..origin/refactor` (origin/refactor 9ba31b5b3 at round 2) touches none of the 3 paths. No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, `~/.mosaic`, a live unit or a live slot. T56 runs on the scratch roots and seeded pointers rows 187 and 189 use. Its config and slot sit in the test's temp dir, @@ -20,7 +20,7 @@ and the slot carries the `proofFile` read watch, so a run that opened it would f | Path | Change | | --- | --- | | `scripts/stack-release.sh` | `--sso` on activate (usage, parse, `sso_for_target`), `sso_checks` and `slot_problem` (item 2), `sso_base` (rollback flags, item 3), `config_checks` (item 3 and N4), the `sso:` plan line and step lines. | -| `packages/cli/tests/stack-release.test.mjs` | Fixture copies gain row 192's `--sso` marker, an old loader without `console`, and a C copy whose `ALGS` lack RS256. T56, 8 subtests. | +| `packages/cli/tests/stack-release.test.mjs` | Fixture copies gain row 192's `--sso` marker, an old loader without `console`, and a C copy whose `ALGS` lack RS256. T56, 10 subtests (2 in round 2). | | `docs/TOOLS.md` | Usage line and two paragraphs after row 189's `--no-send`. | ## What it does @@ -70,11 +70,14 @@ For the row 200 window plan, word for word: `scripts/stack-release.sh rollback acme --plan` must print no `refuses config-console-key:` line. If it prints one, restore `config.json` from the window's step 1 backup and run the plan again. The rollback itself refuses on the same check, so a skipped step stops it before anything stops. +- **Back-out line (webby-0028 N4, sage-1432).** Once a copy with `--sso` support is live, no verb turns `--sso` + off. Only a rollback to a copy before row 192 gets the preview set back, after the `console` key is removed + (the check line above). ## Tests run (this worktree, final tree) -- `node --test --test-name-pattern='^T56' tests/stack-release.test.mjs`: 8/8 - (`/mnt/storage/scratch/launchpad-r200/t56-6.log`). +- `node --test --test-name-pattern='^T56' tests/stack-release.test.mjs`: 10/10 on the round 2 tree + (`/mnt/storage/scratch/launchpad-r200/t56-r2c.log`). Round 1 was 8/8 (`t56-6.log`). - The whole file, the cli suite and every `scripts/test-*.sh` go to Sage's gate. T1 to T55 have not run on this tree. @@ -91,11 +94,16 @@ For the row 200 window plan, word for word: | 7 | keep `--public-host` under `--sso` (item 1) | KILLED | | 8 | rollback takes the latest activation onto the copy, not the arrival (sage-1389) | KILLED | | 9 | `sso-config` names no first step (N4) | KILLED | +| 10 | `slot_problem` compares the unresolved `REPO` (round 2, N1) | not run, Sage's gate | +| 11 | `proof_file_problem` compares the unresolved `REPO` (round 2, N1) | not run, Sage's gate | +| 12 | `config_checks` passes a config that doesn't parse (round 2, N2) | not run, expected equivalent | 9/9 killed (`/mnt/storage/scratch/launchpad-r200/mutants.log`). The script restored `stack-release.sh`, and it matches its pre-run sha256. No mutant hit the 300 s timeout. This batch ran without Sage's slot (sage-1414), while Darkwing's gate was running, so Sage reruns all 9 on the row 200 gate and doesn't take these results -(sage-1423). +(sage-1423). Mutants 10 to 12 are new in round 2 and have not run: they need Sage's slot (sage-1414). Mutant 5's +pattern now names the `holds` message, because the round 2 message also starts with `config-console-key "$cfg`. +Every pattern matches once on the round 2 tree. ## Choices @@ -126,6 +134,25 @@ while Darkwing's gate was running, so Sage reruns all 9 on the row 200 gate and The only way back to the preview set is a rollback to a copy before row 192, which removes the `console` key first (the second check line above). +## Round 2 (sage-1432: N1 and N2; N3 and N4 stay notes) + +- **N1.** `slot_problem` (`:847`) and `proof_file_problem` (`:989`) now compare the slot's real path with + `realpath -m` of both `REPO` and `COPIES`, and refuse "cannot be resolved" if either fails. T56's + "round 2 (webby-0028 N1)" subtest runs the script through a symlink to the fixture repo + (`repo-link.200/scripts/stack-release.sh`). A proof file inside the repository is refused `proof-file-invalid`, + and a slot whose secrets directory links into the repository is refused `sso-slot` "is inside the repository + or the copies". Mutants 10 and 11 undo each check. +- **N2, with a deviation.** `config_checks` (`:867`) now tells "no key" (node exits 1) from "doesn't parse" + (exits 2) and refuses `config-console-key` "cannot be read as JSON" on the second. But webby-0028's case + doesn't reach it: `load_config` (common.sh, through `scripts/mosaic-config.mjs env`) exits 1 on a config that + doesn't parse, before anything is read or stopped. So the refusal is a guard for a config that changes after + `load_config` ran, and no test reaches it without a race. The T56 subtest "round 2 (webby-0028 N2)" checks + what does happen: activate, rollback and `activate --plan` each exit 1 with "common: configuration load + failed", the pointer, the start/stop log and the activation log are unchanged, and activate works after the + config is restored. Mutant 12 drops the guard and is expected to survive as equivalent for that reason. Sage + rules whether to keep the guard, as with row 192's mutant 23. +- **N4.** The back-out line is in the window plan section above. + ## Not done here - No change to the live pointer, units or config. The phase 2 window is Sage's and Mos's. diff --git a/agents/launchpad/work/queue-200/candidate-manifest.sha256 b/agents/launchpad/work/queue-200/candidate-manifest.sha256 index 4745450e8..8d675bff1 100644 --- a/agents/launchpad/work/queue-200/candidate-manifest.sha256 +++ b/agents/launchpad/work/queue-200/candidate-manifest.sha256 @@ -1,3 +1,3 @@ 6ee1adb3951aeb1e3862cb119c12b600b88641f325912c09840b841761aa12dd docs/TOOLS.md -0c1aa38932982b72ea71a6d2d785f6412143c298ea6f2b448c4b4ee615a66da8 packages/cli/tests/stack-release.test.mjs -6cb5f90a85eb86973a5176934f867c7282509e49830933aaa998f70297c79392 scripts/stack-release.sh +9b231532d14b21d0d39ca89035d9067e827e18cc1cf3dfe298bef840ae87ff0c packages/cli/tests/stack-release.test.mjs +d24e75e9927623ad106b55bce82e40ddf7677d483626fb46923c3b3c7c1148ad scripts/stack-release.sh diff --git a/agents/launchpad/work/queue-200/mutants.py b/agents/launchpad/work/queue-200/mutants.py index a1fc359ae..bd2f2a8c9 100644 --- a/agents/launchpad/work/queue-200/mutants.py +++ b/agents/launchpad/work/queue-200/mutants.py @@ -11,11 +11,14 @@ M = [ ('no sso-config refusal (item 2)', [(' check_refuse sso-config "$TO', ' : "$TO')]), ('no sso-slot refusal (item 2)', [('[ -z "$why" ] || check_refuse sso-slot', '[ -z "$why" ] || : sso-slot')]), ('no sso-rollback-flags refusal (item 3)', [(' check_refuse sso-rollback-flags "$TO', ' : "$TO')]), - ('no config-console-key refusal (item 3)', [(' check_refuse config-console-key "$cfg', ' : "$cfg')]), + ('no config-console-key refusal (item 3)', [(' check_refuse config-console-key "$cfg holds', ' : "$cfg holds')]), ('keep --auth-header under --sso (item 1)', [(" --public-host | --auth-header | --allow-user)", " --public-host | --allow-user)")]), ('keep --public-host under --sso (item 1)', [(" --public-host | --auth-header | --allow-user)", " --auth-header | --allow-user)")]), ('rollback takes the latest activation onto the copy, not the arrival (sage-1389)', [('o.to===current&&o.from!==current){process.stdout.write(`${o.activation', 'o.to===current){process.stdout.write(`${o.activation')]), ('sso-config names no first step (N4)', [(' first="; activate $TO without --sso first, then write console.sso, then run activate $TO --sso"', ' first=""')]), + ('slot_problem compares the unresolved REPO (round 2, webby-0028 N1)', [(' case "$real" in "$repo" | "$repo"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$size" != 0 ]', ' case "$real" in "$REPO" | "$REPO"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$size" != 0 ]')]), + ('proof_file_problem compares the unresolved REPO (round 2, webby-0028 N1)', [(' case "$real" in "$repo" | "$repo"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$mode" = 600 ]', ' case "$real" in "$REPO" | "$REPO"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$mode" = 600 ]')]), + ('config_checks passes a config that does not parse (round 2, webby-0028 N2; expected equivalent)', [(' [ "$key" != 1 ] || return 0', ' [ "$key" = 0 ] || return 0')]), ] sha = lambda b: hashlib.sha256(b).hexdigest() only = sys.argv[2:] diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs index 4c1b02ef0..08a333d48 100644 --- a/packages/cli/tests/stack-release.test.mjs +++ b/packages/cli/tests/stack-release.test.mjs @@ -2007,6 +2007,33 @@ test('T56 row 200: activate --sso, its refusals before any stop, and the rollbac r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B); await noLeak(e); }); + await t.test('round 2 (webby-0028 N1): run through a symlinked repository path, a slot or a proof file inside the repository still refuses',async(t)=>{ + const link=join(base,'repo-link.200');if(!existsSync(link))symlinkSync(repo,link); + const e=await mk(t);e.sr=join(link,'scripts','stack-release.sh');await seedPreview(e); + const f=await proofFile(e,'proof.200',0o600,mkdtempSync(join(repo,'t56-proof-')));t.after(()=>rmSync(dirname(f),{recursive:true,force:true})); + await refused(e,['activate',C1,'acme',...pair(f)],'proof-file-invalid',`--proof-file ${f} is inside the repository or the copies`); + await ssoConfig(e,{slot:false}); + let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err); + const inRepo=mkdtempSync(join(repo,'t56-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true})); + writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600); + const secrets=dirname(slotPath(e));rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets); + await refused(e,['activate',C1,'acme','--sso'],'sso-slot',`console.sso provider a client secret slot ${slotPath(e)} is inside the repository or the copies`); + await noLeak(e); + }); + await t.test('round 2 (webby-0028 N2): a config.json that does not parse stops the run before anything is read or stopped',async(t)=>{ + const e=await mk(t);await seedPreview(e); + // load_config exits first, before the pointer, the log or a unit is read; config_checks' own + // refusal is the guard for a file that changes after that (BUILD.md, round 2). + const good=readFileSync(e.cfg,'utf8');writeFileSync(e.cfg,good.slice(0,-1)); + const before=fp(e),n=calls(e).length,logs=logLines(e).length; + for(const args of [['activate',C1,'acme'],['rollback','acme'],['activate',C1,'acme','--plan']]){ + const r=await prun(e,...args);assert.equal(r.code,1,args.join(' '));assert.match(r.err,/common: configuration load failed\n$/); + } + assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs); + writeFileSync(e.cfg,good); + const r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err); + await noLeak(e); + }); }); // --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") --- diff --git a/scripts/stack-release.sh b/scripts/stack-release.sh index fe189603f..3d8601eb5 100755 --- a/scripts/stack-release.sh +++ b/scripts/stack-release.sh @@ -845,7 +845,7 @@ sso_checks() { # Why a slot file can't serve, or nothing: readSlot's checks in packages/webui/src/sso.mjs, in its # order, from lstat, stat and realpath only, then a size of 0 for its one-non-empty-line rule. slot_problem() { - local st uid mode size type real copies + local st uid mode size type real copies repo [ -e "$1" ] || [ -L "$1" ] || { echo "is missing"; return; } [ ! -L "$1" ] || { echo "is a symbolic link"; return; } st="$(stat -c '%u:%a:%s:%F' -- "$1" 2>/dev/null)" || { echo "cannot be read with stat"; return; } @@ -855,8 +855,9 @@ slot_problem() { case "$type" in "regular file" | "regular empty file") ;; *) echo "is not a regular file"; return ;; esac [ "$uid" = "$(id -u)" ] || { echo "is owned by uid $uid, not $(id -u), the uid this script runs as"; return; } (( (8#$mode & 8#077) == 0 )) || { echo "has mode $mode, which gives group or other permission bits"; return; } - real="$(realpath -e -- "$1")" && copies="$(realpath -m -- "$COPIES")" || { echo "cannot be resolved"; return; } - case "$real" in "$REPO" | "$REPO"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac + # REPO is $(pwd), which keeps a symlink in the path the script ran by (webby-0028 N1). + real="$(realpath -e -- "$1")" && copies="$(realpath -m -- "$COPIES")" && repo="$(realpath -m -- "$REPO")" || { echo "cannot be resolved"; return; } + case "$real" in "$repo" | "$repo"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac [ "$size" != 0 ] || echo "is empty" } @@ -865,7 +866,17 @@ slot_problem() { # target's own scripts/mosaic-config.mjs validate; its output, the resolved config, is not kept. config_checks() { local cfg="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}" out loader="$COPIES/$TO/scripts/mosaic-config.mjs" first - node -e 'process.exit(Object.hasOwn(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")),"console")?0:1)' -- "$cfg" 2>/dev/null || return 0 + # 0: a console key; 1: no key, or no file; 2: the file doesn't parse, so whether it holds the key + # is unknown, and the run refuses rather than pass it (webby-0028 N2). load_config already exits on + # such a file before anything is read or stopped, so this is reached only if the file changes after it. + local key=0 + node -e 'const fs=require("fs"),f=process.argv[1];if(!fs.existsSync(f))process.exit(1);let d;try{d=JSON.parse(fs.readFileSync(f,"utf8"))}catch{process.exit(2)} +process.exit(d!==null&&typeof d==="object"&&Object.hasOwn(d,"console")?0:1)' -- "$cfg" 2>/dev/null || key=$? + [ "$key" != 1 ] || return 0 + if [ "$key" != 0 ]; then + check_refuse config-console-key "$cfg cannot be read as JSON, so whether $TO's loader reads it is unknown; fix the config first, then run the $OP again" + return 0 + fi if [ ! -f "$loader" ]; then out="it has no scripts/mosaic-config.mjs" elif out="$(MOSAIC_CONFIG="$cfg" node "$loader" validate 2>&1 >/dev/null)"; then return 0 fi @@ -976,7 +987,7 @@ proof_checks() { # Why the proof file can't serve, or nothing. lstat, stat and realpath only; the order follows # webui-service.sh render, which refuses the same files. proof_file_problem() { - local st uid mode type real copies + local st uid mode type real copies repo case "$1" in /*) ;; *) echo "is not an absolute path"; return ;; esac case "$1" in *[!A-Za-z0-9/._+-]*) echo "holds a character outside [A-Za-z0-9/._+-]"; return ;; esac [ ! -L "$1" ] || { echo "is a symlink"; return; } @@ -984,8 +995,8 @@ proof_file_problem() { uid="${st%%:*}" st="${st#*:}" mode="${st%%:*}" type="${st#*:}" case "$type" in "regular file" | "regular empty file") ;; *) echo "is not a regular file"; return ;; esac - real="$(realpath -e -- "$1")" && copies="$(realpath -m -- "$COPIES")" || { echo "cannot be resolved"; return; } - case "$real" in "$REPO" | "$REPO"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac + real="$(realpath -e -- "$1")" && copies="$(realpath -m -- "$COPIES")" && repo="$(realpath -m -- "$REPO")" || { echo "cannot be resolved"; return; } + case "$real" in "$repo" | "$repo"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac [ "$mode" = 600 ] || [ "$mode" = 400 ] || { echo "has mode $mode, not 600 or 400"; return; } [ "$uid" = "$(id -u)" ] || echo "is owned by uid $uid, not $(id -u), the uid this script runs as" }