@intx/tool-packaging 0.2.2 → 0.3.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/README.md CHANGED
@@ -19,23 +19,28 @@ transitives are reachable from inside it.
19
19
  Concretely the loader:
20
20
 
21
21
  1. Walks every manifest entry and validates that each one is
22
- registry-chain-consistent: every entry the manifest says lives at a
23
- registry resolves end-to-end against that registry, and every entry
24
- that lives in an asset resolves against the asset's `assetMounts`
25
- map.
22
+ source-consistent: every entry the manifest says lives at a
23
+ registry resolves end-to-end against that registry; an asset entry
24
+ whose `package.format` is `"tarball"` resolves against the asset's
25
+ `assetMounts` map; an asset entry whose `package.format` is
26
+ `"source"` (a git subtree, used by source-format workflow
27
+ definitions) resolves against the separate `gitDirs` map.
26
28
  2. Materializes every entry into the content-addressable tarball
27
29
  cache: bytes pulled from the entry's source on a miss are verified
28
30
  through `cache.put`, and the bytes are then unpacked once via
29
31
  `cache.extractTarball`. A single sha512 produces a single
30
- extraction shared across instances.
32
+ extraction shared across instances. A `"source"` asset entry is the
33
+ exception: it has no tarball and no SRI, so it bypasses the cache
34
+ entirely — its subtree is checked out in place from the indexed
35
+ `gitDir` (`materializeGitEntry`) and verified against the frozen git
36
+ `treeOid` rather than an sha512.
31
37
  3. Lays out each entry under `<scratch>/store/<name>/<version>/` by
32
- hardlinking the file tree from the cache extraction. Each layout
33
- directory then gets `node_modules/<dep>` symlinked into it pointing
34
- at the sibling `store/<dep>/<depVersion>/` chosen for that
35
- requirer. Hardlinks keep on-disk usage to one copy per integrity
36
- per filesystem; symlinks at the `node_modules/` boundary let
37
- Node's realpath-based resolver walk to the dep's own layout dir
38
- (which has its own `node_modules/`).
38
+ copying the file tree from the cache extraction or, for a
39
+ `"source"` entry, from its git checkout. Each layout directory then
40
+ gets `node_modules/<dep>` symlinked into it pointing at the sibling
41
+ `store/<dep>/<depVersion>/` chosen for that requirer. Symlinks at
42
+ the `node_modules/` boundary let Node's realpath-based resolver walk
43
+ to the dep's own layout dir (which has its own `node_modules/`).
39
44
  4. Dynamically imports each top-level entry's `interchange.tools`
40
45
  module and collects the `AnnotatedToolFactory` /
41
46
  `AnnotatedPluginFactory` values it exports.
@@ -12,6 +12,11 @@ export interface ApplyAtomicArgs {
12
12
  * Forwarded verbatim to `ToolLoader.loadManifest`.
13
13
  */
14
14
  readonly assetMounts: ReadonlyMap<string, string>;
15
+ /**
16
+ * Maps a source-format asset entry's `source.assetId` to an absolute
17
+ * indexed git directory. Forwarded verbatim to `ToolLoader.loadManifest`.
18
+ */
19
+ readonly gitDirs: ReadonlyMap<string, string>;
15
20
  readonly attemptId: string;
16
21
  /**
17
22
  * The deploy id the instance is currently running. Retained on disk
@@ -127,6 +127,7 @@ export async function applyAtomic(args) {
127
127
  instanceScratchDir: deployDir,
128
128
  assetRoot: args.assetRoot,
129
129
  assetMounts: args.assetMounts,
130
+ gitDirs: args.gitDirs,
130
131
  });
131
132
  }
132
133
  catch (err) {
package/dist/cache.d.ts CHANGED
@@ -18,7 +18,7 @@ export interface TarballCache {
18
18
  * cannot reuse the on-disk extraction. The extraction directory's
19
19
  * physical reclaim is deferred until every in-flight reader released
20
20
  * by `extractTarball` has dropped its reference, so an evict that
21
- * races a concurrent `hardlinkTree` walk of the same extraction does
21
+ * races a concurrent `copyTree` walk of the same extraction does
22
22
  * not pull the tree out from under the walk and surface as ENOENT.
23
23
  *
24
24
  * Until every reader releases, the on-disk extraction is left in
package/dist/cache.js CHANGED
@@ -11,10 +11,11 @@
11
11
  // one entry (used after extraction discovers a corrupted file on disk).
12
12
  //
13
13
  // `maxBytes` covers both the tarball bytes and the size of the
14
- // extracted directory tree the loader hardlinks from. The extraction
14
+ // extracted directory tree the loader copies from. The extraction
15
15
  // tree dominates disk usage in practice (tarballs are gzip-compressed;
16
- // the unpacked tree is multiples larger), so the cap reflects the
17
- // caller-visible cost of holding an entry.
16
+ // the unpacked tree is multiples larger), so the cap reflects the cost
17
+ // of holding one cache entry but excludes the loader's per-instance
18
+ // copies.
18
19
  //
19
20
  // Integrity is verified on store and re-verified inside
20
21
  // `extractTarball` before unpacking. `get` returns bytes without
@@ -24,7 +25,7 @@
24
25
  //
25
26
  // `extractTarball` is the second face of the same on-disk store: it
26
27
  // unpacks the tarball into a sibling `extracted/` directory keyed by
27
- // the same integrity, so the per-instance loader can symlink into a
28
+ // the same integrity, so the per-instance loader can copy from a
28
29
  // stable, deduplicated extraction without re-doing the tar work on
29
30
  // every apply. The unpack is gated by a per-integrity tmp-and-rename
30
31
  // dance with the same crash-safety properties as `put`.
@@ -82,7 +83,7 @@ export function createTarballCache(config) {
82
83
  // defers physical removal of the extraction tree until the count
83
84
  // reaches zero. This decouples mark-as-bad (atomic, prompt) from
84
85
  // physical reclaim (deferred, safe) so an integrity-mismatch evict
85
- // that races a concurrent `hardlinkTree` walk against the same
86
+ // that races a concurrent `copyTree` walk against the same
86
87
  // extraction does not pull the tree out from under the walk and
87
88
  // surface as ENOENT mid-readdir.
88
89
  //
@@ -238,21 +239,15 @@ export function createTarballCache(config) {
238
239
  }
239
240
  /**
240
241
  * Sum the on-disk size of every regular file under `dir` recursively.
241
- * Returns 0 when `dir` does not exist. Hardlinks are counted once
242
- * per inode would be ideal, but `node:fs` does not expose inode-
243
- * dedup walking without a manual ino map; the loader hardlinks
244
- * extraction trees into per-instance store dirs, so the extraction
245
- * tree itself holds one link per file and `stat.size` per entry is
246
- * the right number to charge to this cache entry.
242
+ * Returns 0 when `dir` does not exist. Each regular file's
243
+ * `stat.size` is charged to this cache entry once.
247
244
  *
248
245
  * ACCOUNTING vs. DISK USAGE: `maxBytes` bounds the sum reported by
249
246
  * this walker, not the actual disk consumption of the cache plus
250
- * its downstream hardlink consumers. The loader's per-instance
251
- * store dirs share inodes with `cache/extracted/`; evicting an
252
- * entry here drops the cache's reference but the underlying file
253
- * survives as long as any per-instance dir still points at it.
254
- * The cap is a steady-state ceiling on the cache tree's own
255
- * accounting, not a disk-usage limit. Concurrent vanishes during
247
+ * its downstream consumers. The loader's per-instance store dirs
248
+ * contain independent copies that are outside this cache tree, so
249
+ * the cap is a steady-state ceiling on the cache's own accounting,
250
+ * not a sidecar-wide disk-usage limit. Concurrent vanishes during
256
251
  * the walk are silently dropped via the inner `lstat` try/catch
257
252
  * below; under the single-process contract this is rare, but the
258
253
  * returned `total` reports the cap-relevant sum within one sweep's
@@ -262,7 +257,7 @@ export function createTarballCache(config) {
262
257
  * `lstat` here returns the link itself rather than its target, and
263
258
  * `dirent.isSymbolicLink()` is the entry-walk equivalent. An npm
264
259
  * tarball that ships symlinks is preserved verbatim by the loader's
265
- * hardlink-tree pass; charging the link's own size (zero in our
260
+ * copy-tree pass; charging the link's own size (zero in our
266
261
  * accounting) avoids both symlink-loop divergence and double-counting
267
262
  * the target through whatever path also names it directly.
268
263
  *
@@ -464,7 +459,7 @@ export function createTarballCache(config) {
464
459
  throw err;
465
460
  }
466
461
  // The extraction is derived from the tarball bytes and is
467
- // useless once the tarball is gone. If a hardlinkTree walk is
462
+ // useless once the tarball is gone. If a copyTree walk is
468
463
  // in-flight for the same integrity, removing the tree now would
469
464
  // surface as ENOENT mid-walk; defer the physical reclaim until
470
465
  // every outstanding `release` from `extractTarball` has fired.
@@ -0,0 +1,19 @@
1
+ import type { ToolPackageAssetSourceTree } from "@intx/types/tool-packages";
2
+ /**
3
+ * Read the entry's pinned subtree from `gitDir`, verify it against the frozen
4
+ * `treeOid`, and write it into a fresh scratch directory under
5
+ * `instanceScratchDir`. Returns the same `{ dir, release }` shape the tarball
6
+ * path returns; `release` is a no-op because there is no content-addressed
7
+ * cache handle to hold — the scratch dir lives under the deploy directory the
8
+ * caller reclaims.
9
+ */
10
+ export declare function materializeGitEntry(args: {
11
+ tree: ToolPackageAssetSourceTree;
12
+ name: string;
13
+ version: string;
14
+ gitDir: string;
15
+ instanceScratchDir: string;
16
+ }): Promise<{
17
+ dir: string;
18
+ release: () => void;
19
+ }>;
@@ -0,0 +1,77 @@
1
+ // Materialize a source-format asset closure entry (`kind:"asset"` with
2
+ // `package.format:"source"`) from an indexed pack.
3
+ //
4
+ // A source-format entry's files come from a subtree of a hub `workflow` git
5
+ // asset at a pinned commit, not a tarball. The pack is indexed once at
6
+ // checkout time (the caller supplies the resulting `gitDir`); this reads the
7
+ // pinned subtree straight from those authenticated objects, verifies its git
8
+ // tree oid against the frozen `treeOid`, and writes the subtree into a scratch
9
+ // directory the store layout then copies from. The tarball cache is bypassed:
10
+ // `treeOid` is a git oid, not an SRI.
11
+ import { promises as fs } from "node:fs";
12
+ import path from "node:path";
13
+ import git from "isomorphic-git";
14
+ import { getLogger } from "@intx/log";
15
+ import { DEFAULT_PACK_MATERIALIZATION_LIMITS, writeTreeToDisk, } from "@intx/storage-isogit/node";
16
+ import { ToolLoaderError, describeError } from "./loader-internal.js";
17
+ const logger = getLogger(["sidecar", "tool-packaging", "git-materialize"]);
18
+ /**
19
+ * Read the entry's pinned subtree from `gitDir`, verify it against the frozen
20
+ * `treeOid`, and write it into a fresh scratch directory under
21
+ * `instanceScratchDir`. Returns the same `{ dir, release }` shape the tarball
22
+ * path returns; `release` is a no-op because there is no content-addressed
23
+ * cache handle to hold — the scratch dir lives under the deploy directory the
24
+ * caller reclaims.
25
+ */
26
+ export async function materializeGitEntry(args) {
27
+ const { tree: source, name, version, gitDir, instanceScratchDir } = args;
28
+ // `readTree` peels the commit to its tree; `filepath` then navigates to the
29
+ // pinned member. `packageDir === "."` selects the repo root, i.e. the
30
+ // commit's own tree, so it takes no `filepath`.
31
+ let subtreeOid;
32
+ try {
33
+ const { oid } = await git.readTree({
34
+ fs,
35
+ dir: gitDir,
36
+ oid: source.commitSha,
37
+ ...(source.packageDir === "." ? {} : { filepath: source.packageDir }),
38
+ });
39
+ subtreeOid = oid;
40
+ }
41
+ catch (err) {
42
+ throw new ToolLoaderError({
43
+ category: "git.materialization.failed",
44
+ message: `git subtree ${JSON.stringify(source.packageDir)} for ${name}@${version} could not be read at ${source.commitSha}: ${describeError(err)}`,
45
+ package: { name, version },
46
+ });
47
+ }
48
+ // The frozen `treeOid` is the content identity. The hub read it from the
49
+ // same git objects, so a mismatch means the delivered pack diverges from
50
+ // what was frozen; fail loud rather than materialize unverified bytes.
51
+ if (subtreeOid !== source.treeOid) {
52
+ throw new ToolLoaderError({
53
+ category: "git.materialization.failed",
54
+ message: `git subtree ${JSON.stringify(source.packageDir)} at ${source.commitSha} for ${name}@${version} hashed to tree ${subtreeOid}, not the frozen ${source.treeOid}`,
55
+ package: { name, version },
56
+ });
57
+ }
58
+ const dir = await fs.mkdtemp(path.join(instanceScratchDir, "git-"));
59
+ try {
60
+ await writeTreeToDisk(gitDir, dir, subtreeOid, DEFAULT_PACK_MATERIALIZATION_LIMITS);
61
+ }
62
+ catch (err) {
63
+ // Best-effort cleanup of the partial scratch dir; log a secondary rm
64
+ // failure so it does not silently mask state. The primary error is still
65
+ // thrown. Mirrors the cleanup logging in `applyAssetPack` and the sidecar's
66
+ // source-asset delivery.
67
+ await fs.rm(dir, { recursive: true, force: true }).catch((rmErr) => {
68
+ logger.warn `git subtree scratch cleanup failed at ${dir}: ${describeError(rmErr)}`;
69
+ });
70
+ throw new ToolLoaderError({
71
+ category: "git.materialization.failed",
72
+ message: `writing git subtree for ${name}@${version} failed: ${describeError(err)}`,
73
+ package: { name, version },
74
+ });
75
+ }
76
+ return { dir, release: () => undefined };
77
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { type ClosureResolver, type ClosureResolverConfig, type MaterializedRef, type Packument, type PackumentFetcher, type PackumentVersion, type PeerDependencyViolation, type RegistryConfig, type RegistrySource, type ScopeRoute, AssetRegistrySource, HttpRegistrySource, ManifestInvalidError, createClosureResolver, parsePin, } from "./resolver.js";
2
2
  export { type TarballCache, type TarballCacheConfig, TarballIntegrityMismatchError, createTarballCache, } from "./cache.js";
3
- export { type HostPlatform, type LoadManifestArgs, type LoadedDirectorFactory, type LoadedToolFactory, type LoadedToolPackage, type LoaderConfig, type TarballFetcher, type ToolLoader, ToolLoaderError, createToolLoader, } from "./loader.js";
3
+ export { type HostPlatform, type LoadManifestArgs, type LoadedDirectorFactory, type LoadedToolFactory, type LoadedToolPackage, type LoaderConfig, type MaterializeClosureArgs, type MaterializeClosureResult, type TarballFetcher, type ToolLoader, ToolLoaderError, createToolLoader, materializeClosure, storeEntryDir, } from "./loader.js";
4
4
  export { type ApplyAtomicArgs, type ApplyAtomicFailure, type ApplyAtomicResult, type ApplyAtomicSuccess, applyAtomic, } from "./atomic-apply.js";
5
5
  export { type ExtractPackageJSONOutcome, extractTarballPackageJSON, } from "./package-json-extract.js";
package/dist/index.js CHANGED
@@ -25,6 +25,6 @@
25
25
  // `ApplyAtomicFailure` the caller surfaces as a rejected apply.
26
26
  export { AssetRegistrySource, HttpRegistrySource, ManifestInvalidError, createClosureResolver, parsePin, } from "./resolver.js";
27
27
  export { TarballIntegrityMismatchError, createTarballCache, } from "./cache.js";
28
- export { ToolLoaderError, createToolLoader, } from "./loader.js";
28
+ export { ToolLoaderError, createToolLoader, materializeClosure, storeEntryDir, } from "./loader.js";
29
29
  export { applyAtomic, } from "./atomic-apply.js";
30
30
  export { extractTarballPackageJSON, } from "./package-json-extract.js";
@@ -0,0 +1,47 @@
1
+ import type { DeployApplyErrorCategory } from "@intx/types/sidecar";
2
+ /**
3
+ * Maximum bytes a single registry tarball fetch will read before aborting. The
4
+ * cap bounds the memory a malicious or misconfigured registry can force a
5
+ * deploy to buffer while the atomic-apply mirror replays it.
6
+ */
7
+ export declare const DEFAULT_MAX_REGISTRY_TARBALL_BYTES: number;
8
+ /**
9
+ * Default deadline for a single HTTP-registry tarball fetch, covering both the
10
+ * request and the streamed body read. `readResponseWithLimit` consumes the body
11
+ * through a manual reader loop, so the byte cap bounds size but nothing bounds
12
+ * time: a registry that accepts the connection and then stalls mid-stream would
13
+ * block the fetch -- and the deploy's tool materialization awaiting it --
14
+ * indefinitely. The deadline is generous so a legitimately large tarball on a
15
+ * slow link still completes within it. Callers that need a different bound pass
16
+ * `registryFetchTimeoutMs` to `createToolLoader`.
17
+ */
18
+ export declare const DEFAULT_REGISTRY_FETCH_TIMEOUT_MS: number;
19
+ /**
20
+ * A tool-loader failure carrying the atomic-apply error category (and, when the
21
+ * failure is attributable to a specific package, its name and version) so the
22
+ * apply layer can map every loader failure onto a `DeployApplyErrorCategory`.
23
+ */
24
+ export declare class ToolLoaderError extends Error {
25
+ readonly category: DeployApplyErrorCategory;
26
+ readonly package: {
27
+ readonly name: string;
28
+ readonly version: string;
29
+ } | undefined;
30
+ constructor(opts: {
31
+ category: DeployApplyErrorCategory;
32
+ message: string;
33
+ package?: {
34
+ name: string;
35
+ version: string;
36
+ };
37
+ });
38
+ }
39
+ /**
40
+ * Whether a `platform`/`os`/`cpu`-style allowlist matches `host`. A list with a
41
+ * `!`-negated entry matches everything except the negated hosts; an unnegated
42
+ * list matches only its members.
43
+ */
44
+ export declare function platformListMatches(entries: readonly string[], host: string): boolean;
45
+ export declare function isEEXIST(err: unknown): boolean;
46
+ export declare function isENOENT(err: unknown): boolean;
47
+ export declare function describeError(err: unknown): string;
@@ -0,0 +1,66 @@
1
+ // Shared internals of the tool-package loader: the failure type, the fetch
2
+ // byte/time caps, and the small cross-cutting helpers. Split out so the
3
+ // registry-fetch and store-layout modules and `loader.ts` itself can all depend
4
+ // on them without an import cycle. `loader.ts` re-exports the public members
5
+ // (`ToolLoaderError`, the `DEFAULT_*` caps) so existing consumers are unaffected.
6
+ /**
7
+ * Maximum bytes a single registry tarball fetch will read before aborting. The
8
+ * cap bounds the memory a malicious or misconfigured registry can force a
9
+ * deploy to buffer while the atomic-apply mirror replays it.
10
+ */
11
+ export const DEFAULT_MAX_REGISTRY_TARBALL_BYTES = 10 * 1024 * 1024;
12
+ /**
13
+ * Default deadline for a single HTTP-registry tarball fetch, covering both the
14
+ * request and the streamed body read. `readResponseWithLimit` consumes the body
15
+ * through a manual reader loop, so the byte cap bounds size but nothing bounds
16
+ * time: a registry that accepts the connection and then stalls mid-stream would
17
+ * block the fetch -- and the deploy's tool materialization awaiting it --
18
+ * indefinitely. The deadline is generous so a legitimately large tarball on a
19
+ * slow link still completes within it. Callers that need a different bound pass
20
+ * `registryFetchTimeoutMs` to `createToolLoader`.
21
+ */
22
+ export const DEFAULT_REGISTRY_FETCH_TIMEOUT_MS = 120 * 1000;
23
+ /**
24
+ * A tool-loader failure carrying the atomic-apply error category (and, when the
25
+ * failure is attributable to a specific package, its name and version) so the
26
+ * apply layer can map every loader failure onto a `DeployApplyErrorCategory`.
27
+ */
28
+ export class ToolLoaderError extends Error {
29
+ category;
30
+ package;
31
+ constructor(opts) {
32
+ super(opts.message);
33
+ this.name = "ToolLoaderError";
34
+ this.category = opts.category;
35
+ this.package = opts.package;
36
+ }
37
+ }
38
+ /**
39
+ * Whether a `platform`/`os`/`cpu`-style allowlist matches `host`. A list with a
40
+ * `!`-negated entry matches everything except the negated hosts; an unnegated
41
+ * list matches only its members.
42
+ */
43
+ export function platformListMatches(entries, host) {
44
+ const hasNegation = entries.some((e) => e.startsWith("!"));
45
+ if (hasNegation) {
46
+ return !entries.includes(`!${host}`);
47
+ }
48
+ return entries.includes(host);
49
+ }
50
+ export function isEEXIST(err) {
51
+ if (err === null || typeof err !== "object")
52
+ return false;
53
+ if (!("code" in err))
54
+ return false;
55
+ return err.code === "EEXIST";
56
+ }
57
+ export function isENOENT(err) {
58
+ if (err === null || typeof err !== "object")
59
+ return false;
60
+ if (!("code" in err))
61
+ return false;
62
+ return err.code === "ENOENT";
63
+ }
64
+ export function describeError(err) {
65
+ return err instanceof Error ? err.message : String(err);
66
+ }
package/dist/loader.d.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  import type { AnnotatedDirectorFactory, AnnotatedPluginFactory, AnnotatedToolFactory, BaseEnv } from "@intx/agent";
2
- import type { DeployApplyErrorCategory } from "@intx/types/sidecar";
2
+ import type { ToolCredentialDeclaration } from "@intx/types/package-json";
3
3
  import type { ToolPackageManifest, ToolPackageManifestEntry } from "@intx/types/tool-packages";
4
4
  import type { TarballCache } from "./cache.js";
5
+ import { DEFAULT_MAX_REGISTRY_TARBALL_BYTES, DEFAULT_REGISTRY_FETCH_TIMEOUT_MS, ToolLoaderError } from "./loader-internal.js";
6
+ import { buildRegistryFetchOpts, readResponseWithLimit } from "./registry-fetch.js";
5
7
  import type { RegistryConfig } from "./resolver.js";
8
+ import { materializeClosure, storeEntryDir } from "./store-layout.js";
9
+ export { ToolLoaderError, DEFAULT_MAX_REGISTRY_TARBALL_BYTES, DEFAULT_REGISTRY_FETCH_TIMEOUT_MS, buildRegistryFetchOpts, readResponseWithLimit, materializeClosure, storeEntryDir, };
6
10
  /**
7
11
  * Loaded factory shape. We re-export `AnnotatedToolFactory<BaseEnv>` so
8
12
  * callers receive the canonical agent type without re-deriving it; the
@@ -40,6 +44,17 @@ export interface LoadedToolPackage {
40
44
  * valid.
41
45
  */
42
46
  readonly directors: readonly LoadedDirectorFactory[];
47
+ /**
48
+ * The provider-backed credentials the package's tools statically
49
+ * declare via `interchange.credentials`. A declaration is advisory: it
50
+ * names a handle the agent definition binds to a concrete credential and
51
+ * the launch-time grant gate authorizes; it consents to nothing on its
52
+ * own. Surfaced here -- parsed from the SAME `package.json` the code
53
+ * loaded from -- so the declared set is the authoritative one (the loaded
54
+ * package's own) rather than a hub manifest that could drift. Empty when
55
+ * the package omits the field; a tools-only package stays valid.
56
+ */
57
+ readonly credentials: readonly ToolCredentialDeclaration[];
43
58
  }
44
59
  export interface HostPlatform {
45
60
  readonly os: string;
@@ -96,25 +111,6 @@ export interface LoaderConfig {
96
111
  */
97
112
  readonly importModule?: (importUrl: string) => Promise<unknown>;
98
113
  }
99
- /**
100
- * Default cap on a single HTTP-registry tarball fetch. Matches the
101
- * hub's `DEFAULT_HUB_MAX_TARBALL_BYTES` so a tarball the hub accepted
102
- * on upload is one the sidecar can also fetch back when a registry
103
- * mirror replays it.
104
- */
105
- export declare const DEFAULT_MAX_REGISTRY_TARBALL_BYTES: number;
106
- /**
107
- * Default deadline for a single HTTP-registry tarball fetch, covering
108
- * both the request and the streamed body read. `readResponseWithLimit`
109
- * consumes the body through a manual reader loop, so the byte cap bounds
110
- * size but nothing bounds time: a registry that accepts the connection
111
- * and then stalls mid-stream would block the fetch -- and the deploy's
112
- * tool materialization awaiting it -- indefinitely.
113
- * The deadline is generous so a legitimately large tarball on a slow
114
- * link still completes within it. Callers that need a different bound
115
- * pass `registryFetchTimeoutMs` to `createToolLoader`.
116
- */
117
- export declare const DEFAULT_REGISTRY_FETCH_TIMEOUT_MS: number;
118
114
  export type TarballFetcher = (entry: ToolPackageManifestEntry, ctx: {
119
115
  registries: ReadonlyMap<string, RegistryConfig>;
120
116
  assetRoot: string;
@@ -138,52 +134,65 @@ export interface LoadManifestArgs {
138
134
  * sources from an asset.
139
135
  */
140
136
  readonly assetMounts: ReadonlyMap<string, string>;
137
+ /**
138
+ * Maps a source-format asset entry's `source.assetId` to an absolute path
139
+ * to an indexed git directory whose object database holds the pinned
140
+ * commit and its trees. The caller checks the delivered pack out into
141
+ * this directory once per asset before applying. Empty map is valid
142
+ * when no entry sources from a git subtree.
143
+ */
144
+ readonly gitDirs: ReadonlyMap<string, string>;
141
145
  }
142
146
  export interface ToolLoader {
143
147
  loadManifest(args: LoadManifestArgs): Promise<LoadedToolPackage[]>;
144
148
  }
145
- export declare class ToolLoaderError extends Error {
146
- readonly category: DeployApplyErrorCategory;
147
- readonly package: {
148
- readonly name: string;
149
- readonly version: string;
150
- } | undefined;
151
- constructor(opts: {
152
- category: DeployApplyErrorCategory;
153
- message: string;
154
- package?: {
155
- name: string;
156
- version: string;
157
- };
158
- });
149
+ /**
150
+ * Arguments for `materializeClosure`, the eval-free materialization
151
+ * primitive `loadManifest` wraps. Everything phases 1-2 need is passed
152
+ * explicitly so the function stands alone without a `ToolLoader`
153
+ * instance: the closure's `cache`, `registries`, `host`, and resolved
154
+ * `fetchTarball` are threaded in directly. There is no import seam here
155
+ * because `materializeClosure` never imports author code — that first
156
+ * `import()` belongs to `loadManifest`'s phase-3 loop.
157
+ */
158
+ export interface MaterializeClosureArgs {
159
+ readonly manifest: ToolPackageManifest;
160
+ readonly instanceScratchDir: string;
161
+ readonly assetRoot: string;
162
+ readonly assetMounts: ReadonlyMap<string, string>;
163
+ /**
164
+ * Maps a source-format asset entry's `source.assetId` to an absolute
165
+ * indexed git directory the pinned subtree is read from. Empty map is valid
166
+ * when no entry sources from a git subtree.
167
+ */
168
+ readonly gitDirs: ReadonlyMap<string, string>;
169
+ readonly host: HostPlatform;
170
+ readonly cache: TarballCache;
171
+ /**
172
+ * Registry identifier → registry config, the same map
173
+ * `createToolLoader` keys its registries under. Used both to gate
174
+ * `kind: "registry"` entries against the sidecar config and as the
175
+ * `registries` context handed to `fetchTarball`.
176
+ */
177
+ readonly registries: ReadonlyMap<string, RegistryConfig>;
178
+ /**
179
+ * Resolved tarball fetcher. `createToolLoader` builds the default
180
+ * (npm-registry-fetch + filesystem) fetcher or honors a test seam and
181
+ * threads the result here; `materializeClosure` does not construct one
182
+ * of its own.
183
+ */
184
+ readonly fetchTarball: TarballFetcher;
159
185
  }
160
- export declare function createToolLoader(config: LoaderConfig): ToolLoader;
161
- export declare function buildRegistryFetchOpts(registry: RegistryConfig): Record<string, unknown>;
162
186
  /**
163
- * Read an HTTP-registry tarball response into a Uint8Array while enforcing
164
- * `maxBytes`. Two guards:
165
- *
166
- * 1. If the upstream sent a `Content-Length` header, parse it (digit-
167
- * only, per RFC 9110 §8.6) and reject up front when the declared
168
- * length exceeds the cap. A header that fails the digit shape is
169
- * also rejected so a header like `1e9` cannot read as 1e9 against
170
- * `Number()` while a digit-only cap check would pass.
171
- * 2. Stream the body chunk-by-chunk, tallying byte length, and abort
172
- * the read when the running total crosses the cap. This catches
173
- * the missing-or-lying header case.
174
- *
175
- * An optional `signal` adds a time guard: when it aborts (the caller's
176
- * fetch deadline), the in-flight read is cancelled and the call rejects,
177
- * so a registry that streams the body slowly or stalls mid-stream cannot
178
- * outlast the deadline while staying under the byte cap.
179
- *
180
- * All rejections surface as `registry.fetch.failed` so the apply layer
181
- * routes them the same as any other registry-side fetch defect.
182
- *
183
- * Exported for direct unit testing.
187
+ * Result of `materializeClosure`: the per-instance store directory the
188
+ * closure was laid out under (`<instanceScratchDir>/store`), plus the exact
189
+ * host-filtered entries that were laid out under it. A caller computes a
190
+ * specific package's directory with `storeEntryDir`, and iterates `entries` to
191
+ * load exactly what was materialized -- rather than re-deriving the host filter
192
+ * with a second predicate and risking the two drifting apart.
184
193
  */
185
- export declare function readResponseWithLimit(res: Response, maxBytes: number, ctx: {
186
- readonly registry: string;
187
- readonly name: string;
188
- readonly version: string;
189
- }, signal?: AbortSignal): Promise<Uint8Array>;
194
+ export interface MaterializeClosureResult {
195
+ readonly storeDir: string;
196
+ readonly entries: readonly ToolPackageManifestEntry[];
197
+ }
198
+ export declare function createToolLoader(config: LoaderConfig): ToolLoader;