Skip to content

Commit cbda5ab

Browse files
feat(prepack): gate installed dependencies (AB7014/AB7015); host-native INSTALL.md (#547)
* feat(prepack): gate installed dependencies (AB7014/AB7015) and make emitted INSTALL.md host-native A published plugin installs nothing beyond its own files: the build inlines every dependency into dist/bin and the host packs, so package.json dependencies/optionalDependencies/peerDependencies entries only make every consumer's npm install fetch build-time packages — and fail outright under npm 12's default allow-git=none / allow-remote=none when one is a git or remote specifier (cargo-hauler 0.4.7 is uninstallable for exactly this). - AB7014: an installed-dependency field names packages no packed JavaScript imports or requires (evidence read from the packed bytes, one diagnostic per field). - AB7015: an installed-dependency entry resolves through git, a GitHub shorthand, a remote tarball, or a path. - Emitted INSTALL.md states the bundle is self-contained, uses the host's own claude plugin / codex plugin commands for uninstall, and marks every agent-bundle install/uninstall/doctor mention as optional automation. - create-agent-bundle mcp-server and cli-tool templates, and the examples, declare @agent-bundle/runtime, react, and zod under devDependencies. * chore: changeset PR number * refactor(prepack): pure dependency evidence, shared digest cache, classifier fixes - readModuleImports consults the digest cache itself (a parsed result answers a lexed request); rememberedModuleImports and its three call-site lookups are gone. - pack-dependencies.ts is evidence only (declared entries, imported names, classifiers); AB7014/AB7015 are emitted in pack-inventory.ts beside AB7010-AB7013 through its diagnostic helper and a shared quoteAll. - isRegistrySpecifier catches scp-style git@host:path and Windows drive paths; packageNameOf drops its dead @-guard for one positive shape. - readFile errors other than ENOENT propagate; packed files are read in parallel; AB7015 says optionalDependencies fail to fetch, not install. - Table tests for the classifiers; prepack fixture tests share withPackageDocument and assert codes/names rather than sentences. - Claude/Codex INSTALL.md share the optional-CLI paragraphs. * fix(prepack): dependency gate honours tilde ranges, declaration imports, optional peers, and the packer - ~1.2.3 is a semver range, not a home path (AB7015 false positive). - Packed .d.ts/.d.mts/.d.cts files count as dependency usage: a consumer needs the package that provides referenced types even without a runtime import (AB7014 false positive). - peerDependenciesMeta optional peers are never installed by npm and are not inspected. - workspace:/catalog: are registry specifiers only when pnpm, Yarn, or Bun runs the pack (npm_config_user_agent) and rewrites them; npm publishes them verbatim and consumers fail with EUNSUPPORTEDPROTOCOL. * refactor(prepack): packer policy at the process edge, pure specifier classifiers, table-tested scanners - prepack() turns npm_config_user_agent into packerRewritesWorkspaceProtocols; isRegistrySpecifier classifies the string as written and isWorkspaceProtocol lets the emitter apply the policy. RegistrySpecifierOptions is gone. - optionalPeers is one set computed once; declarationSpecifiers is exported and table-tested; requireCall and declarationSpecifier share quotedLiteral. - The JS path keeps the lexer on purpose: bundled library docblocks contain literal 'from "effect"' lines a text scan would count as usage. - pack-inventory uses isErrno and sha256Hex like its sibling; module-imports caches per check level only; AB7015 wording per partition; docs/changeset name declaration references and workspace protocols. * fix(prepack): resolution calls, bundleDependencies, optional overrides; INSTALL.md gates marketplace removal - require.resolve / createRequire(...).resolve / import.meta.resolve with a literal argument count as usage for AB7014. - bundleDependencies (name list or true, either spelling) are embedded in the tarball and never reported by AB7015. - A name under both dependencies and optionalDependencies is judged by its optional entry, as npm does. - Emitted INSTALL.md no longer lists 'plugin marketplace remove' in the uninstall block: it is a separate step gated on 'plugin list' showing no other plugin from the marketplace (any scope or project, for Claude). * fix(prepack): peers are never bundled; npm: alias targets are classified - bundleDependencies never covers peerDependencies (npm packs no node_modules entry for a peer-only name) and 'true' covers dependencies only, so AB7015 still reports a peer's file:/git/workspace specifier. - An npm: alias is a registry specifier only when its target is: npm:bar@file:../bar, npm:bar@workspace:*, and nested npm: are reported. * fix(prepack): type directives map to @types, computed import() withholds AB7014, Claude marketplace inventory - /// <reference types="x" /> counts for x and @types/x (@types/scope__name when scoped): the declaration cannot say which one the consumer needs. - A computed import(expression) in packed JavaScript may load any declared package, so importedPackageNames reports complete=false and AB7014 is withheld; the recovery text and docs say so. - Claude INSTALL.md: marketplace removal is gated on the cross-project registry plugins/installed_plugins.json, not just 'claude plugin list', and points at the optional uninstaller that performs that inventory. - Docs: externalized runtime packages belong under dependencies; AB7014 permits them. * fix(prepack): imports-map targets, computed require(), and install-script bins count as usage - A packed '#subpath' import counts for every package the manifest's imports map targets (conditional targets included). - require(expression) withholds AB7014 like import(expression); bundler runtimes (__webpack_require__) never match. - A dependency a consumer-side preinstall/install/postinstall/prepare script names, or whose bin command it runs (read from node_modules/<name>), is used. * fix(prepack): computed resolve/createRequire withhold AB7014; unresolvable optional deps warn - require.resolve(x), import.meta.resolve(x), and a direct createRequire(...)(x) with a non-literal argument mark the evidence incomplete like require(x); path.resolve/Promise.resolve never match. - AB7015 on optionalDependencies is a warning: npm continues an install without an optional dependency it cannot fetch. * fix(prepack): warning-only diagnostics pass prepack; literal-prefixed computed loads withhold AB7014 - prepack() throws only on error-severity diagnostics and returns the surviving warnings on PrepackResult.diagnostics; the CLI prints them. - require("driver/" + variant) and the template-literal form count as computed loads and mark the evidence incomplete. * fix(prepack): unknown schemes are non-registry, optional peers keep protocol checks, createRequire bindings are loaders - A specifier scheme npm cannot parse (link:, portal:, jsr:, a typo) is non-registry: consumers fail with EUNSUPPORTEDPROTOCOL before any fetch. - Optional peers stay in the declared list as not-installed: AB7014 and the fetch rules skip them, but an unparseable scheme is still reported. - const load = createRequire(...) makes load(...) a loader: literal calls record the package, computed calls withhold AB7014. * fix(prepack): bundled exemption needs a packed node_modules entry; aliased createRequire; uninstalled bin fallback - bundleDependencies exempts an entry from AB7015 only when the pack inventory contains node_modules/<name>/package.json. - import { createRequire as x } / { createRequire: x } makes x a factory. - An install-script dependency with no readable node_modules manifest is matched by its unscoped name, npm's default bin. * fix(prepack): namespace-qualified createRequire factories; reference summary names every evidence source - const load = Module.createRequire(...) binds a loader like the bare form. - targets-artifacts (en/zh) and the changeset describe what AB7014 accepts (require/resolve, declarations, imports map, install scripts) instead of 'imports' alone, and drop the 'installs nothing beyond its own files' claim. * fix(prepack): validate scheme-less selectors; unparseable optional specifiers stay fatal - A scheme-less specifier must be a semver range (npm's range grammar) or a URL-safe dist-tag; 'not a valid spec' is EINVALIDTAGNAME for consumers. - AB7015 warns only for an optionalDependencies entry npm parses but cannot fetch; an unsupported scheme, invalid selector, or unrewritten workspace protocol on an optional entry remains an error. * fix(prepack): empty known-scheme specifiers unparseable; follow npm run delegation; unused peers warn - 'npm:', 'file:', 'github:' with nothing after the colon are unparseable. - Install-script evidence follows npm/pnpm/yarn/bun run <name> transitively, including pre<name>/post<name> hooks. - AB7014 on peerDependencies is a warning: a required peer nothing imports may be a deliberate host-compatibility contract. * fix(prepack): URL-form specifiers must parse; chained require('node:module').createRequire; AB7014 summary rows list every evidence source * fix(prepack): bare tarball filenames are file sources; invalid dependency names fail AB7015 * fix(prepack): reserved package names, strict semver identifiers, bare directory specs * fix(prepack): alias names validated, inline createRequire literal, npm git transports, leading run flags, declare module, empty fetch sources parse * docs(diagnostics): alias name validity, git transports, empty fetch sources, declare module, run flags * fix(prepack): alias targets must be registry specifiers, NPM: prefix case-insensitive, git+ftp/rsync transports * refactor(prepack): classify dependency entries with npm-package-arg Replace the hand-rolled specifier and name grammar with npm's own parser, so AB7015 agrees with the consumer's install by construction: registry, fetched (git/remote/path), or unparseable. Covers hosted-shorthand validation, scp-style hosts, scoped-name components, loose semver ranges, alias targets, and every git transport in one step. String-form bin names come from the installed manifest's name; literal .resolve() evidence is limited to require/createRequire/import.meta resolvers. * fix(prepack): executables run by packed code, valued npm run options, optional fetches install scripts need A dependency packed JavaScript only shells out to (a string literal that is one of its bin commands) counts as used. Delegated npm run detection no longer assumes valueless options: every token after run that names a script is visited. An optional non-registry dependency a consumer install script runs stays a fatal AB7015, since npm skips the failed fetch and the script then fails on the missing command. * fix(prepack): read dependency values exactly as npm does; follow npm run aliases Pass the manifest value untrimmed to npm-package-arg (a leading space makes an alias an invalid dist-tag, as it does for consumers) and stop trimming in isWorkspaceProtocol, since no packer rewrites such a value either. Delegated run detection also follows npm's rum/urn aliases. * fix(prepack): nested createRequire arguments, quoted delegated script names createRequire(new URL("./entry.js", import.meta.url))("driver") is a load: the factory argument may nest calls two deep. A delegated npm run "setup" names the script the shell unquotes. * fix(prepack): file: sources shipped in the tarball install; install-script files load optional deps A file: or bare path inside the package whose source directory manifest or tarball file is packed is installable from the consumer's copy, so AB7015 exempts it like a packed bundleDependencies entry. An install script that runs a packed file (node install.cjs) needs every package that file loads, following relative imports through the tarball, so a fetched optional dependency reached that way stays fatal. * fix(prepack): resolve extensionless/quoted install-script files, npm test/start delegation, validate packed file: sources installScriptFiles tokenizes the shell command keeping quoted words whole and resolves an extensionless path the way Node does (scripts/install -> scripts/install.js; .cjs/.mjs are never tried). Delegated-run traversal follows npm's direct script commands (test/t/tst/start/stop/restart) with their pre/post hooks. A file: source shipped in the tarball is exempt from AB7015 only when the packed copy is installable: a directory whose package.json parses to an object, or a (gzipped or plain) tar holding <dir>/package.json. * fix(prepack): parse packaged tarball manifests, split shell operators, follow own-file imports targets, allow call-site comments tarHoldsPackage validates each ustar header checksum and payload bounds and JSON-parses the <dir>/package.json payload before a packed file: tarball exempts AB7015 (npm: TAR_BAD_ARCHIVE / EJSONPARSE). shellWords splits &&, ||, ;, |, & without surrounding whitespace so 'node install.js&&echo done' still follows install.js. Install-script module traversal enqueues relative imports-map targets ('#setup' -> ./setup.js) instead of dropping them. Comments between a CommonJS loader and its parentheses, or around the literal, are trivia for literalLoad and never start a computed argument. * fix(prepack): install-script command positions, directory mains, imports wildcards, shadowed peers AB7015 escalation of an optional dependency now needs the install script to run it: a bin in command position (after env assignments, options, npx/bunx/cross-env/env wrappers and pnpm/npm/yarn/bun exec|dlx|x), a node_modules/<name>/ file, or a require in an inline node -e program; a bare mention (echo foo) stays keep-only AB7014 evidence. packedModule consults each packed directory manifest's main before index.js, as Node does. Imports-map specifiers resolve to the exact or best wildcard key with * substituted, for install traversal and for AB7014 reachability. declaredDependencies drops a peer that dependencies or optionalDependencies also names, whose selector npm never reads. * fix(prepack): node . root main, --require/--import preloads, newline command separators * fix(prepack): npm run first-positional delegation, Node option grammar, shell backslash escapes * fix(prepack): npm restart fallback, NODE_OPTIONS preloads, bare require aliases mark evidence incomplete * fix(prepack): qualified createRequire factories, import() in inline programs, unreadable dependency manifests * fix(prepack): treat lexer failure as incomplete evidence; read installed manifests as npm does Second self-review pass on 1e6e5cf (gpt-5.6-sol-medium): - A packed file or inline `node -e` program es-module-lexer rejects now marks its evidence incomplete: its `import()` calls cannot be reported, so AB7014 is withheld for the package and a skipped optional dependency an install script needs escalates to an AB7015 error instead of hiding behind an empty import list. - `node_modules/<dep>/package.json` is parsed with plain `JSON.parse`, as npm parses it, so the last of duplicate keys decides a string-form `bin` name; the strict duplicate-key parser is for our own config, not a third party's manifest (comment says why it is not strict-json.ts). Tests: lexer-rejected packed file withholds AB7014; lexer-rejected inline program escalates AB7015; duplicate-`name` manifest resolves the effective unscoped bin. All three fail on 1e6e5cf. Docs (diagnostics.md, en/zh validation.mdx) and the changeset gain one clause each.
1 parent 2e91ea1 commit cbda5ab

29 files changed

Lines changed: 2570 additions & 161 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'agent-bundle': patch
3+
'create-agent-bundle': patch
4+
---
5+
6+
Gate `agent-bundle prepack` on the installed-dependency fields of `package.json` so a published plugin installs only what its packed files need: `AB7014` reports a `dependencies`, `optionalDependencies`, or `peerDependencies` entry that no packed JavaScript imports, requires, resolves, or runs as an executable (a computed `import`/`require`, a packed file the ESM lexer rejects, or a `require` passed on as a value such as `const load = require`, withholds `AB7014` for the whole package; an installed manifest's `bin` is read as npm reads it, the last of duplicate keys winning), no packed declaration file references, and no `imports` mapping or consumer install script (including scripts it delegates to with `npm run` or `npm test`/`start`/`stop`/`restart`, `npm restart` without a `restart` script running `stop` and `start`) reaches — a warning rather than an error for a `peerDependencies` entry, which may be a deliberate host-compatibility contract — (the build inlines every dependency into `dist/bin` and the host packs, so a runtime external must be reached one of those ways; optional peers are skipped), and `AB7015` reports an entry a consumer's npm cannot resolve through a registry, judged by npm's own parser (`npm-package-arg`, now a dependency of `agent-bundle`): a git, GitHub-shorthand, remote-tarball, or path source, which npm 12 refuses to fetch by default (`allow-git`, `allow-remote`); a name or specifier npm cannot parse (`EINVALIDPACKAGENAME`, `EUNSUPPORTEDPROTOCOL` for `link:`, `portal:`, or a typo, `EINVALIDTAGNAME`, an alias of a non-registry target — reported even on an optional peer, since the manifest read itself fails); and `workspace:`/`catalog:` unless pnpm, Yarn, or Bun is running the pack and will rewrite them; a fetchable-but-unfetched `optionalDependencies` entry warns rather than fails, since npm continues without it (an unparseable one, or one a consumer install script runs, loads from an inline `node -e` program by `require`, `createRequire`, or `import()`, preloads with `node -r`/`--require`/`--import`/`--loader`, or loads from a packed file it executes — `node install.js`, `node .` through the root `main` — stays an error; each command after `&&`, `;`, or a newline counts on its own, shell quotes and backslash escapes are resolved, `node`'s options end at the program so `node install.js --require x` preloads nothing while a `NODE_OPTIONS=--require=x` assignment on the same command does, and `npm run <script>` delegates to the first positional alone — `npm run setup -- dormant` runs `setup`); an entry the tarball itself carries — a bundled dependency npm packed, or a `file:` path whose packed source is an installable package directory or tarball — is not reported — `agent-bundle prepack` prints such warnings and exits 0, and `prepack()` returns them on `PrepackResult.diagnostics`. Emitted `INSTALL.md` files now state that the bundle is self-contained, use the host's own `claude plugin` / `codex plugin` commands for uninstall, and mark every `agent-bundle install`/`uninstall`/`doctor` mention as optional automation. The `create-agent-bundle` `mcp-server` and `cli-tool` templates declare `@agent-bundle/runtime`, `react`, and `zod` under `devDependencies`. (#547)

‎docs/diagnostics.md‎

Lines changed: 45 additions & 2 deletions
Large diffs are not rendered by default.

‎examples/audiobook-curator/package.json‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,12 @@
3030
"typecheck": "tsc -p tsconfig.json --noEmit",
3131
"validate": "agent-bundle validate"
3232
},
33-
"dependencies": {
34-
"@agent-bundle/runtime": "workspace:*",
35-
"react": "19.2.8",
36-
"zod": "4.5.4"
37-
},
3833
"devDependencies": {
34+
"@agent-bundle/runtime": "workspace:*",
3935
"@rstest/core": "0.11.10",
4036
"@types/react": "19.2.18",
41-
"agent-bundle": "workspace:*"
37+
"agent-bundle": "workspace:*",
38+
"react": "19.2.8",
39+
"zod": "4.5.4"
4240
}
4341
}

‎examples/host-test/package.json‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,13 @@
2727
"typecheck": "tsc -p tsconfig.json --noEmit",
2828
"validate": "agent-bundle validate"
2929
},
30-
"dependencies": {
30+
"devDependencies": {
3131
"@agent-bundle/runtime": "workspace:*",
3232
"@modelcontextprotocol/server": "2.0.0",
33-
"react": "19.2.8",
34-
"zod": "4.5.4"
35-
},
36-
"devDependencies": {
3733
"@rstest/core": "0.11.10",
3834
"@types/react": "19.2.18",
39-
"agent-bundle": "workspace:*"
35+
"agent-bundle": "workspace:*",
36+
"react": "19.2.8",
37+
"zod": "4.5.4"
4038
}
4139
}

‎examples/worktree-proximity/package.json‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,12 @@
2121
"typecheck": "tsc -p tsconfig.json --noEmit",
2222
"validate": "agent-bundle validate"
2323
},
24-
"dependencies": {
25-
"@agent-bundle/runtime": "workspace:*",
26-
"react": "19.2.8",
27-
"zod": "4.5.4"
28-
},
2924
"devDependencies": {
25+
"@agent-bundle/runtime": "workspace:*",
3026
"@rstest/core": "0.11.10",
3127
"@types/react": "19.2.18",
32-
"agent-bundle": "workspace:*"
28+
"agent-bundle": "workspace:*",
29+
"react": "19.2.8",
30+
"zod": "4.5.4"
3331
}
3432
}

‎packages/agent-bundle/package.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,14 @@
9494
}
9595
},
9696
"dependencies": {
97+
"@effect/platform-node-shared": "4.0.0-rc.112",
9798
"@modelcontextprotocol/client": "2.0.0",
9899
"@modelcontextprotocol/node": "2.0.0",
99100
"@modelcontextprotocol/server": "2.0.0",
100101
"@rsbuild/core": "2.2.1",
101102
"@rsbuild/plugin-react": "2.1.0",
102103
"@rslib/core": "0.23.2",
103104
"@rslint/core": "0.8.2",
104-
"@effect/platform-node-shared": "4.0.0-rc.112",
105105
"@rstackjs/load-config": "0.1.2",
106106
"acorn": "8.18.0",
107107
"ajv": "8.20.0",
@@ -113,12 +113,14 @@
113113
"fast-glob": "3.3.3",
114114
"ignore": "7.0.7",
115115
"jiti": "2.7.0",
116+
"npm-package-arg": "13.0.2",
116117
"open": "11.0.2",
117118
"ws": "8.21.3",
118119
"yaml": "2.9.0"
119120
},
120121
"devDependencies": {
121122
"@modelcontextprotocol/server": "2.0.0",
123+
"@types/npm-package-arg": "6.1.4",
122124
"@types/react": "19.2.18",
123125
"@types/ws": "8.18.1",
124126
"effect-rstest": "https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f",

‎packages/agent-bundle/src/api.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { TargetArtifactEntry, TargetHookEntry } from './adapters/types.ts';
1111
import { build as buildArtifact, type BuildResult } from './build/build.ts';
1212
import { routedCliBins, targetHostsCliBin } from './build/cli-bins.ts';
1313
import { buildPackageOutputs, type PackageBuildResult } from './build/package-build.ts';
14+
import { rewritesWorkspaceProtocols } from './build/pack-dependencies.ts';
1415
import {
1516
packInventoryDiagnostics,
1617
packOutputFromJson,
@@ -582,6 +583,8 @@ export interface BuildProjectResult {
582583

583584
export interface PrepackResult {
584585
readonly build: BuildProjectResult;
586+
/** Non-error pack-inventory diagnostics; errors throw `DiagnosticError` instead. */
587+
readonly diagnostics: readonly Diagnostic[];
585588
readonly pack: PackOutput;
586589
}
587590

@@ -1292,10 +1295,11 @@ export const prepack = async (options: BuildOptions): Promise<PrepackResult> =>
12921295
model: result.model,
12931296
packageBuild: result.packageBuild,
12941297
packOutput: pack,
1298+
packerRewritesWorkspaceProtocols: rewritesWorkspaceProtocols(process.env.npm_config_user_agent),
12951299
projectRoot: options.root,
12961300
});
1297-
if (diagnostics.length > 0) throw new DiagnosticError(diagnostics);
1298-
return deepFreeze({ build: result, pack });
1301+
if (hasErrors(diagnostics)) throw new DiagnosticError(diagnostics);
1302+
return deepFreeze({ build: result, diagnostics, pack });
12991303
};
13001304

13011305
/** Every eval refusal reaches a caller as one actionable diagnostic, never a raw service error. */

‎packages/agent-bundle/src/build/module-imports.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,21 +48,21 @@ const remember = (key: string, imports: readonly ModuleImport[]): void => {
4848
importsByDigest.set(key, imports);
4949
};
5050

51-
/** Imports previously read (this process) from bytes with this digest at this check level. */
52-
export const rememberedModuleImports = (
53-
check: ModuleSyntaxCheck,
54-
sha256: string,
55-
): readonly ModuleImport[] | undefined => importsByDigest.get(`${check}:${sha256}`);
56-
5751
/**
5852
* Reads the imports of one ES module source, throwing on invalid syntax
5953
* (the lexer's or, for `parsed`, acorn's). When the source's SHA-256 is
60-
* known the result is remembered for the next pass over the same bytes.
54+
* known, a result remembered for those bytes at this check level is
55+
* returned as is, and a fresh read is remembered for the next pass over the
56+
* same bytes.
6157
*/
6258
export const readModuleImports = async (
6359
source: string,
6460
options: { readonly check: ModuleSyntaxCheck; readonly sha256?: string },
6561
): Promise<readonly ModuleImport[]> => {
62+
if (options.sha256 !== undefined) {
63+
const known = importsByDigest.get(`${options.check}:${options.sha256}`);
64+
if (known !== undefined) return known;
65+
}
6666
await init;
6767
if (options.check === 'parsed') parseJavaScript(source, { ecmaVersion: 'latest', sourceType: 'module' });
6868
const [records] = parse(source);

0 commit comments

Comments
 (0)