From 836db6b47d56398f6e11d207ea3649d49662c447 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 14:08:19 +0000 Subject: [PATCH] fix(run): keep an explicit bundleFile from diverging load's root in a monorepo `stasis run --bundle=load --bundle-file=` crashed in a nested-package (monorepo) layout with `Cannot find module '/.../node_modules//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 /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. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UenCGinFJrGDyt8E7XEftP --- stasis-core/src/state.js | 132 ++++++++++++++++++++++++++++----------- tests/cli.test.js | 44 +++++++++++++ 2 files changed, 138 insertions(+), 38 deletions(-) diff --git a/stasis-core/src/state.js b/stasis-core/src/state.js index 3ccd026..bf54a1d 100644 --- a/stasis-core/src/state.js +++ b/stasis-core/src/state.js @@ -388,16 +388,36 @@ export class State { // the explicit one), then substitute the explicit content once root-detection has // committed to a rootDir below. const explicitLockPath = this.config.lockFile + // A construction-time `bundleFile` (a --bundle-file flag or EXODUS_STASIS_BUNDLE_FILE) is + // rootDir-INDEPENDENT: reading it yields the same file at every candidate root. So, exactly + // like explicitLockPath, it must NOT act as a per-rootDir root-detection signal -- if it did, + // the innermost package.json dir would always match first and win, diverging load's root from + // the (outer) root the bundle was captured with. Because every bundle key is stored relative + // to the capture root, that divergence makes every bundled dependency unreachable at load: the + // load hook throws "outside project root" / "not attested", and Node's ESM->CJS translator -- + // which re-resolves a required dependency through Module._load(absPath, /* no parent */) -- hits + // native disk resolution for a file the bundle carries but disk (post-prune / never-shipped) + // does not, i.e. `Cannot find module ''`. Root is instead chosen from + // rootDir-DEPENDENT signals (stasis.config.json, the default lockfile/bundle at the dir), then + // the explicit bundle is loaded at the committed root -- or, when nothing else committed, at the + // outermost root post-loop (mirroring explicitLockPath). A `bundleFile` that comes only from + // stasis.config.json is NOT suppressed: it lives at a dir already carrying the config signal, so + // it selects that dir consistently at capture and load alike. + const explicitBundlePath = this.config.bundleFile for (const rootDir of potentialRoots) { const config = readFileSyncMaybe(rootDir, FILE_CONFIG, 'utf-8') const lockProbe = explicitLockPath ? null : readFileSyncMaybe(rootDir, FILE_LOCK, 'utf-8') - // Probe for a bundle at the construction-time bundleFile (a --bundle-file flag or - // EXODUS_STASIS_BUNDLE_FILE) or, absent that, the default /stasis.code.br. - // This is only a root-detection signal -- the authoritative path can still change when + // Read the bundle at the construction-time bundleFile (a --bundle-file flag or + // EXODUS_STASIS_BUNDLE_FILE) or, absent that, the default /stasis.code.br -- used + // to LOAD the bundle once a root is committed. The authoritative path can still change when // loadConfig() applies a `bundleFile` from stasis.config.json, so it's re-read below. let sourcesPath = this.config.bundleFile || join(rootDir, FILE_CODE) let sources = readFileSyncMaybe(dirname(sourcesPath), basename(sourcesPath)) - if (config !== null || lockProbe !== null || sources !== null) { + // Root-SELECTION signal: only the DEFAULT /stasis.code.br counts. An explicit + // (rootDir-independent) bundleFile is suppressed here (see explicitBundlePath above) so it + // can't bias selection to the innermost dir; it is still loaded via `sources` once committed. + const bundleProbe = explicitBundlePath ? null : sources + if (config !== null || lockProbe !== null || bundleProbe !== null) { if (loaded) throw new Error('Stasis config already loaded') loaded = true this.root = rootDir @@ -473,40 +493,7 @@ export class State { } if (sources && (this.config.writeBundle || this.config.loadBundle || this.config.frozenBundle) && !this.config.replaceBundle) { - // One unified bundle carries both code and resources. Split the flat file - // view into this.sources (code) and this.resources (resource payloads -- - // raw UTF-8 for 'resource', base64 for 'resource:base64') by format. - const bundle = Bundle.parse(brotliDecompressSync(sources).toString('utf-8')) - // `Bundle.parse` accepts v0 for offline tooling (`stasis extract`, `diff`, - // `audit`, `sbom`) -- it has the metadata those commands need. The runtime - // path is stricter: a v0 bundle has no per-file `formats` attestation - // (resources can't be distinguished from code, the loader can't pick - // module-vs-commonjs) and no import map (resolution edges go unchecked), - // so serving / verifying one under `stasis run` would silently widen the - // trust boundary. Refuse it explicitly and point at the upgrade path. - assert.equal(bundle.version, Bundle.VERSION, - `stasis run requires a v1 bundle; ${sourcesPath} is v${bundle.version}. ` + - `Re-bundle with the current stasis (\`stasis bundle\`) or \`stasis run --bundle=replace\` ` + - `against a v0-free starting point to upgrade.`) - assert.equal(bundle.config.scope, this.config.scope) - this.#mergeBundleMetadata(bundle, { lockfileLoaded }) - for (const [file, content] of bundle.sources) { - if (Bundle.isResourceFormat(bundle.formats.get(file))) this.resources.set(file, content) - else this.sources.set(file, content) - } - this.formats = bundle.formats - this.imports = bundle.imports - if (this.config.frozenBundle) { - // Snapshot the attested sets before addFile/addImport mutate the live maps. - // imports is deep-cloned (this.imports shares bundle.imports's nested Maps, - // which addImport extends); formats is a flat file->string map, so a shallow - // copy suffices. Code and resource file sets are tracked separately to match - // addFile's per-kind frozen-bundle membership check. - this.#bundleSources = new Set(this.sources.keys()) - this.#bundleResources = new Set(this.resources.keys()) - this.#bundleImports = objectToMaps(fileMapToObject(bundle.imports)) - this.#bundleFormats = new Map(bundle.formats) - } + this.#absorbCodeBundle(sources, sourcesPath, lockfileLoaded) } // Split-bundle layout: when `resourcesBundleFile` is configured, resources @@ -569,6 +556,35 @@ export class State { } } + // Explicit (flag/env) bundleFile with no rootDir-dependent indicator at any rootDir: like the + // explicit lockfile above, it was suppressed as a root-detection signal so it couldn't pull the + // root down to the innermost package, so the loop never loaded it. Load it here against + // `this.root` (the outermost package.json) -- the same root capture commits to when no signal + // exists -- so its capture-root-relative keys line up with how they're looked up. Inlines the + // same gates as the in-loop bundle branch. + if (!loaded && explicitBundlePath) { + const sourcesPath = this.config.bundleFile + const sources = readFileSyncMaybe(dirname(sourcesPath), basename(sourcesPath)) + if (sources && !this.config.writeBundle && !this.config.loadBundle && !this.config.ignoreBundle && !this.config.frozenBundle) { + throw new Error(`Unexpected ${sourcesPath} with config.bundle = 'none'`) + } + if (sources && !lockfileLoaded && this.config.useLockfile && !this.config.replaceLockfile && !this.config.frozenBundle) { + throw new Error('stasis.lock.json missing, can not use sources') + } + if (sources && (this.config.writeBundle || this.config.loadBundle || this.config.frozenBundle) && !this.config.replaceBundle) { + this.#absorbCodeBundle(sources, sourcesPath, lockfileLoaded) + } + // Split-bundle resources: same post-loop treatment (resourcesBundleFile is likewise an + // explicit, rootDir-independent path). Mirrors the in-loop resources branch. + if (this.config.resourcesBundleFile) { + const resourcesPath = this.config.resourcesBundleFile + const resourcesData = readFileSyncMaybe(dirname(resourcesPath), basename(resourcesPath)) + if (resourcesData && (this.config.writeBundle || this.config.loadBundle || this.config.frozenBundle) && !this.config.replaceBundle) { + this.#absorbResourcesBundle(resourcesData, { lockfileLoaded, resourcesPath }) + } + } + } + // Frozen modes must have actually loaded their attestation. Asserting here -- // after the discovery loop -- rather than only inside it closes a fail-open: with // no stasis files on disk at all the loop body never runs, so a per-rootDir guard @@ -608,6 +624,46 @@ export class State { liveStates().add(this) } + // Absorb a unified code+resource bundle's metadata and payloads into this State. Shared by the + // discovery loop (a committed rootDir carrying a stasis signal) and the post-loop fallback that + // loads an explicit (rootDir-independent) bundleFile against the outermost root. + #absorbCodeBundle(sources, sourcesPath, lockfileLoaded) { + // One unified bundle carries both code and resources. Split the flat file + // view into this.sources (code) and this.resources (resource payloads -- + // raw UTF-8 for 'resource', base64 for 'resource:base64') by format. + const bundle = Bundle.parse(brotliDecompressSync(sources).toString('utf-8')) + // `Bundle.parse` accepts v0 for offline tooling (`stasis extract`, `diff`, + // `audit`, `sbom`) -- it has the metadata those commands need. The runtime + // path is stricter: a v0 bundle has no per-file `formats` attestation + // (resources can't be distinguished from code, the loader can't pick + // module-vs-commonjs) and no import map (resolution edges go unchecked), + // so serving / verifying one under `stasis run` would silently widen the + // trust boundary. Refuse it explicitly and point at the upgrade path. + assert.equal(bundle.version, Bundle.VERSION, + `stasis run requires a v1 bundle; ${sourcesPath} is v${bundle.version}. ` + + `Re-bundle with the current stasis (\`stasis bundle\`) or \`stasis run --bundle=replace\` ` + + `against a v0-free starting point to upgrade.`) + assert.equal(bundle.config.scope, this.config.scope) + this.#mergeBundleMetadata(bundle, { lockfileLoaded }) + for (const [file, content] of bundle.sources) { + if (Bundle.isResourceFormat(bundle.formats.get(file))) this.resources.set(file, content) + else this.sources.set(file, content) + } + this.formats = bundle.formats + this.imports = bundle.imports + if (this.config.frozenBundle) { + // Snapshot the attested sets before addFile/addImport mutate the live maps. + // imports is deep-cloned (this.imports shares bundle.imports's nested Maps, + // which addImport extends); formats is a flat file->string map, so a shallow + // copy suffices. Code and resource file sets are tracked separately to match + // addFile's per-kind frozen-bundle membership check. + this.#bundleSources = new Set(this.sources.keys()) + this.#bundleResources = new Set(this.resources.keys()) + this.#bundleImports = objectToMaps(fileMapToObject(bundle.imports)) + this.#bundleFormats = new Map(bundle.formats) + } + } + // Cross-check bundle metadata (entries/modules) with what the lockfile already // loaded, or absorb it as the source of truth when no lockfile is present. // v0 bundles infer `name` from path for node_modules buckets but carry no diff --git a/tests/cli.test.js b/tests/cli.test.js index bd118b7..0698910 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -266,6 +266,50 @@ test('run honors a config-only bundleFile in a nested-package (monorepo) layout' t.assert.doesNotMatch(r.stderr, /Stasis config already loaded/, 'outer root must not re-detect the bundle') })) +test('run --bundle=load with an explicit --bundle-file resolves the root consistently in a monorepo', withTmp((t, tmp) => { + // Regression: an explicit --bundle-file (or EXODUS_STASIS_BUNDLE_FILE) is a rootDir-INDEPENDENT + // path -- it reads the same file at every candidate root. As a root-detection signal it therefore + // matched the INNERMOST package.json (packages/app) and committed that as the root at load time, + // even though capture -- run before the bundle existed, so with no such signal -- committed the + // OUTER repo root. Bundle keys are stored relative to the capture root, so a dependency hoisted to + // /node_modules then landed OUTSIDE the (leaf) load root: the load hook could not serve it, + // and Node's ESM->CJS translator re-resolved it through Module._load(absPath, /* no parent */), + // which fell through hooks.js's `typeof parent?.filename === 'string'` guard to native disk + // resolution -- `Cannot find module '/node_modules/dep/index.js'` once node_modules was + // pruned/not shipped. Choosing the root without the explicit bundleFile biasing it keeps capture + // and load agreed, so the hoisted dep stays in-root and is served from the bundle. + // + // Layout yields potentialRoots = [packages/app, ] (the .git at stops the upward walk). + mkdirSync(join(tmp, '.git')) + writeFileSync(join(tmp, 'package.json'), JSON.stringify({ name: 'root', version: '1.0.0', private: true })) + // A CJS dependency hoisted to the repo-root node_modules (the npm/pnpm dedup shape). + const dep = join(tmp, 'node_modules', 'dep') + mkdirSync(dep, { recursive: true }) + writeFileSync(join(dep, 'package.json'), JSON.stringify({ name: 'dep', version: '1.0.0', main: 'index.js' })) + writeFileSync(join(dep, 'index.js'), "module.exports = 'DEP-FROM-BUNDLE'\n") + // Leaf package with NO stasis.config.json; its ESM entry imports the hoisted CJS dep (an + // ESM->CJS edge, so Node's translator drives the resolution that tripped the guard). + const app = join(tmp, 'packages', 'app') + mkdirSync(app, { recursive: true }) + writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'app', version: '1.0.0', private: true })) + writeFileSync(join(app, 'index.mjs'), "import dep from 'dep'\nconsole.log(dep)\n") + const bundlePath = join(tmp, 'snapshot.br') + + const save = run(['run', '--lock=none', '--dependencies', '--bundle=add', `--bundle-file=${bundlePath}`, 'index.mjs'], { cwd: app }) + t.assert.equal(save.status, 0, `save stderr: ${save.stderr}`) + t.assert.match(save.stdout, /DEP-FROM-BUNDLE/) + + // Tamper the dep's on-disk bytes: the node_modules layout stays on disk (node_modules scope + // resolves the bare specifier through it), but the CONTENT must come from the bundle. Pre-fix, + // the leaf-root misclassification served (or failed to find) the on-disk copy instead. + writeFileSync(join(dep, 'index.js'), "module.exports = 'DEP-FROM-DISK-TAMPERED'\n") + + const load = run(['run', '--lock=none', '--dependencies', '--bundle=load', `--bundle-file=${bundlePath}`, 'index.mjs'], { cwd: app }) + t.assert.equal(load.status, 0, `load stderr: ${load.stderr}`) + t.assert.doesNotMatch(load.stdout, /TAMPERED/, 'the hoisted dep must be served from the bundle, not the on-disk copy') + t.assert.match(load.stdout, /DEP-FROM-BUNDLE/, 'bundle bytes win for a dependency resolved above the leaf package') +})) + test('run --lock=replace --bundle=add rejects when disk disagrees with the pre-loaded bundle', withTmp((t, tmp) => { cpSync(runFixture, tmp, { recursive: true }) const bundlePath = join(tmp, 'snapshot.br')