Skip to content

fix(studio): auto-launch — confine the substrate plant, and read the off switch case-insensitively - #518

Merged
KnockOutEZ merged 2 commits into
studio-handoff-corefrom
sd-189-fix-studio-auto-launch-exported
Aug 28, 2026
Merged

fix(studio): auto-launch — confine the substrate plant, and read the off switch case-insensitively#518
KnockOutEZ merged 2 commits into
studio-handoff-corefrom
sd-189-fix-studio-auto-launch-exported

Conversation

@KnockOutEZ

Copy link
Copy Markdown
Owner

Closes wigolo-studio-run#189 — PX0 exit-6 (Kimi K3 MED, measured live + security-review LOW-1, verified at tip).

Two defects in one file family.

(a) The suite deleted the user's real substrate root

plantAcquiredSubstrate planted a forged acquisition record at substrateRoot()'s ambient answer, and its caller rmSync(root, { recursive: true, force: true })d that directory — one the suite never created. substrateRoot() takes no data dir (studioLaunchable() reaches it through substratePresent()), so it follows the memoized config, so it follows WIGOLO_DATA_DIR. And tests/setup.ts:179 repoints that variable only when unset, deliberately (:23 — "the guard respects it"). So a developer who exported the documented config knob and ran npm test lost their substrate root while the suite reported green.

Fix: the helper now moves the answer rather than accepting it — it repoints WIGOLO_DATA_DIR at its per-test temp dir and calls resetConfig() (the assignment alone moves nothing; the data dir is memoized on first resolve). The containment check stays as the assert that the repoint took effect: a dropped resetConfig(), or a config that stopped reading the var, would silently restore the original defect, and the refusal fails loudly instead. That also moves the SIGKILL residue — a forged record naming a real on-disk executable — out of the path the launcher reads.

Both shapes the issue offered are in, and they are not one layer keyed on one predicate: the repoint decides where, the refusal answers "did the repoint bite". Recorded as A-189-1 with its reversal condition.

Demo — the decoy repro

BEFORE (tip 06a9469f, unfixed):

$ mkdir -p $DECOY/substrate/existing-component
$ echo "user data" > $DECOY/substrate/existing-component/keep.txt
$ find $DECOY -type f
$DECOY/substrate/existing-component/keep.txt

$ WIGOLO_DATA_DIR=$DECOY npx vitest run tests/unit/studio/auto-launch.test.ts
 Test Files  1 passed (1)
      Tests  37 passed (37)
vitest exit=0

$ find $DECOY -type f
                          <- nothing. keep.txt gone with the whole substrate/ tree
$ ls -la $DECOY
total 0
drwx------@    2 towhidkhan  staff      64 Aug 28 15:53 .
drwx------@ 3901 towhidkhan  staff  124832 Aug 28 15:53 ..

Green suite, exit 0, product silently uninstalled.

AFTER:

$ find $DECOY -type f
$DECOY/substrate/existing-component/keep.txt

$ WIGOLO_DATA_DIR=$DECOY npx vitest run tests/unit/studio/auto-launch.test.ts
 Test Files  1 passed (1)
      Tests  48 passed (48)
vitest exit=0

$ find $DECOY -type f
$DECOY/substrate/existing-component/keep.txt
$ cat $DECOY/substrate/existing-component/keep.txt
user data

The suite stays green and the decoy survives, so the fix is not "refuse on a supported configuration".

The refusal is not dead code

Mutation probe — resetConfig() removed from the helper, nothing else changed:

 Test Files  1 failed (1)
      Tests  1 failed | 47 passed (48)

Error: refusing to plant a forged substrate record at .../wig-decoy-iREYbt/substrate: this helper's
caller DELETES that directory recursively, and it is not the per-test dir .../wig-launch-9EOWki/substrate
this file created. Repointing WIGOLO_DATA_DIR did not take effect.
 ❯ plantAcquiredSubstrate tests/unit/studio/auto-launch.test.ts:191:13

keep.txt survived: [user data]

The decoy arm is what kills the mutation, and that is the point of asserting the forcing took effect: with the config left cold by the previous afterEach, the bare assignment happens to bite, so the other plant arm passes under the mutation. Only an arm that warms the cache with a foreign root first can see the decay. Constant restored by reverse-edit and re-verified green.

(b) The off switch was case-sensitive

=== '0' || === 'false' exact-match, so WIGOLO_STUDIO_AUTO_LAUNCH=False (or FALSE, Off) read as "not disabled" and the substrate was spawned detached and hidden on the fetch path against the operator's stated intent. A hidden desktop process the human asked not to have is a consent surface in this design language, not a preference that failed to apply, so a near-miss spelling has to fail toward off.

Now matched lowercased and trimmed against 0/false/off. envBool (src/config.ts) is the house comparison and normalises for the same reason; the two WIGOLO_STUDIO_HIDDEN fixes this phase (#179, #187) were the env key's casing, which no value comparison inherits. off and the trim go past the house set and are recorded as A-189-2 with the reversal condition (it reverses if the variable ever regains an opt-in half, at which point it is an ordinary two-direction bool and belongs on envBool verbatim).

Demo — the real built dist/, not the test seam

$ node sd189-probe.mjs
"0"         -> launch attempts: 0  handle: null  DECLINED
"false"     -> launch attempts: 0  handle: null  DECLINED
"False"     -> launch attempts: 0  handle: null  DECLINED
"FALSE"     -> launch attempts: 0  handle: null  DECLINED
"off"       -> launch attempts: 0  handle: null  DECLINED
"Off"       -> launch attempts: 0  handle: null  DECLINED
"OFF"       -> launch attempts: 0  handle: null  DECLINED
" false "   -> launch attempts: 0  handle: null  DECLINED
"<unset>"   -> launch attempts: 1  handle: null  LAUNCHED
"1"         -> launch attempts: 1  handle: null  LAUNCHED

The last two rows are the anti-vacuity pair, and they are two distinct claims: an unset variable is a different code path from one that is set to an unrecognised value. Without both, a return null at the top of ensureStudioRunning satisfies all eight disable arms forever.

Red-then-green

f9054891 (test) is the direct parent of 0a287cdb (fix). Against unfixed source the new arms were red on exactly the two defects and nothing else — 7 failed | 41 passed (48):

  • the decoy arm naming the plant target: Expected: ".../wig-launch-Obweyl/substrate" / Received: ".../wig-decoy-kYANEI/substrate"
  • the six non-lowercase spellings, each at 30 s (30254ms, 30058ms, 30044ms, 30025ms, 30062ms, 30006ms) — the file took 180.84s, because a value that failed to disable does not merely mis-answer, it enters the handle poll and burns the full shipped budget. The existing '0'/'false' arms stayed green throughout.

After: 48 passed (48) in 320ms. The 180s → 320ms collapse is itself evidence the short-circuit is the one being taken.

Suites — local

  • npm run build → 0
  • npm test12081 passed | 20 skipped | 7 todo (12108) / 955 files passed, 5 skipped (960), exit 0, zero failures and zero unhandled errors
  • npx tsc --noEmit → 0
  • npm run gate:studio → 0 (debt ratchet holds at baseline 363)

CI deferred — quota (CEO directive 2026-08-28): merged on local green, issue labelled ci-deferred, backfill tracked in wigolo-studio-run#184.

Non-goals held

  • No dataDir-keying of the 60 s memo / single-flight (stays in known-issues.md).
  • No change to what auto-launch sets for the agent path — hidden stays '1', pinned by sd-164/165.
  • No sweep of other env comparisons outside this file.

Territory

Lane core:studio-core: src/studio/auto-launch.ts + tests/unit/studio/auto-launch.test.ts only. Two files, no widening, no lane-extra. Decoy fixtures under $TMPDIR only; nothing written inside the repo.

…casing

Both arms are red against the source in the parent commit.

The plant helper writes a forged acquisition record at `substrateRoot()`'s
ambient answer and its caller `rmSync(root, { recursive: true, force: true })`s
that directory. `substrateRoot()` follows the memoized data dir, and
`tests/setup.ts` repoints `WIGOLO_DATA_DIR` only when unset — deliberately — so
a developer who exported the documented knob and ran `npm test` lost their real
substrate root while the suite reported 37/37 and exited 0. The new arm forces
the export, proves the forcing took effect via `resetConfig()`, and asserts the
plant lands in the per-test dir with the decoy's file surviving both the plant
and the cleanup. It reds naming the decoy root as the plant target.

The off switch compares exact-match against two lowercase literals, so `False`,
`FALSE` and `Off` all read as "not disabled" and the substrate spawns detached
and hidden against the operator's stated intent. Eight spellings plus two
anti-vacuity arms — an unset variable and a set-but-unrecognised one must both
still launch, or a bare `return null` satisfies the whole suite.
…e-insensitively

The plant helper repoints `WIGOLO_DATA_DIR` at its per-test temp dir and drops
the memoized config, so `substrateRoot()` — which takes no data dir, because
`studioLaunchable()` reaches it through `substratePresent()` — answers inside a
directory this file created. Its caller's recursive remove then targets that,
not a root the suite never made. The containment check stays as the assert that
the repoint took effect: a dropped `resetConfig()` or a config that stopped
reading the var would silently restore the original defect, and the refusal is
what fails loudly instead. It also moves the SIGKILL residue — a forged record
naming a real on-disk executable — out of the path the launcher reads.

The off switch was exact-match against two lowercase literals, so `False`,
`FALSE` and `Off` read as "not disabled" and the substrate spawned detached and
hidden against the operator's stated intent. It now matches lowercased and
trimmed against `0`/`false`/`off`. `envBool` (src/config.ts) is the house
comparison and normalises for the same reason; the two `WIGOLO_STUDIO_HIDDEN`
fixes this phase were the env KEY's casing, which no value comparison inherits.

Both widenings past the house set — `off`, and the trim — are recorded with
their reversal conditions as A-189-1 and A-189-2.
@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 37c5fe5d-3b51-4a2b-ac6c-88385057fae6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@KnockOutEZ
KnockOutEZ merged commit 11b58f3 into studio-handoff-core Aug 28, 2026
19 of 20 checks passed
@KnockOutEZ
KnockOutEZ deleted the sd-189-fix-studio-auto-launch-exported branch August 28, 2026 10:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant