@runuai/host 0.9.150 → 0.9.152

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 (107) hide show
  1. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  2. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  3. package/db/migrations/meta/_journal.json +14 -0
  4. package/db/schema.ts +188 -0
  5. package/lib/agent-cli.ts +11 -13
  6. package/lib/agent.ts +177 -7
  7. package/lib/agents/durable-proc.ts +56 -44
  8. package/lib/agents/registry.ts +19 -3
  9. package/lib/agents/session-state.ts +23 -10
  10. package/lib/agents/transport.ts +101 -69
  11. package/lib/apple-one-off.ts +277 -0
  12. package/lib/apple-runtime-recycle.ts +4 -3
  13. package/lib/attachments.ts +19 -17
  14. package/lib/capture-flow.ts +319 -0
  15. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  16. package/lib/cloud-credential-host.ts +512 -0
  17. package/lib/cloud-credential-leases.ts +2544 -0
  18. package/lib/cloud-credential-runtime.ts +1035 -0
  19. package/lib/codex-auth.ts +21 -1
  20. package/lib/container-runtime.ts +76 -0
  21. package/lib/credential-cleanup-inventory.ts +405 -0
  22. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  23. package/lib/credential-consumers/engines.ts +773 -0
  24. package/lib/credential-consumers/github.ts +594 -0
  25. package/lib/credential-consumers/mcp.ts +384 -0
  26. package/lib/credential-consumers/task-files.ts +127 -0
  27. package/lib/credential-store-pickup.ts +15 -2
  28. package/lib/credential-store.ts +361 -33
  29. package/lib/credentials-activation-authority.ts +546 -0
  30. package/lib/credentials-purge.ts +1006 -0
  31. package/lib/db.ts +11 -0
  32. package/lib/docker-exec.ts +3 -1
  33. package/lib/engine-accounts.ts +42 -32
  34. package/lib/engine-connect.ts +47 -2
  35. package/lib/engine-credential-env.ts +50 -0
  36. package/lib/engine-login.ts +83 -1
  37. package/lib/env-file.ts +62 -8
  38. package/lib/env.ts +5 -0
  39. package/lib/fs-aliases.ts +49 -0
  40. package/lib/git-clone-credentials.ts +334 -0
  41. package/lib/git-diff.ts +219 -177
  42. package/lib/git-identity.ts +59 -14
  43. package/lib/github-origin.ts +28 -0
  44. package/lib/github-tokens.ts +56 -21
  45. package/lib/host-env.ts +17 -4
  46. package/lib/launch-mode.ts +25 -0
  47. package/lib/legacy-runtime-retirement.ts +388 -0
  48. package/lib/machine-exec.ts +14 -3
  49. package/lib/machine-provider-aws.ts +50 -8
  50. package/lib/machine-provider-local.ts +4 -0
  51. package/lib/machine-provider.ts +4 -0
  52. package/lib/managed-operation-drain.ts +8 -0
  53. package/lib/managed-runtime.ts +371 -7
  54. package/lib/mcp-capture.ts +860 -0
  55. package/lib/mcp-connections.ts +8 -1
  56. package/lib/mcp-gateway.ts +168 -10
  57. package/lib/obs.ts +2 -6
  58. package/lib/orchestrator.ts +1837 -224
  59. package/lib/person-capture.ts +327 -0
  60. package/lib/project-env-legacy-tags.ts +130 -0
  61. package/lib/project-env-task-replica.ts +1752 -0
  62. package/lib/runner-boot.ts +10 -0
  63. package/lib/runtime-activation-tail.ts +50 -3
  64. package/lib/runtime-archive.ts +68 -1
  65. package/lib/runtime-reconnect.ts +86 -0
  66. package/lib/runtime-state.ts +429 -13
  67. package/lib/safe-http.ts +364 -0
  68. package/lib/secrets-proxy/task-trust.ts +34 -3
  69. package/lib/sidecar-resume.ts +18 -5
  70. package/lib/skills.ts +47 -8
  71. package/lib/standard-image.ts +157 -103
  72. package/lib/stopped-task-status-queue.ts +68 -10
  73. package/lib/task-credential-cleanup.ts +525 -0
  74. package/lib/task-diff-sandbox.ts +464 -0
  75. package/lib/task-diff.ts +66 -163
  76. package/lib/task-env-source.ts +68 -14
  77. package/lib/task-environment/anchor.ts +31 -0
  78. package/lib/task-environment/apple-container.ts +69 -28
  79. package/lib/task-environment/docker.ts +101 -20
  80. package/lib/task-environment/machine-environment.ts +61 -0
  81. package/lib/task-environment/machine-task-up.ts +459 -96
  82. package/lib/task-environment/machine.ts +101 -11
  83. package/lib/task-environment/self.ts +108 -50
  84. package/lib/task-environment/types.ts +89 -5
  85. package/lib/task-environment/workspace-files.ts +650 -25
  86. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  87. package/lib/task-gateway-recovery.ts +4 -1
  88. package/lib/task-gateway-state.ts +15 -0
  89. package/lib/task-machine-swap-safety.ts +729 -0
  90. package/lib/token-broker-adopt.ts +344 -0
  91. package/lib/token-broker-lambda.ts +56 -2
  92. package/lib/token-broker-local.ts +28 -4
  93. package/lib/token-broker-revision.ts +310 -0
  94. package/lib/token-broker-ssm.ts +368 -28
  95. package/lib/token-lanes/openai-oauth.ts +7 -3
  96. package/lib/token-lanes/xai-oauth.ts +7 -3
  97. package/lib/transcript.ts +9 -5
  98. package/package.json +6 -2
  99. package/scripts/agent/task-up.sh +242 -100
  100. package/src/cloud-credentials.ts +2399 -0
  101. package/src/event-outbox.ts +8 -6
  102. package/src/index.ts +1386 -875
  103. package/src/main.ts +1061 -139
  104. package/src/outbound-frame.ts +31 -0
  105. package/src/project-env-policy.ts +125 -10
  106. package/src/protocol.ts +699 -23
  107. package/src/ui/server.ts +48 -10
@@ -24,6 +24,15 @@ import type {
24
24
  } from "../machine-provider";
25
25
  import { machineEnvironmentSshArgs } from "../machine-exec";
26
26
  import { applyProjectEnvProfile } from "../runtime-authority";
27
+ import { rootAnchoredRequest } from "./anchor";
28
+ import {
29
+ BASELINE_PATH,
30
+ ETC_ENVIRONMENT_PATH,
31
+ parseEtcEnvironment,
32
+ withoutEngineCredentials,
33
+ withoutLaunchSecrets,
34
+ } from "./machine-environment";
35
+ import { NotARegularFileError } from "./workspace-files";
27
36
  import { capturedCliExec, streamingCliExec } from "./docker";
28
37
  import {
29
38
  assertTaskEnvironmentExecRequest,
@@ -31,6 +40,9 @@ import {
31
40
  refusePrivilegedExec,
32
41
  assertTaskEnvironmentSessionRequest,
33
42
  assertWorkspaceScopedPath,
43
+ type TaskEnvironmentCallOptions,
44
+ type TaskEnvironmentReadOptions,
45
+ WORKSPACE_READ_MAX_BYTES,
34
46
  type TaskEnvironmentCopyRequest,
35
47
  type TaskEnvironmentDescriptor,
36
48
  type TaskEnvironmentDetachedSessionRequest,
@@ -69,6 +81,19 @@ export interface MachineTaskEnvironmentLocatorValue {
69
81
  sshUser: string;
70
82
  }
71
83
 
84
+ /**
85
+ * A workspace read on a machine, as the task user: argv $1 the path, $2 the
86
+ * most bytes it takes, $3 "end" for the file's last bytes. Absence and
87
+ * error are different facts: 44 marks a MISSING file so a transport or
88
+ * permission failure can never masquerade as one; 45 a path that is not a
89
+ * regular file (a link in its place, a FIFO, a device), never read. A file
90
+ * swapped in after that check is bounded by the read's own timeout.
91
+ */
92
+ export const MACHINE_WORKSPACE_READ_SCRIPT =
93
+ `if [ ! -e "$1" ] && [ ! -L "$1" ]; then exit 44; fi; ` +
94
+ `if [ -L "$1" ] || [ ! -f "$1" ]; then exit 45; fi; ` +
95
+ `if [ "$3" = end ]; then exec tail -c "$2" "$1"; else exec head -c "$2" "$1"; fi`;
96
+
72
97
  export interface MachineTaskInput {
73
98
  image: string;
74
99
  cpus: number;
@@ -180,11 +205,18 @@ function validateMachineLocatorValue(
180
205
  class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
181
206
  readonly descriptor: TaskEnvironmentDescriptor;
182
207
  readonly durableIdentity: string;
208
+ /** ADR-154 §6: anchored requests start under the engine supervisor. */
209
+ readonly anchors = true;
210
+ /** ADR-154 §6: a request's environment rides the ssh command line, so a
211
+ * leased launch's engine credentials travel in its admission. */
212
+ readonly credentialDelivery = "admission";
183
213
  readonly #value: MachineTaskEnvironmentLocatorValue;
184
214
  readonly #deps: Required<
185
215
  Pick<MachineTaskEnvironmentDeps, "machines" | "spawn" | "readyTimeoutMs">
186
216
  > &
187
217
  MachineTaskEnvironmentDeps;
218
+ /** The machine's own /etc/environment, read once (see #anchoredBaseline). */
219
+ #machineWide: Promise<Record<string, string>> | null = null;
188
220
 
189
221
  constructor(
190
222
  locator: TaskEnvironmentLocator,
@@ -248,6 +280,51 @@ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
248
280
  return info;
249
281
  }
250
282
 
283
+ /**
284
+ * ADR-154 §6: an anchored launch starts from nothing of its ssh session's
285
+ * environment (sshd's, PAM's, the login's: an older image may leave an
286
+ * owner's sign-in there), only from what it names: a PATH, the machine's
287
+ * /etc/environment as pam_env would apply it (never an engine's
288
+ * credential, nor an agent's task token), and its own env. Anything else
289
+ * is unchanged.
290
+ */
291
+ async #anchoredBaseline<
292
+ T extends { anchor?: unknown; env?: Readonly<Record<string, string>> },
293
+ >(request: T): Promise<T & { clean?: true }> {
294
+ if (!request.anchor) return request;
295
+ const machineWide = await this.#machineWideEnvironment();
296
+ return {
297
+ ...request,
298
+ // It rides the command line: no secret of the machine's on it.
299
+ env: { PATH: BASELINE_PATH, ...withoutLaunchSecrets(machineWide), ...request.env },
300
+ clean: true,
301
+ };
302
+ }
303
+
304
+ /** The machine's /etc/environment without any engine's credential, read
305
+ * once over ssh (absent: empty); read again after a failed read. */
306
+ #machineWideEnvironment(): Promise<Record<string, string>> {
307
+ if (this.#machineWide) return this.#machineWide;
308
+ const reading = (async () => {
309
+ const result = await this.exec({
310
+ argv: ["/bin/cat", ETC_ENVIRONMENT_PATH],
311
+ projectEnvProfile: "maintenance",
312
+ timeoutMs: 30_000,
313
+ maxOutputBytes: 64 * 1024,
314
+ });
315
+ if (result.exitCode === 1 && result.stdout.length === 0) return {};
316
+ if (result.exitCode !== 0 || result.stdoutTruncated) {
317
+ throw new Error(`machine ${this.#value.machineId}'s environment could not be read`);
318
+ }
319
+ return withoutEngineCredentials(parseEtcEnvironment(Buffer.from(result.stdout).toString("utf8")));
320
+ })();
321
+ this.#machineWide = reading;
322
+ reading.catch(() => {
323
+ if (this.#machineWide === reading) this.#machineWide = null;
324
+ });
325
+ return reading;
326
+ }
327
+
251
328
  #maintenanceRequest<
252
329
  T extends {
253
330
  argv: readonly [string, ...string[]];
@@ -266,9 +343,11 @@ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
266
343
  );
267
344
  }
268
345
 
269
- async readWorkspaceFile(path: string): Promise<Uint8Array | null> {
346
+ async readWorkspaceFile(path: string, options?: TaskEnvironmentReadOptions): Promise<Uint8Array | null> {
270
347
  assertWorkspaceScopedPath(this.descriptor, path);
348
+ const maxBytes = Math.max(0, Math.floor(options?.maxBytes ?? WORKSPACE_READ_MAX_BYTES));
271
349
  const target = await this.#target();
350
+ options?.admit?.();
272
351
  const result = await capturedCliExec(
273
352
  this.#deps.spawn,
274
353
  "ssh",
@@ -278,11 +357,11 @@ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
278
357
  argv: [
279
358
  "/bin/sh",
280
359
  "-c",
281
- // Absence and error are different facts: 44 marks a MISSING file
282
- // so a transport/permission failure can never masquerade as one.
283
- `if [ -e "$1" ]; then exec cat "$1"; else exit 44; fi`,
360
+ MACHINE_WORKSPACE_READ_SCRIPT,
284
361
  "read",
285
362
  path,
363
+ String(maxBytes),
364
+ options?.from === "end" ? "end" : "start",
286
365
  ],
287
366
  }),
288
367
  "interactive",
@@ -295,6 +374,7 @@ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
295
374
  },
296
375
  );
297
376
  if (result.exitCode === 44) return null;
377
+ if (result.exitCode === 45) throw new NotARegularFileError();
298
378
  if (result.exitCode !== 0) {
299
379
  throw new Error(
300
380
  `machine workspace read failed: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
@@ -303,9 +383,10 @@ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
303
383
  return result.stdout;
304
384
  }
305
385
 
306
- async writeWorkspaceFile(path: string, bytes: Uint8Array): Promise<void> {
386
+ async writeWorkspaceFile(path: string, bytes: Uint8Array, options?: TaskEnvironmentCallOptions): Promise<void> {
307
387
  assertWorkspaceScopedPath(this.descriptor, path);
308
388
  const target = await this.#target();
389
+ options?.admit?.();
309
390
  const result = await capturedCliExec(
310
391
  this.#deps.spawn,
311
392
  "ssh",
@@ -343,7 +424,7 @@ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
343
424
  assertTaskEnvironmentExecRequest(request);
344
425
  refusePrivilegedExec(request);
345
426
  const profiled = applyProjectEnvProfile(
346
- request,
427
+ rootAnchoredRequest(await this.#anchoredBaseline(request)),
347
428
  this.descriptor.envMode ?? "baked",
348
429
  "/bin/bash",
349
430
  );
@@ -362,11 +443,12 @@ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
362
443
  ): Promise<TaskEnvironmentProcess> {
363
444
  assertTaskEnvironmentProcessRequest(request);
364
445
  const profiled = applyProjectEnvProfile(
365
- request,
446
+ rootAnchoredRequest(await this.#anchoredBaseline(request)),
366
447
  this.descriptor.envMode ?? "baked",
367
448
  "/bin/bash",
368
449
  );
369
450
  const target = await this.#target();
451
+ request.admit?.();
370
452
  return streamingCliExec(
371
453
  this.#deps.spawn,
372
454
  "ssh",
@@ -381,11 +463,12 @@ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
381
463
  assertTaskEnvironmentSessionRequest(request);
382
464
  // Sessions default to the workspace the way container images default to
383
465
  // WORKDIR /workspace — an agent CLI must start in the task's world.
384
- const sessionRequest = applyProjectEnvProfile({
466
+ const sessionRequest = applyProjectEnvProfile(rootAnchoredRequest(await this.#anchoredBaseline({
385
467
  ...request,
386
468
  cwd: request.cwd ?? this.#value.workspacePath,
387
- }, this.descriptor.envMode ?? "baked", "/bin/bash");
469
+ })), this.descriptor.envMode ?? "baked", "/bin/bash");
388
470
  const target = await this.#target();
471
+ request.admit?.();
389
472
  return streamingCliExec(
390
473
  this.#deps.spawn,
391
474
  "ssh",
@@ -398,11 +481,12 @@ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
398
481
  request: TaskEnvironmentDetachedSessionRequest,
399
482
  ): Promise<TaskEnvironmentExecResult> {
400
483
  assertTaskEnvironmentSessionRequest(request);
401
- const sessionRequest = applyProjectEnvProfile({
484
+ const sessionRequest = applyProjectEnvProfile(rootAnchoredRequest(await this.#anchoredBaseline({
402
485
  ...request,
403
486
  cwd: request.cwd ?? this.#value.workspacePath,
404
- }, this.descriptor.envMode ?? "baked", "/bin/bash");
487
+ })), this.descriptor.envMode ?? "baked", "/bin/bash");
405
488
  const target = await this.#target();
489
+ request.admit?.();
406
490
  return capturedCliExec(
407
491
  this.#deps.spawn,
408
492
  "ssh",
@@ -435,6 +519,7 @@ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
435
519
  ["-C", request.source, "-cf", "-", "."],
436
520
  { encoding: "buffer", maxBuffer: 256 * 1024 * 1024 },
437
521
  );
522
+ request.admit?.();
438
523
  const extract = await capturedCliExec(
439
524
  this.#deps.spawn,
440
525
  "ssh",
@@ -467,6 +552,7 @@ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
467
552
  return;
468
553
  }
469
554
  const bytes = readFileSync(request.source);
555
+ request.admit?.();
470
556
  const result = await capturedCliExec(
471
557
  this.#deps.spawn,
472
558
  "ssh",
@@ -498,6 +584,7 @@ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
498
584
  }
499
585
  return;
500
586
  }
587
+ request.admit?.();
501
588
  const result = await capturedCliExec(
502
589
  this.#deps.spawn,
503
590
  "ssh",
@@ -552,8 +639,10 @@ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
552
639
  async gatewayTunnelSshArgs(
553
640
  machinePort: number,
554
641
  hostPort: number,
642
+ options?: TaskEnvironmentCallOptions,
555
643
  ): Promise<string[]> {
556
644
  const target = await this.#target();
645
+ options?.admit?.();
557
646
  return [
558
647
  "-o", "BatchMode=yes",
559
648
  "-o", "StrictHostKeyChecking=accept-new",
@@ -594,6 +683,7 @@ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
594
683
  state: "stopped",
595
684
  instances: [this.#value.machineId],
596
685
  workspacePresent: true,
686
+ ...(info.frozen ? { frozen: true as const } : {}),
597
687
  };
598
688
  case "absent":
599
689
  return { state: "absent", instances: [], workspacePresent: false };
@@ -16,7 +16,6 @@
16
16
  */
17
17
  import { spawn as nodeSpawn } from "node:child_process";
18
18
  import { existsSync, readFileSync } from "node:fs";
19
- import { readFile } from "node:fs/promises";
20
19
 
21
20
  import { buildRemoteCommand } from "../machine-exec";
22
21
  import {
@@ -31,6 +30,8 @@ import {
31
30
  refusePrivilegedExec,
32
31
  assertTaskEnvironmentSessionRequest,
33
32
  assertWorkspaceScopedPath,
33
+ type TaskEnvironmentCallOptions,
34
+ type TaskEnvironmentReadOptions,
34
35
  type TaskEnvironmentCopyRequest,
35
36
  type TaskEnvironmentDescriptor,
36
37
  type TaskEnvironmentDetachedSessionRequest,
@@ -51,9 +52,17 @@ import {
51
52
  type TaskEnvironmentStatus,
52
53
  TASK_ENVIRONMENT_LOCATOR_SCHEMA_VERSION,
53
54
  } from "./types";
55
+ import {
56
+ BASELINE_PATH,
57
+ ETC_ENVIRONMENT_PATH,
58
+ parseEtcEnvironment,
59
+ withoutEngineCredentials,
60
+ withoutLaunchSecrets,
61
+ } from "./machine-environment";
54
62
  import { SELF_TASK_ENVIRONMENT_PROVIDER, SELF_TASK_USER, TASK_HOSTS_VIEW_PATH } from "./self-identity";
63
+ import { readBoundedFile, workspacePathParts } from "./workspace-files";
55
64
 
56
- export { SELF_TASK_ENVIRONMENT_PROVIDER, SELF_TASK_USER, TASK_HOSTS_VIEW_PATH };
65
+ export { ETC_ENVIRONMENT_PATH, parseEtcEnvironment, SELF_TASK_ENVIRONMENT_PROVIDER, SELF_TASK_USER, TASK_HOSTS_VIEW_PATH };
57
66
 
58
67
  /**
59
68
  * Binds the view, then runs the rest of its argv. With no view yet (nothing
@@ -91,37 +100,19 @@ export interface SelfTaskEnvironmentDeps {
91
100
  /** The machine-wide environment every request runs under (beneath its own
92
101
  * `env`). Defaults to /etc/environment — see `readEtcEnvironment`. */
93
102
  etcEnvironment?: () => Readonly<Record<string, string>>;
103
+ /** Tests only: start an anchored launch without the drop to the task
104
+ * user, so the supervisor and what it carries are the test user's own. No
105
+ * isolation boundary: never a runner's (ADR-154 §6). */
106
+ anchorWithoutIsolationForTests?: boolean;
94
107
  }
95
108
 
96
- /**
97
- * /etc/environment, as pam_env reads it: `KEY=value` lines, optional
98
- * `export`, optional single or double quotes, `#` comments. The machine
99
- * provider gets these for free — sshd applies pam_env to every session — but
100
- * a local `setpriv --reset-env … sh -lc` sees neither pam nor the file, so
109
+ /*
110
+ * /etc/environment (parseEtcEnvironment). The machine provider gets it for
111
+ * free — sshd applies pam_env to every session — but a local
112
+ * `setpriv --reset-env … sh -lc` sees neither pam nor the file, so
101
113
  * `PLAYWRIGHT_BROWSERS_PATH` from the payload image never reached an agent
102
114
  * on the sidecar and every task re-downloaded Chromium (2026-09-10, smoke 6).
103
115
  */
104
- export function parseEtcEnvironment(text: string): Record<string, string> {
105
- const out: Record<string, string> = {};
106
- for (const raw of text.split("\n")) {
107
- const line = raw.trim();
108
- if (!line || line.startsWith("#")) continue;
109
- const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
110
- if (!match) continue;
111
- let value = match[2]!.trim();
112
- if (
113
- value.length >= 2 &&
114
- ((value.startsWith('"') && value.endsWith('"')) ||
115
- (value.startsWith("'") && value.endsWith("'")))
116
- ) {
117
- value = value.slice(1, -1);
118
- }
119
- out[match[1]!] = value;
120
- }
121
- return out;
122
- }
123
-
124
- export const ETC_ENVIRONMENT_PATH = "/etc/environment";
125
116
  let etcEnvironmentCache: Record<string, string> | null = null;
126
117
  /** The parsed /etc/environment, read once per process (the image writes it
127
118
  * at bake time; nothing edits it on a task machine). Empty when absent. */
@@ -174,7 +165,9 @@ export function parseSelfTaskEnvironmentLocator(
174
165
  * line the machine provider ships over ssh, run through setpriv when this
175
166
  * process is root and the request wants a task user. Values ride argv here
176
167
  * exactly as they ride the ssh command line on a machine task — the machine
177
- * is one task's trust domain (machine-exec.ts).
168
+ * is one task's trust domain (machine-exec.ts) — except a leased engine's
169
+ * credentials, which its admission carries to the injector this line runs
170
+ * after its drop (ADR-154 §6).
178
171
  */
179
172
  export function selfCommand(
180
173
  request: {
@@ -183,6 +176,7 @@ export function selfCommand(
183
176
  user?: string;
184
177
  inheritEnv?: readonly string[];
185
178
  env?: Readonly<Record<string, string>>;
179
+ anchor?: { readonly prefix: readonly [string, ...string[]]; readonly injector?: readonly string[] };
186
180
  },
187
181
  mode: "interactive" | "detached",
188
182
  privileged: boolean,
@@ -200,6 +194,15 @@ export function selfCommand(
200
194
  args: [...outerProfileEnv, "/bin/sh", "-lc", line],
201
195
  };
202
196
  const user = request.user ?? SELF_TASK_USER;
197
+ // ADR-154 §6: an anchored process starts under the engine supervisor, as
198
+ // root inside the task's view, which then runs the drop to the task user
199
+ // itself ("-": setpriv drops it). What the launch carries is read by the
200
+ // injector right after that drop, into the environment setpriv reset.
201
+ const anchor = request.anchor ? [...request.anchor.prefix, "-", "--"] : [];
202
+ const injector = request.anchor?.injector ?? [];
203
+ if (request.anchor && user === "root") {
204
+ throw new Error("an anchored process runs as a task user, never as root");
205
+ }
203
206
  if (privileged && user !== "root") {
204
207
  return {
205
208
  command: "/usr/bin/unshare",
@@ -213,27 +216,48 @@ export function selfCommand(
213
216
  TASK_VIEW_SCRIPT,
214
217
  "uai-task-view",
215
218
  TASK_HOSTS_VIEW_PATH,
219
+ ...anchor,
216
220
  "/usr/bin/setpriv",
217
221
  `--reuid=${user}`,
218
222
  `--regid=${user}`,
219
223
  "--init-groups",
220
224
  "--reset-env",
221
225
  "--",
226
+ ...injector,
222
227
  shellCommand.command,
223
228
  ...shellCommand.args,
224
229
  ],
225
230
  };
226
231
  }
232
+ if (anchor.length > 0) {
233
+ return {
234
+ command: anchor[0]!,
235
+ args: [...anchor.slice(1), ...injector, shellCommand.command, ...shellCommand.args],
236
+ };
237
+ }
227
238
  return shellCommand;
228
239
  }
229
240
 
241
+ /**
242
+ * ADR-154 §6: what an anchored launch starts from, instead of this runner's
243
+ * own environment (its host credential, its AWS keys): neither the engine
244
+ * supervisor nor the engine it starts inherits anything of the runner's.
245
+ * The drop resets it again for the task user; the launch's own line and
246
+ * login shell give the engine the rest.
247
+ */
248
+ const ANCHORED_LAUNCH_ENV: NodeJS.ProcessEnv = Object.freeze({ PATH: BASELINE_PATH });
249
+
230
250
  class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
231
251
  readonly descriptor: TaskEnvironmentDescriptor;
232
252
  readonly durableIdentity: string;
253
+ /** ADR-154 §6: a request's environment rides a local shell line, so a
254
+ * leased launch's engine credentials travel in its admission. */
255
+ readonly credentialDelivery = "admission";
233
256
  readonly #value: SelfTaskEnvironmentLocatorValue;
234
257
  readonly #spawn: typeof nodeSpawn;
235
258
  readonly #privileged: () => boolean;
236
259
  readonly #etcEnvironment: () => Readonly<Record<string, string>>;
260
+ readonly #anchorWithoutIsolation: boolean;
237
261
 
238
262
  constructor(
239
263
  locator: TaskEnvironmentLocator,
@@ -245,6 +269,7 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
245
269
  this.#privileged =
246
270
  deps.privileged ?? (() => typeof process.getuid === "function" && process.getuid() === 0);
247
271
  this.#etcEnvironment = deps.etcEnvironment ?? readEtcEnvironment;
272
+ this.#anchorWithoutIsolation = deps.anchorWithoutIsolationForTests === true;
248
273
  this.durableIdentity = `self:${this.#value.taskId}`;
249
274
  this.descriptor = {
250
275
  taskId: this.#value.taskId,
@@ -254,17 +279,13 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
254
279
  };
255
280
  }
256
281
 
257
- async readWorkspaceFile(path: string): Promise<Uint8Array | null> {
282
+ async readWorkspaceFile(path: string, options?: TaskEnvironmentReadOptions): Promise<Uint8Array | null> {
258
283
  assertWorkspaceScopedPath(this.descriptor, path);
259
- try {
260
- return new Uint8Array(await readFile(path));
261
- } catch (error) {
262
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
263
- throw error;
264
- }
284
+ options?.admit?.();
285
+ return readBoundedFile(this.descriptor.workspacePath, workspacePathParts(this.descriptor, path), options);
265
286
  }
266
287
 
267
- async writeWorkspaceFile(path: string, bytes: Uint8Array): Promise<void> {
288
+ async writeWorkspaceFile(path: string, bytes: Uint8Array, options?: TaskEnvironmentCallOptions): Promise<void> {
268
289
  assertWorkspaceScopedPath(this.descriptor, path);
269
290
  // Through the task user, so the file is owned like everything else the
270
291
  // agents touch (a root-owned file inside the workspace is a trap).
@@ -274,6 +295,7 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
274
295
  stdin: bytes,
275
296
  timeoutMs: 120_000,
276
297
  maxOutputBytes: 1024 * 1024,
298
+ ...(options?.admit ? { admit: options.admit } : {}),
277
299
  });
278
300
  if (result.exitCode !== 0) {
279
301
  throw new Error(
@@ -282,15 +304,41 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
282
304
  }
283
305
  }
284
306
 
307
+ /** ADR-154 §6: anchored requests start under the engine supervisor, only
308
+ * where it runs as root and the engine as the task user: otherwise the
309
+ * supervisor, and what a launch carries, would be the task user's own. */
310
+ get anchors(): boolean {
311
+ return this.#privileged() || this.#anchorWithoutIsolation;
312
+ }
313
+
314
+ /** The command for `request`, and the environment it starts with: an
315
+ * anchored one starts from the explicit baseline, never this runner's,
316
+ * and only where the supervisor is isolated from the task user. */
317
+ #command(
318
+ request: Parameters<typeof selfCommand>[0],
319
+ mode: "interactive" | "detached",
320
+ ): { command: string; args: string[]; env?: NodeJS.ProcessEnv } {
321
+ if (request.anchor && !this.anchors) {
322
+ throw new Error("an anchored launch needs the drop to the task user, as root");
323
+ }
324
+ const { command, args } = selfCommand(request, mode, this.#privileged());
325
+ return request.anchor ? { command, args, env: ANCHORED_LAUNCH_ENV } : { command, args };
326
+ }
327
+
285
328
  /** The request with the machine-wide environment beneath its own: what an
286
- * ssh session on a machine task gets from pam_env, applied here by hand. */
329
+ * ssh session on a machine task gets from pam_env, applied here by hand,
330
+ * never an engine's credential (ADR-154 §6), and for an anchored launch,
331
+ * whose secrets come from its admission, no secret of the machine's at
332
+ * all: its environment rides the local command line. */
287
333
  #withMachineEnv<
288
334
  T extends {
289
335
  env?: Readonly<Record<string, string>>;
290
336
  projectEnvProfile: "maintenance" | "task";
337
+ anchor?: unknown;
291
338
  },
292
339
  >(request: T): T {
293
- const base = this.#etcEnvironment();
340
+ const etc = this.#etcEnvironment();
341
+ const base = request.anchor ? withoutLaunchSecrets(etc) : withoutEngineCredentials(etc);
294
342
  const withMachineEnv =
295
343
  Object.keys(base).length === 0
296
344
  ? request
@@ -306,16 +354,17 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
306
354
  assertTaskEnvironmentExecRequest(request);
307
355
  refusePrivilegedExec(request);
308
356
  const scoped = this.#withMachineEnv(request);
309
- const { command, args } = selfCommand(scoped, "interactive", this.#privileged());
357
+ const { command, args, env } = this.#command(scoped, "interactive");
310
358
  request.admit?.();
311
- return capturedCliExec(this.#spawn, command, args, scoped);
359
+ return capturedCliExec(this.#spawn, command, args, scoped, env);
312
360
  }
313
361
 
314
362
  async spawn(request: TaskEnvironmentSpawnRequest): Promise<TaskEnvironmentProcess> {
315
363
  assertTaskEnvironmentProcessRequest(request);
316
364
  const scoped = this.#withMachineEnv(request);
317
- const { command, args } = selfCommand(scoped, "interactive", this.#privileged());
318
- return streamingCliExec(this.#spawn, command, args, scoped);
365
+ const { command, args, env } = this.#command(scoped, "interactive");
366
+ request.admit?.();
367
+ return streamingCliExec(this.#spawn, command, args, scoped, env);
319
368
  }
320
369
 
321
370
  async spawnSession(request: TaskEnvironmentSessionRequest): Promise<TaskEnvironmentProcess> {
@@ -324,8 +373,9 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
324
373
  ...request,
325
374
  cwd: request.cwd ?? this.#value.workspacePath,
326
375
  });
327
- const { command, args } = selfCommand(session, "interactive", this.#privileged());
328
- return streamingCliExec(this.#spawn, command, args, session);
376
+ const { command, args, env } = this.#command(session, "interactive");
377
+ request.admit?.();
378
+ return streamingCliExec(this.#spawn, command, args, session, env);
329
379
  }
330
380
 
331
381
  async launchDetachedSession(
@@ -336,11 +386,18 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
336
386
  ...request,
337
387
  cwd: request.cwd ?? this.#value.workspacePath,
338
388
  });
339
- const { command, args } = selfCommand(session, "detached", this.#privileged());
340
- return capturedCliExec(this.#spawn, command, args, {
341
- ...session,
342
- timeoutMs: session.launchTimeoutMs,
343
- });
389
+ const { command, args, env } = this.#command(session, "detached");
390
+ request.admit?.();
391
+ return capturedCliExec(
392
+ this.#spawn,
393
+ command,
394
+ args,
395
+ {
396
+ ...session,
397
+ timeoutMs: session.launchTimeoutMs,
398
+ },
399
+ env,
400
+ );
344
401
  }
345
402
 
346
403
  async copy(request: TaskEnvironmentCopyRequest): Promise<void> {
@@ -374,6 +431,7 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
374
431
  projectEnvProfile: "maintenance",
375
432
  timeoutMs: 5 * 60_000,
376
433
  maxOutputBytes: 64 * 1024,
434
+ ...(request.admit ? { admit: request.admit } : {}),
377
435
  });
378
436
  if (result.exitCode !== 0) {
379
437
  throw new Error(