Skip to content

Commit aaea74b

Browse files
exo-nikitaclaude
andauthored
fix(run): keep an explicit bundleFile from diverging load's root in a monorepo (#56)
`stasis run --bundle=load --bundle-file=<path>` crashed in a nested-package (monorepo) layout with `Cannot find module '/.../node_modules/<dep>/index.js'` (thrown from patchCjsResolution's Module._resolveFilename shim, hooks.js), or the sibling `stasis: file not attested in bundle` / `file is outside the project root`. Root cause is State's root discovery, not the CJS shim. An explicit `bundleFile` (a --bundle-file flag or EXODUS_STASIS_BUNDLE_FILE) is a rootDir-INDEPENDENT path: reading it yields the same file at every candidate root. It was used as a per-rootDir root-detection signal, so at load it matched the INNERMOST package.json dir and committed that as `state.root` -- while capture, run before the bundle existed (so with no such signal), committed the OUTER repo root. Because every bundle key is stored relative to the capture root, that divergence puts a dependency hoisted to <repo>/node_modules OUTSIDE the (leaf) load root, so the loader classifies it as non-bundled: it defers to Node, and Node's ESM->CJS translator re-resolves the dependency through Module._load(absolutePath, /* no parent */). That call falls through the shim's `typeof parent?.filename === 'string'` guard to native disk resolution and throws when node_modules was pruned / never shipped. Fix: treat an explicit bundleFile exactly like explicitLockPath -- suppress it as a per-rootDir selection signal (root is chosen from rootDir-DEPENDENT signals: stasis.config.json, the default lockfile/bundle at the dir), then load it at the committed root, or at the outermost root post-loop when nothing else committed. This keeps capture and load agreed on the root, so a hoisted dependency stays in-root and is served from the bundle (integrity preserved) instead of read from disk. A `bundleFile` that comes only from stasis.config.json is left as a signal: it lives at a dir already carrying the (rootDir-dependent) config signal, so it selects that dir consistently at capture and load alike. The shared bundle-absorption logic is extracted into #absorbCodeBundle for the in-loop and post-loop paths. Test: monorepo (.git + root package.json + packages/app with no stasis.config.json) with a CJS dependency hoisted to the repo-root node_modules, run from the leaf with an explicit --bundle-file; a bundle=add then bundle=load round-trip serves the dep from the bundle (verified by tampering the on-disk copy) instead of crashing. Claude-Session: https://claude.ai/code/session_01UenCGinFJrGDyt8E7XEftP Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4b3e489 commit aaea74b

2 files changed

Lines changed: 138 additions & 38 deletions

File tree

‎stasis-core/src/state.js‎

Lines changed: 94 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -388,16 +388,36 @@ export class State {
388388
// the explicit one), then substitute the explicit content once root-detection has
389389
// committed to a rootDir below.
390390
const explicitLockPath = this.config.lockFile
391+
// A construction-time `bundleFile` (a --bundle-file flag or EXODUS_STASIS_BUNDLE_FILE) is
392+
// rootDir-INDEPENDENT: reading it yields the same file at every candidate root. So, exactly
393+
// like explicitLockPath, it must NOT act as a per-rootDir root-detection signal -- if it did,
394+
// the innermost package.json dir would always match first and win, diverging load's root from
395+
// the (outer) root the bundle was captured with. Because every bundle key is stored relative
396+
// to the capture root, that divergence makes every bundled dependency unreachable at load: the
397+
// load hook throws "outside project root" / "not attested", and Node's ESM->CJS translator --
398+
// which re-resolves a required dependency through Module._load(absPath, /* no parent */) -- hits
399+
// native disk resolution for a file the bundle carries but disk (post-prune / never-shipped)
400+
// does not, i.e. `Cannot find module '<abs node_modules path>'`. Root is instead chosen from
401+
// rootDir-DEPENDENT signals (stasis.config.json, the default lockfile/bundle at the dir), then
402+
// the explicit bundle is loaded at the committed root -- or, when nothing else committed, at the
403+
// outermost root post-loop (mirroring explicitLockPath). A `bundleFile` that comes only from
404+
// stasis.config.json is NOT suppressed: it lives at a dir already carrying the config signal, so
405+
// it selects that dir consistently at capture and load alike.
406+
const explicitBundlePath = this.config.bundleFile
391407
for (const rootDir of potentialRoots) {
392408
const config = readFileSyncMaybe(rootDir, FILE_CONFIG, 'utf-8')
393409
const lockProbe = explicitLockPath ? null : readFileSyncMaybe(rootDir, FILE_LOCK, 'utf-8')
394-
// Probe for a bundle at the construction-time bundleFile (a --bundle-file flag or
395-
// EXODUS_STASIS_BUNDLE_FILE) or, absent that, the default <rootDir>/stasis.code.br.
396-
// This is only a root-detection signal -- the authoritative path can still change when
410+
// Read the bundle at the construction-time bundleFile (a --bundle-file flag or
411+
// EXODUS_STASIS_BUNDLE_FILE) or, absent that, the default <rootDir>/stasis.code.br -- used
412+
// to LOAD the bundle once a root is committed. The authoritative path can still change when
397413
// loadConfig() applies a `bundleFile` from stasis.config.json, so it's re-read below.
398414
let sourcesPath = this.config.bundleFile || join(rootDir, FILE_CODE)
399415
let sources = readFileSyncMaybe(dirname(sourcesPath), basename(sourcesPath))
400-
if (config !== null || lockProbe !== null || sources !== null) {
416+
// Root-SELECTION signal: only the DEFAULT <rootDir>/stasis.code.br counts. An explicit
417+
// (rootDir-independent) bundleFile is suppressed here (see explicitBundlePath above) so it
418+
// can't bias selection to the innermost dir; it is still loaded via `sources` once committed.
419+
const bundleProbe = explicitBundlePath ? null : sources
420+
if (config !== null || lockProbe !== null || bundleProbe !== null) {
401421
if (loaded) throw new Error('Stasis config already loaded')
402422
loaded = true
403423
this.root = rootDir
@@ -473,40 +493,7 @@ export class State {
473493
}
474494

475495
if (sources && (this.config.writeBundle || this.config.loadBundle || this.config.frozenBundle) && !this.config.replaceBundle) {
476-
// One unified bundle carries both code and resources. Split the flat file
477-
// view into this.sources (code) and this.resources (resource payloads --
478-
// raw UTF-8 for 'resource', base64 for 'resource:base64') by format.
479-
const bundle = Bundle.parse(brotliDecompressSync(sources).toString('utf-8'))
480-
// `Bundle.parse` accepts v0 for offline tooling (`stasis extract`, `diff`,
481-
// `audit`, `sbom`) -- it has the metadata those commands need. The runtime
482-
// path is stricter: a v0 bundle has no per-file `formats` attestation
483-
// (resources can't be distinguished from code, the loader can't pick
484-
// module-vs-commonjs) and no import map (resolution edges go unchecked),
485-
// so serving / verifying one under `stasis run` would silently widen the
486-
// trust boundary. Refuse it explicitly and point at the upgrade path.
487-
assert.equal(bundle.version, Bundle.VERSION,
488-
`stasis run requires a v1 bundle; ${sourcesPath} is v${bundle.version}. ` +
489-
`Re-bundle with the current stasis (\`stasis bundle\`) or \`stasis run --bundle=replace\` ` +
490-
`against a v0-free starting point to upgrade.`)
491-
assert.equal(bundle.config.scope, this.config.scope)
492-
this.#mergeBundleMetadata(bundle, { lockfileLoaded })
493-
for (const [file, content] of bundle.sources) {
494-
if (Bundle.isResourceFormat(bundle.formats.get(file))) this.resources.set(file, content)
495-
else this.sources.set(file, content)
496-
}
497-
this.formats = bundle.formats
498-
this.imports = bundle.imports
499-
if (this.config.frozenBundle) {
500-
// Snapshot the attested sets before addFile/addImport mutate the live maps.
501-
// imports is deep-cloned (this.imports shares bundle.imports's nested Maps,
502-
// which addImport extends); formats is a flat file->string map, so a shallow
503-
// copy suffices. Code and resource file sets are tracked separately to match
504-
// addFile's per-kind frozen-bundle membership check.
505-
this.#bundleSources = new Set(this.sources.keys())
506-
this.#bundleResources = new Set(this.resources.keys())
507-
this.#bundleImports = objectToMaps(fileMapToObject(bundle.imports))
508-
this.#bundleFormats = new Map(bundle.formats)
509-
}
496+
this.#absorbCodeBundle(sources, sourcesPath, lockfileLoaded)
510497
}
511498

512499
// Split-bundle layout: when `resourcesBundleFile` is configured, resources
@@ -569,6 +556,35 @@ export class State {
569556
}
570557
}
571558

559+
// Explicit (flag/env) bundleFile with no rootDir-dependent indicator at any rootDir: like the
560+
// explicit lockfile above, it was suppressed as a root-detection signal so it couldn't pull the
561+
// root down to the innermost package, so the loop never loaded it. Load it here against
562+
// `this.root` (the outermost package.json) -- the same root capture commits to when no signal
563+
// exists -- so its capture-root-relative keys line up with how they're looked up. Inlines the
564+
// same gates as the in-loop bundle branch.
565+
if (!loaded && explicitBundlePath) {
566+
const sourcesPath = this.config.bundleFile
567+
const sources = readFileSyncMaybe(dirname(sourcesPath), basename(sourcesPath))
568+
if (sources && !this.config.writeBundle && !this.config.loadBundle && !this.config.ignoreBundle && !this.config.frozenBundle) {
569+
throw new Error(`Unexpected ${sourcesPath} with config.bundle = 'none'`)
570+
}
571+
if (sources && !lockfileLoaded && this.config.useLockfile && !this.config.replaceLockfile && !this.config.frozenBundle) {
572+
throw new Error('stasis.lock.json missing, can not use sources')
573+
}
574+
if (sources && (this.config.writeBundle || this.config.loadBundle || this.config.frozenBundle) && !this.config.replaceBundle) {
575+
this.#absorbCodeBundle(sources, sourcesPath, lockfileLoaded)
576+
}
577+
// Split-bundle resources: same post-loop treatment (resourcesBundleFile is likewise an
578+
// explicit, rootDir-independent path). Mirrors the in-loop resources branch.
579+
if (this.config.resourcesBundleFile) {
580+
const resourcesPath = this.config.resourcesBundleFile
581+
const resourcesData = readFileSyncMaybe(dirname(resourcesPath), basename(resourcesPath))
582+
if (resourcesData && (this.config.writeBundle || this.config.loadBundle || this.config.frozenBundle) && !this.config.replaceBundle) {
583+
this.#absorbResourcesBundle(resourcesData, { lockfileLoaded, resourcesPath })
584+
}
585+
}
586+
}
587+
572588
// Frozen modes must have actually loaded their attestation. Asserting here --
573589
// after the discovery loop -- rather than only inside it closes a fail-open: with
574590
// no stasis files on disk at all the loop body never runs, so a per-rootDir guard
@@ -608,6 +624,46 @@ export class State {
608624
liveStates().add(this)
609625
}
610626

627+
// Absorb a unified code+resource bundle's metadata and payloads into this State. Shared by the
628+
// discovery loop (a committed rootDir carrying a stasis signal) and the post-loop fallback that
629+
// loads an explicit (rootDir-independent) bundleFile against the outermost root.
630+
#absorbCodeBundle(sources, sourcesPath, lockfileLoaded) {
631+
// One unified bundle carries both code and resources. Split the flat file
632+
// view into this.sources (code) and this.resources (resource payloads --
633+
// raw UTF-8 for 'resource', base64 for 'resource:base64') by format.
634+
const bundle = Bundle.parse(brotliDecompressSync(sources).toString('utf-8'))
635+
// `Bundle.parse` accepts v0 for offline tooling (`stasis extract`, `diff`,
636+
// `audit`, `sbom`) -- it has the metadata those commands need. The runtime
637+
// path is stricter: a v0 bundle has no per-file `formats` attestation
638+
// (resources can't be distinguished from code, the loader can't pick
639+
// module-vs-commonjs) and no import map (resolution edges go unchecked),
640+
// so serving / verifying one under `stasis run` would silently widen the
641+
// trust boundary. Refuse it explicitly and point at the upgrade path.
642+
assert.equal(bundle.version, Bundle.VERSION,
643+
`stasis run requires a v1 bundle; ${sourcesPath} is v${bundle.version}. ` +
644+
`Re-bundle with the current stasis (\`stasis bundle\`) or \`stasis run --bundle=replace\` ` +
645+
`against a v0-free starting point to upgrade.`)
646+
assert.equal(bundle.config.scope, this.config.scope)
647+
this.#mergeBundleMetadata(bundle, { lockfileLoaded })
648+
for (const [file, content] of bundle.sources) {
649+
if (Bundle.isResourceFormat(bundle.formats.get(file))) this.resources.set(file, content)
650+
else this.sources.set(file, content)
651+
}
652+
this.formats = bundle.formats
653+
this.imports = bundle.imports
654+
if (this.config.frozenBundle) {
655+
// Snapshot the attested sets before addFile/addImport mutate the live maps.
656+
// imports is deep-cloned (this.imports shares bundle.imports's nested Maps,
657+
// which addImport extends); formats is a flat file->string map, so a shallow
658+
// copy suffices. Code and resource file sets are tracked separately to match
659+
// addFile's per-kind frozen-bundle membership check.
660+
this.#bundleSources = new Set(this.sources.keys())
661+
this.#bundleResources = new Set(this.resources.keys())
662+
this.#bundleImports = objectToMaps(fileMapToObject(bundle.imports))
663+
this.#bundleFormats = new Map(bundle.formats)
664+
}
665+
}
666+
611667
// Cross-check bundle metadata (entries/modules) with what the lockfile already
612668
// loaded, or absorb it as the source of truth when no lockfile is present.
613669
// v0 bundles infer `name` from path for node_modules buckets but carry no

‎tests/cli.test.js‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,50 @@ test('run honors a config-only bundleFile in a nested-package (monorepo) layout'
266266
t.assert.doesNotMatch(r.stderr, /Stasis config already loaded/, 'outer root must not re-detect the bundle')
267267
}))
268268

269+
test('run --bundle=load with an explicit --bundle-file resolves the root consistently in a monorepo', withTmp((t, tmp) => {
270+
// Regression: an explicit --bundle-file (or EXODUS_STASIS_BUNDLE_FILE) is a rootDir-INDEPENDENT
271+
// path -- it reads the same file at every candidate root. As a root-detection signal it therefore
272+
// matched the INNERMOST package.json (packages/app) and committed that as the root at load time,
273+
// even though capture -- run before the bundle existed, so with no such signal -- committed the
274+
// OUTER repo root. Bundle keys are stored relative to the capture root, so a dependency hoisted to
275+
// <repo>/node_modules then landed OUTSIDE the (leaf) load root: the load hook could not serve it,
276+
// and Node's ESM->CJS translator re-resolved it through Module._load(absPath, /* no parent */),
277+
// which fell through hooks.js's `typeof parent?.filename === 'string'` guard to native disk
278+
// resolution -- `Cannot find module '<abs>/node_modules/dep/index.js'` once node_modules was
279+
// pruned/not shipped. Choosing the root without the explicit bundleFile biasing it keeps capture
280+
// and load agreed, so the hoisted dep stays in-root and is served from the bundle.
281+
//
282+
// Layout yields potentialRoots = [packages/app, <tmp>] (the .git at <tmp> stops the upward walk).
283+
mkdirSync(join(tmp, '.git'))
284+
writeFileSync(join(tmp, 'package.json'), JSON.stringify({ name: 'root', version: '1.0.0', private: true }))
285+
// A CJS dependency hoisted to the repo-root node_modules (the npm/pnpm dedup shape).
286+
const dep = join(tmp, 'node_modules', 'dep')
287+
mkdirSync(dep, { recursive: true })
288+
writeFileSync(join(dep, 'package.json'), JSON.stringify({ name: 'dep', version: '1.0.0', main: 'index.js' }))
289+
writeFileSync(join(dep, 'index.js'), "module.exports = 'DEP-FROM-BUNDLE'\n")
290+
// Leaf package with NO stasis.config.json; its ESM entry imports the hoisted CJS dep (an
291+
// ESM->CJS edge, so Node's translator drives the resolution that tripped the guard).
292+
const app = join(tmp, 'packages', 'app')
293+
mkdirSync(app, { recursive: true })
294+
writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'app', version: '1.0.0', private: true }))
295+
writeFileSync(join(app, 'index.mjs'), "import dep from 'dep'\nconsole.log(dep)\n")
296+
const bundlePath = join(tmp, 'snapshot.br')
297+
298+
const save = run(['run', '--lock=none', '--dependencies', '--bundle=add', `--bundle-file=${bundlePath}`, 'index.mjs'], { cwd: app })
299+
t.assert.equal(save.status, 0, `save stderr: ${save.stderr}`)
300+
t.assert.match(save.stdout, /DEP-FROM-BUNDLE/)
301+
302+
// Tamper the dep's on-disk bytes: the node_modules layout stays on disk (node_modules scope
303+
// resolves the bare specifier through it), but the CONTENT must come from the bundle. Pre-fix,
304+
// the leaf-root misclassification served (or failed to find) the on-disk copy instead.
305+
writeFileSync(join(dep, 'index.js'), "module.exports = 'DEP-FROM-DISK-TAMPERED'\n")
306+
307+
const load = run(['run', '--lock=none', '--dependencies', '--bundle=load', `--bundle-file=${bundlePath}`, 'index.mjs'], { cwd: app })
308+
t.assert.equal(load.status, 0, `load stderr: ${load.stderr}`)
309+
t.assert.doesNotMatch(load.stdout, /TAMPERED/, 'the hoisted dep must be served from the bundle, not the on-disk copy')
310+
t.assert.match(load.stdout, /DEP-FROM-BUNDLE/, 'bundle bytes win for a dependency resolved above the leaf package')
311+
}))
312+
269313
test('run --lock=replace --bundle=add rejects when disk disagrees with the pre-loaded bundle', withTmp((t, tmp) => {
270314
cpSync(runFixture, tmp, { recursive: true })
271315
const bundlePath = join(tmp, 'snapshot.br')

0 commit comments

Comments
 (0)