Skip to content

fix(studio): acquireSubstrate's pre-flight guards answer from inside the try - #517

Merged
KnockOutEZ merged 2 commits into
studio-handoff-corefrom
sd-188-fix-studio-acquiresubstrate-pre
Aug 28, 2026
Merged

fix(studio): acquireSubstrate's pre-flight guards answer from inside the try#517
KnockOutEZ merged 2 commits into
studio-handoff-corefrom
sd-188-fix-studio-acquiresubstrate-pre

Conversation

@KnockOutEZ

Copy link
Copy Markdown
Owner

Closes KnockOutEZ/wigolo-studio-run#188 (PX0 exit-6).

CI deferred — quota.

What was wrong

8f465f67 (#513) moved join(root, version) inside the try precisely so "NEVER THROWS is true of the function's shape rather than of its guards". It left the guards themselves outside — and staysInsideItsDirectory (:136) calls isAbsolute(), which throws ERR_INVALID_ARG_TYPE on a non-string. The exact class #513 closed for version reopened one line higher for executable, on the one seam a manifest can still arrive mistyped through: deps.source, which never passes readSubstrateManifest and which is where S16-alpha's published channel plugs in. warmup awaits this call with no try/catch on the strength of the documented contract, so the throw takes the browser, model and search phases with it.

Sibling asymmetry in the same file (security review LOW-2): readSubstrateRecord (:173) checked truthiness where readSubstrateManifest (:294) establishes typeof === 'string'. The other two fields throw their way to null through the function's own catch, so the shape that actually got through was a numeric version — read back as PRESENT, then flowing into already_present's detail text, the acquisition log and every doctor row downstream as a number.

What changed

  • Both pre-flight guards now answer from inside the try, so the contract is structural rather than a property of the shapes the guards anticipated.
  • The manifest's two path fields are type-checked at the seam entry, the way readSubstrateManifest does for a manifest on disk — so the refusal names its own reason instead of surfacing as the catch's generic install failure. What the guards refuse is unchanged; only where they answer from.
  • readSubstrateRecord establishes typeof === 'string' on version, executable and path.

Demo

Repro against the built dist/, injected source + a hand-edited record.json.

Before (tip 89f423b1):

acquire: non-string executable -> THREW TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string. Received type number (1)
acquire: non-string version    -> RESOLVED outcome=failed error=The "path" argument must be of type string. Received type number (1)
acquire: well-formed source    -> RESOLVED outcome=acquired error=-
record: non-string version    -> PRESENT (wrong)
record: non-string executable -> ABSENT
record: non-string path       -> ABSENT
record: well-formed          -> PRESENT

After:

acquire: non-string executable -> RESOLVED outcome=failed error=unusable manifest: version string, executable number
acquire: non-string version    -> RESOLVED outcome=failed error=unusable manifest: version number, executable string
acquire: well-formed source    -> RESOLVED outcome=acquired error=-
record: non-string version    -> ABSENT
record: non-string executable -> ABSENT
record: non-string path       -> ABSENT
record: well-formed          -> PRESENT

The well-formed lines are the anti-vacuity arms: they are unchanged across the fix, so the new refusals cannot be satisfied by a guard that refuses everything.

Red-then-green

7b0b94cd (test) is the direct parent of d920d200 (fix). Against unfixed source it was red on exactly the two defects and nothing else:

FAIL ... settles rather than rejecting when an injected manifest's executable is a number
FAIL ... settles rather than rejecting when an injected manifest's executable is an array
FAIL ... settles rather than rejecting when an injected manifest's executable is a boolean
FAIL ... refuses a record whose version is a number, even though the substrate is really there
Tests  4 failed | 59 passed (63)

The discriminator in the acquire arms is settle-vs-reject, not an outcome string — that is the property the never-throws contract is about, and an assertion on outcome alone cannot tell a refusal apart from a crash.

Suites (local — CI deferred, quota)

npm test              exit 0   955 passed | 5 skipped (960 files) · 12070 passed | 20 skipped | 7 todo
npx tsc --noEmit      exit 0   (0 lines of output)
npm run gate:studio   exit 0   tests/ type-check debt holds at baseline 363

Non-goals honoured

No concurrent-acquire interlock, no TOCTOU work between record validation and spawn, no change to what the guards refuse.

…type contracts

The injected-source seam never passes `readSubstrateManifest`, so the type
established there does not hold for a manifest handed in through `deps.source` —
the seam a published channel re-enters with a manifest of its own. The
pre-flight guards answer from outside the try, and `staysInsideItsDirectory`
calls `isAbsolute()`, which throws `ERR_INVALID_ARG_TYPE` on a non-string
straight past a documented NEVER THROWS into `warmup`'s unguarded await.

The discriminator is settle-vs-reject rather than an outcome string, because
that is the property the contract is about: an assertion on `outcome` alone
cannot tell a refusal apart from a crash.

`readSubstrateRecord` checks truthiness where `readSubstrateManifest`
establishes `typeof === 'string'`, so a numeric `version` reads back PRESENT
and flows into the detail text, the logs and every doctor row downstream.

Red against unfixed source: three executable-shape arms reject, and the numeric
record version reads back present.
…the try

`8f465f67` moved the join inside the try so NEVER THROWS would be true of the
function's shape rather than of its guards, and then left the guards themselves
outside — where `staysInsideItsDirectory` calls `isAbsolute()`, which throws
`ERR_INVALID_ARG_TYPE` on a non-string. The class reopened one line higher, on
the one seam a manifest can still arrive mistyped through: `deps.source`, which
never passes `readSubstrateManifest` and which is where a published channel
plugs in. `warmup` awaits this call unguarded on the strength of the contract,
so the throw took the browser, model and search phases with it.

Both guards now sit inside the try, and the type is established at the seam the
way `readSubstrateManifest` establishes it for a manifest on disk — so the
refusal names its own reason instead of surfacing as a generic install failure.
What the guards refuse is unchanged; only where they answer from.

Same file, the sibling asymmetry: `readSubstrateRecord` checked truthiness
where `readSubstrateManifest` establishes `typeof === 'string'`. The other two
fields threw their way to null through its own catch, so the shape that got
through was a numeric `version`, reading back as PRESENT and flowing into the
detail text, the log line and every doctor row downstream as a number.
@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: 621af8e8-dc1c-41c4-bbb7-17494df03fa4

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 06a9469 into studio-handoff-core Aug 28, 2026
19 of 20 checks passed
@KnockOutEZ
KnockOutEZ deleted the sd-188-fix-studio-acquiresubstrate-pre branch August 28, 2026 09:31
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