@runuai/host 0.9.150 → 0.9.151
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/meta/_journal.json +14 -0
- package/db/schema.ts +188 -0
- package/lib/agent-cli.ts +11 -13
- package/lib/agent.ts +177 -7
- package/lib/agents/durable-proc.ts +56 -44
- package/lib/agents/registry.ts +19 -3
- package/lib/agents/session-state.ts +23 -10
- package/lib/agents/transport.ts +101 -69
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +4 -3
- package/lib/attachments.ts +19 -17
- package/lib/capture-flow.ts +319 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2544 -0
- package/lib/cloud-credential-runtime.ts +1035 -0
- package/lib/codex-auth.ts +21 -1
- package/lib/container-runtime.ts +76 -0
- package/lib/credential-cleanup-inventory.ts +405 -0
- package/lib/credential-consumers/engine-anchor.ts +1876 -0
- package/lib/credential-consumers/engines.ts +773 -0
- package/lib/credential-consumers/github.ts +594 -0
- package/lib/credential-consumers/mcp.ts +384 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-store-pickup.ts +15 -2
- package/lib/credential-store.ts +361 -33
- package/lib/credentials-activation-authority.ts +546 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/db.ts +11 -0
- package/lib/docker-exec.ts +3 -1
- package/lib/engine-accounts.ts +42 -32
- package/lib/engine-connect.ts +47 -2
- package/lib/engine-credential-env.ts +50 -0
- package/lib/engine-login.ts +83 -1
- package/lib/env-file.ts +62 -8
- package/lib/env.ts +5 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +219 -177
- package/lib/git-identity.ts +59 -14
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +56 -21
- package/lib/host-env.ts +17 -4
- package/lib/launch-mode.ts +25 -0
- package/lib/legacy-runtime-retirement.ts +388 -0
- package/lib/machine-exec.ts +14 -3
- package/lib/machine-provider-aws.ts +50 -8
- package/lib/machine-provider-local.ts +4 -0
- package/lib/machine-provider.ts +4 -0
- package/lib/managed-operation-drain.ts +8 -0
- package/lib/managed-runtime.ts +371 -7
- package/lib/mcp-capture.ts +860 -0
- package/lib/mcp-connections.ts +8 -1
- package/lib/mcp-gateway.ts +168 -10
- package/lib/obs.ts +2 -6
- package/lib/orchestrator.ts +1830 -224
- package/lib/person-capture.ts +327 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +50 -3
- package/lib/runtime-archive.ts +68 -1
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +429 -13
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/task-trust.ts +34 -3
- package/lib/sidecar-resume.ts +18 -5
- package/lib/skills.ts +47 -8
- package/lib/standard-image.ts +157 -103
- package/lib/stopped-task-status-queue.ts +68 -10
- package/lib/task-credential-cleanup.ts +525 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +66 -163
- package/lib/task-env-source.ts +68 -14
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +69 -28
- package/lib/task-environment/docker.ts +101 -20
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +459 -96
- package/lib/task-environment/machine.ts +101 -11
- package/lib/task-environment/self.ts +108 -50
- package/lib/task-environment/types.ts +89 -5
- package/lib/task-environment/workspace-files.ts +623 -25
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-gateway-recovery.ts +4 -1
- package/lib/task-gateway-state.ts +15 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +56 -2
- package/lib/token-broker-local.ts +28 -4
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +368 -28
- package/lib/token-lanes/openai-oauth.ts +7 -3
- package/lib/token-lanes/xai-oauth.ts +7 -3
- package/lib/transcript.ts +9 -5
- package/package.json +6 -2
- package/scripts/agent/task-up.sh +242 -100
- package/src/cloud-credentials.ts +2399 -0
- package/src/event-outbox.ts +8 -6
- package/src/index.ts +1386 -875
- package/src/main.ts +1061 -139
- package/src/outbound-frame.ts +31 -0
- package/src/project-env-policy.ts +125 -10
- package/src/protocol.ts +699 -23
- 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
|
-
|
|
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
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
-
|
|
260
|
-
|
|
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
|
|
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 } =
|
|
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 } =
|
|
318
|
-
|
|
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 } =
|
|
328
|
-
|
|
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 } =
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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(
|