plans/pnpm-auto-deny-ignored-builds.md
Drafted 2026-07-08 from an investigation session (verified against pnpm 11.10.0 and 10.33.2)
When a user installs a package whose build scripts are not in Dyad's curated allow-list (e.g. core-js), pnpm skips the build and — under pnpm 11's strictDepBuilds: true default — any later fresh pnpm install that lacks Dyad's --config.strictDepBuilds=false flag fails hard with ERR_PNPM_IGNORED_BUILDS (exit 1). Users get stuck on the install screen with no recoverable action, and exported repos fail on Vercel/Netlify/CI.
Fix: after every Dyad-run install, record an explicit pkg: false decision in pnpm-workspace.yaml's allowBuilds map for each ignored build (outside the Dyad-managed block, with a Dyad marker), commit it, and emit telemetry so frequently-denied-but-legit packages can be promoted to the remote allow-list.
pnpm 11 defaults strictDepBuilds to true:
pnpm install with a dependency whose build was ignored → ERR_PNPM_IGNORED_BUILDS, exit 1 — but only when pnpm actually (re)installs packages. An "Already up to date" no-op install exits 0 and never re-evaluates build scripts.--config.strictDepBuilds=false (Dyad's PNPM_INSTALL_POLICY_ARGS), the same install exits 0 with only a warning box.pkg: false entry under allowBuilds in pnpm-workspace.yaml silences both the error and the warning, on pnpm 11.x and 10.x. Build behavior is unchanged (the build was already being skipped).node_modules/.modules.yaml records implicitly ignored builds only: an unlisted package lands in ignoredBuilds, but once it is explicitly false it drops out. (Detection of auto-deny candidates therefore reads exactly the right set; promotion-time repair cannot rely on ignoredBuilds — see Promotion & repair.)false → true and re-running a plain pnpm install does not run the previously-skipped build ("Already up to date", no postinstall). Only a fresh install or an explicit pnpm rebuild <pkg> executes it; pnpm rebuild <pkg> was verified to run the skipped postinstall and exit 0.allowBuilds name-matching does not work for file: dependencies — even pkg: true fails a strict install with ERR_PNPM_IGNORED_BUILDS: pkg@file:.... Dyad apps use registry deps, so this is noted but unhandled.<dyad-add-dependency packages="core-js">. Install succeeds (Dyad passes the policy flags) with an "Ignored build scripts" warning. node_modules/.modules.yaml records ignoredBuilds: ["[email protected]"]. Nothing is recorded in the repo.--config.strictDepBuilds=false:
restartApp({ removeNodeModules: true })) on an app with a custom installCommand (custom commands run verbatim; getCommand() in app_runtime_service.ts skips getPnpmInstallCommand() entirely).app_upgrade_handlers.ts:109, intentionally strict) and component-tagger add (app_upgrade_utils.ts:132).install && dev chain, so exit 1 short-circuits: the dev server never starts, no preview URL ever appears, and the preview panel sits forever on the ignored-builds error. Retrying Rebuild fails identically. Restart appears to work (no-op install exits 0), which makes the failure look nondeterministic to users.Once an unlisted-build package is in the lockfile, the repo is poisoned for every standard pnpm install consumer. The only official fix (pnpm approve-builds) is interactive and unreachable from Dyad's UI. Most Dyad users cannot evaluate "should this package run install scripts" — they just need the app to keep working with the same security posture Dyad already applies (builds skipped unless allow-listed).
pnpm install, no special flags) on CI/deploy platforms.dangerouslyAllowAllBuilds.https://api.dyad.sh/v1/default-approve-builds.txt, 1h TTL) can be curated from real-world denial frequency.--config.strictDepBuilds=false from existing Dyad paths (keep as belt-and-suspenders for the first install, before denials are recorded)..modules.yaml, don't scrape outputAfter every Dyad-run pnpm install/add that succeeds, read node_modules/.modules.yaml and parse ignoredBuilds (array of name@version strings; strip versions — allowBuilds keys are bare names). This is authoritative and avoids parsing ANSI-laden PTY output. (pnpm ignored-builds is a non-interactive fallback but spawning is unnecessary when the file is readable.)
Hook points (all already call or sit next to the allow-builds plumbing in socket_firewall.ts):
executeAddDependency.ts → after runAddDependencyCommand succeedsapp_runtime_service.ts → after the install phase of a local/docker run (see §5 for the custom-command reactive path)cloud_sandbox_provider.ts → alongside commitPnpmAllowBuildsConfigIfChangedpkg: false outside the managed block, with a markerWrite denial entries into the top-level allowBuilds: map in pnpm-workspace.yaml:
allowBuilds:
core-js: false # dyad-auto-denied
# dyad-default-allow-builds begin
...managed block, rewritten from local/remote list...
# dyad-default-allow-builds end
Rules:
buildAllowBuildsManagedBlock rewrites it wholesale from the local/remote list on every ensurePnpmAllowBuildsConfigured call.# dyad-auto-denied comment. This distinguishes Dyad's automatic decision from a human's deliberate pkg: false. Critical because updatePnpmAllowBuildsConfigContentWithSource filters managed entries that already exist outside the block — without the marker, a later remote-list promotion of that package to true would be permanently shadowed by our own auto-denial.# dyad-auto-denied entry, remove the denial so the managed pkg: true takes effect. Never touch untagged (user-authored) entries. See "Promotion & repair" below for how the skipped build then actually gets run.true in the resolved allow-list or already present (any value) outside the block. Quote scoped names via the existing quoteYamlMapKey.Editing YAML grants permission; it does not run the build. Verified: after a false → true flip, a plain up-to-date pnpm install skips the build entirely, and .modules.yaml ignoredBuilds cannot be used to detect the gap (explicitly-denied packages are never recorded there). So:
ensurePnpmAllowBuildsConfigured), but it returns the list of promoted package names it just un-denied.executeAddDependency, cloud sandbox command builder) run a best-effort pnpm rebuild <promoted...> after their install step — e.g. the run flow conditionally builds the chain as install && (rebuild <pkgs> || true) && dev. Rebuild failure must not block the dev server; if the build genuinely mattered, the app is no worse off than before promotion..modules.yaml inspection, no rebuild on ordinary runs. Promotions are rare (only when the curated list changes), so 99.9% of runs add zero work.core-js-2 (unlisted, build genuinely needed). Install succeeds under Dyad flags; proactive pass writes core-js-2: false # dyad-auto-denied outside the managed block, commits, emits telemetry. App installs cleanly everywhere; if the build was load-bearing the package misbehaves at runtime (status quo today, but now visible in telemetry).core-js-2 to the remote allow-list (1h TTL, no release needed).core-js-2: true, the caller runs best-effort pnpm rebuild core-js-2, and the change is committed. The app self-heals with zero user interaction; later deploys/CI fresh-install and build it natively.Without the promotion pass this lifecycle deadlocks at T2: parseAllowBuildsExistingKeys filters any managed-list package that already exists outside the block, so Dyad's own T0 denial would shadow the curated true forever. The # dyad-auto-denied marker is what distinguishes revisable-by-Dyad entries from human decisions.
Ownership semantics for unmanaged entries when the curated list later adds the same package:
| Unmanaged entry at T2 | Outcome |
|---|---|
pkg: false # dyad-auto-denied | Promoted: line removed, managed true takes over, pnpm rebuild pkg runs |
pkg: false (user-authored) | Untouched; existing-keys filter keeps pkg out of the managed block — the human deny durably wins |
pkg: true (user-authored/approve-builds) | Untouched; filter avoids a managed duplicate; build already allowed |
Consistency invariant: promotion runs before the managed-block rewrite in the same transform, and the existing-keys filter is the backstop — no state can contain both an outside false and a managed true for the same key (YAML duplicate keys are parser-dependent; we never emit them).
Caveat (accepted): a manual pnpm approve-builds run rewrites pnpm-workspace.yaml through a YAML serializer and strips all comments — managed-block markers and # dyad-auto-denied tags alike. Existing code already re-appends a fresh managed block when markers vanish; stripped denials simply become user-authored (never promoted, still correct and installable). Telemetry fired at deny time, so curation signal is not lost.
Reuse the commitPnpmAllowBuildsConfigIfChanged pattern: gitAdd + gitCommit("[dyad] record denied pnpm dependency builds"). This is what makes the fix travel with exports/deploys.
Emit one event per install that produced new denials, via the existing main→renderer telemetry channel (system.onTelemetryEvent → PostHog): event pnpm:build-auto-denied, properties { packages: ["[email protected]"], source: "add-dependency" | "app-run" | "self-heal" | "cloud-sandbox" }. This is the input signal for curating the remote allow-list.
Must bypass the non-Pro 10% sampling. The renderer's before_send (renderer.tsx) drops ~90% of non-Pro events unless shouldBypassNonProTelemetrySampling (src/lib/posthogTelemetry.ts) matches. pnpm:build-auto-denied matches none of the current bypass rules (not error-shaped, no bypassed prefix), so without an explicit entry the curation signal would shrink 10× and skew toward Pro users' package mix — free users are the volume that curation depends on. Add the event name (or a shared pnpm:build- prefix, if a promotion-success event is added later) to the bypass list. Volume is safe to exempt: the event fires at most once per install that produces new denials, so it is rare and self-extinguishing (after the first denial is recorded, later installs of the same app emit nothing).
Apps with custom installCommand (and the Capacitor flow) hit ERR_PNPM_IGNORED_BUILDS before any proactive denial exists (fresh node_modules, e.g. Rebuild). On install failure:
ERR_PNPM_IGNORED_BUILDS in the failure output (stable error code string, present even in PTY output).node_modules/.modules.yaml — verified: pnpm links packages and writes ignoredBuilds before erroring, even on the exit-1 failure. Fall back to parsing the error line (Ignored build scripts: [email protected], [email protected]) only if the file is unreadable; the error line can wrap in narrow PTYs when many packages are listed.source: "self-heal".false entries make strict-default installs exit 0.This fixes the Rebuild-stuck-forever loop without weakening the intentionally-strict paths.
Append a line to the install results written back into the <dyad-add-dependency> tag: Note: build scripts for core-js were not run (Dyad security policy). If the app later fails at runtime because a denied package genuinely needed its build (native addon → Cannot find module '.../Release/*.node'), the AI has the context to explain/react instead of flailing.
allowBuilds (both true and false). Caveat: Dyad's availability probe accepts any pnpm version (the 10.16 check only gates a warning), and pnpm 10 builds older than the allowBuilds map would silently ignore the managed block. Low priority; consider a version floor note if support tickets appear.file:, git:) — verified asymmetry: pkg: false matches by bare name and suppresses the strict error, but pkg: true does not match (strict install fails even when "allowed"). Auto-deny therefore works on them; promotion cannot — acceptable, since the curated list never contains such names. Self-heal must not assume a denied non-registry package is later allowable by name.ignoredBuilds reports the whole tree (user installs A, native B arrives transitively). Auto-deny handles this naturally since we deny exactly what .modules.yaml reports; telemetry captures packages the user never chose.false entry remains. Inert cruft; garbage collection is a non-goal.package.json pnpm.* settings in imported apps (onlyBuiltDependencies, ignoredBuiltDependencies) — pnpm merges sources; a package ignored at that level never appears in ignoredBuilds, so the design is self-consistent. Do not attempt to reconcile.withLock(appId). Acceptable.alias@npm:real) / user-global .npmrc — deny by the real package name as reported in ignoredBuilds (pnpm resolves aliases there); global strict-dep-builds=false merely removes the failure mode.| Risk | Mitigation |
|---|---|
| Package genuinely needs its build; auto-deny converts install-time error into obscure runtime error | No regression vs today (build already skipped by strictDepBuilds=false); telemetry → remote-list promotion loop; agent-visible note (§6); optional phase-3 UI ("X was blocked from running install scripts — Allow and rebuild") |
| Denial shadows a future curated promotion | # dyad-auto-denied marker + promotion pass in §2 |
| YAML corruption of user-edited workspace files | Reuse the existing line-based editing + marker approach in socket_firewall.ts, extend its unit tests; atomic temp-file write already exists |
| Self-heal retry loops | Retry exactly once per install invocation |
| npm-based apps | Unaffected (no pnpm-workspace.yaml involvement) |
Phase 1 — proactive denial (core)
readIgnoredBuilds(appPath) util (parse node_modules/.modules.yaml).recordDeniedBuilds(appPath, packages) in socket_firewall.ts: marker-tagged false entries outside the managed block + commit helper.ensurePnpmAllowBuildsConfigured returns { changed, promotedPackages }.pnpm rebuild <promotedPackages> after their install step (§2b).executeAddDependency, app-run install completion, cloud sandbox.shouldBypassNonProTelemetrySampling so free-user events are never sampled out (§4); unit test alongside the existing bypass tests.socket_firewall allow-builds tests (marker round-trip, promotion returns promoted names, dedupe vs managed block, scoped-name quoting, user-authored false untouched).Phase 2 — reactive self-heal
ERR_PNPM_IGNORED_BUILDS detection on install failure in app_runtime_service (custom commands) and upgrade flows; deny + retry once.pnpm install command + unlisted-build dep → Rebuild reaches preview.Phase 3 (optional, later)
true + pnpm rebuild.pnpm rebuild <pkg> immediately, or defer to the next fresh install?pnpm rebuild <pkgs> after install. Verified empirically that neither a plain up-to-date install nor .modules.yaml inspection can substitute: the flip alone never runs the build, and explicitly-denied packages are absent from ignoredBuilds.commitPnpmAllowBuildsConfigIfChanged-style flow as the other hook points). The local repo is the source of truth the sandbox file map is built from, so committing locally keeps sandbox restarts and exports consistent.