Support --import flag for extra preloads in stasis run - #169
Merged
Conversation
`stasis run [--import=module ...]` forwards extra preload modules to the
node process running the entry (e.g. `--import=tsx` to run TypeScript
entries through the project's tsx), riding AFTER stasis's own loader
import so every later-registered hook layers ABOVE stasis's and stasis
keeps observing the raw on-disk sources.
The hooks treat everything before the first parent-less resolution (the
CLI entry) as runner infrastructure, like stasis's own loader: a
preload's module graph passes through unobserved. Capturing it instead
would misfile the first preload as the entry (initState from ITS
package root) and pollute the artifact with toolchain files; gating it
would break --bundle=load (the preload itself must keep loading from
disk).
A transforming preload resolves/serves a TypeScript file as its
post-erasure family ('module-typescript' arrives as 'module'), so the
attestation normalizes to the on-disk format on capture and the
bundle=load hook serves the chain's requested family while checking the
attested one. The artifacts therefore attest the raw on-disk TS bytes
and formats: frozen replays reject tampered sources under tsx,
bundle=load serves the attested bytes with tsx transpiling above, and a
replay without tsx hands the raw source to Node's own strip-only
TypeScript mode.
Both bins (stasis, stasis-core) get the flag; the new
cli-run-import-tsx fixture pins a real tsx install (own sub-workspace,
CI pre-installs like popular-npm-modules) and the tests cover capture
scope, frozen/tamper, bundle=load with and without tsx, tsx's
resolver-rewritten extensionless specifiers, a non-transforming
relative preload, and flag validation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015XNfiMmjfjhitFF2Fg2sYG
The pre-entry time gate treated everything before the first parent-less
resolution as runner infrastructure, which review + repros showed to be
fail-open in four ways, plus a capture-pollution bug:
- a module imported by both a preload and the app escaped attestation
entirely (its pre-entry load passed through; the app's later import
hit the module cache, so the load hook never re-fired): the lockfile
shipped a dangling edge and a frozen replay executed a tampered copy
with exit 0
- CJS-flavored TypeScript under `--import tsx` executes through tsx's
require.extensions handler, which bypasses the load hook chain: the
files were silently missing from the artifacts, and frozen replays
never verified them
- an eval (-e) entry never performs a parent-less resolution, so such a
run (e.g. a --child-process capture whose app spawns `node -e`)
passed through entirely unobserved with exit 0, where main failed
closed on assert.ok(state)
- in a no-`type` package a .ts file was attested under tsx's
post-erasure view ('commonjs') instead of the on-disk
'commonjs-typescript', poisoning later native-mode runs
- a preload's DEFERRED dynamic import landed after the entry and was
captured, keyed under an unattested parent, making artifacts depend
on preload timing
The exemption is now graph-scoped, not time-scoped. The preload phase's
resolutions and loads are tracked as an infrastructure set: passthrough
still applies to that graph and its lazy descendants (never to [eval]
parents, which fail closed as before -- the synthetic preload parent is
the cwd DIRECTORY URL, which no loadable module can shadow), but each
infra load stashes its raw {source, format} and each infra resolution
its edge. When the app graph reaches an infra module it is PROMOTED
into the capture with the exact bytes that executed -- addFile
re-verifies them against disk, and against the lockfile/bundle in
frozen modes, rejecting tampering before the entry consumes the module
-- and its remembered edges replay transitively so nothing dangles.
The entry itself being preloaded (a wrapper importing the app) promotes
as the entry.
Loads the hook chain never sees at all are reconciled through the
Module._load shim: every actual CJS execution is recorded, and write()
backfills (add) or verifies (frozen) any executed file whose bytes were
never captured -- covering tsx's CJS pipeline and babel-register-style
'.js' transplants, while resolve-only edges stay byte-less by design
and native addons stay excluded. Under --bundle=load, a preload-cached
module is served only when its executed bytes match the attested ones
(else the run is refused before the entry can consume the divergent
instance), and an attested source that executes from disk anyway --
a transplanted handler on Node minors whose commonjs-sync pipeline
consults require.extensions -- is refused right after the fact.
A no-`type` .ts reported as post-erasure 'commonjs'/'module' is now
attested as the '-typescript' variant, so same-stack replays stay
self-consistent and cross-stack disagreement (tsx defaults CJS, Node
syntax-detects) surfaces as an honest format flip.
`--mock --import` now warns that preloads run under the mock's
side-effect denials (tsx's esbuild service cannot spawn there);
non-transforming preloads keep working. Usage text/README describe the
promotion semantics, and the suite grows tests for every lane: CJS TS
capture/frozen/load, no-`type` formats and the cross-stack flip, shared
preload+app modules (transitive, pre-execution rejection), bundle=load
divergence refusal, deferred infra imports, eval entries, transplanted
'.js' pipelines, wrapper-imported entries, --mock, and stasis-core CLI
parity. Everything passes on Node 24.14.0 and 24.19.0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015XNfiMmjfjhitFF2Fg2sYG
Rechecking `--import tsx` against realistic TypeScript project shapes (on Node 24.14/24.19/26.7, tsx 4.23.12 -- the current latest) shows the integration is real but PARTIAL, so the usage text, README, and warning strings now document --import with a plain instrumentation preload and make no TypeScript claims. The passthrough itself is unchanged. What holds, and stays pinned by the test suite: capture, frozen verify/tamper-reject, and bundle=load WITH sources on disk all work -- for explicit-.ts imports, '.js'-suffixed imports (nodenext), tsconfig paths aliases, JSX/.tsx, CJS-flavored TS, npm deps, and dynamic import alike. What breaks: "run from the bundle alone". Whenever the transformer REWRITES a specifier -- './util.js' -> './util.ts', tsconfig aliases, extensionless -- the artifacts record the rewritten edge (stasis's inner hook can only see what the outer resolver passes down), and a source-less replay cannot re-derive the rewrite because tsx's resolver probes the filesystem. The replay fails closed (ERR_MODULE_NOT_FOUND), but the flagship ship-the-bundle workflow does not hold for the dominant TS conventions, so tsx is not something the docs should advertise. A new test pins both halves: the on-disk lanes pass, the recorded edge is the rewritten specifier, and the source-less replay fails closed rather than misresolving. The tsx fixture and tests stay: they exercise the transformer interactions the hooks defend (promotion, require.extensions reconciliation, bundle-divergence refusal) and document the exact envelope in the suite header. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015XNfiMmjfjhitFF2Fg2sYG
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Add support for the
--importflag instasis runto allow extra preload modules (liketsxfor TypeScript support) to be passed through to the spawned Node process. Preload modules are treated as runner infrastructure and are excluded from capture into the lockfile/bundle, similar to stasis's own loader.Key Changes
--importflag (repeatable) to bothstasisandstasis-coreCLIs with proper usage documentationhooks.jsto detect and pass through preload module graphs before the entry point is resolved:entryUnresolvedflag to track when the first parent-less resolution (the entry) occurstsxrequests a post-erasure format (e.g.,modulefor amodule-typescriptfile), the loader serves the requested format while keeping the on-disk format in attestationserasedTypeScriptFormat()utility to map TypeScript formats to their post-erasure equivalentsrun-import-passthrough.test.jswith 7 tests covering:--importvaluescli-run-import-tsxfixture with TypeScript sources using enums (non-erasable syntax) to prove tsx transformation is load-bearingNotable Implementation Details
modulesbuckets), keeping artifacts clean and focused on app codemodule-typescript), not the transformed format, ensuring integrity verification works correctlyhttps://claude.ai/code/session_015XNfiMmjfjhitFF2Fg2sYG