Skip to content

Support --import flag for extra preloads in stasis run - #169

Merged
ChALkeR merged 3 commits into
mainfrom
claude/stasis-run-import-passthrough-l7civq
Sep 4, 2026
Merged

Support --import flag for extra preloads in stasis run#169
ChALkeR merged 3 commits into
mainfrom
claude/stasis-run-import-passthrough-l7civq

Conversation

@exo-nikita

Copy link
Copy Markdown
Collaborator

Summary

Add support for the --import flag in stasis run to allow extra preload modules (like tsx for 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

  • CLI argument parsing: Added --import flag (repeatable) to both stasis and stasis-core CLIs with proper usage documentation
  • Hook passthrough logic: Modified hooks.js to detect and pass through preload module graphs before the entry point is resolved:
    • Added entryUnresolved flag to track when the first parent-less resolution (the entry) occurs
    • Preloads resolve with a parent context (cwd or their own files) and load before the entry, so they bypass capture
    • The first parent-less resolution marks the actual entry point
  • Format handling for transforming preloads: When a transforming preload like tsx requests a post-erasure format (e.g., module for a module-typescript file), the loader serves the requested format while keeping the on-disk format in attestations
    • Added erasedTypeScriptFormat() utility to map TypeScript formats to their post-erasure equivalents
    • Allows tsx to transpile TypeScript while stasis records the raw on-disk bytes
  • Comprehensive test suite: Added run-import-passthrough.test.js with 7 tests covering:
    • Basic TypeScript execution with tsx preload
    • Frozen replay and tamper detection with transforming preloads
    • Bundle loading with raw on-disk bytes over tampered disk
    • Extensionless specifier resolution through tsx's resolver
    • Non-transforming relative preloads staying out of capture
    • Usage error handling for empty --import values
  • Test fixture: Created cli-run-import-tsx fixture with TypeScript sources using enums (non-erasable syntax) to prove tsx transformation is load-bearing

Notable Implementation Details

  • Preload module graphs are completely excluded from the lockfile/bundle (no modules buckets), keeping artifacts clean and focused on app code
  • The attestation format remains the on-disk format (e.g., module-typescript), not the transformed format, ensuring integrity verification works correctly
  • Extensionless specifiers resolved by tsx are recorded with their rewritten paths, allowing replay to work through tsx's resolver again
  • CI pre-installs fixture dependencies to avoid network costs on every test run

https://claude.ai/code/session_015XNfiMmjfjhitFF2Fg2sYG

claude added 3 commits August 20, 2026 17:08
`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
@ChALkeR
ChALkeR merged commit 3397fbe into main Sep 4, 2026
5 checks passed
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.

3 participants