@runuai/host 0.9.147 → 0.9.149

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 (98) hide show
  1. package/db/migrations/0024_task_env_mode.sql +9 -0
  2. package/db/migrations/0025_project_env_authority.sql +147 -0
  3. package/db/migrations/0026_project_env_replica.sql +150 -0
  4. package/db/migrations/meta/_journal.json +21 -0
  5. package/db/schema.ts +312 -2
  6. package/images/standard/Dockerfile +44 -6
  7. package/images/standard/container/uai-init +85 -2
  8. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  9. package/lib/agent-cli.ts +18 -1
  10. package/lib/agent.ts +99 -34
  11. package/lib/agents/attach-compatibility.ts +51 -0
  12. package/lib/agents/codex.ts +137 -13
  13. package/lib/agents/cursor.ts +15 -1
  14. package/lib/agents/durable-proc.ts +2 -0
  15. package/lib/agents/grok.ts +8 -0
  16. package/lib/agents/kimi.ts +1 -0
  17. package/lib/agents/machine-durable.ts +17 -0
  18. package/lib/agents/opencode.ts +1 -0
  19. package/lib/agents/proc.ts +6 -1
  20. package/lib/agents/session-state.ts +107 -0
  21. package/lib/agents/transport.ts +125 -58
  22. package/lib/agents/types.ts +3 -3
  23. package/lib/browser-testing.ts +6 -0
  24. package/lib/codex-auth.ts +2 -0
  25. package/lib/credential-store.ts +141 -6
  26. package/lib/engine-accounts.ts +1 -0
  27. package/lib/env-file.ts +7 -0
  28. package/lib/env-schema.ts +841 -51
  29. package/lib/git-diff.ts +1 -0
  30. package/lib/git-identity.ts +2 -0
  31. package/lib/github-tokens.ts +1 -0
  32. package/lib/host-env.ts +98 -18
  33. package/lib/lease.ts +11 -3
  34. package/lib/machine-gateway-tunnel.ts +195 -17
  35. package/lib/machine-project-env-loader.ts +152 -0
  36. package/lib/managed-runtime.ts +270 -51
  37. package/lib/mcp-gateway.ts +777 -26
  38. package/lib/orchestrator.ts +1681 -138
  39. package/lib/project-env-authority.ts +1319 -0
  40. package/lib/project-env-feature.ts +74 -0
  41. package/lib/project-env-loader.ts +237 -0
  42. package/lib/project-env-replica.ts +2094 -0
  43. package/lib/project-env-schema-capture.ts +342 -0
  44. package/lib/project-env-staging.ts +148 -0
  45. package/lib/project-env-task-policy.ts +176 -0
  46. package/lib/runtime-archive.ts +79 -16
  47. package/lib/runtime-authority.ts +44 -0
  48. package/lib/runtime-state.ts +175 -10
  49. package/lib/secrets-proxy/address.ts +72 -0
  50. package/lib/secrets-proxy/audit.ts +30 -0
  51. package/lib/secrets-proxy/ca-store.ts +200 -0
  52. package/lib/secrets-proxy/ca.ts +470 -0
  53. package/lib/secrets-proxy/container-steps.ts +138 -0
  54. package/lib/secrets-proxy/der.ts +273 -0
  55. package/lib/secrets-proxy/engine.ts +1633 -0
  56. package/lib/secrets-proxy/host.ts +93 -0
  57. package/lib/secrets-proxy/hostname.ts +60 -0
  58. package/lib/secrets-proxy/placeholders.ts +196 -0
  59. package/lib/secrets-proxy/policy.ts +141 -0
  60. package/lib/secrets-proxy/readiness.ts +148 -0
  61. package/lib/secrets-proxy/runtime.ts +582 -0
  62. package/lib/secrets-proxy/scrub.ts +559 -0
  63. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  64. package/lib/secrets-proxy/task-scripts.ts +262 -0
  65. package/lib/secrets-proxy/task-trust.ts +574 -0
  66. package/lib/secrets-proxy/trust-watch.ts +80 -0
  67. package/lib/shared-files-s3.ts +3 -0
  68. package/lib/sidecar-guard.ts +58 -0
  69. package/lib/sidecar-resume.ts +51 -3
  70. package/lib/standard-image.ts +50 -12
  71. package/lib/task-env-forwarder.ts +228 -0
  72. package/lib/task-env-source.ts +193 -0
  73. package/lib/task-environment/apple-container.ts +45 -11
  74. package/lib/task-environment/docker.ts +51 -12
  75. package/lib/task-environment/index.ts +6 -2
  76. package/lib/task-environment/machine-task-up.ts +258 -25
  77. package/lib/task-environment/machine.ts +80 -22
  78. package/lib/task-environment/registry.ts +3 -2
  79. package/lib/task-environment/self-identity.ts +20 -0
  80. package/lib/task-environment/self.ts +92 -16
  81. package/lib/task-environment/types.ts +63 -0
  82. package/lib/task-gateway-port.ts +22 -0
  83. package/lib/task-gateway-protocol.ts +190 -0
  84. package/lib/task-gateway-recovery.ts +44 -0
  85. package/lib/task-gateway-state.ts +497 -0
  86. package/lib/transcript.ts +1 -0
  87. package/lib/ulid.ts +10 -1
  88. package/package.json +13 -1
  89. package/scripts/agent/env-hook.sh +626 -0
  90. package/scripts/agent/task-up.sh +476 -27
  91. package/scripts/agent/uai-env-exec.sh +26 -0
  92. package/src/cli.ts +26 -3
  93. package/src/index.ts +273 -19
  94. package/src/main.ts +941 -48
  95. package/src/project-env-latest.ts +130 -0
  96. package/src/project-env-policy.ts +281 -0
  97. package/src/project-env-wording.ts +47 -0
  98. package/src/protocol.ts +2239 -15
@@ -28,6 +28,16 @@ const BASE_TOP_LEVEL = Object.freeze(["agent", "bin", "node", "runtime.json"]);
28
28
  export const BUNDLED_RUNTIME_ROOT = "agent/bundled-runtime" as const;
29
29
  export const BUNDLED_RUNTIME_CAPABILITY =
30
30
  "agent/bundled-runtime/capability.json" as const;
31
+ /**
32
+ * Cross-platform managed-runtime generation capability. Keep this outside the
33
+ * Darwin-only bundled subtree: the 0.9.148 validator tolerates additive files
34
+ * below `agent/`, so it can install the first capability-4 generation without
35
+ * understanding the marker. That generation then uses it to fence every
36
+ * later update and rollback after project-env activation.
37
+ */
38
+ export const MANAGED_RUNTIME_CAPABILITY =
39
+ "agent/managed-runtime-capability.json" as const;
40
+ export const MANAGED_RUNTIME_CAPABILITY_VERSION = 4 as const;
31
41
  /** Plugin directory == plugin binary name in Apple's signed pkg payload. */
32
42
  const APPLE_CONTAINER_PLUGINS = Object.freeze([
33
43
  "container-core-images",
@@ -43,6 +53,7 @@ const STANDARD_IMAGE_ASSETS = Object.freeze([
43
53
  Object.freeze({ path: "container/corepack-version", mode: 0o644 as const }),
44
54
  Object.freeze({ path: "container/uai-init", mode: 0o755 as const }),
45
55
  Object.freeze({ path: "container/uai-materialize-runtimes", mode: 0o755 as const }),
56
+ Object.freeze({ path: "container/uai-task-forwarder.mjs", mode: 0o644 as const }),
46
57
  Object.freeze({ path: "tool-versions", mode: 0o644 as const }),
47
58
  ]);
48
59
  const RUNTIME_ASSET_ROOTS = Object.freeze([
@@ -107,11 +118,22 @@ export interface RuntimeCapabilityDescriptorV3 {
107
118
  readonly bundledRuntime: BundledRuntimeDescriptor;
108
119
  }
109
120
 
110
- export type RuntimeDescriptor = RuntimeDescriptorV1;
111
- export type ResolvedRuntimeDescriptor = RuntimeDescriptor & (
121
+ export interface ManagedRuntimeCapabilityDescriptorV4 {
122
+ readonly capabilitySchemaVersion: 4;
123
+ }
124
+
125
+ export type RuntimePlatformCapabilities =
112
126
  | { readonly capabilitySchemaVersion: 1 }
113
- | RuntimeCapabilityDescriptorV3
114
- );
127
+ | RuntimeCapabilityDescriptorV3;
128
+
129
+ export type ResolvedRuntimeCapabilities = RuntimePlatformCapabilities & {
130
+ /** Effective rollback/update floor carried by this whole generation. */
131
+ readonly runtimeCapabilityVersion: 1 | 3 | 4;
132
+ };
133
+
134
+ export type RuntimeDescriptor = RuntimeDescriptorV1;
135
+ export type ResolvedRuntimeDescriptor = RuntimeDescriptor &
136
+ ResolvedRuntimeCapabilities;
115
137
 
116
138
  export interface RuntimeAssetManifestEntry {
117
139
  readonly path: string;
@@ -138,9 +160,7 @@ interface ArchiveEntry {
138
160
 
139
161
  export interface InspectedRuntimeArchive {
140
162
  readonly descriptor: RuntimeDescriptor;
141
- readonly capabilities:
142
- | { readonly capabilitySchemaVersion: 1 }
143
- | RuntimeCapabilityDescriptorV3;
163
+ readonly capabilities: ResolvedRuntimeCapabilities;
144
164
  readonly memberCount: number;
145
165
  }
146
166
 
@@ -245,9 +265,7 @@ export function inspectRuntimeArchive(
245
265
  ): {
246
266
  entries: readonly ArchiveEntry[];
247
267
  descriptor: RuntimeDescriptor;
248
- capabilities:
249
- | { readonly capabilitySchemaVersion: 1 }
250
- | RuntimeCapabilityDescriptorV3;
268
+ capabilities: ResolvedRuntimeCapabilities;
251
269
  } {
252
270
  assertCanonicalGzipHeader(archiveBytes);
253
271
  const tar = gunzipSync(archiveBytes, { maxOutputLength: MAX_UNCOMPRESSED_BYTES });
@@ -423,7 +441,7 @@ function validateEntryLayout(
423
441
  entries: readonly ArchiveEntry[],
424
442
  byPath: ReadonlyMap<string, ArchiveEntry>,
425
443
  descriptor: RuntimeDescriptor,
426
- ): { readonly capabilitySchemaVersion: 1 } | RuntimeCapabilityDescriptorV3 {
444
+ ): ResolvedRuntimeCapabilities {
427
445
  const topLevel = new Set(entries.map((entry) => entry.memberPath.split("/")[0]!));
428
446
  if (
429
447
  [...topLevel].sort().join("\0") !== [...BASE_TOP_LEVEL].sort().join("\0")
@@ -447,9 +465,16 @@ function validateEntryLayout(
447
465
  "agent/scripts/agent/task-down.sh",
448
466
  "agent/scripts/agent/task-status.sh",
449
467
  ];
450
- const capabilities = validateArchivedRuntimeCapability(byPath, descriptor);
451
- if (capabilities.capabilitySchemaVersion === 3) {
452
- const bundled = capabilities.bundledRuntime;
468
+ const platformCapabilities = validateArchivedRuntimeCapability(
469
+ byPath,
470
+ descriptor,
471
+ );
472
+ const runtimeCapabilityVersion = validateArchivedManagedRuntimeCapability(
473
+ byPath,
474
+ platformCapabilities.capabilitySchemaVersion,
475
+ );
476
+ if (platformCapabilities.capabilitySchemaVersion === 3) {
477
+ const bundled = platformCapabilities.bundledRuntime;
453
478
  const pluginBinaries = APPLE_CONTAINER_PLUGINS.map(
454
479
  (plugin) => `${bundled.layout.plugins}/${plugin}/bin/${plugin}`,
455
480
  );
@@ -484,7 +509,7 @@ function validateEntryLayout(
484
509
  `${bundled.layout.standardImage}/container`,
485
510
  bundled.layout.licenses,
486
511
  );
487
- validateArchivedRuntimeAssets(byPath, capabilities);
512
+ validateArchivedRuntimeAssets(byPath, platformCapabilities);
488
513
  executables.push(
489
514
  bundled.layout.containerCli,
490
515
  bundled.layout.apiserver,
@@ -506,7 +531,27 @@ function validateEntryLayout(
506
531
  throw new Error(`runtime archive executable mode is invalid: ${executable}`);
507
532
  }
508
533
  }
509
- return capabilities;
534
+ return { ...platformCapabilities, runtimeCapabilityVersion };
535
+ }
536
+
537
+ function validateArchivedManagedRuntimeCapability(
538
+ byPath: ReadonlyMap<string, ArchiveEntry>,
539
+ legacyCapabilityVersion: 1 | 3,
540
+ ): 1 | 3 | 4 {
541
+ const entry = byPath.get(MANAGED_RUNTIME_CAPABILITY);
542
+ if (entry === undefined) return legacyCapabilityVersion;
543
+ if (entry.type !== "0" || entry.data.length > MAX_PAX_BYTES) {
544
+ throw new Error(
545
+ "managed runtime capability descriptor is missing or too large",
546
+ );
547
+ }
548
+ let raw: unknown;
549
+ try {
550
+ raw = JSON.parse(entry.data.toString("utf8"));
551
+ } catch {
552
+ throw new Error("managed runtime capability descriptor is invalid JSON");
553
+ }
554
+ return validateManagedRuntimeCapabilityDescriptor(raw).capabilitySchemaVersion;
510
555
  }
511
556
 
512
557
  function validateArchivedRuntimeCapability(
@@ -609,6 +654,24 @@ export function validateRuntimeCapabilityDescriptor(
609
654
  };
610
655
  }
611
656
 
657
+ export function validateManagedRuntimeCapabilityDescriptor(
658
+ value: unknown,
659
+ ): ManagedRuntimeCapabilityDescriptorV4 {
660
+ const object = strictObject(
661
+ value,
662
+ ["capabilitySchemaVersion"],
663
+ "managed runtime capability descriptor",
664
+ );
665
+ if (
666
+ object.capabilitySchemaVersion !== MANAGED_RUNTIME_CAPABILITY_VERSION
667
+ ) {
668
+ throw new Error("unsupported managed runtime capability schema");
669
+ }
670
+ return {
671
+ capabilitySchemaVersion: MANAGED_RUNTIME_CAPABILITY_VERSION,
672
+ };
673
+ }
674
+
612
675
  function validateBundledRuntimeDescriptor(
613
676
  value: unknown,
614
677
  ): BundledRuntimeDescriptor {
@@ -1,3 +1,8 @@
1
+ import type {
2
+ ProjectEnvMode,
3
+ ProjectEnvProfile,
4
+ } from "./task-environment/types";
5
+
1
6
  /**
2
7
  * Host-owned runtime/cache authority for every command launched in a task
3
8
  * container. Admission and recovery separately prove the container's fixed
@@ -95,6 +100,45 @@ export const MACHINE_RUNTIME_AUTHORITY_ENV: Readonly<Record<string, string>> =
95
100
  ENV: "",
96
101
  });
97
102
 
103
+ /** Root-owned loader paths installed in every PR2+ task environment. */
104
+ export const PROJECT_ENV_HOOK_PATH = "/usr/local/share/uai/env-hook.sh";
105
+ export const PROJECT_ENV_EXEC_PATH = "/usr/local/bin/uai-env-exec";
106
+
107
+ /** Names that must reach a provider-added outer shell as well as the payload. */
108
+ export const PROJECT_ENV_PROFILE_ENV_NAMES = Object.freeze([
109
+ "BASH_ENV",
110
+ "SHELL",
111
+ "UAI_ENV_HOOK",
112
+ ] as const);
113
+
114
+ /**
115
+ * Apply the caller's classified project-env policy at the final provider
116
+ * boundary. The overlay is deliberately last, so inherited/caller env cannot
117
+ * opt a maintenance command into the hook or replace the task login shell.
118
+ * Legacy baked environments are a rollout no-op: their image has no loader.
119
+ */
120
+ export function applyProjectEnvProfile<
121
+ T extends {
122
+ projectEnvProfile: ProjectEnvProfile;
123
+ env?: Readonly<Record<string, string>>;
124
+ },
125
+ >(
126
+ request: T,
127
+ envMode: ProjectEnvMode,
128
+ taskShell: "/usr/bin/zsh" | "/bin/bash",
129
+ ): T {
130
+ if (envMode === "baked") return request;
131
+ const profileEnv =
132
+ request.projectEnvProfile === "maintenance"
133
+ ? { BASH_ENV: "", UAI_ENV_HOOK: "0" }
134
+ : {
135
+ BASH_ENV: PROJECT_ENV_HOOK_PATH,
136
+ SHELL: taskShell,
137
+ UAI_ENV_HOOK: "1",
138
+ };
139
+ return { ...request, env: { ...request.env, ...profileEnv } };
140
+ }
141
+
98
142
  /** Rewrite a session env assembled for a CONTAINER into its machine form:
99
143
  * every container-authority key is dropped, then the machine authority is
100
144
  * overlaid. Non-authority keys (engine credentials, task tokens, preview
@@ -1,12 +1,13 @@
1
- import { eq, inArray, isNull, notInArray, or } from "drizzle-orm";
1
+ import { and, eq, inArray, isNull, notInArray, or } from "drizzle-orm";
2
2
 
3
3
  import { getDb, schema, type Db } from "./db";
4
4
  import { isTerminal, TERMINAL_STATUSES } from "./task-status";
5
5
  import { newId } from "./ulid";
6
6
  import type { HostTask } from "../db/schema";
7
- import type { TaskUpResult } from "../src/protocol";
7
+ import type { TaskLaunchInput, TaskUpResult } from "../src/protocol";
8
8
  import {
9
9
  serializeTaskEnvironmentLocator,
10
+ type ProjectEnvMode,
10
11
  type TaskEnvironmentLocator,
11
12
  } from "./task-environment/types";
12
13
 
@@ -29,6 +30,22 @@ export function getHostTask(taskId: string): HostTask | null {
29
30
  );
30
31
  }
31
32
 
33
+ /** Loader tasks whose authenticated gateway generation may still have a live
34
+ * runtime after this host process restarts. Terminal/stopped rows are never
35
+ * reactivated merely because an old credential file survived. */
36
+ export function activeLoaderHostTasks(): HostTask[] {
37
+ return getDb()
38
+ .select()
39
+ .from(schema.hostTasks)
40
+ .where(
41
+ and(
42
+ eq(schema.hostTasks.envMode, "loader"),
43
+ inArray(schema.hostTasks.statusMirror, ["starting", "running"]),
44
+ ),
45
+ )
46
+ .all();
47
+ }
48
+
32
49
  /** Remove a provisional first-seen row when admission loses a lifecycle gate. */
33
50
  export function deleteHostTask(taskId: string): void {
34
51
  // Row deletions are rare and load-bearing; a silent one cost a live
@@ -153,18 +170,17 @@ function upsertHostTaskIn(
153
170
  export function recordTaskUpResult(
154
171
  taskId: string,
155
172
  result: TaskUpResult,
156
- environment?: TaskEnvironmentLocator,
173
+ environment: TaskEnvironmentLocator,
174
+ envMode: ProjectEnvMode,
157
175
  ): void {
158
176
  upsertHostTask(taskId, {
159
177
  codeServerPort: result.codeServerPort ?? null,
160
178
  previewPorts: JSON.stringify(result.previewPorts ?? []),
161
179
  composeProject: result.composeProject,
162
180
  worktreePath: result.worktreePath,
163
- environmentProvider: environment?.provider ?? null,
164
- environmentLocator:
165
- environment === undefined
166
- ? null
167
- : serializeTaskEnvironmentLocator(environment),
181
+ environmentProvider: environment.provider,
182
+ environmentLocator: serializeTaskEnvironmentLocator(environment),
183
+ envMode,
168
184
  statusMirror: "running",
169
185
  lockedAt: null,
170
186
  startedAt: Date.now(),
@@ -187,10 +203,12 @@ export function recordTaskStarting(taskId: string): void {
187
203
  export function recordTaskEnvironmentPrepared(
188
204
  taskId: string,
189
205
  environment: TaskEnvironmentLocator,
206
+ envMode: ProjectEnvMode,
190
207
  ): void {
191
208
  upsertHostTask(taskId, {
192
209
  environmentProvider: environment.provider,
193
210
  environmentLocator: serializeTaskEnvironmentLocator(environment),
211
+ envMode,
194
212
  });
195
213
  }
196
214
 
@@ -204,6 +222,8 @@ export function recordTaskDown(taskId: string, status: string | undefined): void
204
222
  worktreePath: null,
205
223
  environmentProvider: null,
206
224
  environmentLocator: null,
225
+ envMode: null,
226
+ projectEnvSpec: null,
207
227
  statusMirror: status ?? "killed",
208
228
  lockedAt: null,
209
229
  endedAt: Date.now(),
@@ -245,8 +265,8 @@ export function recordTaskOwner(
245
265
  });
246
266
  }
247
267
 
248
- /** The task's project ids, lead project first; `[]` for rows that predate
249
- * the column or carry an unreadable value. */
268
+ /** Legacy baked-machine project ids, lead first; `[]` for rows that predate
269
+ * the column or carry an unreadable value. Loader resolution uses projectEnvSpec. */
250
270
  export function hostTaskProjectIds(task: { projectIds?: string | null }): string[] {
251
271
  try {
252
272
  const parsed: unknown = JSON.parse(task.projectIds ?? "[]");
@@ -258,6 +278,151 @@ export function hostTaskProjectIds(task: { projectIds?: string | null }): string
258
278
  }
259
279
  }
260
280
 
281
+ /** Null is the durable meaning of a task created before ADR-153 PR2. */
282
+ export function hostTaskEnvMode(task: {
283
+ envMode?: string | null;
284
+ }): ProjectEnvMode {
285
+ if (task.envMode === null || task.envMode === undefined) return "baked";
286
+ if (task.envMode === "baked" || task.envMode === "loader") {
287
+ return task.envMode;
288
+ }
289
+ throw new Error(`unsupported task project env mode: ${task.envMode}`);
290
+ }
291
+
292
+ export interface TaskProjectEnvSpec {
293
+ schemaVersion: 1;
294
+ projects: Array<{
295
+ projectId: string;
296
+ position: number;
297
+ declaredNames: string[];
298
+ }>;
299
+ /** Cloud-computed, explicitly non-secret task literals (preview/theme). */
300
+ previewEnv: Record<string, string>;
301
+ }
302
+
303
+ const PROJECT_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
304
+
305
+ /** Build the value-free resolver input while the authoritative launch frame is present. */
306
+ export function taskProjectEnvSpec(
307
+ input: Pick<TaskLaunchInput, "projects" | "task">,
308
+ ): TaskProjectEnvSpec {
309
+ const projects = input.projects
310
+ .map((project) => {
311
+ let parsed: unknown = [];
312
+ try {
313
+ parsed = JSON.parse(project.env);
314
+ } catch {
315
+ // Match task-up's established malformed-env behavior: no declarations.
316
+ }
317
+ const names: string[] = [];
318
+ const seen = new Set<string>();
319
+ if (Array.isArray(parsed)) {
320
+ for (const declaration of parsed) {
321
+ if (
322
+ !declaration ||
323
+ typeof declaration !== "object" ||
324
+ Array.isArray(declaration)
325
+ ) {
326
+ continue;
327
+ }
328
+ const name = (declaration as { key?: unknown }).key;
329
+ if (
330
+ typeof name === "string" &&
331
+ PROJECT_ENV_NAME.test(name) &&
332
+ !seen.has(name)
333
+ ) {
334
+ seen.add(name);
335
+ names.push(name);
336
+ }
337
+ }
338
+ }
339
+ return {
340
+ projectId: project.id,
341
+ position: project.position,
342
+ declaredNames: names,
343
+ };
344
+ })
345
+ .sort(
346
+ (left, right) =>
347
+ left.position - right.position ||
348
+ left.projectId.localeCompare(right.projectId),
349
+ );
350
+ const previewEnv = Object.fromEntries(
351
+ Object.entries(input.task.previewEnv ?? {}).sort(([left], [right]) =>
352
+ left.localeCompare(right),
353
+ ),
354
+ );
355
+ return { schemaVersion: 1, projects, previewEnv };
356
+ }
357
+
358
+ /** Persist resolver metadata before environment provisioning starts. */
359
+ export function recordTaskProjectEnvSpec(
360
+ taskId: string,
361
+ input: Pick<TaskLaunchInput, "projects" | "task">,
362
+ ): void {
363
+ upsertHostTask(taskId, {
364
+ projectEnvSpec: JSON.stringify(taskProjectEnvSpec(input)),
365
+ });
366
+ }
367
+
368
+ /** Null is a pre-feature row; malformed/newer persisted state fails closed. */
369
+ export function hostTaskProjectEnvSpec(task: {
370
+ projectEnvSpec?: string | null;
371
+ }): TaskProjectEnvSpec | null {
372
+ if (task.projectEnvSpec === null || task.projectEnvSpec === undefined) {
373
+ return null;
374
+ }
375
+ let parsed: unknown;
376
+ try {
377
+ parsed = JSON.parse(task.projectEnvSpec);
378
+ } catch {
379
+ throw new Error("persisted task project env spec is malformed");
380
+ }
381
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
382
+ throw new Error("persisted task project env spec is malformed");
383
+ }
384
+ const record = parsed as Record<string, unknown>;
385
+ if (
386
+ record.schemaVersion !== 1 ||
387
+ !Array.isArray(record.projects) ||
388
+ !record.previewEnv ||
389
+ typeof record.previewEnv !== "object" ||
390
+ Array.isArray(record.previewEnv)
391
+ ) {
392
+ throw new Error("unsupported persisted task project env spec");
393
+ }
394
+ const projects = record.projects.map((entry) => {
395
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
396
+ throw new Error("persisted task project env spec has a malformed project");
397
+ }
398
+ const project = entry as Record<string, unknown>;
399
+ if (
400
+ typeof project.projectId !== "string" ||
401
+ project.projectId.length === 0 ||
402
+ !Number.isSafeInteger(project.position) ||
403
+ !Array.isArray(project.declaredNames) ||
404
+ project.declaredNames.some(
405
+ (name) => typeof name !== "string" || !PROJECT_ENV_NAME.test(name),
406
+ )
407
+ ) {
408
+ throw new Error("persisted task project env spec has a malformed project");
409
+ }
410
+ return {
411
+ projectId: project.projectId,
412
+ position: project.position as number,
413
+ declaredNames: [...new Set(project.declaredNames as string[])],
414
+ };
415
+ });
416
+ const previewEnv: Record<string, string> = {};
417
+ for (const [name, value] of Object.entries(record.previewEnv)) {
418
+ if (!PROJECT_ENV_NAME.test(name) || typeof value !== "string") {
419
+ throw new Error("persisted task project env spec has malformed preview env");
420
+ }
421
+ previewEnv[name] = value;
422
+ }
423
+ return { schemaVersion: 1, projects, previewEnv };
424
+ }
425
+
261
426
  /** Append a task lifecycle event to the host event log (local `/api/events`). */
262
427
  export function recordHostEvent(
263
428
  taskId: string,
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Which upstream addresses the secrets proxy may dial (ADR-153 §3, rule 6).
3
+ *
4
+ * A proxied request carries a real secret, so its destination must be the
5
+ * public API the key is bound to — never something only the runner can
6
+ * reach (its loopback, the Docker bridge, a cloud metadata endpoint, the
7
+ * tailnet). The runner resolves the domain itself, and one non-public
8
+ * address anywhere in the answer rejects the whole answer, so a rebinding
9
+ * record cannot win by mixing a private address in with a public one.
10
+ *
11
+ * IPv6 is an allow-list (global unicast 2000::/3 only) minus special-purpose
12
+ * blocks; everything that embeds an IPv4 address (mapped, compatible,
13
+ * NAT64, 6to4, Teredo) is refused outright rather than unpacked.
14
+ */
15
+
16
+ import { BlockList, isIP } from "node:net";
17
+
18
+ const DENIED_IPV4: ReadonlyArray<readonly [string, number]> = [
19
+ ["0.0.0.0", 8], // "this network"
20
+ ["10.0.0.0", 8], // RFC 1918
21
+ ["100.64.0.0", 10], // CGNAT (RFC 6598) — Tailscale lives here
22
+ ["127.0.0.0", 8], // loopback
23
+ ["169.254.0.0", 16], // link-local, incl. cloud metadata
24
+ ["172.16.0.0", 12], // RFC 1918
25
+ ["192.0.0.0", 24], // IETF protocol assignments
26
+ ["192.0.2.0", 24], // TEST-NET-1
27
+ ["192.88.99.0", 24], // deprecated 6to4 relay anycast
28
+ ["192.168.0.0", 16], // RFC 1918
29
+ ["198.18.0.0", 15], // benchmarking
30
+ ["198.51.100.0", 24], // TEST-NET-2
31
+ ["203.0.113.0", 24], // TEST-NET-3
32
+ ["224.0.0.0", 4], // multicast
33
+ ["240.0.0.0", 4], // reserved, incl. 255.255.255.255
34
+ ];
35
+
36
+ const DENIED_IPV6: ReadonlyArray<readonly [string, number]> = [
37
+ ["::ffff:0:0", 96], // IPv4-mapped
38
+ ["2001::", 23], // IETF protocol assignments, incl. Teredo 2001::/32
39
+ ["2001:db8::", 32], // documentation
40
+ ["2002::", 16], // 6to4
41
+ ["3fff::", 20], // documentation (RFC 9637)
42
+ ];
43
+
44
+ // Two lists, never one: BlockList compares IPv4 against IPv6 rules through
45
+ // the mapped form, so a `::ffff:0:0/96` rule in a shared list denies EVERY
46
+ // IPv4 address (checked live on Node 22.11).
47
+ const deniedIpv4 = new BlockList();
48
+ for (const [network, prefix] of DENIED_IPV4) deniedIpv4.addSubnet(network, prefix, "ipv4");
49
+ const deniedIpv6 = new BlockList();
50
+ for (const [network, prefix] of DENIED_IPV6) deniedIpv6.addSubnet(network, prefix, "ipv6");
51
+
52
+ // Loopback, unspecified, ULA fc00::/7, link-local fe80::/10, multicast
53
+ // ff00::/8, NAT64 64:ff9b::/96 and IPv4-compatible ::/96 all fall outside.
54
+ const globalUnicast = new BlockList();
55
+ globalUnicast.addSubnet("2000::", 3, "ipv6");
56
+
57
+ export function isPublicAddress(ip: string): boolean {
58
+ // isIP accepts zone ids (`fe80::1%eth0`); BlockList would quietly miss them.
59
+ if (typeof ip !== "string" || ip.includes("%")) return false;
60
+ try {
61
+ switch (isIP(ip)) {
62
+ case 4:
63
+ return !deniedIpv4.check(ip, "ipv4");
64
+ case 6:
65
+ return globalUnicast.check(ip, "ipv6") && !deniedIpv6.check(ip, "ipv6");
66
+ default:
67
+ return false;
68
+ }
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The secrets proxy's audit log (ADR-153 §3 rule 8), next to
3
+ * mcp-audit.jsonl. The engine already keeps values, placeholders, paths and
4
+ * queries out of every entry; this only appends.
5
+ *
6
+ * It THROWS when a line cannot be written. An injection is recorded before
7
+ * its value leaves, and the engine refuses the request when that record
8
+ * fails; refusals and skips go through the engine's best-effort wrapper.
9
+ */
10
+
11
+ import { appendFileSync, mkdirSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ export const SECRETS_PROXY_AUDIT_FILE = "secrets-proxy-audit.jsonl";
15
+
16
+ export function createAuditWriter(logsDir: string): (entry: Record<string, unknown>) => void {
17
+ const file = join(logsDir, SECRETS_PROXY_AUDIT_FILE);
18
+ return (entry) => {
19
+ const line = `${JSON.stringify(entry)}\n`;
20
+ try {
21
+ appendFileSync(file, line, { mode: 0o600 });
22
+ } catch (error) {
23
+ // A logs dir removed under a running runner (a cleanup job) must not
24
+ // refuse every proxied request until a restart: recreate and retry once.
25
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
26
+ mkdirSync(logsDir, { recursive: true, mode: 0o700 });
27
+ appendFileSync(file, line, { mode: 0o600 });
28
+ }
29
+ };
30
+ }