@runuai/host 0.9.14 → 0.9.42

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.
Files changed (97) hide show
  1. package/README.md +22 -5
  2. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  3. package/db/migrations/0015_host_settings.sql +9 -0
  4. package/db/migrations/0016_task_environment.sql +2 -0
  5. package/db/migrations/meta/_journal.json +21 -0
  6. package/db/schema.ts +80 -30
  7. package/images/standard/Dockerfile +36 -10
  8. package/images/standard/README.md +63 -18
  9. package/images/standard/container/corepack-version +1 -0
  10. package/images/standard/container/uai-init +308 -38
  11. package/images/standard/container/uai-materialize-runtimes +1527 -0
  12. package/lib/agent-cli.ts +33 -2
  13. package/lib/agent.ts +46 -7
  14. package/lib/agents/claude.ts +13 -8
  15. package/lib/agents/codex.ts +11 -6
  16. package/lib/agents/cursor.ts +39 -29
  17. package/lib/agents/durable-proc.ts +20 -27
  18. package/lib/agents/factory.ts +9 -25
  19. package/lib/agents/grok.ts +43 -30
  20. package/lib/agents/kimi.ts +44 -29
  21. package/lib/agents/opencode.ts +43 -31
  22. package/lib/agents/proc.ts +149 -114
  23. package/lib/agents/transport.ts +62 -50
  24. package/lib/agents/types.ts +6 -4
  25. package/lib/apple-runtime-recycle.ts +236 -0
  26. package/lib/apple-uninstall-teardown.ts +224 -0
  27. package/lib/browser-testing.ts +233 -93
  28. package/lib/codex-auth.ts +40 -6
  29. package/lib/command-db.ts +20 -0
  30. package/lib/container-runtime.ts +1338 -0
  31. package/lib/db.ts +1 -0
  32. package/lib/docker-exec.ts +87 -5
  33. package/lib/engine-accounts.ts +68 -5
  34. package/lib/engine-login.ts +1952 -0
  35. package/lib/enrollment-state.ts +251 -0
  36. package/lib/env-file.ts +155 -0
  37. package/lib/env.ts +4 -0
  38. package/lib/git-diff.ts +98 -32
  39. package/lib/git-identity.ts +199 -87
  40. package/lib/github-tokens.ts +202 -91
  41. package/lib/host-cloud-url.ts +62 -0
  42. package/lib/host-config.ts +279 -0
  43. package/lib/host-logs.ts +962 -0
  44. package/lib/keyed-promise-tail.ts +23 -0
  45. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  46. package/lib/managed-activation-watcher.ts +72 -0
  47. package/lib/managed-install-owner-watcher.ts +55 -0
  48. package/lib/managed-operation-drain.ts +49 -0
  49. package/lib/managed-runtime.ts +3644 -0
  50. package/lib/managed-update-scheduler.ts +125 -0
  51. package/lib/mcp-gateway.ts +450 -23
  52. package/lib/orchestrator.ts +3060 -218
  53. package/lib/preview-sidecar.ts +57 -13
  54. package/lib/release-manifest.ts +708 -0
  55. package/lib/release-trust.ts +28 -0
  56. package/lib/runtime-activation-tail.ts +232 -0
  57. package/lib/runtime-archive.ts +1086 -0
  58. package/lib/runtime-authority.ts +79 -0
  59. package/lib/runtime-guard.ts +36 -0
  60. package/lib/runtime-provider-state.ts +169 -0
  61. package/lib/runtime-state.ts +232 -12
  62. package/lib/skills.ts +24 -3
  63. package/lib/ssh.ts +18 -0
  64. package/lib/standard-image.ts +1104 -141
  65. package/lib/stopped-task-status-queue.ts +44 -0
  66. package/lib/task-container-cli.ts +269 -0
  67. package/lib/task-diff.ts +66 -46
  68. package/lib/task-environment/apple-container.ts +757 -0
  69. package/lib/task-environment/docker.ts +945 -0
  70. package/lib/task-environment/index.ts +364 -0
  71. package/lib/task-environment/legacy-adoption.ts +443 -0
  72. package/lib/task-environment/registry.ts +58 -0
  73. package/lib/task-environment/types.ts +408 -0
  74. package/lib/task-identity.ts +19 -0
  75. package/lib/task-inventory.ts +585 -0
  76. package/lib/tunnel-registry.ts +135 -19
  77. package/lib/tunnel-runtime.ts +235 -0
  78. package/package.json +1 -1
  79. package/scripts/agent/_common.sh +123 -3
  80. package/scripts/agent/task-down.sh +146 -38
  81. package/scripts/agent/task-status.sh +19 -3
  82. package/scripts/agent/task-up.sh +1405 -107
  83. package/scripts/install/darwin.ts +848 -50
  84. package/scripts/install/linux.ts +838 -35
  85. package/scripts/install/types.ts +43 -0
  86. package/scripts/install/util.ts +215 -8
  87. package/scripts/install/win.ts +12 -0
  88. package/src/apple-tunnel-route.ts +104 -0
  89. package/src/cli.ts +1464 -72
  90. package/src/event-outbox.ts +83 -4
  91. package/src/index.ts +766 -42
  92. package/src/main.ts +1398 -255
  93. package/src/paths.ts +17 -1
  94. package/src/protocol.ts +695 -1
  95. package/src/runtime-bootstrap.ts +165 -0
  96. package/src/ui/server.ts +46 -10
  97. package/src/ui/types.ts +37 -0
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Serialize asynchronous maintenance while coalescing callers for the same
3
+ * requested configuration. A different key always queues a distinct pass;
4
+ * this is stronger than a plain in-flight memo, which can let configuration C
5
+ * accidentally join an older B operation and then be declared reconciled.
6
+ */
7
+ export class KeyedPromiseTail<T> {
8
+ private tail: { key: string; promise: Promise<T> } | null = null;
9
+
10
+ run(key: string, operation: () => Promise<T>): Promise<T> {
11
+ if (this.tail?.key === key) return this.tail.promise;
12
+ const predecessor = this.tail?.promise;
13
+ let running!: Promise<T>;
14
+ running = (predecessor
15
+ ? predecessor.catch(() => undefined)
16
+ : Promise.resolve()
17
+ ).then(operation).finally(() => {
18
+ if (this.tail?.promise === running) this.tail = null;
19
+ });
20
+ this.tail = { key, promise: running };
21
+ return running;
22
+ }
23
+ }
@@ -0,0 +1,627 @@
1
+ /**
2
+ * Intentionally frozen compatibility oracle copied from
3
+ * f2d638d52c641282db8e01430c3196364b2d2d3f before ADR-101 Phase B.
4
+ *
5
+ * Do not update this parser to understand new runtime capabilities. The
6
+ * rolling-bridge regression executes new artifacts through these exact legacy
7
+ * archive bytes/layout checks. Only the imports below were widened so the
8
+ * verbatim legacy installed-layout validator can live in the same fixture.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import { constants as fsConstants } from "node:fs";
12
+ import {
13
+ chmod,
14
+ lstat,
15
+ mkdir,
16
+ open,
17
+ readFile,
18
+ readdir,
19
+ realpath,
20
+ symlink,
21
+ } from "node:fs/promises";
22
+ import path from "node:path";
23
+ import { gzipSync, gunzipSync } from "node:zlib";
24
+
25
+ import {
26
+ MAX_RELEASE_ARTIFACT_BYTES,
27
+ isReleasePlatform,
28
+ type ReleaseArtifact,
29
+ verifyReleaseArtifactIntegrity,
30
+ } from "./release-manifest";
31
+
32
+ const BLOCK_SIZE = 512;
33
+ const MAX_UNCOMPRESSED_BYTES = 1024 * 1024 * 1024;
34
+ const MAX_ARCHIVE_MEMBERS = 100_000;
35
+ const MAX_MEMBER_PATH_BYTES = 4096;
36
+ const MAX_PAX_BYTES = 64 * 1024;
37
+ const ZERO_BLOCK = Buffer.alloc(BLOCK_SIZE);
38
+ const EXPECTED_TOP_LEVEL = Object.freeze(["agent", "bin", "node", "runtime.json"]);
39
+ const VERSION = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
40
+
41
+ export interface RuntimeDescriptor {
42
+ readonly schemaVersion: 1;
43
+ readonly hostVersion: string;
44
+ readonly platform: string;
45
+ readonly node: {
46
+ readonly version: string;
47
+ readonly modules: string;
48
+ };
49
+ readonly requirements: Readonly<Record<string, string>>;
50
+ readonly layout: {
51
+ readonly agent: "agent";
52
+ readonly launcher: "bin/uai-host";
53
+ readonly node: "node";
54
+ };
55
+ }
56
+
57
+ interface ArchiveEntry {
58
+ readonly memberPath: string;
59
+ readonly type: "0" | "2" | "5";
60
+ readonly mode: number;
61
+ readonly data: Buffer;
62
+ readonly linkTarget: string;
63
+ readonly resolvedLinkTarget: string | null;
64
+ }
65
+
66
+ export interface InspectedRuntimeArchive {
67
+ readonly descriptor: RuntimeDescriptor;
68
+ readonly memberCount: number;
69
+ }
70
+
71
+ /**
72
+ * Read, count and hash one already-downloaded regular file through one open
73
+ * descriptor. Extraction consumes the exact returned bytes; there is no
74
+ * verify-by-name/reopen window in which the pathname could be swapped.
75
+ */
76
+ export async function readVerifiedArtifactFile(
77
+ archivePath: string,
78
+ artifact: ReleaseArtifact,
79
+ ): Promise<Buffer> {
80
+ const noFollow = fsConstants.O_NOFOLLOW ?? 0;
81
+ const handle = await open(archivePath, fsConstants.O_RDONLY | noFollow);
82
+ try {
83
+ const before = await handle.stat();
84
+ if (!before.isFile()) throw new Error("runtime archive must be a regular file");
85
+ if (before.size > MAX_RELEASE_ARTIFACT_BYTES) {
86
+ throw new Error(`runtime archive exceeds ${MAX_RELEASE_ARTIFACT_BYTES} bytes`);
87
+ }
88
+
89
+ const hash = createHash("sha256");
90
+ const chunks: Buffer[] = [];
91
+ let size = 0;
92
+ for await (const raw of handle.createReadStream({ autoClose: false })) {
93
+ const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
94
+ size += chunk.length;
95
+ if (size > MAX_RELEASE_ARTIFACT_BYTES) {
96
+ throw new Error(`runtime archive exceeds ${MAX_RELEASE_ARTIFACT_BYTES} bytes`);
97
+ }
98
+ hash.update(chunk);
99
+ chunks.push(chunk);
100
+ }
101
+ const after = await handle.stat();
102
+ if (
103
+ before.dev !== after.dev ||
104
+ before.ino !== after.ino ||
105
+ before.size !== after.size ||
106
+ size !== after.size
107
+ ) {
108
+ throw new Error("runtime archive changed while it was being verified");
109
+ }
110
+ verifyReleaseArtifactIntegrity(artifact, {
111
+ size,
112
+ sha256: hash.digest(),
113
+ });
114
+ return Buffer.concat(chunks, size);
115
+ } finally {
116
+ await handle.close();
117
+ }
118
+ }
119
+
120
+ export async function extractVerifiedRuntimeArchive(
121
+ archiveBytes: Buffer,
122
+ destination: string,
123
+ expected: { version: string; platform: string },
124
+ ): Promise<InspectedRuntimeArchive> {
125
+ const { entries, descriptor } = inspectRuntimeArchive(archiveBytes, expected);
126
+ // The caller gives us a fresh, private staging path. `recursive: false`
127
+ // refuses to merge archive contents into any pre-existing tree.
128
+ await mkdir(destination, { mode: 0o700, recursive: false });
129
+ const directories = [destination];
130
+
131
+ for (const entry of entries) {
132
+ const output = path.join(destination, ...entry.memberPath.split("/"));
133
+ if (entry.type === "5") {
134
+ await mkdir(output, { mode: 0o755, recursive: false });
135
+ await chmod(output, 0o755);
136
+ directories.push(output);
137
+ } else if (entry.type === "0") {
138
+ const handle = await open(output, "wx", entry.mode);
139
+ try {
140
+ await handle.writeFile(entry.data);
141
+ await handle.chmod(entry.mode);
142
+ await handle.sync();
143
+ } finally {
144
+ await handle.close();
145
+ }
146
+ }
147
+ }
148
+ // Create links only after every regular target exists. Archive validation
149
+ // has already rejected absolute/escaping targets and any child below a link.
150
+ for (const entry of entries) {
151
+ if (entry.type !== "2") continue;
152
+ const output = path.join(destination, ...entry.memberPath.split("/"));
153
+ await symlink(entry.linkTarget, output);
154
+ }
155
+ // Persist children before parents so activation never points at a directory
156
+ // whose archive contents only existed in the page cache at power loss.
157
+ for (const directory of directories.reverse()) {
158
+ await syncDirectory(directory);
159
+ }
160
+ return { descriptor, memberCount: entries.length };
161
+ }
162
+
163
+ export function inspectRuntimeArchive(
164
+ archiveBytes: Buffer,
165
+ expected: { version: string; platform: string },
166
+ ): { entries: readonly ArchiveEntry[]; descriptor: RuntimeDescriptor } {
167
+ assertCanonicalGzipHeader(archiveBytes);
168
+ const tar = gunzipSync(archiveBytes, { maxOutputLength: MAX_UNCOMPRESSED_BYTES });
169
+ // The release builder emits a single deterministic gzip stream. Rebuilding
170
+ // it under the packaged, exactly-pinned Node rejects concatenated streams,
171
+ // trailing bytes and alternate encodings before any filesystem mutation.
172
+ // Modern Node always writes a zero gzip mtime (the option was removed from
173
+ // its public typings); the builder runs the same pinned Node patch.
174
+ const canonical = gzipSync(tar, { level: 9 });
175
+ canonical[9] = 255;
176
+ if (!archiveBytes.equals(canonical)) {
177
+ throw new Error("runtime archive gzip stream is not canonical");
178
+ }
179
+
180
+ const entries: ArchiveEntry[] = [];
181
+ const byPath = new Map<string, ArchiveEntry>();
182
+ let offset = 0;
183
+ let pendingPax: { path?: string; linkpath?: string } | null = null;
184
+ let previousMember: string | null = null;
185
+ let terminated = false;
186
+
187
+ while (offset + BLOCK_SIZE <= tar.length) {
188
+ const header = tar.subarray(offset, offset + BLOCK_SIZE);
189
+ if (header.equals(ZERO_BLOCK)) {
190
+ if (
191
+ offset + 2 * BLOCK_SIZE !== tar.length ||
192
+ !tar.subarray(offset + BLOCK_SIZE).equals(ZERO_BLOCK)
193
+ ) {
194
+ throw new Error("runtime archive has trailing bytes or an invalid terminator");
195
+ }
196
+ offset += 2 * BLOCK_SIZE;
197
+ terminated = true;
198
+ break;
199
+ }
200
+
201
+ validateHeaderChecksum(header);
202
+ if (parseString(header, 257, 6) !== "ustar") {
203
+ throw new Error("runtime archive is not ustar");
204
+ }
205
+ const name = parseString(header, 0, 100);
206
+ const prefix = parseString(header, 345, 155);
207
+ const headerPath = prefix ? `${prefix}/${name}` : name;
208
+ const mode = parseOctal(header, 100, 8, "mode");
209
+ const uid = parseOctal(header, 108, 8, "uid");
210
+ const gid = parseOctal(header, 116, 8, "gid");
211
+ const size = parseOctal(header, 124, 12, "size");
212
+ const mtime = parseOctal(header, 136, 12, "mtime");
213
+ const type = (parseString(header, 156, 1) || "0") as string;
214
+ const rawLinkTarget = parseString(header, 157, 100);
215
+ if (
216
+ uid !== 0 ||
217
+ gid !== 0 ||
218
+ mtime !== 0 ||
219
+ parseString(header, 265, 32) !== "root" ||
220
+ parseString(header, 297, 32) !== "root" ||
221
+ parseOctal(header, 329, 8, "devmajor") !== 0 ||
222
+ parseOctal(header, 337, 8, "devminor") !== 0
223
+ ) {
224
+ throw new Error("runtime archive metadata is not canonical");
225
+ }
226
+ if (!Number.isSafeInteger(size) || size < 0 || size > MAX_UNCOMPRESSED_BYTES) {
227
+ throw new Error("runtime archive member size is invalid");
228
+ }
229
+ const dataStart = offset + BLOCK_SIZE;
230
+ const dataEnd = dataStart + size;
231
+ const paddedEnd = dataStart + Math.ceil(size / BLOCK_SIZE) * BLOCK_SIZE;
232
+ if (dataEnd > tar.length || paddedEnd > tar.length) {
233
+ throw new Error("runtime archive member exceeds input");
234
+ }
235
+ if (tar.subarray(dataEnd, paddedEnd).some((byte) => byte !== 0)) {
236
+ throw new Error(`runtime archive member padding is not zero: ${headerPath}`);
237
+ }
238
+ const data = tar.subarray(dataStart, dataEnd);
239
+ offset = paddedEnd;
240
+
241
+ if (type === "x") {
242
+ if (pendingPax) throw new Error("runtime archive has consecutive pax headers");
243
+ if (size > MAX_PAX_BYTES || mode !== 0o644) {
244
+ throw new Error("runtime archive pax header is invalid");
245
+ }
246
+ pendingPax = parsePax(data);
247
+ continue;
248
+ }
249
+ if (type !== "0" && type !== "2" && type !== "5") {
250
+ throw new Error(`unsupported runtime archive member type: ${type}`);
251
+ }
252
+ const rawMemberPath = pendingPax?.path ?? headerPath;
253
+ if ((type === "5") !== rawMemberPath.endsWith("/")) {
254
+ throw new Error(`runtime archive member path/type mismatch: ${rawMemberPath}`);
255
+ }
256
+ const memberPath = safeMemberPath(rawMemberPath);
257
+ if (
258
+ previousMember !== null &&
259
+ Buffer.compare(Buffer.from(previousMember), Buffer.from(memberPath)) >= 0
260
+ ) {
261
+ throw new Error(`runtime archive members are not strictly sorted: ${memberPath}`);
262
+ }
263
+ previousMember = memberPath;
264
+ if (byPath.has(memberPath)) {
265
+ throw new Error(`duplicate runtime archive member: ${memberPath}`);
266
+ }
267
+ if (entries.length >= MAX_ARCHIVE_MEMBERS) {
268
+ throw new Error(`runtime archive exceeds ${MAX_ARCHIVE_MEMBERS} members`);
269
+ }
270
+ if (type !== "0" && size !== 0) {
271
+ throw new Error(`non-file runtime archive member carries data: ${memberPath}`);
272
+ }
273
+ const expectedMode = type === "5" ? 0o755 : type === "2" ? 0o777 : mode;
274
+ if (
275
+ mode !== expectedMode ||
276
+ (type === "0" && mode !== 0o644 && mode !== 0o755)
277
+ ) {
278
+ throw new Error(`runtime archive member mode is invalid: ${memberPath}`);
279
+ }
280
+ const linkTarget = pendingPax?.linkpath ?? rawLinkTarget;
281
+ const resolvedLinkTarget =
282
+ type === "2" ? safeLinkTarget(memberPath, linkTarget) : null;
283
+ const entry: ArchiveEntry = {
284
+ memberPath,
285
+ type,
286
+ mode,
287
+ data,
288
+ linkTarget,
289
+ resolvedLinkTarget,
290
+ };
291
+ entries.push(entry);
292
+ byPath.set(memberPath, entry);
293
+ pendingPax = null;
294
+ }
295
+
296
+ if (!terminated || offset !== tar.length || pendingPax) {
297
+ throw new Error("runtime archive is truncated");
298
+ }
299
+ validateEntryGraph(entries, byPath);
300
+ const descriptorEntry = byPath.get("runtime.json");
301
+ if (!descriptorEntry || descriptorEntry.data.length > MAX_PAX_BYTES) {
302
+ throw new Error("runtime archive descriptor is missing or too large");
303
+ }
304
+ let rawDescriptor: unknown;
305
+ try {
306
+ rawDescriptor = JSON.parse(descriptorEntry.data.toString("utf8"));
307
+ } catch {
308
+ throw new Error("runtime archive descriptor is invalid JSON");
309
+ }
310
+ const descriptor = validateRuntimeDescriptor(rawDescriptor, expected);
311
+ return { entries, descriptor };
312
+ }
313
+
314
+ function validateEntryGraph(
315
+ entries: readonly ArchiveEntry[],
316
+ byPath: ReadonlyMap<string, ArchiveEntry>,
317
+ ): void {
318
+ const topLevel = new Set(entries.map((entry) => entry.memberPath.split("/")[0]!));
319
+ if (
320
+ [...topLevel].sort().join("\0") !== [...EXPECTED_TOP_LEVEL].sort().join("\0")
321
+ ) {
322
+ throw new Error("runtime archive top-level layout is invalid");
323
+ }
324
+ for (const entry of entries) {
325
+ const parts = entry.memberPath.split("/");
326
+ for (let length = 1; length < parts.length; length += 1) {
327
+ const ancestor = parts.slice(0, length).join("/");
328
+ const parent = byPath.get(ancestor);
329
+ if (!parent || parent.type !== "5") {
330
+ throw new Error(`runtime archive parent is not a directory: ${ancestor}`);
331
+ }
332
+ }
333
+ if (
334
+ entry.type === "2" &&
335
+ (!entry.resolvedLinkTarget || !byPath.has(entry.resolvedLinkTarget))
336
+ ) {
337
+ throw new Error(`runtime archive symlink target does not exist: ${entry.memberPath}`);
338
+ }
339
+ }
340
+ for (const required of [
341
+ "agent/bin/uai-host.mjs",
342
+ "bin/uai-host",
343
+ "node/bin/node",
344
+ "runtime.json",
345
+ ]) {
346
+ if (byPath.get(required)?.type !== "0") {
347
+ throw new Error(`runtime archive is missing regular file ${required}`);
348
+ }
349
+ }
350
+ for (const required of ["agent", "bin", "node"]) {
351
+ if (byPath.get(required)?.type !== "5") {
352
+ throw new Error(`runtime archive is missing directory ${required}`);
353
+ }
354
+ }
355
+ for (const executable of ["bin/uai-host", "node/bin/node"]) {
356
+ if (byPath.get(executable)?.mode !== 0o755) {
357
+ throw new Error(`runtime archive executable mode is invalid: ${executable}`);
358
+ }
359
+ }
360
+ }
361
+
362
+ export function validateRuntimeDescriptor(
363
+ value: unknown,
364
+ expected: { version: string; platform: string },
365
+ ): RuntimeDescriptor {
366
+ const object = strictObject(
367
+ value,
368
+ ["schemaVersion", "hostVersion", "platform", "node", "requirements", "layout"],
369
+ "runtime descriptor",
370
+ );
371
+ if (object.schemaVersion !== 1) throw new Error("unsupported runtime descriptor schema");
372
+ if (object.hostVersion !== expected.version || !VERSION.test(expected.version)) {
373
+ throw new Error("runtime descriptor version does not match the authenticated release");
374
+ }
375
+ if (object.platform !== expected.platform) {
376
+ throw new Error("runtime descriptor platform does not match this host");
377
+ }
378
+ const node = strictObject(object.node, ["version", "modules"], "runtime descriptor node");
379
+ if (typeof node.version !== "string" || !VERSION.test(node.version)) {
380
+ throw new Error("runtime descriptor Node version is invalid");
381
+ }
382
+ if (typeof node.modules !== "string" || !/^\d+$/.test(node.modules)) {
383
+ throw new Error("runtime descriptor Node ABI is invalid");
384
+ }
385
+ const layout = strictObject(
386
+ object.layout,
387
+ ["agent", "launcher", "node"],
388
+ "runtime descriptor layout",
389
+ );
390
+ if (
391
+ layout.agent !== "agent" ||
392
+ layout.launcher !== "bin/uai-host" ||
393
+ layout.node !== "node"
394
+ ) {
395
+ throw new Error("runtime descriptor layout is invalid");
396
+ }
397
+ const requirements = strictStringObject(object.requirements, "runtime requirements");
398
+ validateRequirements(expected.platform, requirements);
399
+ return {
400
+ schemaVersion: 1,
401
+ hostVersion: expected.version,
402
+ platform: expected.platform,
403
+ node: { version: node.version, modules: node.modules },
404
+ requirements,
405
+ layout: { agent: "agent", launcher: "bin/uai-host", node: "node" },
406
+ };
407
+ }
408
+
409
+ function validateRequirements(platform: string, value: Readonly<Record<string, string>>): void {
410
+ const [os, arch] = platform.split("-");
411
+ if (value.os !== os || value.arch !== arch) {
412
+ throw new Error("runtime descriptor requirements do not match its platform");
413
+ }
414
+ const keys = Object.keys(value).sort().join("\0");
415
+ if (os === "darwin") {
416
+ if (keys !== ["arch", "minVersion", "os"].join("\0") || value.minVersion !== "13.5") {
417
+ throw new Error("runtime descriptor macOS requirements are invalid");
418
+ }
419
+ } else if (
420
+ os !== "linux" ||
421
+ keys !== ["arch", "libc", "minKernel", "minLibcVersion", "os"].join("\0") ||
422
+ value.libc !== "glibc" ||
423
+ value.minKernel !== "4.18" ||
424
+ value.minLibcVersion !== "2.29"
425
+ ) {
426
+ throw new Error("runtime descriptor Linux requirements are invalid");
427
+ }
428
+ }
429
+
430
+ function strictObject(
431
+ value: unknown,
432
+ expectedKeys: readonly string[],
433
+ label: string,
434
+ ): Record<string, unknown> {
435
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
436
+ throw new Error(`${label} must be an object`);
437
+ }
438
+ const object = value as Record<string, unknown>;
439
+ if (Object.getPrototypeOf(object) !== Object.prototype) {
440
+ throw new Error(`${label} must be a plain object`);
441
+ }
442
+ if (Object.keys(object).sort().join("\0") !== [...expectedKeys].sort().join("\0")) {
443
+ throw new Error(`${label} fields are invalid`);
444
+ }
445
+ return object;
446
+ }
447
+
448
+ function strictStringObject(value: unknown, label: string): Readonly<Record<string, string>> {
449
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
450
+ throw new Error(`${label} must be an object`);
451
+ }
452
+ const output: Record<string, string> = {};
453
+ for (const [key, item] of Object.entries(value)) {
454
+ if (typeof item !== "string") throw new Error(`${label}.${key} must be a string`);
455
+ output[key] = item;
456
+ }
457
+ return output;
458
+ }
459
+
460
+ function assertCanonicalGzipHeader(bytes: Buffer): void {
461
+ if (
462
+ bytes.length < 18 ||
463
+ bytes[0] !== 0x1f ||
464
+ bytes[1] !== 0x8b ||
465
+ bytes[2] !== 8 ||
466
+ bytes[3] !== 0 ||
467
+ bytes.readUInt32LE(4) !== 0 ||
468
+ bytes[8] !== 2 ||
469
+ bytes[9] !== 255
470
+ ) {
471
+ throw new Error("runtime archive gzip header is not canonical");
472
+ }
473
+ }
474
+
475
+ function validateHeaderChecksum(header: Buffer): void {
476
+ const expected = parseOctal(header, 148, 8, "checksum");
477
+ const copy = Buffer.from(header);
478
+ copy.fill(0x20, 148, 156);
479
+ const actual = copy.reduce((sum, byte) => sum + byte, 0);
480
+ if (actual !== expected) throw new Error("runtime archive header checksum mismatch");
481
+ }
482
+
483
+ function parseString(block: Buffer, offset: number, length: number): string {
484
+ const end = block.indexOf(0, offset);
485
+ const stop = end === -1 || end > offset + length ? offset + length : end;
486
+ const bytes = block.subarray(offset, stop);
487
+ const decoded = bytes.toString("utf8");
488
+ if (!Buffer.from(decoded, "utf8").equals(bytes)) {
489
+ throw new Error("runtime archive header contains invalid UTF-8");
490
+ }
491
+ return decoded;
492
+ }
493
+
494
+ function parseOctal(block: Buffer, offset: number, length: number, label: string): number {
495
+ const raw = parseString(block, offset, length).trim();
496
+ if (!/^[0-7]+$/.test(raw)) throw new Error(`runtime archive has invalid ${label}`);
497
+ const value = Number.parseInt(raw, 8);
498
+ if (!Number.isSafeInteger(value)) throw new Error(`runtime archive has invalid ${label}`);
499
+ return value;
500
+ }
501
+
502
+ function parsePax(body: Buffer): { path?: string; linkpath?: string } {
503
+ const values: { path?: string; linkpath?: string } = {};
504
+ let offset = 0;
505
+ while (offset < body.length) {
506
+ const space = body.indexOf(0x20, offset);
507
+ if (space === -1) throw new Error("runtime archive pax record has no length");
508
+ const rawLength = body.subarray(offset, space).toString("ascii");
509
+ if (!/^[1-9]\d*$/.test(rawLength)) throw new Error("runtime archive pax length is invalid");
510
+ const length = Number.parseInt(rawLength, 10);
511
+ if (!Number.isSafeInteger(length) || offset + length > body.length) {
512
+ throw new Error("runtime archive pax boundary is invalid");
513
+ }
514
+ const record = body.subarray(space + 1, offset + length);
515
+ if (record.at(-1) !== 0x0a) throw new Error("runtime archive pax terminator is invalid");
516
+ const text = record.subarray(0, -1).toString("utf8");
517
+ const equals = text.indexOf("=");
518
+ if (equals <= 0) throw new Error("runtime archive pax record is invalid");
519
+ const key = text.slice(0, equals);
520
+ const value = text.slice(equals + 1);
521
+ if ((key !== "path" && key !== "linkpath") || Object.hasOwn(values, key)) {
522
+ throw new Error(`unsupported or duplicate runtime archive pax key: ${key}`);
523
+ }
524
+ values[key] = value;
525
+ offset += length;
526
+ }
527
+ return values;
528
+ }
529
+
530
+ function safeMemberPath(value: string): string {
531
+ const normalizedValue = value.endsWith("/") ? value.slice(0, -1) : value;
532
+ if (
533
+ !normalizedValue ||
534
+ Buffer.byteLength(normalizedValue) > MAX_MEMBER_PATH_BYTES ||
535
+ normalizedValue.startsWith("/") ||
536
+ /[\x00-\x1f\x7f]/.test(normalizedValue) ||
537
+ normalizedValue
538
+ .split("/")
539
+ .some((part) => !part || part === "." || part === "..") ||
540
+ path.posix.normalize(normalizedValue) !== normalizedValue
541
+ ) {
542
+ throw new Error(`unsafe runtime archive member path: ${JSON.stringify(value)}`);
543
+ }
544
+ return normalizedValue;
545
+ }
546
+
547
+ function safeLinkTarget(memberPath: string, target: string): string {
548
+ if (
549
+ !target ||
550
+ Buffer.byteLength(target) > MAX_MEMBER_PATH_BYTES ||
551
+ target.startsWith("/") ||
552
+ /[\x00-\x1f\x7f]/.test(target)
553
+ ) {
554
+ throw new Error(`unsafe runtime archive symlink target: ${JSON.stringify(target)}`);
555
+ }
556
+ const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(memberPath), target));
557
+ if (resolved === ".." || resolved.startsWith("../") || resolved.startsWith("/")) {
558
+ throw new Error(`runtime archive symlink escapes the payload: ${memberPath}`);
559
+ }
560
+ return resolved;
561
+ }
562
+
563
+ async function syncDirectory(directory: string): Promise<void> {
564
+ let handle;
565
+ try {
566
+ handle = await open(directory, fsConstants.O_RDONLY);
567
+ await handle.sync();
568
+ } catch (error) {
569
+ const code = (error as NodeJS.ErrnoException).code;
570
+ if (code !== "EINVAL" && code !== "ENOTSUP" && code !== "EISDIR") {
571
+ throw error;
572
+ }
573
+ } finally {
574
+ await handle?.close();
575
+ }
576
+ }
577
+
578
+
579
+ /**
580
+ * Frozen verbatim body of the pre-Phase-B validateInstalledRuntime path.
581
+ * It deliberately knows only the schema-v1 envelope and four top-level names.
582
+ */
583
+ export async function validateLegacyInstalledRuntime(
584
+ directory: string,
585
+ expectedVersion: string,
586
+ expectedPlatform?: string,
587
+ ): Promise<RuntimeDescriptor> {
588
+ const root = await realpath(directory);
589
+ const topLevel = (await readdir(root)).sort();
590
+ if (topLevel.join("\0") !== ["agent", "bin", "node", "runtime.json"].join("\0")) {
591
+ throw new Error(`managed runtime ${expectedVersion} has an invalid top-level layout`);
592
+ }
593
+ for (const [relative, executable] of [
594
+ ["agent/bin/uai-host.mjs", false],
595
+ ["bin/uai-host", true],
596
+ ["node/bin/node", true],
597
+ ["runtime.json", false],
598
+ ] as const) {
599
+ const absolute = path.join(root, ...relative.split("/"));
600
+ const stat = await lstat(absolute);
601
+ if (!stat.isFile()) throw new Error(`managed runtime is missing regular file ${relative}`);
602
+ if (executable && (stat.mode & 0o111) === 0) {
603
+ throw new Error(`managed runtime file is not executable: ${relative}`);
604
+ }
605
+ const resolved = await realpath(absolute);
606
+ if (!isInside(root, resolved)) {
607
+ throw new Error(`managed runtime file escapes its version directory: ${relative}`);
608
+ }
609
+ }
610
+ const raw = JSON.parse(await readFile(path.join(root, "runtime.json"), "utf8")) as {
611
+ platform?: unknown;
612
+ };
613
+ const platform = expectedPlatform ??
614
+ (typeof raw.platform === "string" && isReleasePlatform(raw.platform)
615
+ ? raw.platform
616
+ : null);
617
+ if (!platform) throw new Error("managed runtime descriptor platform is invalid");
618
+ return validateRuntimeDescriptor(raw, {
619
+ version: expectedVersion,
620
+ platform,
621
+ });
622
+ }
623
+
624
+ function isInside(root: string, candidate: string): boolean {
625
+ const relative = path.relative(root, candidate);
626
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
627
+ }