@intx/tool-packaging 0.2.2
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/LICENSE +176 -0
- package/README.md +116 -0
- package/dist/atomic-apply.d.ts +65 -0
- package/dist/atomic-apply.js +223 -0
- package/dist/cache.d.ts +89 -0
- package/dist/cache.js +750 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +30 -0
- package/dist/loader.d.ts +189 -0
- package/dist/loader.js +1387 -0
- package/dist/package-json-extract.d.ts +56 -0
- package/dist/package-json-extract.js +125 -0
- package/dist/resolver.d.ts +241 -0
- package/dist/resolver.js +821 -0
- package/package.json +39 -0
package/dist/loader.js
ADDED
|
@@ -0,0 +1,1387 @@
|
|
|
1
|
+
// eslint-disable-next-line @typescript-eslint/triple-slash-reference -- npm-team packages ship no types; declarations.d.ts must be visible to downstream typecheckers that import from this package's source.
|
|
2
|
+
/// <reference path="./declarations.d.ts" />
|
|
3
|
+
// Sidecar-side tool-package loader.
|
|
4
|
+
//
|
|
5
|
+
// Given a resolved `ToolPackageManifest`, the loader builds an
|
|
6
|
+
// npm-compatible nested `node_modules/` layout under the per-instance
|
|
7
|
+
// scratch directory so each top-level package and each transitive
|
|
8
|
+
// dependency can satisfy its own `require()` / `import` calls without
|
|
9
|
+
// help from the sidecar host.
|
|
10
|
+
//
|
|
11
|
+
// 1. Filters by host os/cpu metadata; mismatches are skipped with a
|
|
12
|
+
// debug log (`platform.mismatch.skipped`).
|
|
13
|
+
// 2. Materializes every remaining entry into the content-addressable
|
|
14
|
+
// cache: bytes are pulled from the entry's source on a miss and
|
|
15
|
+
// verified through `cache.put`; the bytes are then unpacked via
|
|
16
|
+
// `cache.extractTarball` so a single sha512 has a single extraction
|
|
17
|
+
// shared across instances.
|
|
18
|
+
// 3. Lays out each entry under `<scratch>/store/<name>/<version>/` by
|
|
19
|
+
// hardlinking the file tree from the cache extraction. Each layout
|
|
20
|
+
// directory gets its own `node_modules/<dep>` symlink to the
|
|
21
|
+
// sibling `store/<dep>/<depVersion>/` chosen for that requirer.
|
|
22
|
+
// Diamond dependencies share a single store entry; version
|
|
23
|
+
// conflicts coexist as separate store entries and Node's standard
|
|
24
|
+
// ancestor-walk resolves each requirer's deps to the version that
|
|
25
|
+
// satisfies its own range.
|
|
26
|
+
// 4. Reads each top-level package's unpacked `package.json`, resolves
|
|
27
|
+
// the `interchange.tools` entry path, and dynamic-import()s it.
|
|
28
|
+
// 5. Validates each named export is an `AnnotatedToolFactory` (a
|
|
29
|
+
// callable with `id: string` and `requires: readonly string[]`).
|
|
30
|
+
//
|
|
31
|
+
// Only entries listed in `manifest.topLevel` contribute tools; the
|
|
32
|
+
// loader still materializes every other entry (modulo platform
|
|
33
|
+
// filtering) because top-level packages reach them through Node's
|
|
34
|
+
// `node_modules/` resolution at apply time.
|
|
35
|
+
//
|
|
36
|
+
// Errors are surfaced as `ToolLoaderError` with a `category` matching
|
|
37
|
+
// one of the `DeployApplyErrorCategory` values. The atomic-apply layer
|
|
38
|
+
// catches these and translates them into wire-level frames.
|
|
39
|
+
import { promises as fs } from "node:fs";
|
|
40
|
+
import path from "node:path";
|
|
41
|
+
import { pathToFileURL } from "node:url";
|
|
42
|
+
import semver from "semver";
|
|
43
|
+
import npmRegistryFetch from "npm-registry-fetch";
|
|
44
|
+
import { isAnnotatedPluginFactory } from "@intx/agent";
|
|
45
|
+
import { getLogger } from "@intx/log";
|
|
46
|
+
import { TarballIntegrityMismatchError } from "./cache.js";
|
|
47
|
+
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
|
+
export function createToolLoader(config) {
|
|
78
|
+
const registriesByName = config.registries;
|
|
79
|
+
const maxRegistryTarballBytes = config.maxRegistryTarballBytes ?? DEFAULT_MAX_REGISTRY_TARBALL_BYTES;
|
|
80
|
+
if (!Number.isFinite(maxRegistryTarballBytes) ||
|
|
81
|
+
maxRegistryTarballBytes <= 0) {
|
|
82
|
+
throw new Error(`createToolLoader: maxRegistryTarballBytes must be a positive finite number; got ${String(maxRegistryTarballBytes)}`);
|
|
83
|
+
}
|
|
84
|
+
const registryFetchTimeoutMs = config.registryFetchTimeoutMs ?? DEFAULT_REGISTRY_FETCH_TIMEOUT_MS;
|
|
85
|
+
if (!Number.isFinite(registryFetchTimeoutMs) || registryFetchTimeoutMs <= 0) {
|
|
86
|
+
throw new Error(`createToolLoader: registryFetchTimeoutMs must be a positive finite number; got ${String(registryFetchTimeoutMs)}`);
|
|
87
|
+
}
|
|
88
|
+
const fetchTarball = config.fetchTarball ?? makeDefaultTarballFetcher();
|
|
89
|
+
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
|
+
async function loadTopLevel(entry, pkgDir) {
|
|
180
|
+
const pkgJsonPath = path.join(pkgDir, "package.json");
|
|
181
|
+
let pkgJsonRaw;
|
|
182
|
+
try {
|
|
183
|
+
pkgJsonRaw = await fs.readFile(pkgJsonPath, "utf8");
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
throw new ToolLoaderError({
|
|
187
|
+
category: "package.entry.invalid",
|
|
188
|
+
message: `package.json missing for ${entry.name}@${entry.version}: ${describeError(err)}`,
|
|
189
|
+
package: { name: entry.name, version: entry.version },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
let pkgJson;
|
|
193
|
+
try {
|
|
194
|
+
pkgJson = JSON.parse(pkgJsonRaw);
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
throw new ToolLoaderError({
|
|
198
|
+
category: "package.entry.invalid",
|
|
199
|
+
message: `malformed package.json in ${entry.name}@${entry.version}: ${describeError(err)}`,
|
|
200
|
+
package: { name: entry.name, version: entry.version },
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
const toolsRel = readInterchangeEntry(pkgJson, "tools");
|
|
204
|
+
if (toolsRel === null) {
|
|
205
|
+
throw new ToolLoaderError({
|
|
206
|
+
category: "package.entry.missing",
|
|
207
|
+
message: `${entry.name}@${entry.version} package.json has no "interchange.tools" field`,
|
|
208
|
+
package: { name: entry.name, version: entry.version },
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
const toolsMod = await importInterchangeEntry({
|
|
212
|
+
entry,
|
|
213
|
+
pkgDir,
|
|
214
|
+
entryRel: toolsRel,
|
|
215
|
+
field: "tools",
|
|
216
|
+
});
|
|
217
|
+
const factories = [];
|
|
218
|
+
const plugins = [];
|
|
219
|
+
for (const value of Object.values(toolsMod)) {
|
|
220
|
+
if (isAnnotatedPluginFactory(value)) {
|
|
221
|
+
plugins.push(value);
|
|
222
|
+
}
|
|
223
|
+
else if (isAnnotatedToolFactory(value)) {
|
|
224
|
+
factories.push(applyNamespacePrefix(value, {
|
|
225
|
+
name: entry.name,
|
|
226
|
+
version: entry.version,
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (factories.length === 0 && plugins.length === 0) {
|
|
231
|
+
throw new ToolLoaderError({
|
|
232
|
+
category: "package.entry.invalid",
|
|
233
|
+
message: `${entry.name}@${entry.version} interchange.tools entry exported no AnnotatedToolFactory or AnnotatedPluginFactory values`,
|
|
234
|
+
package: { name: entry.name, version: entry.version },
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
// Director walk: separate `package.json` field, separate dynamic
|
|
238
|
+
// import, separate structural validation. Absence is a no-op so a
|
|
239
|
+
// tools-only package stays valid; a directors-only package is not
|
|
240
|
+
// supported because the tools field's absence is already a hard
|
|
241
|
+
// error above. A package whose director-entry module exports
|
|
242
|
+
// nothing director-shaped is rejected the same way the tool entry
|
|
243
|
+
// would be.
|
|
244
|
+
const directors = [];
|
|
245
|
+
const directorsRel = readInterchangeEntry(pkgJson, "directors");
|
|
246
|
+
if (directorsRel !== null) {
|
|
247
|
+
const directorsMod = await importInterchangeEntry({
|
|
248
|
+
entry,
|
|
249
|
+
pkgDir,
|
|
250
|
+
entryRel: directorsRel,
|
|
251
|
+
field: "directors",
|
|
252
|
+
});
|
|
253
|
+
for (const value of Object.values(directorsMod)) {
|
|
254
|
+
if (isAnnotatedDirectorFactory(value)) {
|
|
255
|
+
directors.push(value);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (directors.length === 0) {
|
|
259
|
+
throw new ToolLoaderError({
|
|
260
|
+
category: "package.entry.invalid",
|
|
261
|
+
message: `${entry.name}@${entry.version} interchange.directors entry exported no AnnotatedDirectorFactory values`,
|
|
262
|
+
package: { name: entry.name, version: entry.version },
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
name: entry.name,
|
|
268
|
+
version: entry.version,
|
|
269
|
+
factories,
|
|
270
|
+
plugins,
|
|
271
|
+
directors,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Resolve `entryRel` against `pkgDir`, enforce path-safety
|
|
276
|
+
* (`..`-traversal, absolute-path, and node_modules symlink-graph
|
|
277
|
+
* escapes), and dynamic-import the result. Centralized so the
|
|
278
|
+
* `interchange.tools` and `interchange.directors` walkers share one
|
|
279
|
+
* containment surface.
|
|
280
|
+
*/
|
|
281
|
+
async function importInterchangeEntry(args) {
|
|
282
|
+
const { entry, pkgDir, entryRel, field } = args;
|
|
283
|
+
const entryAbs = path.resolve(pkgDir, entryRel);
|
|
284
|
+
// `entryRel` originates from the tarball's `package.json` and
|
|
285
|
+
// crosses the trust boundary into the sidecar process. `..` or an
|
|
286
|
+
// absolute path inside `entryRel` would let a malicious tarball
|
|
287
|
+
// import any file the sidecar process can read. Confine the
|
|
288
|
+
// resolved import target to the package's own extraction
|
|
289
|
+
// directory and reject anything that escapes.
|
|
290
|
+
//
|
|
291
|
+
// The string-level check rejects `..` and absolute paths inside
|
|
292
|
+
// `entryRel`. It is not enough on its own: the per-instance
|
|
293
|
+
// scratch tree contains a `node_modules/` symlink graph the
|
|
294
|
+
// loader builds to satisfy nested resolution, and an
|
|
295
|
+
// `interchange.*` entry that traverses that graph would
|
|
296
|
+
// string-contain inside `pkgDir` but resolve via realpath to
|
|
297
|
+
// another package's code (or anywhere else the symlink target
|
|
298
|
+
// points). Re-check containment against the realpath so a
|
|
299
|
+
// tarball cannot reach another package's bytes through its own
|
|
300
|
+
// package directory's symlinks.
|
|
301
|
+
//
|
|
302
|
+
// The containment check assumes POSIX-shaped path separators on
|
|
303
|
+
// disk — the sidecar runs on Linux and macOS only; Windows
|
|
304
|
+
// path-separator handling (drive letters, mixed `/` and `\\`,
|
|
305
|
+
// case-insensitive comparison) is out of scope.
|
|
306
|
+
const containmentRoot = pkgDir.endsWith(path.sep)
|
|
307
|
+
? pkgDir
|
|
308
|
+
: pkgDir + path.sep;
|
|
309
|
+
if (entryAbs !== pkgDir && !entryAbs.startsWith(containmentRoot)) {
|
|
310
|
+
throw new ToolLoaderError({
|
|
311
|
+
category: "package.entry.invalid",
|
|
312
|
+
message: `${entry.name}@${entry.version} interchange.${field} entry path ${JSON.stringify(entryRel)} escapes the package directory`,
|
|
313
|
+
package: { name: entry.name, version: entry.version },
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
// Realpath the entry so a `node_modules/` symlink traversal
|
|
317
|
+
// inside `entryRel` does not let a tarball point at another
|
|
318
|
+
// package's bytes. The package directory itself is resolved the
|
|
319
|
+
// same way so the comparison is realpath-vs-realpath rather than
|
|
320
|
+
// realpath-vs-as-declared (the per-instance scratch tree may
|
|
321
|
+
// itself live under a symlinked tmpdir, notably on macOS where
|
|
322
|
+
// `/tmp` is a symlink to `/private/tmp`).
|
|
323
|
+
let realPkgDir;
|
|
324
|
+
let realEntryAbs;
|
|
325
|
+
try {
|
|
326
|
+
realPkgDir = await fs.realpath(pkgDir);
|
|
327
|
+
realEntryAbs = await fs.realpath(entryAbs);
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
throw new ToolLoaderError({
|
|
331
|
+
category: "package.entry.invalid",
|
|
332
|
+
message: `${entry.name}@${entry.version} interchange.${field} entry path ${JSON.stringify(entryRel)} could not be resolved: ${describeError(err)}`,
|
|
333
|
+
package: { name: entry.name, version: entry.version },
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
const realContainmentRoot = realPkgDir.endsWith(path.sep)
|
|
337
|
+
? realPkgDir
|
|
338
|
+
: realPkgDir + path.sep;
|
|
339
|
+
if (realEntryAbs !== realPkgDir &&
|
|
340
|
+
!realEntryAbs.startsWith(realContainmentRoot)) {
|
|
341
|
+
throw new ToolLoaderError({
|
|
342
|
+
category: "package.entry.invalid",
|
|
343
|
+
message: `${entry.name}@${entry.version} interchange.${field} entry path ${JSON.stringify(entryRel)} escapes the package extraction directory via a symlink`,
|
|
344
|
+
package: { name: entry.name, version: entry.version },
|
|
345
|
+
});
|
|
346
|
+
}
|
|
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
|
|
355
|
+
// extracted for this apply.
|
|
356
|
+
const importUrl = `${pathToFileURL(entryAbs).href}?integrity=${encodeURIComponent(entry.integrity)}`;
|
|
357
|
+
let mod;
|
|
358
|
+
try {
|
|
359
|
+
mod = await importModule(importUrl);
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
throw new ToolLoaderError({
|
|
363
|
+
category: "package.entry.invalid",
|
|
364
|
+
message: `dynamic import of ${entry.name}@${entry.version} interchange.${field} failed: ${describeError(err)}`,
|
|
365
|
+
package: { name: entry.name, version: entry.version },
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
if (mod === null || typeof mod !== "object") {
|
|
369
|
+
throw new ToolLoaderError({
|
|
370
|
+
category: "package.entry.invalid",
|
|
371
|
+
message: `${entry.name}@${entry.version} interchange.${field} entry did not return an object`,
|
|
372
|
+
package: { name: entry.name, version: entry.version },
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
return mod;
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
async loadManifest(args) {
|
|
379
|
+
const filtered = args.manifest.entries.filter(passesPlatformFilter);
|
|
380
|
+
const storeDir = path.join(args.instanceScratchDir, "store");
|
|
381
|
+
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
|
+
}
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
function makeDefaultTarballFetcher() {
|
|
463
|
+
return async (entry, ctx) => {
|
|
464
|
+
if (entry.source.kind === "asset") {
|
|
465
|
+
// The mount lookup is guaranteed by `materialize`'s
|
|
466
|
+
// pre-fetch gate, but reassert here so the narrowing is
|
|
467
|
+
// visible to readers — the caller of fetchTarball has no
|
|
468
|
+
// structural guarantee it ran through that gate.
|
|
469
|
+
const mount = ctx.assetMounts.get(entry.source.assetId);
|
|
470
|
+
if (mount === undefined) {
|
|
471
|
+
throw new ToolLoaderError({
|
|
472
|
+
category: "asset.mount.missing",
|
|
473
|
+
message: `default fetcher reached without a mount for assetId "${entry.source.assetId}"`,
|
|
474
|
+
package: { name: entry.name, version: entry.version },
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
// Both `mount` and `entry.source.path` originate from the hub
|
|
478
|
+
// and cross the trust boundary into the sidecar process. A `..`
|
|
479
|
+
// segment in either would let a malicious manifest read any
|
|
480
|
+
// file the sidecar can open. Resolve the join and assert the
|
|
481
|
+
// result still sits under `assetRoot` so a traversal attempt
|
|
482
|
+
// surfaces as a structured manifest rejection rather than a
|
|
483
|
+
// silent arbitrary read.
|
|
484
|
+
//
|
|
485
|
+
// Reject absolute mount paths up front: `path.resolve` would
|
|
486
|
+
// discard the assetRoot prefix when handed an absolute segment,
|
|
487
|
+
// letting an absolute mount escape the containment check that
|
|
488
|
+
// follows. Defense-in-depth for the (today-trusted) hub-side
|
|
489
|
+
// mount producer.
|
|
490
|
+
if (path.isAbsolute(mount)) {
|
|
491
|
+
throw new ToolLoaderError({
|
|
492
|
+
category: "package.entry.invalid",
|
|
493
|
+
message: `assetMounts entry for ${entry.name}@${entry.version} is absolute (${JSON.stringify(mount)}); mounts must be assetRoot-relative`,
|
|
494
|
+
package: { name: entry.name, version: entry.version },
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
const mountAbs = path.resolve(ctx.assetRoot, mount);
|
|
498
|
+
const absPath = path.resolve(mountAbs, entry.source.path);
|
|
499
|
+
const mountContainmentRoot = mountAbs.endsWith(path.sep)
|
|
500
|
+
? mountAbs
|
|
501
|
+
: mountAbs + path.sep;
|
|
502
|
+
if (absPath !== mountAbs && !absPath.startsWith(mountContainmentRoot)) {
|
|
503
|
+
throw new ToolLoaderError({
|
|
504
|
+
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)`,
|
|
506
|
+
package: { name: entry.name, version: entry.version },
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
try {
|
|
510
|
+
return await fs.readFile(absPath);
|
|
511
|
+
}
|
|
512
|
+
catch (err) {
|
|
513
|
+
throw new ToolLoaderError({
|
|
514
|
+
category: "tarball.missing",
|
|
515
|
+
message: `asset-stored tarball for ${entry.name}@${entry.version} not present at ${absPath}: ${describeError(err)}`,
|
|
516
|
+
package: { name: entry.name, version: entry.version },
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const registry = registriesByName.get(entry.source.registry);
|
|
521
|
+
if (registry === undefined) {
|
|
522
|
+
throw new ToolLoaderError({
|
|
523
|
+
category: "registry.unknown",
|
|
524
|
+
message: `manifest references registry "${entry.source.registry}" which is not in the sidecar config`,
|
|
525
|
+
package: { name: entry.name, version: entry.version },
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
const tarballUrl = entry.tarballUrl ??
|
|
529
|
+
defaultTarballUrl(registry.url, entry.name, entry.version);
|
|
530
|
+
// Bound the whole fetch -- request and streamed body read -- so a
|
|
531
|
+
// stalled registry cannot block the awaiting deploy forever.
|
|
532
|
+
// npm-registry-fetch honors the signal for the request phase;
|
|
533
|
+
// readResponseWithLimit honors it for the manual body read. The
|
|
534
|
+
// timer spans both phases and is cleared only once the read settles.
|
|
535
|
+
const controller = new AbortController();
|
|
536
|
+
const timer = setTimeout(() => {
|
|
537
|
+
controller.abort();
|
|
538
|
+
}, registryFetchTimeoutMs);
|
|
539
|
+
try {
|
|
540
|
+
const res = await npmRegistryFetch(tarballUrl, {
|
|
541
|
+
...buildRegistryFetchOpts(registry),
|
|
542
|
+
signal: controller.signal,
|
|
543
|
+
});
|
|
544
|
+
if (res.status === 401 || res.status === 403) {
|
|
545
|
+
throw new ToolLoaderError({
|
|
546
|
+
category: "registry.auth.failed",
|
|
547
|
+
message: `registry "${entry.source.registry}" rejected credentials for ${entry.name}@${entry.version} (HTTP ${String(res.status)})`,
|
|
548
|
+
package: { name: entry.name, version: entry.version },
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
if (!res.ok) {
|
|
552
|
+
throw new ToolLoaderError({
|
|
553
|
+
category: "registry.fetch.failed",
|
|
554
|
+
message: `registry "${entry.source.registry}" returned HTTP ${String(res.status)} fetching ${entry.name}@${entry.version}`,
|
|
555
|
+
package: { name: entry.name, version: entry.version },
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
return await readResponseWithLimit(res, maxRegistryTarballBytes, {
|
|
559
|
+
registry: entry.source.registry,
|
|
560
|
+
name: entry.name,
|
|
561
|
+
version: entry.version,
|
|
562
|
+
}, controller.signal);
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
if (err instanceof ToolLoaderError)
|
|
566
|
+
throw err;
|
|
567
|
+
if (controller.signal.aborted) {
|
|
568
|
+
throw new ToolLoaderError({
|
|
569
|
+
category: "registry.fetch.failed",
|
|
570
|
+
message: `registry "${entry.source.registry}" fetch for ${entry.name}@${entry.version} exceeded the ${String(registryFetchTimeoutMs)}ms timeout`,
|
|
571
|
+
package: { name: entry.name, version: entry.version },
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
throw new ToolLoaderError({
|
|
575
|
+
category: "registry.fetch.failed",
|
|
576
|
+
message: `registry "${entry.source.registry}" fetch failed for ${entry.name}@${entry.version}: ${describeError(err)}`,
|
|
577
|
+
package: { name: entry.name, version: entry.version },
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
finally {
|
|
581
|
+
clearTimeout(timer);
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
}
|
|
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
|
+
function readInterchangeEntry(pkgJson, field) {
|
|
1189
|
+
if (pkgJson === null || typeof pkgJson !== "object")
|
|
1190
|
+
return null;
|
|
1191
|
+
if (!("interchange" in pkgJson))
|
|
1192
|
+
return null;
|
|
1193
|
+
const interchange = pkgJson.interchange;
|
|
1194
|
+
if (interchange === null || typeof interchange !== "object")
|
|
1195
|
+
return null;
|
|
1196
|
+
// Branch on the field rather than dynamic index-access so each path
|
|
1197
|
+
// narrows through a single-property shape — matches the pattern the
|
|
1198
|
+
// surrounding helpers use to inspect package.json without widening
|
|
1199
|
+
// through a `Record<string, unknown>` assertion.
|
|
1200
|
+
let value;
|
|
1201
|
+
if (field === "tools") {
|
|
1202
|
+
if (!("tools" in interchange))
|
|
1203
|
+
return null;
|
|
1204
|
+
value = interchange.tools;
|
|
1205
|
+
}
|
|
1206
|
+
else {
|
|
1207
|
+
if (!("directors" in interchange))
|
|
1208
|
+
return null;
|
|
1209
|
+
value = interchange.directors;
|
|
1210
|
+
}
|
|
1211
|
+
if (typeof value !== "string")
|
|
1212
|
+
return null;
|
|
1213
|
+
return value;
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Wrap a factory so the bundle it returns has its tool definitions
|
|
1217
|
+
* prefixed by the bundle's `id`. Package authors write bare tool
|
|
1218
|
+
* names; the model and the grant evaluator see
|
|
1219
|
+
* `<bundle.id>:<def.name>`. Audit provenance recorded against the
|
|
1220
|
+
* bundle id stays correct because the prefix is the bundle id.
|
|
1221
|
+
*/
|
|
1222
|
+
function applyNamespacePrefix(factory, pkg) {
|
|
1223
|
+
const prefix = `${factory.id}:`;
|
|
1224
|
+
// Freeze the wrapper AND the requires array it points at so
|
|
1225
|
+
// downstream consumers cannot mutate the `id`/`requires` metadata
|
|
1226
|
+
// the namespacing depends on. A mutated `id` would skew audit-trail
|
|
1227
|
+
// provenance away from the bundle the loader actually constructed;
|
|
1228
|
+
// a mutated `requires` would let a wrapper accumulate unintended
|
|
1229
|
+
// capability requests over its lifetime. Freezing the wrapper alone
|
|
1230
|
+
// blocks reassigning `wrapped.requires`; freezing the array (after
|
|
1231
|
+
// copying so the source factory's own `requires` is not also frozen
|
|
1232
|
+
// as a side-effect) blocks the `push` / `splice` mutations that
|
|
1233
|
+
// would otherwise grow the surface in place.
|
|
1234
|
+
const frozenRequires = Object.freeze([...factory.requires]);
|
|
1235
|
+
const wrapped = Object.freeze(Object.assign((env) => {
|
|
1236
|
+
const bundle = factory(env);
|
|
1237
|
+
// A definition whose raw name already starts with the bundle's
|
|
1238
|
+
// prefix indicates the package author either double-prefixed or
|
|
1239
|
+
// happened to choose a name that collides with the prefix shape.
|
|
1240
|
+
// Either way silently passing it through would yield surprising
|
|
1241
|
+
// results in the audit trail and grant evaluator — surface it.
|
|
1242
|
+
// Build the prefixed-definition list and the prefixed→raw name
|
|
1243
|
+
// map in a single pass so the name map is provably aligned with
|
|
1244
|
+
// the array TypeScript already proved was the same length.
|
|
1245
|
+
// Shape-check `bundle.definitions` before iterating: a factory
|
|
1246
|
+
// that returns `definitions: null` (or omits the field) would
|
|
1247
|
+
// otherwise yield a bare TypeError that the apply pipeline
|
|
1248
|
+
// surfaces as `factory.construct.failed` instead of the more
|
|
1249
|
+
// accurate `package.entry.invalid` (the bundle's shape is
|
|
1250
|
+
// wrong, not its construction).
|
|
1251
|
+
if (!Array.isArray(bundle.definitions)) {
|
|
1252
|
+
throw new ToolLoaderError({
|
|
1253
|
+
category: "package.entry.invalid",
|
|
1254
|
+
message: `bundle ${factory.id} returned a non-array \`definitions\` field; AnnotatedToolFactory bundles must produce an array of tool definitions`,
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
// Surface intra-bundle name collisions BEFORE prefixing so two
|
|
1258
|
+
// definitions named `search` would not silently collapse to a
|
|
1259
|
+
// single `<id>:search` entry in the name map.
|
|
1260
|
+
//
|
|
1261
|
+
// Timing note: this check runs at first factory invocation
|
|
1262
|
+
// (agent construction at sidecar boot), NOT at apply time. The
|
|
1263
|
+
// loader cannot read `bundle.definitions` without invoking the
|
|
1264
|
+
// factory, and the `BaseEnv` the factory needs is constructed
|
|
1265
|
+
// by the sidecar harness only after the apply commits. As a
|
|
1266
|
+
// consequence, an intra-bundle duplicate surfaces on the
|
|
1267
|
+
// runtime construct-failure channel rather than as an
|
|
1268
|
+
// apply-error frame. The cross-bundle case (in atomic-apply.ts)
|
|
1269
|
+
// catches the same category at apply time because it operates
|
|
1270
|
+
// on `factory.id` metadata, which is available without invoking
|
|
1271
|
+
// the factory. See the `tool.name.duplicate` category docstring
|
|
1272
|
+
// on `DeployApplyErrorCategory` for the operator-facing
|
|
1273
|
+
// contract this split honors.
|
|
1274
|
+
const rawSeen = new Set();
|
|
1275
|
+
for (const def of bundle.definitions) {
|
|
1276
|
+
if (rawSeen.has(def.name)) {
|
|
1277
|
+
throw new ToolLoaderError({
|
|
1278
|
+
category: "tool.name.duplicate",
|
|
1279
|
+
message: `bundle ${factory.id} exports two tool definitions named ${JSON.stringify(def.name)}; tool names must be unique within a bundle`,
|
|
1280
|
+
package: pkg,
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
rawSeen.add(def.name);
|
|
1284
|
+
}
|
|
1285
|
+
const nameMap = new Map();
|
|
1286
|
+
const prefixed = bundle.definitions.map((def) => {
|
|
1287
|
+
if (def.name.startsWith(prefix)) {
|
|
1288
|
+
throw new ToolLoaderError({
|
|
1289
|
+
category: "package.entry.invalid",
|
|
1290
|
+
message: `tool definition name ${JSON.stringify(def.name)} already begins with bundle prefix ${JSON.stringify(prefix)}; raw definition names must not include the bundle id`,
|
|
1291
|
+
package: pkg,
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
const prefixedName = `${prefix}${def.name}`;
|
|
1295
|
+
nameMap.set(prefixedName, def.name);
|
|
1296
|
+
return { ...def, name: prefixedName };
|
|
1297
|
+
});
|
|
1298
|
+
return {
|
|
1299
|
+
definitions: prefixed,
|
|
1300
|
+
run: (call, signal) => {
|
|
1301
|
+
const original = nameMap.get(call.name);
|
|
1302
|
+
// nameMap holds every prefixed form this bundle minted; a
|
|
1303
|
+
// miss means `call.name` is not one of those prefixed
|
|
1304
|
+
// names. Forwarding the unprefixed name into the inner
|
|
1305
|
+
// bundle would bypass the namespacing the wrapper exists
|
|
1306
|
+
// to enforce — an unprefixed name that happened to match
|
|
1307
|
+
// the bundle's raw tool name would run the tool — so
|
|
1308
|
+
// return a structured unknown-tool error directly.
|
|
1309
|
+
if (original === undefined) {
|
|
1310
|
+
return Promise.resolve({
|
|
1311
|
+
callId: call.id,
|
|
1312
|
+
content: `unknown tool: ${call.name}`,
|
|
1313
|
+
isError: true,
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
const inner = { ...call, name: original };
|
|
1317
|
+
return bundle.run(inner, signal);
|
|
1318
|
+
},
|
|
1319
|
+
...(bundle.dispose !== undefined ? { dispose: bundle.dispose } : {}),
|
|
1320
|
+
};
|
|
1321
|
+
}, { id: factory.id, requires: frozenRequires }));
|
|
1322
|
+
return wrapped;
|
|
1323
|
+
}
|
|
1324
|
+
function isAnnotatedToolFactory(value) {
|
|
1325
|
+
if (typeof value !== "function")
|
|
1326
|
+
return false;
|
|
1327
|
+
// Plugin factories carry the same id/requires duck-shape — explicitly
|
|
1328
|
+
// reject anything bearing the plugin marker so the predicate stands
|
|
1329
|
+
// alone instead of relying on the loader's ordering at the call site.
|
|
1330
|
+
if (isAnnotatedPluginFactory(value))
|
|
1331
|
+
return false;
|
|
1332
|
+
if (!("id" in value) || !("requires" in value))
|
|
1333
|
+
return false;
|
|
1334
|
+
// Director factories carry id/requires plus a callable `configSchema`;
|
|
1335
|
+
// without this guard a director placed in `interchange.tools` would be
|
|
1336
|
+
// silently classified as a tool and namespace-prefixed. Mirrors the
|
|
1337
|
+
// discriminator `isAnnotatedDirectorFactory` uses against tool shapes.
|
|
1338
|
+
if ("configSchema" in value) {
|
|
1339
|
+
const configSchema = value.configSchema;
|
|
1340
|
+
if (typeof configSchema === "function")
|
|
1341
|
+
return false;
|
|
1342
|
+
}
|
|
1343
|
+
const id = value.id;
|
|
1344
|
+
const requires = value.requires;
|
|
1345
|
+
if (typeof id !== "string")
|
|
1346
|
+
return false;
|
|
1347
|
+
if (!Array.isArray(requires))
|
|
1348
|
+
return false;
|
|
1349
|
+
return requires.every((r) => typeof r === "string");
|
|
1350
|
+
}
|
|
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
|
+
}
|