@tetsuo-ai/agenc 0.6.0 → 0.7.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.
@@ -10,36 +10,109 @@
10
10
  // `postinstall` before anything else is available.
11
11
 
12
12
  import { spawnSync } from "node:child_process";
13
- import { createHash } from "node:crypto";
13
+ import { createHash, randomUUID } from "node:crypto";
14
14
  import {
15
15
  chmodSync,
16
+ closeSync,
17
+ constants as fsConstants,
16
18
  createReadStream,
17
19
  createWriteStream,
18
20
  existsSync,
21
+ fstatSync,
22
+ fsyncSync,
23
+ lstatSync,
19
24
  mkdirSync,
25
+ mkdtempSync,
26
+ openSync,
27
+ readSync,
20
28
  readFileSync,
29
+ readdirSync,
30
+ realpathSync,
31
+ renameSync,
21
32
  rmSync,
22
- writeFileSync,
33
+ statSync,
34
+ writeSync,
23
35
  } from "node:fs";
24
- import { homedir, tmpdir } from "node:os";
25
- import { dirname, join, resolve } from "node:path";
36
+ import { homedir } from "node:os";
37
+ import { basename, dirname, isAbsolute, join, relative, resolve, win32 } from "node:path";
38
+ import { performance } from "node:perf_hooks";
26
39
  import { Readable } from "node:stream";
27
40
  import { pipeline } from "node:stream/promises";
28
41
  import { fileURLToPath } from "node:url";
42
+ import {
43
+ assertArtifactCompatible,
44
+ currentRuntimeCompatibility,
45
+ runtimeArtifactKey,
46
+ } from "./runtime-compatibility.mjs";
47
+ import { validateRuntimeArchive } from "./runtime-archive.mjs";
48
+ import {
49
+ canonicalLocalFileUrlToPath,
50
+ MAX_RUNTIME_ARTIFACT_BYTES,
51
+ MAX_RUNTIME_MANIFEST_BYTES,
52
+ validateRuntimeReleaseManifest,
53
+ } from "./runtime-release-contract.mjs";
54
+ import { acquireLocalSqliteLock } from "./sqlite-lock.mjs";
55
+
56
+ export {
57
+ MAX_RUNTIME_ARTIFACT_BYTES,
58
+ MAX_RUNTIME_MANIFEST_BYTES,
59
+ } from "./runtime-release-contract.mjs";
29
60
 
30
61
  const __dirname = dirname(fileURLToPath(import.meta.url));
62
+ const LAUNCHER_VERSION = JSON.parse(
63
+ readFileSync(resolve(__dirname, "..", "package.json"), "utf8"),
64
+ ).version;
31
65
  const MANIFEST_PATH = resolve(
32
66
  __dirname,
33
67
  "..",
34
68
  "generated",
35
- "agenc-runtime-manifest.json",
69
+ "agenc-runtime-manifest-v2.json",
36
70
  );
71
+ const DEFAULT_RUNTIME_FETCH_TIMEOUT_MS = 120_000;
72
+ const DEFAULT_RUNTIME_EXTRACTION_TIMEOUT_MS = 120_000;
73
+ const WINDOWS_SYSTEM_ROOT = String.raw`\\?\GLOBALROOT\SystemRoot`;
37
74
 
38
75
  export function resolveAgenCHome(env = process.env, userHome = homedir()) {
39
- const configured = env.AGENC_HOME?.trim();
40
- return configured && configured.length > 0
76
+ const configured = env.AGENC_HOME;
77
+ const requested = configured && configured.length > 0
41
78
  ? configured
42
79
  : join(userHome, ".agenc");
80
+ if (!isAbsolute(requested)) {
81
+ throw new Error(
82
+ "agenc: AGENC_HOME must be an absolute path so its identity does not change with the working directory",
83
+ );
84
+ }
85
+ return resolve(requested);
86
+ }
87
+
88
+ function canonicalizeAgenCHome(requested) {
89
+ if (!isAbsolute(requested)) {
90
+ throw new Error("agenc: AGENC_HOME must be an absolute path");
91
+ }
92
+ const existed = existsSync(requested);
93
+ mkdirSync(requested, { recursive: true, mode: 0o700 });
94
+ const requestedStat = lstatSync(requested);
95
+ if (!requestedStat.isDirectory() && !requestedStat.isSymbolicLink()) {
96
+ throw new Error(`agenc: AGENC_HOME is not a directory: ${requested}`);
97
+ }
98
+ // An existing symlink alias is deliberately resolved once. All subsequent
99
+ // install paths and lock identities use the canonical target, so changing
100
+ // the alias cannot move an in-flight install into another home.
101
+ if (!existed && requestedStat.isSymbolicLink()) {
102
+ throw new Error(`agenc: newly created AGENC_HOME became a symlink: ${requested}`);
103
+ }
104
+ const canonical = realpathSync(requested);
105
+ const canonicalStat = lstatSync(canonical);
106
+ if (!canonicalStat.isDirectory() || canonicalStat.isSymbolicLink()) {
107
+ throw new Error(`agenc: canonical AGENC_HOME is not a real directory: ${canonical}`);
108
+ }
109
+ if (process.platform !== "win32" && typeof process.getuid === "function") {
110
+ if (canonicalStat.uid !== process.getuid()) {
111
+ throw new Error(`agenc: AGENC_HOME is owned by another user: ${canonical}`);
112
+ }
113
+ chmodSync(canonical, 0o700);
114
+ }
115
+ return canonical;
43
116
  }
44
117
 
45
118
  export function platformSlug(platform = process.platform, arch = process.arch) {
@@ -47,82 +120,987 @@ export function platformSlug(platform = process.platform, arch = process.arch) {
47
120
  return { os, arch };
48
121
  }
49
122
 
50
- export function readManifest(manifestPath = MANIFEST_PATH) {
123
+ function readBoundedManifest(path, maximumBytes) {
124
+ const before = lstatSync(path);
125
+ if (!before.isFile() || before.isSymbolicLink()) {
126
+ throw new Error(`agenc: runtime manifest must be a plain file (${path})`);
127
+ }
128
+ if (before.size > maximumBytes) {
129
+ throw new Error(
130
+ `agenc: runtime manifest exceeds ${maximumBytes} bytes (${before.size})`,
131
+ );
132
+ }
133
+ const fd = openSync(path, "r");
134
+ try {
135
+ const opened = fstatSync(fd);
136
+ if (!opened.isFile() || opened.size > maximumBytes) {
137
+ throw new Error(`agenc: runtime manifest exceeds ${maximumBytes} bytes`);
138
+ }
139
+ if (
140
+ process.platform !== "win32" &&
141
+ (opened.dev !== before.dev || opened.ino !== before.ino)
142
+ ) {
143
+ throw new Error("agenc: runtime manifest changed while it was opened");
144
+ }
145
+ const bytes = Buffer.allocUnsafe(maximumBytes + 1);
146
+ let offset = 0;
147
+ while (offset < bytes.length) {
148
+ const count = readSync(fd, bytes, offset, bytes.length - offset, null);
149
+ if (count === 0) break;
150
+ offset += count;
151
+ }
152
+ if (offset > maximumBytes) {
153
+ throw new Error(`agenc: runtime manifest exceeds ${maximumBytes} bytes`);
154
+ }
155
+ try {
156
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, offset));
157
+ } catch {
158
+ throw new Error("agenc: runtime manifest is not valid UTF-8");
159
+ }
160
+ } finally {
161
+ closeSync(fd);
162
+ }
163
+ }
164
+
165
+ export function readManifest(
166
+ manifestPath = MANIFEST_PATH,
167
+ maximumBytes = MAX_RUNTIME_MANIFEST_BYTES,
168
+ ) {
51
169
  if (!existsSync(manifestPath)) {
52
170
  throw new Error(
53
171
  `agenc: runtime manifest missing (${manifestPath}); this launcher build is incomplete`,
54
172
  );
55
173
  }
56
- return JSON.parse(readFileSync(manifestPath, "utf8"));
174
+ if (
175
+ !Number.isSafeInteger(maximumBytes) ||
176
+ maximumBytes < 1 ||
177
+ maximumBytes > MAX_RUNTIME_MANIFEST_BYTES
178
+ ) {
179
+ throw new TypeError("agenc: runtime manifest byte limit is invalid");
180
+ }
181
+ const source = readBoundedManifest(manifestPath, maximumBytes);
182
+ try {
183
+ return JSON.parse(source);
184
+ } catch (error) {
185
+ throw new Error("agenc: runtime manifest is not valid JSON", { cause: error });
186
+ }
57
187
  }
58
188
 
59
- export function selectArtifact(manifest, slug = platformSlug()) {
60
- const match = manifest.artifacts?.find(
61
- (a) => a.platform === slug.os && a.arch === slug.arch,
189
+ export function selectArtifact(
190
+ manifest,
191
+ slug = platformSlug(),
192
+ nodeModuleAbi = process.versions.modules,
193
+ runtime = currentRuntimeCompatibility({
194
+ platform: slug.os,
195
+ arch: slug.arch,
196
+ nodeModuleAbi,
197
+ }),
198
+ { trustMode = "official" } = {},
199
+ ) {
200
+ validateRuntimeReleaseManifest(manifest, {
201
+ trustMode,
202
+ expectedRuntimeVersion: trustMode === "official" ? LAUNCHER_VERSION : undefined,
203
+ });
204
+ const matches = manifest.artifacts.filter(
205
+ (a) =>
206
+ a.platform === slug.os &&
207
+ a.arch === slug.arch &&
208
+ a.nodeModuleAbi === nodeModuleAbi,
62
209
  );
63
- if (!match) {
64
- const have = (manifest.artifacts ?? [])
65
- .map((a) => `${a.platform}-${a.arch}`)
210
+ if (matches.length !== 1) {
211
+ const have = manifest.artifacts
212
+ .map((a) => `${a.platform}-${a.arch}/abi${a.nodeModuleAbi ?? "?"}`)
66
213
  .join(", ");
214
+ if (matches.length > 1) {
215
+ throw new Error(
216
+ `agenc: runtime manifest contains multiple builds for ` +
217
+ `${slug.os}-${slug.arch}/abi${nodeModuleAbi}`,
218
+ );
219
+ }
67
220
  throw new Error(
68
- `agenc: no runtime build for ${slug.os}-${slug.arch} (available: ${have || "none"})`,
221
+ `agenc: no runtime build for ${slug.os}-${slug.arch}/abi${nodeModuleAbi} ` +
222
+ `(Node ${process.version}; available: ${have || "none"}). ` +
223
+ `Use a Node.js version supported by @tetsuo-ai/agenc ${LAUNCHER_VERSION}, ` +
224
+ "then reinstall @tetsuo-ai/agenc before retrying; no runtime was downloaded",
69
225
  );
70
226
  }
71
- return match;
227
+ const [match] = matches;
228
+ return assertArtifactCompatible(match, runtime);
72
229
  }
73
230
 
74
231
  // Directory the artifact extracts to, and the runtime bin inside it.
75
- export function runtimeInstallDir(home, version) {
76
- return join(home, "runtime", version);
232
+ export function runtimeInstallDir(
233
+ home,
234
+ version,
235
+ artifact,
236
+ ) {
237
+ return join(
238
+ home,
239
+ "runtime",
240
+ version,
241
+ `${runtimeArtifactKey(artifact)}-sha256-${artifact.sha256}`,
242
+ );
77
243
  }
78
244
  export function runtimeBinPath(home, version, artifact) {
79
245
  const rel = artifact?.bins?.agenc ?? "node_modules/@tetsuo-ai/runtime/bin/agenc";
80
- return join(runtimeInstallDir(home, version), rel);
246
+ return join(runtimeInstallDir(home, version, artifact), rel);
81
247
  }
82
248
 
83
249
  function markerPath(installDir) {
84
250
  return join(installDir, ".agenc-runtime-ok");
85
251
  }
86
252
 
253
+ function strictRelativeRegularFile(root, relativePath) {
254
+ if (
255
+ relativePath.length === 0 ||
256
+ isAbsolute(relativePath) ||
257
+ relativePath.split(/[\\/]/).some((part) => part.length === 0 || part === "." || part === "..")
258
+ ) return false;
259
+ const finalPath = resolve(root, relativePath);
260
+ const within = relative(resolve(root), finalPath);
261
+ if (within === "" || within === ".." || within.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(within)) {
262
+ return false;
263
+ }
264
+ let current = root;
265
+ const parts = relativePath.split(/[\\/]/);
266
+ try {
267
+ for (let index = 0; index < parts.length; index += 1) {
268
+ current = join(current, parts[index]);
269
+ const stat = lstatSync(current);
270
+ if (stat.isSymbolicLink()) return false;
271
+ if (index === parts.length - 1 ? !stat.isFile() : !stat.isDirectory()) return false;
272
+ }
273
+ return true;
274
+ } catch {
275
+ return false;
276
+ }
277
+ }
278
+
279
+ function strictMarkerMatches(installDir, expectedSha) {
280
+ try {
281
+ const marker = markerPath(installDir);
282
+ const stat = lstatSync(marker);
283
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 128) return false;
284
+ const content = readFileSync(marker, "utf8");
285
+ return content === expectedSha || content === `${expectedSha}\n`;
286
+ } catch {
287
+ return false;
288
+ }
289
+ }
290
+
87
291
  // A runtime install is "good" only if the success marker exists AND records the
88
292
  // expected sha256 — guards against a half-extracted or stale tree.
89
- export function isInstalled(installDir, expectedSha) {
90
- const marker = markerPath(installDir);
91
- if (!existsSync(marker)) return false;
293
+ export function isInstalled(installDir, expectedSha, expectedBin) {
294
+ try {
295
+ const root = lstatSync(installDir);
296
+ if (!root.isDirectory() || root.isSymbolicLink()) return false;
297
+ if (!strictMarkerMatches(installDir, expectedSha)) return false;
298
+ return expectedBin === undefined || strictRelativeRegularFile(
299
+ installDir,
300
+ relative(installDir, expectedBin),
301
+ );
302
+ } catch {
303
+ return false;
304
+ }
305
+ }
306
+
307
+ function readyInstallAt(path, expectedSha, binRel) {
92
308
  try {
93
- return readFileSync(marker, "utf8").trim() === expectedSha;
309
+ const root = lstatSync(path);
310
+ return root.isDirectory() &&
311
+ !root.isSymbolicLink() &&
312
+ strictRelativeRegularFile(path, binRel) &&
313
+ strictMarkerMatches(path, expectedSha);
94
314
  } catch {
95
315
  return false;
96
316
  }
97
317
  }
98
318
 
99
- async function sha256File(file) {
100
- const hash = createHash("sha256");
101
- await pipeline(createReadStream(file), hash);
102
- return hash.digest("hex");
319
+ function hasInstallResidue(versionDir, base) {
320
+ return readdirSync(versionDir).some((name) =>
321
+ name.startsWith(`.${base}.install-`) ||
322
+ name.startsWith(`${base}.old-`));
323
+ }
324
+
325
+ function durabilityEvent(hook, event, details) {
326
+ hook?.(event, details);
327
+ }
328
+
329
+ function syncRegularFile(path, event, hook) {
330
+ durabilityEvent(hook, event, { path });
331
+ const before = lstatSync(path, { bigint: true });
332
+ if (!before.isFile() || before.isSymbolicLink()) {
333
+ throw new Error(`agenc: durability target is not a regular file: ${path}`);
334
+ }
335
+ const fd = openSync(
336
+ path,
337
+ (process.platform === "win32" ? fsConstants.O_RDWR : fsConstants.O_RDONLY) |
338
+ (fsConstants.O_NOFOLLOW ?? 0) |
339
+ (fsConstants.O_NONBLOCK ?? 0),
340
+ );
341
+ try {
342
+ const opened = fstatSync(fd, { bigint: true });
343
+ if (!opened.isFile() || !sameLocalArtifactIdentity(before, opened)) {
344
+ throw new Error(`agenc: durability target changed while it was opened: ${path}`);
345
+ }
346
+ fsyncSync(fd);
347
+ } finally {
348
+ closeSync(fd);
349
+ }
103
350
  }
104
351
 
105
- async function download(url, dest, fetchImpl = globalThis.fetch) {
106
- // Local file:// support keeps the whole flow testable without a real release.
107
- if (url.startsWith("file://")) {
108
- await pipeline(createReadStream(fileURLToPath(url)), createWriteStream(dest));
352
+ function syncDirectory(path, event, hook) {
353
+ if (process.platform === "win32") {
354
+ // Node does not expose Win32 directory handles with write-through flags.
355
+ // Reopen and enumerate the renamed directory so Windows at least verifies
356
+ // the new name, while every regular file is flushed individually.
357
+ durabilityEvent(hook, `verify-${event}`, { path });
358
+ const metadata = lstatSync(path);
359
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
360
+ throw new Error(`agenc: durable directory verification failed: ${path}`);
361
+ }
362
+ readdirSync(path);
109
363
  return;
110
364
  }
111
- const res = await fetchImpl(url, { redirect: "follow" });
112
- if (!res.ok) {
113
- throw new Error(`agenc: download failed ${res.status} ${res.statusText}: ${url}`);
365
+ durabilityEvent(hook, event, { path });
366
+ const fd = openSync(
367
+ path,
368
+ fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY ?? 0),
369
+ );
370
+ try {
371
+ fsyncSync(fd);
372
+ } finally {
373
+ closeSync(fd);
374
+ }
375
+ }
376
+
377
+ function syncExtractedTree(root, hook) {
378
+ const entries = readdirSync(root, { withFileTypes: true })
379
+ .sort((left, right) => left.name.localeCompare(right.name));
380
+ for (const entry of entries) {
381
+ const path = join(root, entry.name);
382
+ const metadata = lstatSync(path);
383
+ if (metadata.isSymbolicLink()) continue;
384
+ if (metadata.isDirectory()) {
385
+ syncExtractedTree(path, hook);
386
+ } else if (metadata.isFile()) {
387
+ syncRegularFile(path, "sync-tree-file", hook);
388
+ } else {
389
+ throw new Error(`agenc: extracted runtime contains an unsupported file type: ${path}`);
390
+ }
391
+ }
392
+ syncDirectory(root, "sync-tree-directory", hook);
393
+ }
394
+
395
+ function durableWriteMarker(path, content, hook) {
396
+ durabilityEvent(hook, "write-marker", { path });
397
+ const fd = openSync(
398
+ path,
399
+ fsConstants.O_WRONLY |
400
+ fsConstants.O_CREAT |
401
+ fsConstants.O_EXCL |
402
+ (fsConstants.O_NOFOLLOW ?? 0),
403
+ 0o600,
404
+ );
405
+ try {
406
+ const bytes = Buffer.from(content);
407
+ let offset = 0;
408
+ while (offset < bytes.length) {
409
+ const written = writeSync(fd, bytes, offset, bytes.length - offset, null);
410
+ if (written <= 0) throw new Error("agenc: marker write made no progress");
411
+ offset += written;
412
+ }
413
+ durabilityEvent(hook, "sync-marker", { path });
414
+ fsyncSync(fd);
415
+ } finally {
416
+ closeSync(fd);
417
+ }
418
+ }
419
+
420
+ function verifyRenamedDirectory(path, hook) {
421
+ if (process.platform !== "win32") return;
422
+ durabilityEvent(hook, "verify-rename", { path });
423
+ const metadata = lstatSync(path);
424
+ if (
425
+ !metadata.isDirectory() ||
426
+ metadata.isSymbolicLink()
427
+ ) {
428
+ throw new Error(`agenc: renamed runtime directory failed verification: ${path}`);
429
+ }
430
+ readdirSync(path);
431
+ }
432
+
433
+ function durableRenameDirectory(from, to, event, hook) {
434
+ durabilityEvent(hook, event, { from, to });
435
+ renameSync(from, to);
436
+ verifyRenamedDirectory(to, hook);
437
+ syncDirectory(dirname(to), "sync-parent-after-rename", hook);
438
+ }
439
+
440
+ function durableRemoveDirectory(path, parent, hook) {
441
+ durabilityEvent(hook, "remove-residue", { path });
442
+ rmSync(path, { recursive: true, force: true });
443
+ syncDirectory(parent, "sync-parent-after-remove", hook);
444
+ }
445
+
446
+ function reconcileInstallState(versionDir, installDir, expectedSha, binRel, durabilityHook) {
447
+ const base = basename(installDir);
448
+ const entries = readdirSync(versionDir);
449
+ const readyStages = entries
450
+ .filter((name) => name.startsWith(`.${base}.install-`))
451
+ .map((name) => join(versionDir, name))
452
+ .filter((path) => readyInstallAt(path, expectedSha, binRel))
453
+ .sort((left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs);
454
+ const readyBackups = entries
455
+ .filter((name) => name.startsWith(`${base}.old-`))
456
+ .map((name) => join(versionDir, name))
457
+ .filter((path) => readyInstallAt(path, expectedSha, binRel))
458
+ .sort((left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs);
459
+
460
+ if (!readyInstallAt(installDir, expectedSha, binRel)) {
461
+ // A staged tree is written and marked before the first promotion rename,
462
+ // so it is the newest intended state. Prefer it over a backup, then use a
463
+ // verified backup as the fallback. promoteInstall quarantines any invalid
464
+ // canonical tree and rolls it back if the candidate rename fails.
465
+ const recoveryCandidate = readyStages[0] ?? readyBackups[0];
466
+ if (recoveryCandidate !== undefined) {
467
+ syncExtractedTree(recoveryCandidate, durabilityHook);
468
+ promoteInstall(recoveryCandidate, installDir, durabilityHook);
469
+ }
470
+ }
471
+ const ready = readyInstallAt(installDir, expectedSha, binRel);
472
+ // Never destroy crash evidence or the only recoverable tree until a fully
473
+ // verified canonical installation exists.
474
+ if (!ready) return false;
475
+ for (const name of readdirSync(versionDir)) {
476
+ if (
477
+ name.startsWith(`.${base}.install-`) ||
478
+ name.startsWith(`${base}.old-`)
479
+ ) {
480
+ try {
481
+ durableRemoveDirectory(join(versionDir, name), versionDir, durabilityHook);
482
+ } catch { /* residue cleanup is retried on the next launch */ }
483
+ }
484
+ }
485
+ return true;
486
+ }
487
+
488
+ function promoteInstall(stagingDir, installDir, durabilityHook) {
489
+ const backup = `${installDir}.old-${process.pid}-${randomUUID()}`;
490
+ let movedExisting = false;
491
+ try {
492
+ if (existsSync(installDir)) {
493
+ durableRenameDirectory(
494
+ installDir,
495
+ backup,
496
+ "rename-current-to-backup",
497
+ durabilityHook,
498
+ );
499
+ movedExisting = true;
500
+ }
501
+ durableRenameDirectory(
502
+ stagingDir,
503
+ installDir,
504
+ "rename-stage-to-current",
505
+ durabilityHook,
506
+ );
507
+ } catch (error) {
508
+ if (!existsSync(installDir) && movedExisting && existsSync(backup)) {
509
+ try {
510
+ durableRenameDirectory(
511
+ backup,
512
+ installDir,
513
+ "rename-backup-to-current",
514
+ durabilityHook,
515
+ );
516
+ } catch (rollbackError) {
517
+ throw new AggregateError(
518
+ [error, rollbackError],
519
+ `agenc: runtime promotion failed; previous install retained at ${backup}`,
520
+ );
521
+ }
522
+ }
523
+ throw error;
524
+ }
525
+ }
526
+
527
+ function identityOf(stats) {
528
+ return {
529
+ dev: stats.dev,
530
+ ino: stats.ino,
531
+ size: stats.size,
532
+ mtimeNs: stats.mtimeNs,
533
+ ctimeNs: stats.ctimeNs,
534
+ };
535
+ }
536
+
537
+ function sameIdentity(left, right, { includeContent = true } = {}) {
538
+ return left.dev === right.dev && left.ino === right.ino &&
539
+ (!includeContent || (
540
+ left.size === right.size &&
541
+ left.mtimeNs === right.mtimeNs &&
542
+ left.ctimeNs === right.ctimeNs
543
+ ));
544
+ }
545
+
546
+ function snapshotRegularFile(path, label) {
547
+ const stats = lstatSync(path, { bigint: true });
548
+ if (!stats.isFile() || stats.isSymbolicLink() || stats.nlink !== 1n) {
549
+ throw new Error(`agenc: ${label} must be a regular single-link file`);
550
+ }
551
+ return identityOf(stats);
552
+ }
553
+
554
+ function assertRegularFileIdentity(path, expected, label) {
555
+ const actual = snapshotRegularFile(path, label);
556
+ if (!sameIdentity(actual, expected)) {
557
+ throw new Error(`agenc: ${label} identity changed during installation`);
558
+ }
559
+ }
560
+
561
+ function snapshotDirectory(path, label) {
562
+ const stats = lstatSync(path, { bigint: true });
563
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
564
+ throw new Error(`agenc: ${label} must be a real directory`);
565
+ }
566
+ return identityOf(stats);
567
+ }
568
+
569
+ function assertDirectoryIdentity(path, expected, label) {
570
+ const actual = snapshotDirectory(path, label);
571
+ if (!sameIdentity(actual, expected, { includeContent: false })) {
572
+ throw new Error(`agenc: ${label} identity changed during installation`);
114
573
  }
115
- await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
574
+ }
575
+
576
+ function createPrivateTemporaryDirectory(parent, prefix) {
577
+ const requestedParent = resolve(parent);
578
+ const canonicalParent = realpathSync.native(requestedParent);
579
+ if (canonicalParent !== requestedParent) {
580
+ throw new Error(`agenc: temporary parent must use its canonical path: ${requestedParent}`);
581
+ }
582
+ const parentStats = lstatSync(canonicalParent, { bigint: true });
583
+ if (!parentStats.isDirectory() || parentStats.isSymbolicLink()) {
584
+ throw new Error(`agenc: temporary parent is not a real directory: ${canonicalParent}`);
585
+ }
586
+ if (process.platform !== "win32" && typeof process.getuid === "function") {
587
+ if (parentStats.uid !== BigInt(process.getuid()) || (parentStats.mode & 0o022n) !== 0n) {
588
+ throw new Error(`agenc: temporary parent is not privately owned: ${canonicalParent}`);
589
+ }
590
+ }
591
+ const created = mkdtempSync(join(canonicalParent, prefix));
592
+ const before = snapshotDirectory(created, "temporary directory");
593
+ chmodSync(created, 0o700);
594
+ const canonical = realpathSync.native(created);
595
+ const after = snapshotDirectory(canonical, "temporary directory");
596
+ if (canonical !== created || !sameIdentity(before, after, { includeContent: false })) {
597
+ throw new Error("agenc: temporary directory identity changed while it was secured");
598
+ }
599
+ return { path: canonical, identity: after };
600
+ }
601
+
602
+ function sha256File(file, expectedIdentity) {
603
+ const before = snapshotRegularFile(file, "runtime archive");
604
+ if (expectedIdentity !== undefined && !sameIdentity(before, expectedIdentity)) {
605
+ throw new Error("agenc: runtime archive identity changed before hashing");
606
+ }
607
+ const fd = openSync(
608
+ file,
609
+ fsConstants.O_RDONLY |
610
+ (fsConstants.O_NOFOLLOW ?? 0) |
611
+ (fsConstants.O_NONBLOCK ?? 0),
612
+ );
613
+ try {
614
+ const opened = identityOf(fstatSync(fd, { bigint: true }));
615
+ if (!sameIdentity(opened, before)) {
616
+ throw new Error("agenc: runtime archive changed while it was opened for hashing");
617
+ }
618
+ const hash = createHash("sha256");
619
+ const buffer = Buffer.allocUnsafe(64 * 1024);
620
+ for (;;) {
621
+ const count = readSync(fd, buffer, 0, buffer.length, null);
622
+ if (count === 0) break;
623
+ hash.update(buffer.subarray(0, count));
624
+ }
625
+ const after = identityOf(fstatSync(fd, { bigint: true }));
626
+ if (!sameIdentity(after, before)) {
627
+ throw new Error("agenc: runtime archive changed while it was hashed");
628
+ }
629
+ assertRegularFileIdentity(file, before, "runtime archive");
630
+ return hash.digest("hex");
631
+ } finally {
632
+ closeSync(fd);
633
+ }
634
+ }
635
+
636
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
637
+
638
+ function createFetchDeadline(timeoutMs) {
639
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 600_000) {
640
+ throw new TypeError("agenc: runtime fetch timeout must be an integer from 1 to 600000 ms");
641
+ }
642
+ return {
643
+ controller: new AbortController(),
644
+ deadline: performance.now() + timeoutMs,
645
+ timeoutMs,
646
+ };
647
+ }
648
+
649
+ function fetchTimeoutError(context, label) {
650
+ const error = new Error(
651
+ `agenc: ${label} timed out after ${context.timeoutMs} ms total`,
652
+ );
653
+ error.code = "AGENC_RUNTIME_FETCH_TIMEOUT";
654
+ return error;
655
+ }
656
+
657
+ async function awaitWithinFetchDeadline(value, context, label) {
658
+ const remaining = context.deadline - performance.now();
659
+ if (remaining <= 0) {
660
+ const error = fetchTimeoutError(context, label);
661
+ context.controller.abort(error);
662
+ throw error;
663
+ }
664
+ let timer;
665
+ const expired = new Promise((_, reject) => {
666
+ timer = setTimeout(() => {
667
+ const error = fetchTimeoutError(context, label);
668
+ // Settle the deadline promise with the policy error before aborting the
669
+ // transport/pipeline, whose AbortError would otherwise obscure it.
670
+ reject(error);
671
+ context.controller.abort(error);
672
+ }, Math.max(1, Math.ceil(remaining)));
673
+ });
674
+ try {
675
+ return await Promise.race([Promise.resolve(value), expired]);
676
+ } finally {
677
+ clearTimeout(timer);
678
+ }
679
+ }
680
+
681
+ async function cancelResponseBody(response, context, label) {
682
+ if (typeof response?.body?.cancel !== "function") return;
683
+ try {
684
+ const cancellation = response.body.cancel();
685
+ if (context === undefined) {
686
+ await cancellation;
687
+ } else {
688
+ await awaitWithinFetchDeadline(cancellation, context, label);
689
+ }
690
+ } catch (error) {
691
+ if (error?.code === "AGENC_RUNTIME_FETCH_TIMEOUT") throw error;
692
+ // Cancellation is best-effort after a stronger policy error. Aborting the
693
+ // shared controller below still tears down the transport.
694
+ }
695
+ }
696
+
697
+ async function fetchHttps(url, fetchImpl, label, context) {
698
+ let current;
699
+ try {
700
+ current = new URL(url);
701
+ } catch {
702
+ throw new Error(`agenc: ${label} URL is invalid`);
703
+ }
704
+ if (current.protocol !== "https:") {
705
+ throw new Error(`agenc: refusing non-HTTPS ${label} URL: ${current.href}`);
706
+ }
707
+ for (let redirects = 0; redirects <= 5; redirects += 1) {
708
+ const response = await awaitWithinFetchDeadline(
709
+ fetchImpl(current, {
710
+ redirect: "manual",
711
+ headers: { "accept-encoding": "identity" },
712
+ signal: context.controller.signal,
713
+ }),
714
+ context,
715
+ `${label} response headers`,
716
+ );
717
+ if (!REDIRECT_STATUSES.has(response.status)) {
718
+ let finalProtocolIsHttps = true;
719
+ try {
720
+ finalProtocolIsHttps = !response.url || new URL(response.url).protocol === "https:";
721
+ } catch {
722
+ finalProtocolIsHttps = false;
723
+ }
724
+ if (response.redirected || !finalProtocolIsHttps) {
725
+ await cancelResponseBody(response, context, `${label} response cancellation`);
726
+ throw new Error(`agenc: ${label} fetch performed an unreviewed redirect`);
727
+ }
728
+ return response;
729
+ }
730
+ const location = response.headers?.get?.("location");
731
+ if (!location) {
732
+ await cancelResponseBody(response, context, `${label} redirect cancellation`);
733
+ throw new Error(`agenc: ${label} redirect is missing Location`);
734
+ }
735
+ let next;
736
+ try {
737
+ next = new URL(location, current);
738
+ } catch {
739
+ await cancelResponseBody(response, context, `${label} redirect cancellation`);
740
+ throw new Error(`agenc: ${label} redirect Location is invalid`);
741
+ }
742
+ if (next.protocol !== "https:") {
743
+ await cancelResponseBody(response, context, `${label} redirect cancellation`);
744
+ throw new Error(`agenc: refusing HTTPS downgrade while fetching ${label}`);
745
+ }
746
+ await cancelResponseBody(response, context, `${label} redirect cancellation`);
747
+ current = next;
748
+ }
749
+ throw new Error(`agenc: too many redirects while fetching ${label}`);
750
+ }
751
+
752
+ async function* requireExactByteCount(source, expectedBytes, context) {
753
+ let received = 0;
754
+ const iterator = source[Symbol.asyncIterator]();
755
+ let complete = false;
756
+ try {
757
+ while (true) {
758
+ const step = context === undefined
759
+ ? await iterator.next()
760
+ : await awaitWithinFetchDeadline(
761
+ iterator.next(),
762
+ context,
763
+ "runtime artifact response body",
764
+ );
765
+ if (step.done) {
766
+ complete = true;
767
+ break;
768
+ }
769
+ const chunk = Buffer.isBuffer(step.value) ? step.value : Buffer.from(step.value);
770
+ received += chunk.length;
771
+ if (received > expectedBytes) {
772
+ throw new Error(
773
+ `agenc: runtime byte count exceeds signed size ` +
774
+ `(expected ${expectedBytes}, received at least ${received})`,
775
+ );
776
+ }
777
+ yield chunk;
778
+ }
779
+ } finally {
780
+ if (!complete && typeof iterator.return === "function") {
781
+ try {
782
+ const returning = iterator.return();
783
+ if (context === undefined) await returning;
784
+ else await awaitWithinFetchDeadline(
785
+ returning,
786
+ context,
787
+ "runtime artifact body cancellation",
788
+ );
789
+ } catch { /* the primary error wins */ }
790
+ }
791
+ }
792
+ if (received !== expectedBytes) {
793
+ throw new Error(
794
+ `agenc: runtime byte count mismatch (expected ${expectedBytes}, got ${received})`,
795
+ );
796
+ }
797
+ }
798
+
799
+ function parseContentLength(response) {
800
+ const value = response.headers?.get?.("content-length");
801
+ if (value === null || value === undefined) return undefined;
802
+ if (!/^(0|[1-9][0-9]*)$/.test(value)) {
803
+ throw new Error("agenc: runtime artifact Content-Length is invalid");
804
+ }
805
+ const parsed = Number(value);
806
+ if (!Number.isSafeInteger(parsed)) {
807
+ throw new Error("agenc: runtime artifact Content-Length is invalid");
808
+ }
809
+ return parsed;
810
+ }
811
+
812
+ function sameLocalArtifactIdentity(left, right) {
813
+ return left.dev === right.dev && left.ino === right.ino;
814
+ }
815
+
816
+ function sameLocalArtifactSnapshot(left, right) {
817
+ return sameLocalArtifactIdentity(left, right) &&
818
+ left.nlink === right.nlink &&
819
+ left.size === right.size &&
820
+ left.mode === right.mode &&
821
+ left.mtimeNs === right.mtimeNs &&
822
+ left.ctimeNs === right.ctimeNs;
823
+ }
824
+
825
+ function assertPlainSingleLinkArtifact(metadata, expectedBytes) {
826
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1n) {
827
+ throw new Error("agenc: local runtime artifact must be a plain single-link file");
828
+ }
829
+ if (metadata.dev === 0n && metadata.ino === 0n) {
830
+ throw new Error("agenc: local runtime artifact has no stable filesystem identity");
831
+ }
832
+ if (metadata.size !== BigInt(expectedBytes)) {
833
+ throw new Error(
834
+ `agenc: runtime byte count mismatch (expected ${expectedBytes}, got ${metadata.size})`,
835
+ );
836
+ }
837
+ }
838
+
839
+ async function copyLocalArtifact(
840
+ url,
841
+ dest,
842
+ expectedBytes,
843
+ localArtifactHook,
844
+ durabilityHook,
845
+ ) {
846
+ const path = canonicalLocalFileUrlToPath(url);
847
+ const before = lstatSync(path, { bigint: true });
848
+ assertPlainSingleLinkArtifact(before, expectedBytes);
849
+ localArtifactHook?.("after-lstat", { path, metadata: before });
850
+ const flags = fsConstants.O_RDONLY |
851
+ (fsConstants.O_NOFOLLOW ?? 0) |
852
+ (fsConstants.O_NONBLOCK ?? 0);
853
+ let fd;
854
+ let operationError;
855
+ try {
856
+ fd = openSync(path, flags);
857
+ const opened = fstatSync(fd, { bigint: true });
858
+ assertPlainSingleLinkArtifact(opened, expectedBytes);
859
+ if (!sameLocalArtifactSnapshot(before, opened)) {
860
+ throw new Error("agenc: local runtime artifact changed while it was opened");
861
+ }
862
+ localArtifactHook?.("after-open", { path, fd, metadata: opened });
863
+ await pipeline(
864
+ createReadStream(path, { fd, autoClose: false }),
865
+ (source) => requireExactByteCount(source, expectedBytes),
866
+ createWriteStream(dest, { flags: "wx", mode: 0o600 }),
867
+ );
868
+ localArtifactHook?.("before-eof-validation", { path, fd, metadata: opened });
869
+ const openedAfterRead = fstatSync(fd, { bigint: true });
870
+ const pathAfterRead = lstatSync(path, { bigint: true });
871
+ assertPlainSingleLinkArtifact(openedAfterRead, expectedBytes);
872
+ assertPlainSingleLinkArtifact(pathAfterRead, expectedBytes);
873
+ if (
874
+ !sameLocalArtifactSnapshot(opened, openedAfterRead) ||
875
+ !sameLocalArtifactSnapshot(openedAfterRead, pathAfterRead)
876
+ ) {
877
+ throw new Error("agenc: local runtime artifact changed while it was read");
878
+ }
879
+ syncRegularFile(dest, "sync-download", durabilityHook);
880
+ } catch (error) {
881
+ operationError = error;
882
+ }
883
+ let closeError;
884
+ if (fd !== undefined) {
885
+ try { closeSync(fd); } catch (error) { closeError = error; }
886
+ }
887
+ if (operationError !== undefined && closeError !== undefined) {
888
+ throw new AggregateError(
889
+ [operationError, closeError],
890
+ "agenc: local runtime artifact copy and descriptor close did not both complete",
891
+ );
892
+ }
893
+ if (operationError !== undefined) throw operationError;
894
+ if (closeError !== undefined) throw closeError;
895
+ }
896
+
897
+ async function download(
898
+ url,
899
+ dest,
900
+ expectedBytes,
901
+ trustMode,
902
+ fetchImpl = globalThis.fetch,
903
+ {
904
+ fetchTimeoutMs = DEFAULT_RUNTIME_FETCH_TIMEOUT_MS,
905
+ localArtifactHook,
906
+ durabilityHook,
907
+ } = {},
908
+ ) {
909
+ if (
910
+ !Number.isSafeInteger(expectedBytes) ||
911
+ expectedBytes <= 0 ||
912
+ expectedBytes > MAX_RUNTIME_ARTIFACT_BYTES
913
+ ) {
914
+ throw new Error(
915
+ `agenc: runtime artifact signed size must be between 1 and ` +
916
+ `${MAX_RUNTIME_ARTIFACT_BYTES} bytes`,
917
+ );
918
+ }
919
+ if (trustMode === "explicitLocal") {
920
+ await copyLocalArtifact(url, dest, expectedBytes, localArtifactHook, durabilityHook);
921
+ return;
922
+ }
923
+ const parsed = new URL(url);
924
+ if (parsed.protocol === "file:") {
925
+ throw new Error("agenc: remote runtime manifests must not access local files");
926
+ }
927
+ const context = createFetchDeadline(fetchTimeoutMs);
928
+ let res;
929
+ try {
930
+ res = await fetchHttps(url, fetchImpl, "runtime artifact", context);
931
+ if (!res.ok || res.body === null) {
932
+ await cancelResponseBody(res, context, "runtime artifact error-body cancellation");
933
+ throw new Error(`agenc: download failed ${res.status} ${res.statusText}: ${url}`);
934
+ }
935
+ const contentEncoding = res.headers?.get?.("content-encoding");
936
+ if (
937
+ contentEncoding !== null &&
938
+ contentEncoding !== undefined &&
939
+ contentEncoding !== "identity"
940
+ ) {
941
+ await cancelResponseBody(res, context, "runtime artifact error-body cancellation");
942
+ throw new Error("agenc: runtime artifact response must use identity encoding");
943
+ }
944
+ let contentLength;
945
+ try {
946
+ contentLength = parseContentLength(res);
947
+ } catch (error) {
948
+ await cancelResponseBody(res, context, "runtime artifact error-body cancellation");
949
+ throw error;
950
+ }
951
+ if (contentLength !== undefined && contentLength !== expectedBytes) {
952
+ await cancelResponseBody(res, context, "runtime artifact error-body cancellation");
953
+ throw new Error(
954
+ `agenc: runtime artifact Content-Length mismatch ` +
955
+ `(expected ${expectedBytes}, got ${contentLength})`,
956
+ );
957
+ }
958
+ const readable = typeof res.body.getReader === "function"
959
+ ? Readable.fromWeb(res.body)
960
+ : res.body;
961
+ await awaitWithinFetchDeadline(
962
+ pipeline(
963
+ readable,
964
+ (source) => requireExactByteCount(source, expectedBytes, context),
965
+ createWriteStream(dest, { flags: "wx", mode: 0o600 }),
966
+ { signal: context.controller.signal },
967
+ ),
968
+ context,
969
+ "runtime artifact download",
970
+ );
971
+ syncRegularFile(dest, "sync-download", durabilityHook);
972
+ } catch (error) {
973
+ context.controller.abort(error);
974
+ if (res !== undefined) {
975
+ // Do not let a transport that ignores abort hold the installer open.
976
+ void cancelResponseBody(res, undefined, "runtime artifact cancellation")
977
+ .catch(() => {});
978
+ }
979
+ throw error;
980
+ }
981
+ }
982
+
983
+ function assertRootOwnedSystemExecutable(path) {
984
+ const canonical = realpathSync.native(path);
985
+ const executable = lstatSync(canonical, { bigint: true });
986
+ if (
987
+ !executable.isFile() || executable.isSymbolicLink() ||
988
+ executable.nlink !== 1n || executable.uid !== 0n ||
989
+ (executable.mode & 0o022n) !== 0n
990
+ ) {
991
+ throw new Error(`agenc: operating-system tar is not a trusted regular file: ${canonical}`);
992
+ }
993
+ for (let ancestor = dirname(canonical); ; ancestor = dirname(ancestor)) {
994
+ const metadata = lstatSync(ancestor, { bigint: true });
995
+ if (
996
+ !metadata.isDirectory() || metadata.isSymbolicLink() ||
997
+ metadata.uid !== 0n || (metadata.mode & 0o022n) !== 0n
998
+ ) {
999
+ throw new Error(`agenc: operating-system tar has an untrusted ancestor: ${ancestor}`);
1000
+ }
1001
+ if (dirname(ancestor) === ancestor) break;
1002
+ }
1003
+ return canonical;
1004
+ }
1005
+
1006
+ /** Resolve tar independently of npm/project PATH and caller environment controls. */
1007
+ export function resolveTrustedSystemTar(platform = process.platform) {
1008
+ if (platform === "linux" || platform === "darwin") {
1009
+ const candidates = platform === "darwin"
1010
+ ? ["/usr/bin/tar"]
1011
+ : ["/usr/bin/tar", "/bin/tar"];
1012
+ const candidate = candidates.find((path) => existsSync(path));
1013
+ if (candidate === undefined) {
1014
+ throw new Error(`agenc: trusted operating-system tar is unavailable on ${platform}`);
1015
+ }
1016
+ return {
1017
+ path: assertRootOwnedSystemExecutable(candidate),
1018
+ env: { PATH: "/usr/bin:/bin", LANG: "C", LC_ALL: "C", TZ: "UTC" },
1019
+ };
1020
+ }
1021
+ if (platform !== "win32") {
1022
+ throw new Error(`agenc: trusted operating-system tar is unsupported on ${platform}`);
1023
+ }
1024
+
1025
+ // GLOBALROOT reaches the kernel's real SystemRoot namespace and cannot be
1026
+ // redirected with caller-controlled SystemRoot/WINDIR values or drive maps.
1027
+ const system32 = win32.join(WINDOWS_SYSTEM_ROOT, "System32");
1028
+ const powershellRoot = win32.join(system32, "WindowsPowerShell", "v1.0");
1029
+ const tarPath = win32.join(system32, "tar.exe");
1030
+ const powershell = win32.join(powershellRoot, "powershell.exe");
1031
+ for (const [path, label] of [[tarPath, "tar"], [powershell, "PowerShell"]]) {
1032
+ const metadata = lstatSync(path, { bigint: true });
1033
+ if (!metadata.isFile() || metadata.isSymbolicLink()) {
1034
+ throw new Error(`agenc: trusted Windows ${label} is unavailable`);
1035
+ }
1036
+ }
1037
+ const env = {
1038
+ APPDATA: "",
1039
+ COMSPEC: win32.join(system32, "cmd.exe"),
1040
+ HOME: "",
1041
+ LOCALAPPDATA: "",
1042
+ PATH: `${system32};${powershellRoot}`,
1043
+ PATHEXT: ".COM;.EXE",
1044
+ PSModulePath: win32.join(powershellRoot, "Modules"),
1045
+ SystemRoot: WINDOWS_SYSTEM_ROOT,
1046
+ TEMP: win32.join(WINDOWS_SYSTEM_ROOT, "Temp"),
1047
+ TMP: win32.join(WINDOWS_SYSTEM_ROOT, "Temp"),
1048
+ USERPROFILE: powershellRoot,
1049
+ WINDIR: WINDOWS_SYSTEM_ROOT,
1050
+ };
1051
+ const signature = spawnSync(
1052
+ powershell,
1053
+ [
1054
+ "-NoLogo",
1055
+ "-NoProfile",
1056
+ "-NonInteractive",
1057
+ "-ExecutionPolicy",
1058
+ "Bypass",
1059
+ "-Command",
1060
+ "$s=Microsoft.PowerShell.Security\\Get-AuthenticodeSignature -LiteralPath $args[0];" +
1061
+ "if($s.Status -ne 'Valid' -or $s.SignerCertificate.Subject -notmatch 'Microsoft'){exit 51}",
1062
+ tarPath,
1063
+ ],
1064
+ {
1065
+ cwd: powershellRoot,
1066
+ encoding: "utf8",
1067
+ env,
1068
+ maxBuffer: 1024 * 1024,
1069
+ stdio: ["ignore", "pipe", "pipe"],
1070
+ timeout: 30_000,
1071
+ killSignal: "SIGKILL",
1072
+ windowsHide: true,
1073
+ },
1074
+ );
1075
+ if (signature.error !== undefined || signature.status !== 0) {
1076
+ throw new Error("agenc: Windows operating-system tar failed Authenticode validation");
1077
+ }
1078
+ return { path: tarPath, env };
116
1079
  }
117
1080
 
118
1081
  function extractTarGz(archive, destDir) {
119
1082
  mkdirSync(destDir, { recursive: true, mode: 0o700 });
120
1083
  try { chmodSync(destDir, 0o700); } catch { /* ignore */ }
121
- const res = spawnSync("tar", ["-xzf", archive, "-C", destDir], {
122
- stdio: ["ignore", "ignore", "inherit"],
1084
+ const tar = resolveTrustedSystemTar();
1085
+ const res = spawnSync(tar.path, ["-xzf", archive, "-C", destDir], {
1086
+ cwd: destDir,
1087
+ encoding: "utf8",
1088
+ env: tar.env,
1089
+ maxBuffer: 1024 * 1024,
1090
+ stdio: ["ignore", "ignore", "pipe"],
1091
+ timeout: DEFAULT_RUNTIME_EXTRACTION_TIMEOUT_MS,
1092
+ killSignal: "SIGKILL",
1093
+ windowsHide: true,
123
1094
  });
1095
+ if (res.error !== undefined) {
1096
+ throw new Error(`agenc: tar extraction failed: ${res.error.message}`, { cause: res.error });
1097
+ }
124
1098
  if (res.status !== 0) {
125
- throw new Error(`agenc: tar extraction failed (status ${res.status ?? res.signal})`);
1099
+ const detail = res.stderr?.trim();
1100
+ throw new Error(
1101
+ `agenc: tar extraction failed (status ${res.status ?? res.signal})` +
1102
+ (detail ? `: ${detail}` : ""),
1103
+ );
126
1104
  }
127
1105
  }
128
1106
 
@@ -134,45 +1112,169 @@ export async function ensureRuntime({
134
1112
  manifest = readManifest(),
135
1113
  slug = platformSlug(),
136
1114
  fetchImpl = globalThis.fetch,
1115
+ runtimeFetchTimeoutMs = DEFAULT_RUNTIME_FETCH_TIMEOUT_MS,
1116
+ manifestTrust = "official",
1117
+ runtimeCompatibility,
1118
+ localArtifactHook,
1119
+ durabilityHook,
1120
+ acquireLock = acquireLocalSqliteLock,
1121
+ remove = rmSync,
137
1122
  log = (m) => process.stderr.write(m + "\n"),
138
1123
  } = {}) {
139
- const home = resolveAgenCHome(env, userHome);
1124
+ const version = manifest.runtimeVersion;
1125
+ const artifact = selectArtifact(
1126
+ manifest,
1127
+ slug,
1128
+ process.versions.modules,
1129
+ runtimeCompatibility ?? currentRuntimeCompatibility(),
1130
+ { trustMode: manifestTrust },
1131
+ );
1132
+ // Validate identity, provenance, compatibility, and byte ceilings before
1133
+ // creating or changing any installation path.
1134
+ const home = canonicalizeAgenCHome(resolveAgenCHome(env, userHome));
140
1135
  // Owner-only home (todo-120); install.sh already chmods 700.
141
- mkdirSync(home, { recursive: true, mode: 0o700 });
142
1136
  try {
143
1137
  chmodSync(home, 0o700);
144
1138
  } catch {
145
1139
  /* ignore */
146
1140
  }
147
- const version = manifest.runtimeVersion;
148
- const artifact = selectArtifact(manifest, slug);
149
- const installDir = runtimeInstallDir(home, version);
1141
+ const installDir = runtimeInstallDir(home, version, artifact);
1142
+ const bin = runtimeBinPath(home, version, artifact);
1143
+ const binRel = artifact?.bins?.agenc ?? "node_modules/@tetsuo-ai/runtime/bin/agenc";
1144
+ const versionDir = dirname(installDir);
1145
+ mkdirSync(versionDir, { recursive: true, mode: 0o700 });
1146
+ chmodSync(versionDir, 0o700);
1147
+ const lockPath = `${installDir}.agenc-lock.sqlite`;
150
1148
 
151
- if (isInstalled(installDir, artifact.sha256)) {
152
- return runtimeBinPath(home, version, artifact);
153
- }
1149
+ if (
1150
+ readyInstallAt(installDir, artifact.sha256, binRel) &&
1151
+ !hasInstallResidue(versionDir, basename(installDir))
1152
+ ) return bin;
154
1153
 
155
- log(`agenc: fetching runtime ${version} (${slug.os}-${slug.arch})...`);
156
- const tmp = join(tmpdir(), `agenc-runtime-${version}-${process.pid}.tar.gz`);
1154
+ // Reconcile a prior crash before doing network I/O. A process killed between
1155
+ // the two promotion renames leaves a complete backup that can be restored
1156
+ // offline under the same artifact-scoped lock.
1157
+ let releaseLock;
1158
+ let downloadDir;
1159
+ let downloadDirIdentity;
1160
+ let stagingDir;
1161
+ let stagingDirIdentity;
1162
+ let operationError;
157
1163
  try {
158
- await download(artifact.url, tmp, fetchImpl);
159
- const actual = await sha256File(tmp);
1164
+ releaseLock = await acquireLock(lockPath, {
1165
+ label: "runtime install",
1166
+ timeoutMs: 60_000,
1167
+ });
1168
+ if (reconcileInstallState(
1169
+ versionDir,
1170
+ installDir,
1171
+ artifact.sha256,
1172
+ binRel,
1173
+ durabilityHook,
1174
+ )) {
1175
+ return bin;
1176
+ }
1177
+ // Network I/O must not hold the filesystem lock. Keep the release handle
1178
+ // registered until it succeeds so the outer cleanup can retry a failed
1179
+ // SQLite close without losing the original error.
1180
+ releaseLock();
1181
+ releaseLock = undefined;
1182
+
1183
+ log(`agenc: fetching runtime ${version} (${slug.os}-${slug.arch})...`);
1184
+ ({ path: downloadDir, identity: downloadDirIdentity } =
1185
+ createPrivateTemporaryDirectory(versionDir, ".agenc-runtime-download-"));
1186
+ const tmp = join(downloadDir, "runtime.tar.gz");
1187
+ await download(artifact.url, tmp, artifact.bytes, manifestTrust, fetchImpl, {
1188
+ fetchTimeoutMs: runtimeFetchTimeoutMs,
1189
+ localArtifactHook,
1190
+ durabilityHook,
1191
+ });
1192
+ const archiveIdentity = snapshotRegularFile(tmp, "runtime archive");
1193
+ const actual = sha256File(tmp, archiveIdentity);
160
1194
  if (actual !== artifact.sha256) {
161
1195
  throw new Error(
162
1196
  `agenc: runtime checksum mismatch (expected ${artifact.sha256}, got ${actual})`,
163
1197
  );
164
1198
  }
165
- // Replace any partial/old tree atomically-ish: clean then extract.
166
- rmSync(installDir, { recursive: true, force: true });
167
- extractTarGz(tmp, installDir);
168
- const bin = runtimeBinPath(home, version, artifact);
169
- if (!existsSync(bin)) {
170
- throw new Error(`agenc: runtime extracted but entry missing: ${bin}`);
1199
+ if (archiveIdentity.size !== BigInt(artifact.bytes)) {
1200
+ throw new Error("agenc: bounded runtime download violated its byte-count invariant");
1201
+ }
1202
+ validateRuntimeArchive(tmp, artifact.platform);
1203
+ assertRegularFileIdentity(tmp, archiveIdentity, "runtime archive");
1204
+ releaseLock = await acquireLock(lockPath, {
1205
+ label: "runtime install",
1206
+ timeoutMs: 60_000,
1207
+ });
1208
+ // Another process may have completed while this one downloaded.
1209
+ if (reconcileInstallState(
1210
+ versionDir,
1211
+ installDir,
1212
+ artifact.sha256,
1213
+ binRel,
1214
+ durabilityHook,
1215
+ )) return bin;
1216
+ ({ path: stagingDir, identity: stagingDirIdentity } =
1217
+ createPrivateTemporaryDirectory(
1218
+ versionDir,
1219
+ `.${basename(installDir)}.install-`,
1220
+ ));
1221
+ assertRegularFileIdentity(tmp, archiveIdentity, "runtime archive");
1222
+ extractTarGz(tmp, stagingDir);
1223
+ assertRegularFileIdentity(tmp, archiveIdentity, "runtime archive");
1224
+ const stagedBin = join(stagingDir, binRel);
1225
+ if (!existsSync(stagedBin)) {
1226
+ throw new Error(`agenc: runtime extracted but entry missing: ${stagedBin}`);
1227
+ }
1228
+ durableWriteMarker(markerPath(stagingDir), artifact.sha256, durabilityHook);
1229
+ syncExtractedTree(stagingDir, durabilityHook);
1230
+ promoteInstall(stagingDir, installDir, durabilityHook);
1231
+ stagingDir = undefined;
1232
+ stagingDirIdentity = undefined;
1233
+ if (!reconcileInstallState(
1234
+ versionDir,
1235
+ installDir,
1236
+ artifact.sha256,
1237
+ binRel,
1238
+ durabilityHook,
1239
+ )) {
1240
+ throw new Error("agenc: promoted runtime did not satisfy the marker contract");
171
1241
  }
172
- writeFileSync(markerPath(installDir), artifact.sha256);
173
1242
  log(`agenc: runtime ${version} ready`);
174
1243
  return bin;
1244
+ } catch (error) {
1245
+ operationError = error;
1246
+ throw error;
175
1247
  } finally {
176
- rmSync(tmp, { force: true });
1248
+ const cleanupErrors = [];
1249
+ if (stagingDir !== undefined && existsSync(stagingDir)) {
1250
+ try {
1251
+ assertDirectoryIdentity(stagingDir, stagingDirIdentity, "runtime staging directory");
1252
+ durabilityEvent(durabilityHook, "remove-temporary", { path: stagingDir });
1253
+ remove(stagingDir, { recursive: true, force: true });
1254
+ syncDirectory(dirname(stagingDir), "sync-parent-after-remove", durabilityHook);
1255
+ }
1256
+ catch (error) { cleanupErrors.push(error); }
1257
+ }
1258
+ if (releaseLock !== undefined) {
1259
+ try { releaseLock(); }
1260
+ catch (error) { cleanupErrors.push(error); }
1261
+ }
1262
+ if (downloadDir !== undefined) {
1263
+ try {
1264
+ assertDirectoryIdentity(downloadDir, downloadDirIdentity, "runtime download directory");
1265
+ durabilityEvent(durabilityHook, "remove-temporary", { path: downloadDir });
1266
+ remove(downloadDir, { recursive: true, force: true });
1267
+ syncDirectory(dirname(downloadDir), "sync-parent-after-remove", durabilityHook);
1268
+ }
1269
+ catch (error) { cleanupErrors.push(error); }
1270
+ }
1271
+ if (cleanupErrors.length > 0) {
1272
+ throw new AggregateError(
1273
+ operationError === undefined
1274
+ ? cleanupErrors
1275
+ : [operationError, ...cleanupErrors],
1276
+ "agenc: runtime install and cleanup did not both complete",
1277
+ );
1278
+ }
177
1279
  }
178
1280
  }