env: read varlock's own resolution sentinel instead of nub's wrap marker - #724
env: read varlock's own resolution sentinel instead of nub's wrap marker#724colinhacks wants to merge 1 commit into
Conversation
A project that already wires varlock into its own scripts got a second, nub-inserted resolution in front of the one it asked for. That second resolution carries nub's arguments rather than the script's, so it re-ran `exec()` resolvers a `--filter` had excluded, and — where the script passed its own `--path` — died on the root schema's validation before the script's own invocation ever ran. Measured against varlock 1.16.1: `npm run` exited 0 on the same project, nub exited 1. Two rules replace the `__NUB_ENV_OWNER_WRAPPED` marker, which is deleted: - The loader already ran. `__VARLOCK_ENV` carries an absolute `basePath` and a typed `sources` entry per schema file, so nub can ask whether THIS schema was resolved rather than whether the loader ran at all. Containment against the schema directory keeps the case the marker existed for: a workspace root's resolution does not stand down a member that ships its own schema, and another project's says nothing about this one. - The loader is what nub is about to launch. No blob exists at that moment, so this one recognizes the program instead. It also replaces the marker as the recursion guard, structurally rather than by flag. Reading the loader's own surface covers launchers nub cannot observe — a Makefile, a CI wrapper, a standalone binary. Two of the three objections the 2026-08-02 decision raised against it do not reproduce on 1.16.1: an encrypted blob keeps a plain-JSON envelope, and `--path` does move `basePath`. An outer `--filter` is now honored deliberately. The stub loader in the tests now publishes the blob a real loader does, which is what makes the end-to-end count meaningful. Both rules verified by disabling each and watching the new regression test go red.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Important
Two things worth settling before merge: launches_loader matches on arguments as well as the program, which can hand a program an environment nothing resolved; and the "encryption keeps the envelope plain JSON" premise holds only for Varlock's default --inject mode.
Reviewed changes — the full diff for 2cdd8d0d, plus the surrounding env-owner call sites in cli.rs, spawn_node's loader command, and Varlock 1.16.1's published dist to check the parsed contract.
- Stand down on Varlock's own blob.
already_resolved_forreplaceswrapped_for: containment ofbasePathagainst the schema directory nub found, plus a scan of the blob'stype: "schema"sources for the@importcase. Above / unrelated / unparseable all still wrap. - Recognize the loader as the program being launched. New
EnvOwner::launches_loaderandin_loader_packagetake over as the recursion guard, andrun_file_in_dirfilters both thecheck_schema_usablediagnostic andSpawnConfig::env_ownerthrough it. __NUB_ENV_OWNER_WRAPPEDdeleted.WRAPPED_ENVandwrapped_markerare gone along with both stamp sites (the file run andrun_watch); the field is renamedalready_resolved, andsuppresses_env_files()stays ungated on the new filter.- Tests. Six unit tests over the new rules, the stub loader now publishes the blob a real loader does, and a
#[cfg(unix)]end-to-end test asserting exactly one loader invocation for a script that runs Varlock itself. - Docs. The Varlock page states that hand-written invocations run as written; both wiki records carry the reversal and a measured-shape table.
Two premises checked independently and confirmed, so they need no further work: Varlock 1.16.1's getSerializedGraph really does emit basePath absolute and sources[].path as path.relative(basePath, fullPath) with the literal type: "schema", including a separate entry per @import; and the chain stays bounded when the blob is unreadable, because every nub-inserted wrap hands the loader an absolute Node path (spawn.rs:785, cli.rs:6313) so the loader's terminal spawn never re-enters the PATH shim.
ℹ️ Nitpicks
crates/nub-cli/src/cli.rs:5032-5036still says what must flow from the run path is "the env-owner MARKERS and preload tokens". The marker is now deleted, so the term names nothing; inheritance of__VARLOCK_ENVis what carries the fact today.wiki/research/varlock-integration.md(~line 220, inside the 2026-08-01 section) still reads "Nub should set and check its own neutral marker … treating__VARLOCK_ENVas one recognized signal among others rather than the contract." The changelog records the reversal, but that line is now the opposite of the decision and carries no superseded note.
Claude Opus | 𝕏
| /// `--require` of something inside it — does not lose its wrap. | ||
| pub(crate) fn launches_loader(&self, args: &[String]) -> bool { | ||
| let cli = self.cli.as_deref().map(canonical); | ||
| args.iter().filter(|arg| !arg.starts_with('-')).any(|arg| { |
There was a problem hiding this comment.
This scans every non-flag argument, not the program position, so a command whose arguments merely reference a path under node_modules/varlock/ also stands nub down — nub scripts/check-version.js node_modules/varlock/package.json is enough. On that path nub drops the wrap and the diagnostic, while suppresses_env_files() stays true because it is deliberately not gated on this filter, so the program runs with neither a resolution nor .env* and nothing is printed.
Technical details
# `launches_loader` matches arguments, not just the program
## Affected sites
- `crates/nub-cli/src/env_owner.rs:394-400` — `.any()` over all non-flag args; `in_loader_package` matches any `node_modules/varlock/` component, so a data argument counts.
- `crates/nub-cli/src/cli.rs:3905-3910` — a true result filters the owner to `None`, skipping `check_schema_usable` (both the explicit-`--env-file` conflict and `SchemaProblem`).
- `crates/nub-cli/src/cli.rs:3960-3963` — and drops the loader from `SpawnConfig::env_owner`.
- `crates/nub-cli/src/cli.rs:3719` + `3724-3731` — `runtime_child_env` gets the UNfiltered owner, `suppresses_env_files()` is `cli.is_some() || already_resolved`, so the `.env*` cascade is off. Net: the silent unresolved environment that `SchemaProblem` exists to refuse.
- `crates/nub-cli/src/env_owner.rs:812-820` — the negative assertions only cover an entry followed by a *flag* (`["/app/src/index.js", "--path"]`) and a `--require=` flag, so an entry followed by a non-flag argument inside the package is untested.
## Required outcome
- Only the program nub is about to launch decides this, not the arguments passed to it.
- A test where an ordinary entry point is followed by a non-flag argument under `node_modules/varlock/` and the wrap is retained.
## Suggested approach
- Narrow to the first non-flag argument. The real recursion channel always delivers the loader's bin in that position: a `#!/usr/bin/env node` shebang hands the interpreter exactly `node <script> <args…>`, so no node flags can precede it, and the `--enable-source-maps` prefix in the unit test is hypothetical rather than a shape nub receives.| /// ``` | ||
| /// | ||
| /// `basePath` and `sources` stay plain JSON even when the loader encrypts the | ||
| /// injected values, which covers the envelope's contents and not the envelope. |
There was a problem hiding this comment.
This reads as unconditional, but the plain-JSON envelope is a property of the default --inject all mode, not of encryption. In buildInjectedBlobEnv, plain JSON is returned on the injectVars branch; with --inject blob and @encryptInjectedEnv the whole envelope becomes varlock:v1:<base64>, so nub falls through to wrapping and the --path / --filter clobbering this PR fixes still reproduces for that configuration.
Technical details
# The encrypted-envelope premise is scoped to `--inject all`
Verified against the published `varlock@1.16.1` dist (`https://unpkg.com/varlock@1.16.1/dist/chunk-OWQP2FL6.js`):
```js
function buildInjectedBlobEnv(opts) {
if (!opts.injectBlob) return {};
const json = JSON.stringify(opts.serializedGraph);
if (opts.injectVars) return { __VARLOCK_ENV: json };
if (opts.serializedGraph.settings?.encryptInjectedEnv) {
const key = opts.ambientEnvKey ?? generateEncryptionKeyHex();
return { __VARLOCK_ENV: encryptEnvBlobSync(json, key), _VARLOCK_ENV_KEY: key };
}
...
```
`resolveInjectMode` in the same chunk sets `injectVars` for `all` and `vars` only, and defaults to `all` — which is why the 2026-08-12 measurement saw plain JSON. `--inject blob` is a documented flag.
## Affected sites
- `crates/nub-cli/src/env_owner.rs:78-81` — "stay plain JSON even when the loader encrypts the injected values" holds only for the default inject mode.
- `wiki/research/varlock-integration.md` — the `Encryption hides it | No` row and the reversal table's "The opaque form did not reproduce at all" are load-bearing for the decision this PR reverses, and are unqualified.
## Required outcome
- Both claims scoped to the inject mode they were measured under.
- `--inject blob` + `@encryptInjectedEnv` recorded as a residual case that still double-resolves, alongside the per-Node-process cost already listed.
## Open questions for the human
- The in-process `auto-load` integration builds `__VARLOCK_ENV` from the same JSON and appears to have no `injectVars` bypass, which would make an encrypted envelope reachable there too. Worth confirming before wording the residual, since that path also feeds every child by inheritance.
A project that already wires Varlock into its own scripts got a second, Nub-inserted resolution in front of the one it asked for. That second resolution carries Nub's arguments rather than the script's.
Measured against Varlock 1.16.1, with the loader's bin instrumented to log every invocation:
varlock run --path ./config -- node …varlock run --filter=PLAIN -- node …exec()resolvers firedvarlock run -- nub app.jsThe first row is the sharp one: Nub's inserted
varlock run --path <project-root>resolved the root schema, failed validation, and killed the run before the script's own--path ./configinvocation ever executed.What changed
Two rules replace
__NUB_ENV_OWNER_WRAPPED, which is deleted.The loader already ran.
__VARLOCK_ENVcarries an absolutebasePathand a typedsourcesentry per schema file, so Nub can ask whether this schema was resolved rather than whether the loader ran at all. Reading the loader's own surface instead of a marker Nub mints covers launchers Nub cannot observe — a Makefile, a CI wrapper, a standalone binary.The test is containment against the schema directory Nub found, plus a scan of the blob's schema sources:
basePathvarlock run -- nub app.js/repo/repovarlock run --path ./config -- …/repo/config/repo/repo/repo/pkgs/web@imports the member's/repo/repo/pkgs/websources— stand downThe third and fourth rows are what the 2026-08-02 decision record demanded of any such comparison, and they hold.
The loader is what Nub is about to launch. No blob exists at that moment, so this rule recognizes the program instead. It also replaces the marker as the recursion guard — structurally rather than by flag — which matters because the loader's bin is a
#!/usr/bin/env nodescript whose interpreter re-enters Nub through the PATH shim.On the earlier decision
The 2026-08-02 record rejected reading
__VARLOCK_ENVon three grounds. Two do not reproduce on 1.16.1:encryptInjectedEnvconfirmed on, the envelope stayed plain JSON andbasePathread fine.--pathflag does movebasePath.--filterdoes leavebasePathunchanged with a subset injected. That is now honored deliberately: it is an explicit user flag, and the same principle already makes an explicit--env-filebeat Nub's auto-discovery.An unreadable blob degrades toward resolving, never toward an empty environment.
Verification
Not addressed
The per-Node-process cost is unchanged: three nested
nub runcalls still boot Varlock three times, because each leaf resolves independently and no sibling inherits the result. Reaching one resolution per chain needs the resolution to happen at the script launcher, which is a separate decision with a security-posture trade-off. There is also still no way to turn the hand-over off short of--node.Reported by Varlock's author.