@runuai/host 0.9.149 → 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.
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 +1876 -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 +1847 -236
  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 +623 -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 +1062 -140
  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
package/lib/task-diff.ts CHANGED
@@ -1,188 +1,91 @@
1
- import { existsSync } from "node:fs";
2
- import { spawnSync } from "node:child_process";
3
- import { join, posix } from "node:path";
4
-
5
1
  import type { TaskDiffInput, TaskDiffResult } from "../src/protocol";
6
- import { taskWorkspaceDir } from "./env";
7
2
  import {
3
+ countDiffLines,
4
+ jsonEscapedBytes,
8
5
  parseGitDiff,
9
- runGitDiff,
10
- runGitDiffInEnvironment,
11
- runGitDiffUntracked,
12
- runGitDiffUntrackedInEnvironment,
13
- tryRunGitTextInEnvironment,
6
+ parseTaskDiffOutput,
7
+ taskDiffExecFailure,
8
+ taskDiffRequest,
9
+ TASK_DIFF_LINE_RESULT_BYTES,
10
+ TASK_DIFF_MAX_LINES,
11
+ TASK_DIFF_OUTPUT_BYTES,
12
+ TASK_DIFF_REPO_RESULT_BYTES,
13
+ TASK_DIFF_RESULT_BYTES,
14
14
  } from "./git-diff";
15
15
  import type { TaskEnvironmentHandle } from "./task-environment/types";
16
16
 
17
- // Cap how many untracked files we render as new-file patches. Each is a
18
- // separate in-environment Git execution, so a pathological untracked dir that
19
- // slips past .gitignore shouldn't turn the diff into thousands of execs.
20
- const MAX_UNTRACKED = 200;
21
-
22
17
  export type TaskDiffEnvironment = Pick<
23
18
  TaskEnvironmentHandle,
24
19
  "descriptor" | "exec"
25
20
  >;
26
21
 
22
+ /**
23
+ * A task's diff, as one run of TASK_DIFF_SCRIPT in `environment`: the task's
24
+ * own runtime while it runs, a throwaway sandbox once it has stopped
25
+ * (task-diff-sandbox.ts). Never Git on the host: what a task configured in
26
+ * its repositories would run there as the runner's user (#cb3j0z).
27
+ *
28
+ * Each project's diff is the whole branch against its base, including
29
+ * uncommitted work: the merge-base (where the branch diverged from the
30
+ * remote's default branch) against the working tree, then each untracked
31
+ * file as a new-file patch. A project the run could not diff, or whose
32
+ * output the bound cut short, carries an error and no files: a partial
33
+ * patch is never taken for the whole. The whole diff renders at most
34
+ * TASK_DIFF_MAX_LINES lines, and its result serializes to at most
35
+ * TASK_DIFF_RESULT_BYTES (its patches JSON-escaped, and each line's
36
+ * object): a project that would pass what is left of either is an error,
37
+ * never parsed.
38
+ */
27
39
  export async function buildTaskDiff(
28
40
  input: TaskDiffInput,
29
- environment: TaskDiffEnvironment | null = null,
41
+ environment: TaskDiffEnvironment,
30
42
  ): Promise<TaskDiffResult> {
31
- if (
32
- environment !== null &&
33
- environment.descriptor.taskId !== input.taskId
34
- ) {
43
+ if (environment.descriptor.taskId !== input.taskId) {
35
44
  throw new Error("task diff environment belongs to a different task");
36
45
  }
37
- const workspaceRoot = taskWorkspaceDir(input.taskId);
38
- const out: TaskDiffResult["repos"] = [];
39
-
40
- for (const project of input.projects) {
41
- const cwd = join(workspaceRoot, project.slug);
42
- const environmentCwd = environment
43
- ? posix.join(environment.descriptor.workspacePath, project.slug)
44
- : null;
45
- if (!environment && !existsSync(cwd)) continue;
46
+ if (input.projects.length === 0) return { repos: [] };
47
+ const slugs = input.projects.map((project) => project.slug);
48
+ const result = await environment.exec(taskDiffRequest(environment.descriptor.workspacePath, slugs));
49
+ const failure = taskDiffExecFailure(result);
50
+ const output = failure === null ? parseTaskDiffOutput(Buffer.from(result.stdout).toString("utf8")) : [];
51
+ const bounded = failure === null && result.stdout.length >= TASK_DIFF_OUTPUT_BYTES;
52
+ let linesLeft = TASK_DIFF_MAX_LINES;
53
+ // Each project's own entry, and the result's wrapper, come out first: the
54
+ // bound is on the whole result as it is sent.
55
+ let bytesLeft = Math.max(0, TASK_DIFF_RESULT_BYTES - 64 - input.projects.length * TASK_DIFF_REPO_RESULT_BYTES);
46
56
 
47
- // Base branch is the remote's default branch (where task branches are cut
48
- // from at task-up) — NOT the worktree's @{upstream}, which flips to
49
- // origin/<task-branch> once the branch is pushed and would then hide all
50
- // committed work (see resolveBase).
51
- const base = await resolveBase(environment, cwd, environmentCwd);
52
- // Diff the whole branch against base, including UNCOMMITTED work: compare
53
- // the merge-base (where the branch diverged) to the WORKING TREE, not HEAD.
54
- // The merge-base keeps base's own later commits from showing up reversed if
55
- // it advanced; falling back to `base` itself if merge-base can't resolve.
56
- const mergeBase =
57
- (await runGitText(environment, cwd, environmentCwd, [
58
- "merge-base",
59
- base,
60
- "HEAD",
61
- ])) ?? base;
62
- try {
63
- // Tracked changes (committed + staged + unstaged): a commit-ish vs the
64
- // working tree, so omitting `..HEAD` is the point.
65
- let text = environment
66
- ? await runGitDiffInEnvironment(
67
- environment,
68
- environmentCwd!,
69
- mergeBase,
70
- )
71
- : await runGitDiff(cwd, mergeBase);
72
-
73
- // Untracked files: `git diff <commit>` skips them, so list (honouring
74
- // .gitignore) and append each as a /dev/null new-file patch.
75
- const untracked = await listUntracked(
76
- environment,
77
- cwd,
78
- environmentCwd,
79
- );
80
- for (const file of untracked) {
81
- const patch = environment
82
- ? await runGitDiffUntrackedInEnvironment(
83
- environment,
84
- environmentCwd!,
85
- file,
86
- )
87
- : await runGitDiffUntracked(cwd, file);
88
- if (patch.trim().length === 0) continue;
89
- if (text.length > 0 && !text.endsWith("\n")) text += "\n";
90
- text += patch;
91
- }
92
-
93
- out.push({
94
- id: project.id,
95
- name: project.slug,
96
- mountPath: project.slug,
97
- defaultBranch: stripOriginPrefix(base),
98
- files: parseGitDiff(text),
99
- });
100
- } catch (err) {
101
- out.push({
57
+ return {
58
+ repos: input.projects.map((project, index) => {
59
+ const found = output[index]?.slug === project.slug ? output[index] : undefined;
60
+ const entry = {
102
61
  id: project.id,
103
62
  name: project.slug,
104
63
  mountPath: project.slug,
105
- defaultBranch: stripOriginPrefix(base),
106
- files: [],
107
- error: err instanceof Error ? err.message : String(err),
108
- });
109
- }
110
- }
111
-
112
- return { repos: out };
113
- }
114
-
115
- /**
116
- * Resolve the diff base for a worktree: the remote's default branch
117
- * (`origin/HEAD`, set at task-up via `remote set-head`), falling back to
118
- * `origin/main`. Runs Git in-environment when the task is live so credential
119
- * behavior stays inside the sandbox (see git-diff).
120
- *
121
- * We deliberately do NOT use the worktree's `@{upstream}`. At task-up it is
122
- * `origin/<defaultBranch>`, but once the task branch is pushed (e.g. a PR is
123
- * opened) its upstream flips to `origin/<task-branch>` — then
124
- * `merge-base(upstream, HEAD) ≈ HEAD`, so the diff would show only uncommitted
125
- * work instead of the whole branch.
126
- */
127
- async function resolveBase(
128
- environment: TaskDiffEnvironment | null,
129
- hostCwd: string,
130
- environmentCwd: string | null,
131
- ): Promise<string> {
132
- const head = await runGitText(
133
- environment,
134
- hostCwd,
135
- environmentCwd,
136
- ["rev-parse", "--abbrev-ref", "origin/HEAD"],
137
- );
138
- if (head) return head;
139
-
140
- return "origin/main";
64
+ defaultBranch: stripOriginPrefix(found?.base ?? "origin/main"),
65
+ };
66
+ if (found && found.error === undefined && found.tracked && found.untracked) {
67
+ const patches = found.patches ?? "";
68
+ const lines = countDiffLines(patches);
69
+ if (lines > linesLeft) {
70
+ return { ...entry, files: [], error: `the diff has more than ${TASK_DIFF_MAX_LINES} lines` };
71
+ }
72
+ const bytes = jsonEscapedBytes(patches) + lines * TASK_DIFF_LINE_RESULT_BYTES;
73
+ if (bytes > bytesLeft) {
74
+ return { ...entry, files: [], error: "the diff is too large to send" };
75
+ }
76
+ linesLeft -= lines;
77
+ bytesLeft -= bytes;
78
+ return { ...entry, files: parseGitDiff(patches) };
79
+ }
80
+ const error =
81
+ failure ??
82
+ found?.error ??
83
+ (bounded ? "the diff exceeded its output bound" : "the diff did not finish");
84
+ return { ...entry, files: [], error };
85
+ }),
86
+ };
141
87
  }
142
88
 
143
89
  function stripOriginPrefix(ref: string): string {
144
90
  return ref.startsWith("origin/") ? ref.slice("origin/".length) : ref;
145
91
  }
146
-
147
- /**
148
- * List untracked files in the worktree, honouring .gitignore
149
- * (`--exclude-standard`), capped at {@link MAX_UNTRACKED}. Empty on error.
150
- */
151
- async function listUntracked(
152
- environment: TaskDiffEnvironment | null,
153
- hostCwd: string,
154
- environmentCwd: string | null,
155
- ): Promise<string[]> {
156
- const raw = await runGitText(environment, hostCwd, environmentCwd, [
157
- "ls-files",
158
- "--others",
159
- "--exclude-standard",
160
- ]);
161
- if (!raw) return [];
162
- return raw
163
- .split("\n")
164
- .filter((line) => line.length > 0)
165
- .slice(0, MAX_UNTRACKED);
166
- }
167
-
168
- /**
169
- * Run a read-only Git command (host or in-environment) and return trimmed
170
- * stdout, or null on any failure. Used for base-branch resolution where a
171
- * missing upstream is expected, not exceptional.
172
- */
173
- async function runGitText(
174
- environment: TaskDiffEnvironment | null,
175
- hostCwd: string,
176
- environmentCwd: string | null,
177
- args: string[],
178
- ): Promise<string | null> {
179
- if (environment) {
180
- return tryRunGitTextInEnvironment(environment, environmentCwd!, args);
181
- }
182
- const result = spawnSync("git", ["-C", hostCwd, ...args], {
183
- encoding: "utf8",
184
- });
185
- if (result.status !== 0) return null;
186
- const text = (result.stdout ?? "").trim();
187
- return text.length > 0 ? text : null;
188
- }
@@ -18,9 +18,18 @@
18
18
  * store). Its observations are stored once and never change. So a change
19
19
  * reaches the next command and the next request, while a command that
20
20
  * changes nothing reads nothing.
21
+ *
22
+ * ADR-154: once the runner begins leaving its own credentials, neither
23
+ * store is read. On cloud credentials a task's env is what the composer
24
+ * serves for exactly the run the reading generation serves (/env's gateway
25
+ * generation, the proxy's live one) while that run is the task's open one,
26
+ * read afresh every time (its gate closes the moment the run's credentials
27
+ * do, and no copy of it is kept here). Until the runner is active, a task
28
+ * gets no project env at all.
21
29
  */
22
30
 
23
- import { projectEnvAuthorityRevision, projectEnvRevision } from "./host-env";
31
+ import { activeCloudCredentials, onLeavingLegacy, onOwnCredentials } from "./cloud-credential-host";
32
+ import { noteProjectEnvViewChange, projectEnvAuthorityRevision, projectEnvRevision } from "./host-env";
24
33
  import { readProjectEnvAuthoritySnapshot } from "./project-env-authority";
25
34
  import { readProjectEnvReplicaSnapshot, readProjectEnvReplicaStatus } from "./project-env-replica";
26
35
  import { taskProjectEnvResolver, type TaskEnvSource } from "./project-env-loader";
@@ -32,11 +41,11 @@ import {
32
41
  type TaskEnvHead,
33
42
  type TaskEnvSnapshot,
34
43
  } from "./project-env-task-policy";
35
- import { getHostTask, hostTaskProjectEnvSpec, type TaskProjectEnvSpec } from "./runtime-state";
44
+ import { getHostTask, hostTaskProjectEnvSpec, readTaskRunEpochState, type TaskProjectEnvSpec } from "./runtime-state";
36
45
  import { sidecarMode } from "./sidecar";
37
46
  import { taskProxiedEnv } from "./secrets-proxy/policy";
38
47
  import { taskTrustEnv } from "./secrets-proxy/readiness";
39
- import type { TaskGatewayEnvResolver } from "./task-gateway-state";
48
+ import { currentTaskGatewayRun, type TaskGatewayEnvResolver } from "./task-gateway-state";
40
49
 
41
50
  /** The task's projects, lead first, as its launch pinned them. */
42
51
  function projectIdsOf(spec: TaskProjectEnvSpec): string[] {
@@ -52,8 +61,13 @@ function projectIdsOf(spec: TaskProjectEnvSpec): string[] {
52
61
  * otherwise the records, values and observations the task's decisions come
53
62
  * from.
54
63
  */
55
- export function readTaskEnvSnapshot(taskId: string, spec: TaskProjectEnvSpec): TaskEnvSnapshot | "legacy" | null {
64
+ export function readTaskEnvSnapshot(
65
+ taskId: string,
66
+ spec: TaskProjectEnvSpec,
67
+ runEpoch: number | null,
68
+ ): TaskEnvSnapshot | "legacy" | null {
56
69
  const projectIds = projectIdsOf(spec);
70
+ if (!onOwnCredentials()) return cloudTaskEnvSnapshot(taskId, projectIds, runEpoch);
57
71
  const key = JSON.stringify([projectEnvAuthorityRevision(), projectIds.map((projectId) => [projectId, projectEnvRevision(projectId)])]);
58
72
  const hit = snapshots.get(taskId);
59
73
  if (hit?.key === key) return hit.snapshot;
@@ -79,6 +93,33 @@ export function forgetTaskEnvSnapshot(taskId: string): void {
79
93
  snapshots.delete(taskId);
80
94
  }
81
95
 
96
+ /**
97
+ * ADR-154: the task's env as the composer serves it for `runEpoch`, the
98
+ * reading generation's run, with the task's own observations; null unless
99
+ * the runner is active and that run is the task's admitted, open one (a
100
+ * generation from an older runtime gets nothing while a newer run starts),
101
+ * or while the composer serves nothing for it.
102
+ */
103
+ function cloudTaskEnvSnapshot(
104
+ taskId: string,
105
+ projectIds: readonly string[],
106
+ runEpoch: number | null,
107
+ ): TaskEnvSnapshot | null {
108
+ const composer = activeCloudCredentials();
109
+ if (composer === null || runEpoch === null) return null;
110
+ const run = readTaskRunEpochState(taskId);
111
+ if (run === null || !run.assignmentOpen || run.highestRunEpoch !== runEpoch) return null;
112
+ const served = composer.envSnapshot({ taskId, runEpoch }, projectIds);
113
+ if (served === null) return null;
114
+ return snapshotOf(taskId, projectIds, (projectId) =>
115
+ (served.projects.find((project) => project.projectId === projectId)?.entries ?? []).map((entry) => ({
116
+ key: entry.state.key,
117
+ state: entry.state,
118
+ value: entry.materialization.kind === "installed" ? entry.value : null,
119
+ })),
120
+ );
121
+ }
122
+
82
123
  function freshTaskEnvSnapshot(taskId: string, projectIds: readonly string[]): TaskEnvSnapshot | "legacy" | null {
83
124
  return sidecarMode() ? replicaTaskEnvSnapshot(taskId, projectIds) : ownedTaskEnvSnapshot(taskId, projectIds);
84
125
  }
@@ -152,16 +193,18 @@ function snapshotOf(taskId: string, projectIds: readonly string[], entriesOf: (p
152
193
  };
153
194
  }
154
195
 
155
- /** Where the task's /env takes its project values from right now. */
156
- export function taskEnvSource(taskId: string, spec: TaskProjectEnvSpec): TaskEnvSource {
157
- const snapshot = readTaskEnvSnapshot(taskId, spec);
196
+ /** Where the task's /env takes its project values from right now, for a
197
+ * generation serving `runEpoch`. */
198
+ export function taskEnvSource(taskId: string, spec: TaskProjectEnvSpec, runEpoch: number | null): TaskEnvSource {
199
+ const snapshot = readTaskEnvSnapshot(taskId, spec, runEpoch);
158
200
  if (snapshot === "legacy") return { kind: "stored" };
159
201
  if (snapshot === null) return { kind: "none" };
160
202
  return { kind: "view", view: taskEnvView(snapshot) };
161
203
  }
162
204
 
163
205
  /** The task's snapshot for its proxy, by task id alone: what its persisted
164
- * launch spec names. Nothing is proxied from a store never activated. */
206
+ * launch spec names, for the run its live gateway generation serves.
207
+ * Nothing is proxied from a store never activated. */
165
208
  export function proxyTaskEnvSnapshot(taskId: string): TaskEnvSnapshot | null {
166
209
  const task = getHostTask(taskId);
167
210
  let spec: TaskProjectEnvSpec | null = null;
@@ -171,23 +214,34 @@ export function proxyTaskEnvSnapshot(taskId: string): TaskEnvSnapshot | null {
171
214
  return null;
172
215
  }
173
216
  if (spec === null) return null;
174
- const snapshot = readTaskEnvSnapshot(taskId, spec);
217
+ const snapshot = readTaskEnvSnapshot(taskId, spec, currentTaskGatewayRun(taskId));
175
218
  return snapshot === "legacy" ? null : snapshot;
176
219
  }
177
220
 
178
221
  /** The task's note about the keys it does not get, from the store as it is
179
222
  * now; null when it gets every one, or the store gives no view. */
180
- export function taskEnvWithheldNoteNow(taskId: string, spec: TaskProjectEnvSpec): string | null {
181
- const source = taskEnvSource(taskId, spec);
223
+ export function taskEnvWithheldNoteNow(taskId: string, spec: TaskProjectEnvSpec, runEpoch: number | null): string | null {
224
+ const source = taskEnvSource(taskId, spec, runEpoch);
182
225
  return source.kind === "view" ? taskEnvWithheldNote(source.view.policies) : null;
183
226
  }
184
227
 
185
- /** The resolver a loader task's gateway generation renders /env with. */
186
- export function taskEnvResolver(taskId: string, spec: TaskProjectEnvSpec): TaskGatewayEnvResolver {
228
+ /** The resolver a loader task's gateway generation renders /env with, for
229
+ * the run that generation serves (null: one from before run epochs). */
230
+ export function taskEnvResolver(taskId: string, spec: TaskProjectEnvSpec, runEpoch: number | null): TaskGatewayEnvResolver {
187
231
  return taskProjectEnvResolver(
188
232
  spec,
189
233
  () => taskTrustEnv(taskId),
190
234
  () => taskProxiedEnv(taskId),
191
- () => taskEnvSource(taskId, spec),
235
+ () => taskEnvSource(taskId, spec, runEpoch),
192
236
  );
193
237
  }
238
+
239
+ /**
240
+ * ADR-154: the moment the runner begins leaving its own credentials, every
241
+ * read above stops taking their values (each checks `onOwnCredentials()`),
242
+ * and whoever follows the env (the proxy's trust delivery, which unpins
243
+ * what is no longer proxied) looks again. Registered once, at boot.
244
+ */
245
+ export function followLeavingLegacy(): () => void {
246
+ return onLeavingLegacy(() => noteProjectEnvViewChange());
247
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * ADR-154 §6, for providers whose runtime is a container or a machine they
3
+ * exec into (Docker, Apple, a machine over ssh): an anchored request runs
4
+ * as root, the engine supervisor first, which drops to the task user the
5
+ * request would have run as before it starts the request's own argv.
6
+ */
7
+
8
+ /** Who a request with no user runs as in a task runtime, as the engines
9
+ * always have (their homes are under /home/node). */
10
+ export const ANCHORED_TASK_USER = "node";
11
+
12
+ /** `request`, run as root under its anchor; unchanged without one. */
13
+ export function rootAnchoredRequest<
14
+ T extends {
15
+ argv: readonly [string, ...string[]];
16
+ user?: string;
17
+ anchor?: { readonly prefix: readonly [string, ...string[]] };
18
+ },
19
+ >(request: T): T {
20
+ const { anchor } = request;
21
+ if (!anchor) return request;
22
+ if (request.user === "root") {
23
+ throw new Error("an anchored process runs as a task user, never as root");
24
+ }
25
+ const { anchor: _anchor, ...rest } = request;
26
+ return {
27
+ ...rest,
28
+ user: "root",
29
+ argv: [...anchor.prefix, request.user ?? ANCHORED_TASK_USER, "--", ...request.argv],
30
+ } as unknown as T;
31
+ }
@@ -28,8 +28,10 @@ import {
28
28
  } from "./workspace-files";
29
29
  import { assertSafeHostTaskId } from "../task-identity";
30
30
  import { applyProjectEnvProfile } from "../runtime-authority";
31
+ import { rootAnchoredRequest } from "./anchor";
31
32
  import {
32
33
  capturedCliExec,
34
+ cliExecEnv,
33
35
  streamingCliExec,
34
36
  } from "./docker";
35
37
  import {
@@ -37,6 +39,8 @@ import {
37
39
  assertTaskEnvironmentProcessRequest,
38
40
  refusePrivilegedExec,
39
41
  assertTaskEnvironmentSessionRequest,
42
+ type TaskEnvironmentCallOptions,
43
+ type TaskEnvironmentReadOptions,
40
44
  type TaskEnvironmentCopyRequest,
41
45
  type TaskEnvironmentDescriptor,
42
46
  type TaskEnvironmentDetachedSessionRequest,
@@ -54,6 +58,7 @@ import {
54
58
  type TaskEnvironmentSpawnRequest,
55
59
  type TaskEnvironmentStatus,
56
60
  } from "./types";
61
+ import type { TaskWriteEnvironment } from "../credential-consumers/task-files";
57
62
 
58
63
  export const APPLE_TASK_ENVIRONMENT_PROVIDER = "apple-container";
59
64
  const APPLE_LOCATOR_SCHEMA_VERSION = 1;
@@ -88,7 +93,13 @@ export interface AppleCliResult {
88
93
 
89
94
  export type AppleCliRunner = (
90
95
  args: readonly string[],
91
- options: { timeoutMs: number; maxOutputBytes: number },
96
+ options: {
97
+ timeoutMs: number;
98
+ maxOutputBytes: number;
99
+ /** The CLI child's own environment, when an exec names a credential
100
+ * bare (cliExecEnv). Absent, it inherits the runner's. */
101
+ env?: NodeJS.ProcessEnv;
102
+ },
92
103
  ) => Promise<AppleCliResult>;
93
104
 
94
105
  export interface AppleContainerTaskEnvironmentDeps<
@@ -137,6 +148,7 @@ export interface AppleContainerProvisioningDeps<TInput, TCredentials, TResult> {
137
148
  input: TInput,
138
149
  credentials?: TCredentials,
139
150
  envMode?: ProjectEnvMode,
151
+ runtimeEnvironment?: () => TaskWriteEnvironment,
140
152
  ) => Promise<TResult>;
141
153
  inputTaskId: (input: TInput) => string;
142
154
  /** The host worktree path this task will occupy, derivable before task-up
@@ -193,6 +205,9 @@ export function createAppleContainerTaskEnvironmentProvider<
193
205
  request.input,
194
206
  request.credentials,
195
207
  request.envMode ?? "loader",
208
+ // ADR-154 §12: the container as task-up has created it, for the
209
+ // runtime-ready writes before uai-init.
210
+ () => new AppleContainerTaskEnvironmentHandle(preparedLocator, resolved, request.envMode ?? "loader"),
196
211
  );
197
212
  const prepared = parseAppleTaskEnvironmentLocator(preparedLocator);
198
213
  // task-up composes its worktree path in shell from the operator's
@@ -271,6 +286,8 @@ class AppleContainerTaskEnvironmentHandle
271
286
  {
272
287
  readonly descriptor: TaskEnvironmentDescriptor;
273
288
  readonly durableIdentity: string;
289
+ /** ADR-154 §6: anchored requests start under the engine supervisor. */
290
+ readonly anchors = true;
274
291
  readonly #value: AppleTaskEnvironmentLocatorValue;
275
292
 
276
293
  constructor(
@@ -288,16 +305,19 @@ class AppleContainerTaskEnvironmentHandle
288
305
  };
289
306
  }
290
307
 
291
- async readWorkspaceFile(filePath: string): Promise<Uint8Array | null> {
308
+ async readWorkspaceFile(filePath: string, options?: TaskEnvironmentReadOptions): Promise<Uint8Array | null> {
309
+ options?.admit?.();
292
310
  return hostWorkspaceRead(
293
311
  this.descriptor,
294
312
  path.join(this.#value.hostWorktreePath, "workspace"),
295
313
  filePath,
314
+ options,
296
315
  );
297
316
  }
298
317
 
299
- async writeWorkspaceFile(filePath: string, bytes: Uint8Array): Promise<void> {
300
- hostWorkspaceWrite(
318
+ async writeWorkspaceFile(filePath: string, bytes: Uint8Array, options?: TaskEnvironmentCallOptions): Promise<void> {
319
+ options?.admit?.();
320
+ await hostWorkspaceWrite(
301
321
  this.descriptor,
302
322
  path.join(this.#value.hostWorktreePath, "workspace"),
303
323
  filePath,
@@ -309,16 +329,22 @@ class AppleContainerTaskEnvironmentHandle
309
329
  assertTaskEnvironmentExecRequest(request);
310
330
  refusePrivilegedExec(request);
311
331
  const profiled = applyProjectEnvProfile(
312
- request,
332
+ rootAnchoredRequest(request),
313
333
  this.descriptor.envMode ?? "baked",
314
334
  "/usr/bin/zsh",
315
335
  );
316
336
  request.admit?.();
337
+ const invocation = appleExecArgs(
338
+ this.#value.containerName,
339
+ profiled,
340
+ "interactive",
341
+ );
317
342
  return capturedCliExec(
318
343
  this.deps.spawn,
319
344
  this.deps.containerCliPath(),
320
- appleExecArgs(this.#value.containerName, profiled, "interactive"),
345
+ invocation.args,
321
346
  profiled,
347
+ invocation.env,
322
348
  );
323
349
  }
324
350
 
@@ -327,15 +353,22 @@ class AppleContainerTaskEnvironmentHandle
327
353
  ): Promise<TaskEnvironmentProcess> {
328
354
  assertTaskEnvironmentProcessRequest(request);
329
355
  const profiled = applyProjectEnvProfile(
330
- request,
356
+ rootAnchoredRequest(request),
331
357
  this.descriptor.envMode ?? "baked",
332
358
  "/usr/bin/zsh",
333
359
  );
360
+ const invocation = appleExecArgs(
361
+ this.#value.containerName,
362
+ profiled,
363
+ "interactive",
364
+ );
365
+ request.admit?.();
334
366
  return streamingCliExec(
335
367
  this.deps.spawn,
336
368
  this.deps.containerCliPath(),
337
- appleExecArgs(this.#value.containerName, profiled, "interactive"),
369
+ invocation.args,
338
370
  profiled,
371
+ invocation.env,
339
372
  );
340
373
  }
341
374
 
@@ -344,15 +377,22 @@ class AppleContainerTaskEnvironmentHandle
344
377
  ): Promise<TaskEnvironmentProcess> {
345
378
  assertTaskEnvironmentSessionRequest(request);
346
379
  const profiled = applyProjectEnvProfile(
347
- request,
380
+ rootAnchoredRequest(request),
348
381
  this.descriptor.envMode ?? "baked",
349
382
  "/usr/bin/zsh",
350
383
  );
384
+ const invocation = appleExecArgs(
385
+ this.#value.containerName,
386
+ profiled,
387
+ "interactive",
388
+ );
389
+ request.admit?.();
351
390
  return streamingCliExec(
352
391
  this.deps.spawn,
353
392
  this.deps.containerCliPath(),
354
- appleExecArgs(this.#value.containerName, profiled, "interactive"),
393
+ invocation.args,
355
394
  profiled,
395
+ invocation.env,
356
396
  );
357
397
  }
358
398
 
@@ -361,17 +401,21 @@ class AppleContainerTaskEnvironmentHandle
361
401
  ): Promise<TaskEnvironmentExecResult> {
362
402
  assertTaskEnvironmentSessionRequest(request);
363
403
  const profiled = applyProjectEnvProfile(
364
- request,
404
+ rootAnchoredRequest(request),
365
405
  this.descriptor.envMode ?? "baked",
366
406
  "/usr/bin/zsh",
367
407
  );
368
- const result = await this.deps.run(
369
- appleExecArgs(this.#value.containerName, profiled, "detached"),
370
- {
371
- timeoutMs: request.launchTimeoutMs,
372
- maxOutputBytes: request.maxOutputBytes,
373
- },
408
+ const invocation = appleExecArgs(
409
+ this.#value.containerName,
410
+ profiled,
411
+ "detached",
374
412
  );
413
+ request.admit?.();
414
+ const result = await this.deps.run(invocation.args, {
415
+ timeoutMs: request.launchTimeoutMs,
416
+ maxOutputBytes: request.maxOutputBytes,
417
+ ...(invocation.env === undefined ? {} : { env: invocation.env }),
418
+ });
375
419
  return {
376
420
  exitCode: result.status,
377
421
  signal: null,
@@ -396,6 +440,7 @@ class AppleContainerTaskEnvironmentHandle
396
440
  `${this.#value.containerName}:${request.source}`,
397
441
  request.destination,
398
442
  ];
443
+ request.admit?.();
399
444
  const result = await this.deps.run(args, {
400
445
  timeoutMs: 120_000,
401
446
  maxOutputBytes: 1024 * 1024,
@@ -598,6 +643,8 @@ class AppleContainerTaskEnvironmentHandle
598
643
  }
599
644
  }
600
645
 
646
+ /** The argv of one `container exec`, and the environment the CLI must run
647
+ * with to resolve the credentials it names (cliExecEnv). */
601
648
  function appleExecArgs(
602
649
  containerName: string,
603
650
  request:
@@ -606,20 +653,13 @@ function appleExecArgs(
606
653
  | TaskEnvironmentSessionRequest
607
654
  | TaskEnvironmentDetachedSessionRequest,
608
655
  mode: "interactive" | "detached",
609
- ): string[] {
656
+ ): { args: string[]; env: NodeJS.ProcessEnv | undefined } {
610
657
  const args = ["exec", mode === "detached" ? "-d" : "-i"];
611
658
  if (request.cwd) args.push("--workdir", request.cwd);
612
659
  if (request.user) args.push("--user", request.user);
613
- for (const name of request.inheritEnv ?? []) {
614
- if (process.env[name] !== undefined) args.push("--env", name);
615
- }
616
- for (const [name, value] of Object.entries(request.env ?? {}).sort(
617
- ([left], [right]) => left.localeCompare(right),
618
- )) {
619
- args.push("--env", `${name}=${value}`);
620
- }
621
- args.push(containerName, ...request.argv);
622
- return args;
660
+ const execEnv = cliExecEnv(request);
661
+ args.push(...execEnv.args, containerName, ...request.argv);
662
+ return { args, env: execEnv.env };
623
663
  }
624
664
 
625
665
  export function appleCliRunner(
@@ -633,6 +673,7 @@ function defaultRunner(containerCliPath: () => string): AppleCliRunner {
633
673
  new Promise((resolve) => {
634
674
  const child = nodeSpawn(containerCliPath(), [...args], {
635
675
  stdio: ["ignore", "pipe", "pipe"],
676
+ ...(options.env === undefined ? {} : { env: options.env }),
636
677
  });
637
678
  const stdout: Buffer[] = [];
638
679
  const stderr: Buffer[] = [];