@intx/tool-packaging 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/loader.js CHANGED
@@ -16,7 +16,7 @@
16
16
  // `cache.extractTarball` so a single sha512 has a single extraction
17
17
  // shared across instances.
18
18
  // 3. Lays out each entry under `<scratch>/store/<name>/<version>/` by
19
- // hardlinking the file tree from the cache extraction. Each layout
19
+ // copying the file tree from the cache extraction. Each layout
20
20
  // directory gets its own `node_modules/<dep>` symlink to the
21
21
  // sibling `store/<dep>/<depVersion>/` chosen for that requirer.
22
22
  // Diamond dependencies share a single store entry; version
@@ -39,41 +39,21 @@
39
39
  import { promises as fs } from "node:fs";
40
40
  import path from "node:path";
41
41
  import { pathToFileURL } from "node:url";
42
- import semver from "semver";
43
42
  import npmRegistryFetch from "npm-registry-fetch";
44
- import { isAnnotatedPluginFactory } from "@intx/agent";
43
+ import { type } from "arktype";
44
+ import { isAnnotatedDirectorFactory, isAnnotatedPluginFactory, } from "@intx/agent";
45
+ import { ToolCredentialDeclarationArray } from "@intx/types/package-json";
45
46
  import { getLogger } from "@intx/log";
46
- import { TarballIntegrityMismatchError } from "./cache.js";
47
+ import { getToolPackageSourceContentIdentity } from "@intx/types/tool-packages";
48
+ import { DEFAULT_MAX_REGISTRY_TARBALL_BYTES, DEFAULT_REGISTRY_FETCH_TIMEOUT_MS, ToolLoaderError, describeError, } from "./loader-internal.js";
49
+ import { buildRegistryFetchOpts, defaultTarballUrl, readResponseWithLimit, } from "./registry-fetch.js";
50
+ import { materializeClosure, storeEntryDir } from "./store-layout.js";
51
+ // Re-export the members that moved to focused modules so existing `./loader`
52
+ // and package-root consumers keep resolving them here: the public failure type
53
+ // and fetch caps (now in `loader-internal`), the registry fetch helpers, and
54
+ // the closure materialization + store-layout entry points.
55
+ export { ToolLoaderError, DEFAULT_MAX_REGISTRY_TARBALL_BYTES, DEFAULT_REGISTRY_FETCH_TIMEOUT_MS, buildRegistryFetchOpts, readResponseWithLimit, materializeClosure, storeEntryDir, };
47
56
  const logger = getLogger(["sidecar", "tool-packaging", "loader"]);
48
- /**
49
- * Default cap on a single HTTP-registry tarball fetch. Matches the
50
- * hub's `DEFAULT_HUB_MAX_TARBALL_BYTES` so a tarball the hub accepted
51
- * on upload is one the sidecar can also fetch back when a registry
52
- * mirror replays it.
53
- */
54
- export const DEFAULT_MAX_REGISTRY_TARBALL_BYTES = 10 * 1024 * 1024;
55
- /**
56
- * Default deadline for a single HTTP-registry tarball fetch, covering
57
- * both the request and the streamed body read. `readResponseWithLimit`
58
- * consumes the body through a manual reader loop, so the byte cap bounds
59
- * size but nothing bounds time: a registry that accepts the connection
60
- * and then stalls mid-stream would block the fetch -- and the deploy's
61
- * tool materialization awaiting it -- indefinitely.
62
- * The deadline is generous so a legitimately large tarball on a slow
63
- * link still completes within it. Callers that need a different bound
64
- * pass `registryFetchTimeoutMs` to `createToolLoader`.
65
- */
66
- export const DEFAULT_REGISTRY_FETCH_TIMEOUT_MS = 120 * 1000;
67
- export class ToolLoaderError extends Error {
68
- category;
69
- package;
70
- constructor(opts) {
71
- super(opts.message);
72
- this.name = "ToolLoaderError";
73
- this.category = opts.category;
74
- this.package = opts.package;
75
- }
76
- }
77
57
  export function createToolLoader(config) {
78
58
  const registriesByName = config.registries;
79
59
  const maxRegistryTarballBytes = config.maxRegistryTarballBytes ?? DEFAULT_MAX_REGISTRY_TARBALL_BYTES;
@@ -87,95 +67,6 @@ export function createToolLoader(config) {
87
67
  }
88
68
  const fetchTarball = config.fetchTarball ?? makeDefaultTarballFetcher();
89
69
  const importModule = config.importModule ?? ((u) => import(u));
90
- async function materialize(entry, assetRoot, assetMounts) {
91
- // Resolve registry-sourced entries against the sidecar config
92
- // before doing any I/O. If the manifest references an unknown
93
- // registry name the apply fails loudly here, regardless of whether
94
- // the bytes are already cached, so the failure surfaces even on
95
- // cache hits that would otherwise hide the misconfiguration.
96
- if (entry.source.kind === "registry") {
97
- if (!registriesByName.has(entry.source.registry)) {
98
- throw new ToolLoaderError({
99
- category: "registry.unknown",
100
- message: `manifest references registry "${entry.source.registry}" which is not in the sidecar config`,
101
- package: { name: entry.name, version: entry.version },
102
- });
103
- }
104
- }
105
- else if (entry.source.kind === "asset") {
106
- // Reject up front (parallel to the registry.unknown gate) so a
107
- // cache hit cannot hide a missing mount from the manifest fan-out.
108
- if (!assetMounts.has(entry.source.assetId)) {
109
- throw new ToolLoaderError({
110
- category: "asset.mount.missing",
111
- message: `manifest entry references assetId "${entry.source.assetId}" which is not in the deploy pack's asset-mounts map`,
112
- package: { name: entry.name, version: entry.version },
113
- });
114
- }
115
- }
116
- // Probe cache presence with `has` rather than `get`: the bytes are
117
- // only needed when they have to be fetched-then-stored, and
118
- // `extractTarball` below re-reads them from disk on the way to the
119
- // per-integrity unpack directory. `has` checks file existence
120
- // without reading or atime-touching the bytes, so a cache-hit
121
- // apply avoids the wasted read of a tarball that immediately gets
122
- // discarded.
123
- if (!(await config.cache.has(entry.integrity))) {
124
- const bytes = await fetchTarball(entry, {
125
- registries: config.registries,
126
- assetRoot,
127
- assetMounts,
128
- });
129
- try {
130
- await config.cache.put(entry.integrity, bytes);
131
- }
132
- catch (err) {
133
- if (err instanceof TarballIntegrityMismatchError) {
134
- throw new ToolLoaderError({
135
- category: "integrity.mismatch",
136
- message: `bytes for ${entry.name}@${entry.version} did not match pinned integrity`,
137
- package: { name: entry.name, version: entry.version },
138
- });
139
- }
140
- throw err;
141
- }
142
- }
143
- try {
144
- return await config.cache.extractTarball(entry.integrity);
145
- }
146
- catch (err) {
147
- // Eviction is reserved for the integrity-mismatch path: the bytes
148
- // on disk no longer match the pinned hash, so the entry is poison
149
- // and must be re-fetched. Other failures — tar parse errors, FS
150
- // transients (EIO, ENOSPC) — leave the cached bytes intact. The
151
- // cache's `evict` defers physical reclaim of the extraction tree
152
- // until every outstanding `release` from a concurrent
153
- // `extractTarball` has fired, so a parallel agent's in-flight
154
- // `hardlinkTree` walk against the same extraction will not
155
- // ENOENT mid-readdir.
156
- if (err instanceof TarballIntegrityMismatchError) {
157
- await config.cache.evict(entry.integrity);
158
- }
159
- throw new ToolLoaderError({
160
- category: "tarball.extract.failed",
161
- message: `tar extraction failed for ${entry.name}@${entry.version}: ${describeError(err)}`,
162
- package: { name: entry.name, version: entry.version },
163
- });
164
- }
165
- }
166
- function passesPlatformFilter(entry) {
167
- if (entry.os !== undefined &&
168
- !platformListMatches(entry.os, config.host.os)) {
169
- logger.debug `platform.mismatch.skipped: ${entry.name}@${entry.version} requires os ${entry.os.join(",")} (host is ${config.host.os})`;
170
- return false;
171
- }
172
- if (entry.cpu !== undefined &&
173
- !platformListMatches(entry.cpu, config.host.cpu)) {
174
- logger.debug `platform.mismatch.skipped: ${entry.name}@${entry.version} requires cpu ${entry.cpu.join(",")} (host is ${config.host.cpu})`;
175
- return false;
176
- }
177
- return true;
178
- }
179
70
  async function loadTopLevel(entry, pkgDir) {
180
71
  const pkgJsonPath = path.join(pkgDir, "package.json");
181
72
  let pkgJsonRaw;
@@ -263,12 +154,18 @@ export function createToolLoader(config) {
263
154
  });
264
155
  }
265
156
  }
157
+ // Inline credential declarations: read + validate from the same
158
+ // package.json above. Runs after the tools/directors walks so a
159
+ // malformed `interchange.credentials` surfaces the same
160
+ // package.entry.invalid class as a bad tool/director entry.
161
+ const credentials = readInterchangeCredentials(pkgJson, entry);
266
162
  return {
267
163
  name: entry.name,
268
164
  version: entry.version,
269
165
  factories,
270
166
  plugins,
271
167
  directors,
168
+ credentials,
272
169
  };
273
170
  }
274
171
  /**
@@ -344,16 +241,16 @@ export function createToolLoader(config) {
344
241
  package: { name: entry.name, version: entry.version },
345
242
  });
346
243
  }
347
- // Cache-bust the ESM module cache by appending the entry integrity
348
- // as a query string. Node keys the ESM cache by resolved URL/path,
349
- // not by content: a `(name, version)` pair whose bytes change
350
- // across applies (an operator-recompiled built-in, a hot-fixed
351
- // tarball republished under the same version) would otherwise
352
- // resolve to the previously-imported module instance until the
353
- // sidecar restarts. Same path with a different query is a distinct
354
- // ESM cache entry, so the import reflects the bytes actually
244
+ // Cache-bust the ESM module cache by appending the entry's content
245
+ // identity as a query string. Node keys the ESM cache by resolved
246
+ // URL/path, not by content: a `(name, version)` pair whose bytes
247
+ // change across applies (an operator-recompiled built-in, a
248
+ // hot-fixed tarball republished under the same version) would
249
+ // otherwise resolve to the previously-imported module instance until
250
+ // the sidecar restarts. Same path with a different query is a
251
+ // distinct ESM cache entry, so the import reflects the bytes actually
355
252
  // extracted for this apply.
356
- const importUrl = `${pathToFileURL(entryAbs).href}?integrity=${encodeURIComponent(entry.integrity)}`;
253
+ const importUrl = `${pathToFileURL(entryAbs).href}?integrity=${encodeURIComponent(getToolPackageSourceContentIdentity(entry.source))}`;
357
254
  let mod;
358
255
  try {
359
256
  mod = await importModule(importUrl);
@@ -376,92 +273,70 @@ export function createToolLoader(config) {
376
273
  }
377
274
  return {
378
275
  async loadManifest(args) {
379
- const filtered = args.manifest.entries.filter(passesPlatformFilter);
380
- const storeDir = path.join(args.instanceScratchDir, "store");
276
+ // Phases 1-2 (fetch + SRI-verify + extract into the cache, then
277
+ // resolve the closure ranges and lay out the per-instance store)
278
+ // are the eval-free materialization primitive. They import no
279
+ // author code; that first `import()` happens only in phase 3
280
+ // below. `materializeClosure` returns the exact host-filtered
281
+ // entries it laid out, so phase 3 loads precisely those -- there is
282
+ // no second platform-filter pass that could drift from the first.
283
+ const { storeDir, entries } = await materializeClosure({
284
+ manifest: args.manifest,
285
+ instanceScratchDir: args.instanceScratchDir,
286
+ assetRoot: args.assetRoot,
287
+ assetMounts: args.assetMounts,
288
+ gitDirs: args.gitDirs,
289
+ host: config.host,
290
+ cache: config.cache,
291
+ registries: config.registries,
292
+ fetchTarball,
293
+ });
381
294
  const topLevelKeys = new Set(args.manifest.topLevel.map((p) => `${p.name}@${p.version}`));
382
- // 1. Materialize every filtered entry into the cache and capture
383
- // its extraction directory. This validates the manifest is
384
- // registry-chain-consistent (each entry resolves end-to-end
385
- // against its declared source) and primes the cache so the
386
- // layout step can hardlink without re-fetching.
387
- //
388
- // Each materialize() returns an `{ dir, release }` pair: the
389
- // cache treats the returned `dir` as held until `release` is
390
- // called, so a concurrent eviction of the same integrity
391
- // defers its physical reclaim of the extraction tree until
392
- // after the buildStoreLayout pass below has finished walking
393
- // every dir to hardlink files out. Releases are aggregated and
394
- // drained in a `finally` so an error mid-layout still hands
395
- // the cache its references back.
396
- const extractionByEntry = new Map();
397
- const entriesByNameVersion = new Map();
398
- const releases = [];
399
- try {
400
- for (const entry of filtered) {
401
- const handle = await materialize(entry, args.assetRoot, args.assetMounts);
402
- const key = `${entry.name}@${entry.version}`;
403
- extractionByEntry.set(key, handle.dir);
404
- entriesByNameVersion.set(key, entry);
405
- releases.push(handle.release);
406
- }
407
- // 2. Build the per-instance store layout. Each filtered entry
408
- // gets a real directory at `<store>/<name>/<version>/`
409
- // populated by hardlinks from its cache extraction; the
410
- // direct-dependency walk then symlinks `node_modules/<dep>`
411
- // into each layout dir so Node's standard ancestor walk
412
- // resolves bare-specifier imports from inside the package's
413
- // body against the closure's pinned versions.
414
- const rangeResolution = await resolveRangesByFirstArrival({
415
- topLevel: args.manifest.topLevel,
416
- filtered,
417
- extractionByEntry,
418
- entriesByNameVersion,
419
- });
420
- await buildStoreLayout({
421
- filtered,
422
- storeDir,
423
- extractionByEntry,
424
- rangeResolution,
425
- });
426
- // 3. Then load only the top-level packages; transitive entries
427
- // exist for `node_modules/` satisfaction but do not contribute
428
- // factories of their own.
429
- const loaded = [];
430
- const coveredTopLevelKeys = new Set();
431
- for (const entry of filtered) {
432
- const key = `${entry.name}@${entry.version}`;
433
- if (!topLevelKeys.has(key))
434
- continue;
435
- const pkgDir = storeEntryDir(storeDir, entry.name, entry.version);
436
- loaded.push(await loadTopLevel(entry, pkgDir));
437
- coveredTopLevelKeys.add(key);
438
- }
439
- // Top-level pins the platform filter dropped contribute zero
440
- // factories, which is a legitimate operator choice (e.g. an
441
- // optionalDependencies-shaped opt-in for a single-platform
442
- // helper). Surface it as a warn so an apply that produces no
443
- // tools at all because every pin was platform-filtered out is
444
- // diagnosable from the logs without re-reading the manifest.
445
- const droppedTopLevelKeys = [];
446
- for (const key of topLevelKeys) {
447
- if (!coveredTopLevelKeys.has(key))
448
- droppedTopLevelKeys.push(key);
449
- }
450
- if (droppedTopLevelKeys.length > 0) {
451
- logger.warn `tool-package apply dropped top-level pins via platform filter on host os=${config.host.os} cpu=${config.host.cpu}: ${droppedTopLevelKeys.join(", ")}`;
452
- }
453
- return loaded;
454
- }
455
- finally {
456
- for (const release of releases) {
457
- release();
458
- }
459
- }
295
+ // 3. Load only the top-level packages; transitive entries exist
296
+ // for `node_modules/` satisfaction but do not contribute
297
+ // factories of their own. This is the first point at which
298
+ // author code is imported.
299
+ const loaded = [];
300
+ const coveredTopLevelKeys = new Set();
301
+ for (const entry of entries) {
302
+ const key = `${entry.name}@${entry.version}`;
303
+ if (!topLevelKeys.has(key))
304
+ continue;
305
+ const pkgDir = storeEntryDir(storeDir, entry.name, entry.version);
306
+ loaded.push(await loadTopLevel(entry, pkgDir));
307
+ coveredTopLevelKeys.add(key);
308
+ }
309
+ // Top-level pins the platform filter dropped contribute zero
310
+ // factories, which is a legitimate operator choice (e.g. an
311
+ // optionalDependencies-shaped opt-in for a single-platform
312
+ // helper). Surface it as a warn so an apply that produces no
313
+ // tools at all because every pin was platform-filtered out is
314
+ // diagnosable from the logs without re-reading the manifest.
315
+ const droppedTopLevelKeys = [];
316
+ for (const key of topLevelKeys) {
317
+ if (!coveredTopLevelKeys.has(key))
318
+ droppedTopLevelKeys.push(key);
319
+ }
320
+ if (droppedTopLevelKeys.length > 0) {
321
+ logger.warn `tool-package apply dropped top-level pins via platform filter on host os=${config.host.os} cpu=${config.host.cpu}: ${droppedTopLevelKeys.join(", ")}`;
322
+ }
323
+ return loaded;
460
324
  },
461
325
  };
462
326
  function makeDefaultTarballFetcher() {
463
327
  return async (entry, ctx) => {
464
328
  if (entry.source.kind === "asset") {
329
+ // A source-format asset is a git subtree materialized by checkout
330
+ // in the store layout, never fetched as a tarball. Reaching the
331
+ // tarball fetcher with one is a loader bug; fail loud.
332
+ if (entry.source.package.format !== "tarball") {
333
+ throw new ToolLoaderError({
334
+ category: "git.materialization.failed",
335
+ message: `source-format asset entry ${entry.name}@${entry.version} reached the tarball fetcher`,
336
+ package: { name: entry.name, version: entry.version },
337
+ });
338
+ }
339
+ const tarballPath = entry.source.package.path;
465
340
  // The mount lookup is guaranteed by `materialize`'s
466
341
  // pre-fetch gate, but reassert here so the narrowing is
467
342
  // visible to readers — the caller of fetchTarball has no
@@ -474,8 +349,8 @@ export function createToolLoader(config) {
474
349
  package: { name: entry.name, version: entry.version },
475
350
  });
476
351
  }
477
- // Both `mount` and `entry.source.path` originate from the hub
478
- // and cross the trust boundary into the sidecar process. A `..`
352
+ // Both `mount` and `entry.source.package.path` originate from the
353
+ // hub and cross the trust boundary into the sidecar process. A `..`
479
354
  // segment in either would let a malicious manifest read any
480
355
  // file the sidecar can open. Resolve the join and assert the
481
356
  // result still sits under `assetRoot` so a traversal attempt
@@ -495,14 +370,14 @@ export function createToolLoader(config) {
495
370
  });
496
371
  }
497
372
  const mountAbs = path.resolve(ctx.assetRoot, mount);
498
- const absPath = path.resolve(mountAbs, entry.source.path);
373
+ const absPath = path.resolve(mountAbs, tarballPath);
499
374
  const mountContainmentRoot = mountAbs.endsWith(path.sep)
500
375
  ? mountAbs
501
376
  : mountAbs + path.sep;
502
377
  if (absPath !== mountAbs && !absPath.startsWith(mountContainmentRoot)) {
503
378
  throw new ToolLoaderError({
504
379
  category: "package.entry.invalid",
505
- message: `source.path for ${entry.name}@${entry.version} resolves to ${JSON.stringify(absPath)} which escapes the declared mount ${JSON.stringify(mountAbs)} (cross-mount traversal)`,
380
+ message: `source.package.path for ${entry.name}@${entry.version} resolves to ${JSON.stringify(absPath)} which escapes the declared mount ${JSON.stringify(mountAbs)} (cross-mount traversal)`,
506
381
  package: { name: entry.name, version: entry.version },
507
382
  });
508
383
  }
@@ -583,608 +458,6 @@ export function createToolLoader(config) {
583
458
  };
584
459
  }
585
460
  }
586
- export function buildRegistryFetchOpts(registry) {
587
- const opts = { registry: registry.url };
588
- if (registry.auth?.token !== undefined) {
589
- opts.token = registry.auth.token;
590
- }
591
- if (registry.auth?.basic !== undefined) {
592
- const { user, pass } = registry.auth.basic;
593
- // `npm-registry-fetch` builds the `Authorization: Basic` header by
594
- // base64-encoding `<username>:<password>` itself. Pre-encoding
595
- // `pass` would double-encode the password component (the registry
596
- // would see `base64(plaintext)` as the password, not `plaintext`).
597
- opts.forceAuth = { username: user, password: pass };
598
- }
599
- return opts;
600
- }
601
- function defaultTarballUrl(registryUrl, name, version) {
602
- const base = registryUrl.endsWith("/") ? registryUrl : `${registryUrl}/`;
603
- // Match npm's canonical tarball URL: {registry}/{name}/-/{basename}-{version}.tgz
604
- const basename = name.startsWith("@") ? name.split("/")[1] : name;
605
- if (basename === undefined) {
606
- throw new Error(`internal: cannot derive tarball basename for ${name}`);
607
- }
608
- return `${base}${name}/-/${basename}-${version}.tgz`;
609
- }
610
- /**
611
- * Read an HTTP-registry tarball response into a Uint8Array while enforcing
612
- * `maxBytes`. Two guards:
613
- *
614
- * 1. If the upstream sent a `Content-Length` header, parse it (digit-
615
- * only, per RFC 9110 §8.6) and reject up front when the declared
616
- * length exceeds the cap. A header that fails the digit shape is
617
- * also rejected so a header like `1e9` cannot read as 1e9 against
618
- * `Number()` while a digit-only cap check would pass.
619
- * 2. Stream the body chunk-by-chunk, tallying byte length, and abort
620
- * the read when the running total crosses the cap. This catches
621
- * the missing-or-lying header case.
622
- *
623
- * An optional `signal` adds a time guard: when it aborts (the caller's
624
- * fetch deadline), the in-flight read is cancelled and the call rejects,
625
- * so a registry that streams the body slowly or stalls mid-stream cannot
626
- * outlast the deadline while staying under the byte cap.
627
- *
628
- * All rejections surface as `registry.fetch.failed` so the apply layer
629
- * routes them the same as any other registry-side fetch defect.
630
- *
631
- * Exported for direct unit testing.
632
- */
633
- export async function readResponseWithLimit(res, maxBytes, ctx, signal) {
634
- const declaredLengthRaw = res.headers.get("content-length");
635
- if (declaredLengthRaw !== null) {
636
- if (!/^\d+$/.test(declaredLengthRaw)) {
637
- throw new ToolLoaderError({
638
- category: "registry.fetch.failed",
639
- message: `registry "${ctx.registry}" returned non-digit Content-Length ${JSON.stringify(declaredLengthRaw)} for ${ctx.name}@${ctx.version}`,
640
- package: { name: ctx.name, version: ctx.version },
641
- });
642
- }
643
- const declaredLength = Number(declaredLengthRaw);
644
- if (!Number.isFinite(declaredLength) || declaredLength > maxBytes) {
645
- throw new ToolLoaderError({
646
- category: "registry.fetch.failed",
647
- message: `tarball for ${ctx.name}@${ctx.version} declares Content-Length ${declaredLengthRaw} which exceeds the ${String(maxBytes)}-byte cap`,
648
- package: { name: ctx.name, version: ctx.version },
649
- });
650
- }
651
- }
652
- const body = res.body;
653
- if (body === null) {
654
- // No body and the upstream returned 2xx: treat as a zero-byte
655
- // tarball. The cache and tar-extract layers will reject the
656
- // resulting bytes as non-tar content, but the fetch itself didn't
657
- // fail — keep this path simple rather than over-rejecting.
658
- return new Uint8Array(0);
659
- }
660
- const reader = body.getReader();
661
- const chunks = [];
662
- let total = 0;
663
- // Cancelling the reader settles any pending read() as done, so the
664
- // post-read check below surfaces the timeout even when the underlying
665
- // body stream does not itself observe the abort signal.
666
- let timedOut = false;
667
- const onAbort = () => {
668
- timedOut = true;
669
- void reader.cancel();
670
- };
671
- signal?.addEventListener("abort", onAbort, { once: true });
672
- if (signal?.aborted === true)
673
- onAbort();
674
- try {
675
- for (;;) {
676
- const { value, done } = await reader.read();
677
- if (timedOut) {
678
- throw new ToolLoaderError({
679
- category: "registry.fetch.failed",
680
- message: `tarball read for ${ctx.name}@${ctx.version} exceeded the registry fetch timeout`,
681
- package: { name: ctx.name, version: ctx.version },
682
- });
683
- }
684
- if (done)
685
- break;
686
- if (value === undefined)
687
- continue;
688
- total += value.byteLength;
689
- if (total > maxBytes) {
690
- // Stop reading; we already have enough evidence the upstream
691
- // is over the cap. The reader.cancel() call requests
692
- // cancellation upstream; the runtime decides whether to drop
693
- // the in-flight TCP frames or just unsubscribe our reader.
694
- await reader.cancel();
695
- throw new ToolLoaderError({
696
- category: "registry.fetch.failed",
697
- message: `tarball for ${ctx.name}@${ctx.version} streamed past the ${String(maxBytes)}-byte cap`,
698
- package: { name: ctx.name, version: ctx.version },
699
- });
700
- }
701
- chunks.push(value);
702
- }
703
- }
704
- finally {
705
- signal?.removeEventListener("abort", onAbort);
706
- reader.releaseLock();
707
- }
708
- const out = new Uint8Array(total);
709
- let offset = 0;
710
- for (const chunk of chunks) {
711
- out.set(chunk, offset);
712
- offset += chunk.byteLength;
713
- }
714
- return out;
715
- }
716
- function storeEntryDir(storeDir, name, version) {
717
- // `@scope/name` carries a slash that, taken naively, would push the
718
- // package's contents one directory deeper than `loadTopLevel`
719
- // expects. Mirror npm's on-disk shape: `node_modules/@scope/name/`,
720
- // so a scoped entry's dir is `<store>/@scope/name/<version>/`.
721
- return path.join(storeDir, name, version);
722
- }
723
- /**
724
- * Build the per-instance `<store>/<name>/<version>/` tree for every
725
- * filtered manifest entry: hardlink each entry's source files in from
726
- * the cache extraction, then symlink each direct dep into the entry's
727
- * `node_modules/`. Hardlinks keep byte usage to one copy per integrity
728
- * per filesystem; symlinks at the `node_modules/` boundary let Node's
729
- * realpath-based resolver walk to the dep's own layout dir (with its
730
- * own `node_modules/`) so transitive resolution composes recursively.
731
- */
732
- async function buildStoreLayout(args) {
733
- // First materialize every layout dir with its hardlinked contents.
734
- // node_modules symlinks come after, so a dep's layout dir is already
735
- // populated when its parent's symlink starts pointing at it.
736
- for (const entry of args.filtered) {
737
- const key = `${entry.name}@${entry.version}`;
738
- const extraction = args.extractionByEntry.get(key);
739
- if (extraction === undefined) {
740
- throw new Error(`internal: layout build for ${key} found no cache extraction`);
741
- }
742
- const layoutDir = storeEntryDir(args.storeDir, entry.name, entry.version);
743
- await fs.mkdir(path.dirname(layoutDir), { recursive: true });
744
- await hardlinkTree(extraction, layoutDir);
745
- }
746
- for (const entry of args.filtered) {
747
- const key = `${entry.name}@${entry.version}`;
748
- const extraction = args.extractionByEntry.get(key);
749
- if (extraction === undefined) {
750
- throw new Error(`internal: layout link pass for ${key} found no cache extraction`);
751
- }
752
- const layoutDir = storeEntryDir(args.storeDir, entry.name, entry.version);
753
- const deps = await readDirectDependencies(extraction, entry);
754
- if (deps.length === 0)
755
- continue;
756
- const modulesDir = path.join(layoutDir, "node_modules");
757
- await fs.mkdir(modulesDir, { recursive: true });
758
- for (const dep of deps) {
759
- const pickedVersion = args.rangeResolution.lookup(dep.name, dep.range);
760
- if (pickedVersion === null) {
761
- if (dep.optional) {
762
- logger.debug `optional.dropped.skipped: ${entry.name}@${entry.version} optional dep ${dep.name}@${dep.range} has no satisfying version in the closure (likely platform-filtered out)`;
763
- continue;
764
- }
765
- throw new ToolLoaderError({
766
- category: "package.entry.invalid",
767
- message: `${entry.name}@${entry.version} depends on ${dep.name}@${dep.range} but the manifest closure has no satisfying version; the resolver was expected to include it`,
768
- package: { name: entry.name, version: entry.version },
769
- });
770
- }
771
- const target = storeEntryDir(args.storeDir, dep.name, pickedVersion);
772
- const symlinkPath = path.join(modulesDir, dep.name);
773
- // Scoped deps live one directory deep under `node_modules/`;
774
- // ensure the scope dir exists before linking.
775
- await fs.mkdir(path.dirname(symlinkPath), { recursive: true });
776
- const relativeTarget = path.relative(path.dirname(symlinkPath), target);
777
- try {
778
- await fs.symlink(relativeTarget, symlinkPath, "dir");
779
- }
780
- catch (err) {
781
- if (!isEEXIST(err))
782
- throw err;
783
- const existing = await fs.readlink(symlinkPath);
784
- if (existing !== relativeTarget) {
785
- // A symlink collision inside the loader's per-package
786
- // layout pass is a loader-layer invariant violation, not an
787
- // unknown error shape — route it through the same structured
788
- // envelope every other loader failure uses so atomic-apply
789
- // surfaces it as `package.entry.invalid` instead of falling
790
- // back to the unknown-shape catch-all (`factory.construct.
791
- // failed`).
792
- throw new ToolLoaderError({
793
- category: "package.entry.invalid",
794
- message: `symlink collision at ${symlinkPath}: existing target ${existing} differs from ${relativeTarget}`,
795
- });
796
- }
797
- }
798
- }
799
- }
800
- }
801
- /**
802
- * Walk the closure in BFS order from the top-level pins (in their
803
- * input order) and record, for each `(name, range)` first encountered,
804
- * the version chosen out of the closure. Subsequent edges with the
805
- * same `(name, range)` reuse the recorded pick instead of re-running
806
- * `semver.maxSatisfying` against the current closure shape.
807
- *
808
- * Mirrors the resolver's first-arrival-per-`(name, range)` semantics
809
- * on the loader side. Without this, two requirers with overlapping
810
- * ranges of the same dep could each pick a different version of that
811
- * dep — `maxSatisfying` is deterministic given its candidate set, but
812
- * the candidate set is the full closure for the name and a transitive
813
- * addition since the first arrival can shift the answer. Recording
814
- * the first arrival per range freezes the pick so every requirer in
815
- * the same equivalence class lands on the same version of the dep.
816
- *
817
- * Returns null for a `(name, range)` that has no satisfying entry in
818
- * the filtered closure; callers decide whether that is fatal (hard
819
- * dep) or skippable (optional dep).
820
- */
821
- async function resolveRangesByFirstArrival(args) {
822
- const recorded = new Map();
823
- const visited = new Set();
824
- const filteredKeys = new Set(args.filtered.map((e) => `${e.name}@${e.version}`));
825
- function rangeKey(name, range) {
826
- return `${name}@${range}`;
827
- }
828
- function pickFromClosure(name, range) {
829
- const candidates = [];
830
- for (const entry of args.entriesByNameVersion.values()) {
831
- if (entry.name !== name)
832
- continue;
833
- if (!filteredKeys.has(`${entry.name}@${entry.version}`))
834
- continue;
835
- candidates.push(entry.version);
836
- }
837
- if (candidates.length === 0)
838
- return null;
839
- const valid = candidates.filter((v) => semver.valid(v) !== null);
840
- if (valid.length > 0) {
841
- const picked = semver.maxSatisfying(valid, range, {
842
- includePrerelease: true,
843
- });
844
- if (picked !== null)
845
- return picked;
846
- }
847
- // Literal-version fallback: when a transitive dep's range is
848
- // itself a concrete version string (e.g. `'1.0.0'` not
849
- // `'^1.0.0'`), `maxSatisfying` rejects on prerelease semantics but
850
- // the literal match is valid.
851
- if (candidates.includes(range))
852
- return range;
853
- return null;
854
- }
855
- // BFS frontier carries the entry whose direct deps we are about to
856
- // fan out on next. Seed with the top-level pins in pin order, mapped
857
- // through the filtered closure so platform-filtered tops are skipped
858
- // (their deps would not have layout dirs to link into).
859
- const queue = [];
860
- for (const pin of args.topLevel) {
861
- const key = `${pin.name}@${pin.version}`;
862
- const entry = args.entriesByNameVersion.get(key);
863
- if (entry === undefined)
864
- continue;
865
- if (!filteredKeys.has(key))
866
- continue;
867
- if (visited.has(key))
868
- continue;
869
- visited.add(key);
870
- queue.push(entry);
871
- }
872
- while (queue.length > 0) {
873
- const entry = queue.shift();
874
- if (entry === undefined)
875
- break;
876
- const extraction = args.extractionByEntry.get(`${entry.name}@${entry.version}`);
877
- if (extraction === undefined)
878
- continue;
879
- const deps = await readDirectDependencies(extraction, entry);
880
- for (const dep of deps) {
881
- const key = rangeKey(dep.name, dep.range);
882
- // `recorded.get(key)` returning `null` is the "we picked this
883
- // range against the closure and got nothing" cached answer.
884
- // Caching the null is safe only because the closure is static
885
- // across this loader pass — `entriesByNameVersion` does not
886
- // grow underneath us. If a future change starts adding entries
887
- // mid-walk (e.g. lazy fetches during BFS), the cached null
888
- // would shadow the new candidates and produce a phantom miss;
889
- // the cache key would need to be invalidated alongside the
890
- // closure additions.
891
- let picked = recorded.get(key);
892
- if (picked === undefined) {
893
- picked = pickFromClosure(dep.name, dep.range);
894
- recorded.set(key, picked);
895
- }
896
- if (picked === null)
897
- continue;
898
- const depKey = `${dep.name}@${picked}`;
899
- if (visited.has(depKey))
900
- continue;
901
- visited.add(depKey);
902
- const depEntry = args.entriesByNameVersion.get(depKey);
903
- if (depEntry === undefined)
904
- continue;
905
- queue.push(depEntry);
906
- }
907
- }
908
- return {
909
- lookup(name, range) {
910
- const key = rangeKey(name, range);
911
- if (recorded.has(key)) {
912
- const picked = recorded.get(key);
913
- return picked === undefined ? null : picked;
914
- }
915
- // The BFS only walks entries reachable from the top-level pins.
916
- // A dep declared by an entry the BFS did not reach (e.g. a
917
- // closure entry that no top-level chain ever required) is not
918
- // pre-recorded; fall through to a fresh pick from the closure
919
- // so the layout for such entries still resolves deterministically.
920
- const fallback = pickFromClosure(name, range);
921
- recorded.set(key, fallback);
922
- return fallback;
923
- },
924
- };
925
- }
926
- async function hardlinkTree(srcDir, destDir, extractionRoot = srcDir) {
927
- await fs.mkdir(destDir, { recursive: true });
928
- const entries = await fs.readdir(srcDir, { withFileTypes: true });
929
- for (const entry of entries) {
930
- const src = path.join(srcDir, entry.name);
931
- const dest = path.join(destDir, entry.name);
932
- if (entry.isDirectory()) {
933
- await hardlinkTree(src, dest, extractionRoot);
934
- }
935
- else if (entry.isFile()) {
936
- try {
937
- await fs.link(src, dest);
938
- }
939
- catch (err) {
940
- if (!isEEXIST(err))
941
- throw err;
942
- }
943
- }
944
- else if (entry.isSymbolicLink()) {
945
- // Preserve symlinks from the tarball verbatim; npm packages
946
- // occasionally ship them and clobbering with a hardlink would
947
- // change the file's identity.
948
- //
949
- // ISOMORPHIC-LAYOUT ASSUMPTION: writing the source-side
950
- // relative target verbatim into the destination only works
951
- // because the source extraction tree and the per-instance
952
- // store tree mirror each other entry-for-entry — the symlink
953
- // copies into the same shape, so the relative target still
954
- // resolves to the same sibling in the destination. A future
955
- // change that flattens, reshapes, or partially copies the
956
- // extraction tree would invalidate every symlink it touched
957
- // and would need to rewrite the targets instead of preserving
958
- // them.
959
- //
960
- // Symlink targets originate from the tarball and cross the trust
961
- // boundary into the sidecar. Resolve each target against the
962
- // symlink's own directory and verify it lands inside the
963
- // extraction root; a target that escapes would let a malicious
964
- // tarball point at arbitrary sidecar-readable files via the
965
- // layout dir's `node_modules` walk.
966
- //
967
- // The `tar` package version we use rejects absolute symlink
968
- // targets during extraction, so by the time we observe a
969
- // symlink here it is necessarily relative.
970
- //
971
- // The immediate target of `src` may itself be a directory whose
972
- // own contents include another symlink. Resolving only the
973
- // first hop with `path.resolve(path.dirname(src), target)`
974
- // checks containment of the link's literal target — a chain
975
- // whose first hop lands inside the extraction root but whose
976
- // realpath ultimately escapes (target is a directory that
977
- // itself contains an escaping symlink) would slip past.
978
- // `fs.realpath` walks the full chain and returns the canonical
979
- // absolute path; verify containment against that.
980
- const target = await fs.readlink(src);
981
- // Compare against the realpath of the extraction root so a chain
982
- // whose canonical path lands under the same logical root, but
983
- // via a symlinked tmpdir prefix (notably macOS where `/tmp`
984
- // resolves to `/private/tmp`), is not incorrectly flagged as
985
- // an escape.
986
- let realExtractionRoot;
987
- try {
988
- realExtractionRoot = await fs.realpath(extractionRoot);
989
- }
990
- catch (err) {
991
- throw new ToolLoaderError({
992
- category: "package.entry.invalid",
993
- message: `tarball symlink ${src} → ${target}: extraction-root realpath failed: ${describeError(err)}`,
994
- });
995
- }
996
- // `path.resolve` produces the absolute path the symlink would
997
- // dereference to without following any links itself; realpath
998
- // walks the chain. A dangling symlink — one whose target chain
999
- // ENOENTs before the final inode — is harmless on disk (it
1000
- // points at a name that does not exist), so the containment
1001
- // check falls back to the literal resolved path in that case.
1002
- // Any other realpath error is fatal; we cannot prove containment
1003
- // and the package is rejected.
1004
- //
1005
- // The fallback anchors the literal resolution at `realpath(src
1006
- // dirname)` rather than the as-declared `dirname(src)`. The
1007
- // dirname already exists on disk (extraction wrote it); realpath
1008
- // walks any symlinks in the prefix so the comparison against
1009
- // `realExtractionRoot` is realpath-vs-realpath on both sides.
1010
- // Without this, platforms whose extraction-root prefix contains
1011
- // symlinks (notably macOS, where `/var/folders/...` resolves to
1012
- // `/private/var/folders/...`) would reject a properly-contained
1013
- // dangling link because the literal path keeps the as-declared
1014
- // prefix while the extraction root has been realpath'd.
1015
- let targetAbs;
1016
- try {
1017
- targetAbs = await fs.realpath(path.resolve(path.dirname(src), target));
1018
- }
1019
- catch (err) {
1020
- if (!isENOENT(err)) {
1021
- throw new ToolLoaderError({
1022
- category: "package.entry.invalid",
1023
- message: `tarball contains symlink ${src} → ${target} whose target could not be resolved: ${describeError(err)}`,
1024
- });
1025
- }
1026
- let srcDirReal;
1027
- try {
1028
- srcDirReal = await fs.realpath(path.dirname(src));
1029
- }
1030
- catch (dirErr) {
1031
- throw new ToolLoaderError({
1032
- category: "package.entry.invalid",
1033
- message: `tarball symlink ${src} → ${target}: dirname realpath failed during dangling-link fallback: ${describeError(dirErr)}`,
1034
- });
1035
- }
1036
- targetAbs = path.resolve(srcDirReal, target);
1037
- }
1038
- const realContainmentRoot = realExtractionRoot.endsWith(path.sep)
1039
- ? realExtractionRoot
1040
- : realExtractionRoot + path.sep;
1041
- if (targetAbs !== realExtractionRoot &&
1042
- !targetAbs.startsWith(realContainmentRoot)) {
1043
- throw new ToolLoaderError({
1044
- category: "package.entry.invalid",
1045
- message: `tarball contains symlink ${src} → ${target} that escapes the package extraction directory`,
1046
- });
1047
- }
1048
- try {
1049
- await fs.symlink(target, dest);
1050
- }
1051
- catch (err) {
1052
- if (!isEEXIST(err))
1053
- throw err;
1054
- }
1055
- }
1056
- }
1057
- }
1058
- /**
1059
- * Read the package.json at `extractionDir/package.json` and return the
1060
- * union of `dependencies` and `optionalDependencies`. Each entry is
1061
- * tagged with whether it came from the optional field so the layout
1062
- * pass can decide whether a missing closure entry is fatal (hard dep)
1063
- * or skippable (the resolver's platform filter excluded it from the
1064
- * closure for this host).
1065
- *
1066
- * `dependencies` shadows `optionalDependencies` when the same name
1067
- * appears in both — npm treats the dep as required in that case.
1068
- */
1069
- async function readDirectDependencies(extractionDir, entry) {
1070
- const pkgJsonRaw = await fs.readFile(path.join(extractionDir, "package.json"), "utf8");
1071
- let pkg;
1072
- try {
1073
- pkg = JSON.parse(pkgJsonRaw);
1074
- }
1075
- catch (err) {
1076
- throw new ToolLoaderError({
1077
- category: "package.entry.invalid",
1078
- message: `malformed package.json in ${entry.name}@${entry.version}: ${describeError(err)}`,
1079
- package: { name: entry.name, version: entry.version },
1080
- });
1081
- }
1082
- const byName = new Map();
1083
- if (pkg === null || typeof pkg !== "object")
1084
- return [];
1085
- const record = { ...pkg };
1086
- // A non-string range value (number, null, nested object, array) is
1087
- // a malformed package.json the npm CLI would also reject. Silently
1088
- // dropping it would let the closure resolver later reject the apply
1089
- // with a misleading `package.entry.invalid` for the wrong layer —
1090
- // the malformation is here, not in the closure walk. Surface it as
1091
- // `package.entry.invalid` directly so the operator-facing message
1092
- // points at the bad package.
1093
- //
1094
- // Iteration order matters: write optionalDependencies FIRST, then
1095
- // dependencies. The `dependencies` write overwrites the same key on
1096
- // collision, which is the npm-shadowing rule documented above.
1097
- // Reversing these two blocks would silently make the optional
1098
- // declaration win and demote a hard dependency to optional.
1099
- const optionalDeps = record["optionalDependencies"];
1100
- if (optionalDeps !== undefined) {
1101
- assertDepMapShape(optionalDeps, "optionalDependencies", entry);
1102
- if (optionalDeps !== null && typeof optionalDeps === "object") {
1103
- for (const [name, range] of Object.entries(optionalDeps)) {
1104
- if (typeof range !== "string") {
1105
- throw new ToolLoaderError({
1106
- category: "package.entry.invalid",
1107
- message: `package.json field optionalDependencies["${name}"] in ${entry.name}@${entry.version} is ${typeof range}, expected a string range`,
1108
- package: { name: entry.name, version: entry.version },
1109
- });
1110
- }
1111
- byName.set(name, { name, range, optional: true });
1112
- }
1113
- }
1114
- }
1115
- const deps = record["dependencies"];
1116
- if (deps !== undefined) {
1117
- assertDepMapShape(deps, "dependencies", entry);
1118
- if (deps !== null && typeof deps === "object") {
1119
- for (const [name, range] of Object.entries(deps)) {
1120
- if (typeof range !== "string") {
1121
- throw new ToolLoaderError({
1122
- category: "package.entry.invalid",
1123
- message: `package.json field dependencies["${name}"] in ${entry.name}@${entry.version} is ${typeof range}, expected a string range`,
1124
- package: { name: entry.name, version: entry.version },
1125
- });
1126
- }
1127
- byName.set(name, { name, range, optional: false });
1128
- }
1129
- }
1130
- }
1131
- return Array.from(byName.values());
1132
- }
1133
- /**
1134
- * Reject array-shaped `dependencies` / `optionalDependencies`. The
1135
- * surrounding code narrows with `typeof X === "object"`, which is true
1136
- * for arrays — and `Object.entries(["foo"])` produces `[["0", "foo"]]`,
1137
- * feeding nonsense package names into the closure resolver. Failure
1138
- * downstream is loud but the message points at the wrong layer. Reject
1139
- * at the package-json read with a clear, structured failure instead.
1140
- */
1141
- function assertDepMapShape(value, field, entry) {
1142
- if (Array.isArray(value)) {
1143
- throw new ToolLoaderError({
1144
- category: "package.entry.invalid",
1145
- message: `package.json#${field} for ${entry.name}@${entry.version} must be an object map of name→range, not an array`,
1146
- package: { name: entry.name, version: entry.version },
1147
- });
1148
- }
1149
- }
1150
- /**
1151
- * npm's `os`/`cpu` filter language. Each list entry is either a bare
1152
- * platform string (allow-list) or a `!`-prefixed string (block-list).
1153
- *
1154
- * - Any `!`-prefixed entry switches the list into block-list mode:
1155
- * the entry matches the host iff no `!host` token appears. Bare
1156
- * entries in the same list are ignored (this matches npm's own
1157
- * `npm-install-checks` semantics, which keys "blocked" off the
1158
- * presence of any `!` token).
1159
- * - With no `!` token the list is an allow-list: the entry matches
1160
- * iff the host string appears verbatim.
1161
- *
1162
- * The plain `entries.includes(host)` check the loader used previously
1163
- * treated `!win32` as a literal token, so `os: ["!win32"]` on linux
1164
- * read as a never-matching allow-list and the package was incorrectly
1165
- * filtered out.
1166
- */
1167
- function platformListMatches(entries, host) {
1168
- const hasNegation = entries.some((e) => e.startsWith("!"));
1169
- if (hasNegation) {
1170
- return !entries.includes(`!${host}`);
1171
- }
1172
- return entries.includes(host);
1173
- }
1174
- function isEEXIST(err) {
1175
- if (err === null || typeof err !== "object")
1176
- return false;
1177
- if (!("code" in err))
1178
- return false;
1179
- return err.code === "EEXIST";
1180
- }
1181
- function isENOENT(err) {
1182
- if (err === null || typeof err !== "object")
1183
- return false;
1184
- if (!("code" in err))
1185
- return false;
1186
- return err.code === "ENOENT";
1187
- }
1188
461
  function readInterchangeEntry(pkgJson, field) {
1189
462
  if (pkgJson === null || typeof pkgJson !== "object")
1190
463
  return null;
@@ -1212,6 +485,38 @@ function readInterchangeEntry(pkgJson, field) {
1212
485
  return null;
1213
486
  return value;
1214
487
  }
488
+ /**
489
+ * Read a package's inline `interchange.credentials` declarations from its
490
+ * parsed `package.json`. Unlike `tools`/`directors` (module-path fields
491
+ * `readInterchangeEntry` resolves and imports), `credentials` is inline
492
+ * data, so it is validated here against `ToolCredentialDeclarationArray` --
493
+ * the same arktype the upload boundary enforces. A duplicate handle or a
494
+ * malformed entry is therefore rejected at load with parity to the push
495
+ * gate rather than collapsing silently downstream. Absence is a no-op: a
496
+ * tools-only package that declares no credentials returns an empty array
497
+ * and stays valid.
498
+ */
499
+ function readInterchangeCredentials(pkgJson, entry) {
500
+ if (pkgJson === null || typeof pkgJson !== "object")
501
+ return [];
502
+ if (!("interchange" in pkgJson))
503
+ return [];
504
+ const interchange = pkgJson.interchange;
505
+ if (interchange === null || typeof interchange !== "object")
506
+ return [];
507
+ if (!("credentials" in interchange))
508
+ return [];
509
+ const raw = interchange.credentials;
510
+ const validated = ToolCredentialDeclarationArray(raw);
511
+ if (validated instanceof type.errors) {
512
+ throw new ToolLoaderError({
513
+ category: "package.entry.invalid",
514
+ message: `${entry.name}@${entry.version} interchange.credentials failed validation: ${validated.summary}`,
515
+ package: { name: entry.name, version: entry.version },
516
+ });
517
+ }
518
+ return validated;
519
+ }
1215
520
  /**
1216
521
  * Wrap a factory so the bundle it returns has its tool definitions
1217
522
  * prefixed by the bundle's `id`. Package authors write bare tool
@@ -1232,6 +537,14 @@ function applyNamespacePrefix(factory, pkg) {
1232
537
  // as a side-effect) blocks the `push` / `splice` mutations that
1233
538
  // would otherwise grow the surface in place.
1234
539
  const frozenRequires = Object.freeze([...factory.requires]);
540
+ // The wrapped factory contributes prefixed tool names at runtime, so
541
+ // its static declaration must carry the same prefixed names to stay
542
+ // truthful for callers that enumerate `definitions` without invoking
543
+ // the factory.
544
+ const frozenDefinitions = Object.freeze(factory.definitions.map((def) => ({
545
+ ...def,
546
+ name: `${prefix}${def.name}`,
547
+ })));
1235
548
  const wrapped = Object.freeze(Object.assign((env) => {
1236
549
  const bundle = factory(env);
1237
550
  // A definition whose raw name already starts with the bundle's
@@ -1318,7 +631,11 @@ function applyNamespacePrefix(factory, pkg) {
1318
631
  },
1319
632
  ...(bundle.dispose !== undefined ? { dispose: bundle.dispose } : {}),
1320
633
  };
1321
- }, { id: factory.id, requires: frozenRequires }));
634
+ }, {
635
+ id: factory.id,
636
+ requires: frozenRequires,
637
+ definitions: frozenDefinitions,
638
+ }));
1322
639
  return wrapped;
1323
640
  }
1324
641
  function isAnnotatedToolFactory(value) {
@@ -1348,40 +665,3 @@ function isAnnotatedToolFactory(value) {
1348
665
  return false;
1349
666
  return requires.every((r) => typeof r === "string");
1350
667
  }
1351
- /**
1352
- * Structural check for an `AnnotatedDirectorFactory` export. The shape
1353
- * is callable + `{ id: string, requires: string[], configSchema:
1354
- * function }`. The `configSchema` field is the discriminator against
1355
- * tool factories (which carry only `id` and `requires`); without it,
1356
- * any tool-factory export from a directors-entry module would be
1357
- * accepted as a director.
1358
- */
1359
- function isAnnotatedDirectorFactory(value) {
1360
- if (typeof value !== "function")
1361
- return false;
1362
- if (isAnnotatedPluginFactory(value))
1363
- return false;
1364
- if (!("id" in value) || !("requires" in value))
1365
- return false;
1366
- if (!("configSchema" in value))
1367
- return false;
1368
- const id = value.id;
1369
- const requires = value.requires;
1370
- const configSchema = value.configSchema;
1371
- if (typeof id !== "string")
1372
- return false;
1373
- if (!Array.isArray(requires))
1374
- return false;
1375
- if (!requires.every((r) => typeof r === "string"))
1376
- return false;
1377
- // `defineDirector` requires a callable arktype validator. A non-
1378
- // callable schema would crash later inside `validateDirectorConfig`;
1379
- // reject here so the failure surfaces as `package.entry.invalid` at
1380
- // load time rather than at first config-validation call.
1381
- if (typeof configSchema !== "function")
1382
- return false;
1383
- return true;
1384
- }
1385
- function describeError(err) {
1386
- return err instanceof Error ? err.message : String(err);
1387
- }