@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.
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 +1830 -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 +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 +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
package/lib/git-diff.ts CHANGED
@@ -11,12 +11,11 @@
11
11
  * is small and the chat-side Markdown renderer already sets the
12
12
  * dependency-free precedent (see components/markdown.tsx).
13
13
  */
14
- import { spawn } from "node:child_process";
15
14
 
16
15
  import { RUNTIME_AUTHORITY_ENV } from "./runtime-authority";
17
16
  import type {
17
+ TaskEnvironmentExecRequest,
18
18
  TaskEnvironmentExecResult,
19
- TaskEnvironmentHandle,
20
19
  } from "./task-environment/types";
21
20
 
22
21
  export type DiffStatus =
@@ -61,15 +60,9 @@ export interface DiffFile {
61
60
  hunks: DiffHunk[];
62
61
  }
63
62
 
64
- const GIT_DIFF_ARGS = [
65
- "-c",
66
- "core.quotepath=false",
67
- "diff",
68
- "--no-color",
69
- "--no-ext-diff",
70
- "--src-prefix=a/",
71
- "--dst-prefix=b/",
72
- ];
63
+ /** How every patch is rendered: no colour, no external diff driver, the
64
+ * usual a/ b/ prefixes, paths as they are. */
65
+ const GIT_DIFF_ARGS = "-c core.quotepath=false diff --no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/";
73
66
 
74
67
  const NON_INTERACTIVE_GIT_ENV = {
75
68
  GIT_TERMINAL_PROMPT: "0",
@@ -77,198 +70,247 @@ const NON_INTERACTIVE_GIT_ENV = {
77
70
  GIT_SSH_COMMAND: "ssh -o BatchMode=yes",
78
71
  };
79
72
 
80
- const TASK_ENVIRONMENT_GIT_TIMEOUT_MS = 120_000;
81
- const TASK_ENVIRONMENT_GIT_OUTPUT_BYTES = 64 * 1024 * 1024;
82
- const TASK_ENVIRONMENT_GIT_TEXT_OUTPUT_BYTES = 4 * 1024 * 1024;
83
-
84
- type TaskEnvironmentExecutor = Pick<TaskEnvironmentHandle, "exec">;
73
+ /** A diff's bound on what each of a project's producers may give: its
74
+ * tracked patches, and its untracked ones. Past it, the project's diff is
75
+ * an error, never a partial patch. */
76
+ export const TASK_DIFF_PATCH_BYTES = 8 * 1024 * 1024;
77
+ /** A diff's bound on its whole output, however many projects it has: the
78
+ * projects past it are errors. */
79
+ export const TASK_DIFF_OUTPUT_BYTES = 48 * 1024 * 1024;
80
+ /** A diff's bound on the lines it renders, every project of it: each line
81
+ * becomes an object, so a valid patch of millions of tiny lines within the
82
+ * byte bounds would otherwise cost the runner hundreds of MiB to parse. A
83
+ * project that would pass what is left of it is an error, unparsed. */
84
+ export const TASK_DIFF_MAX_LINES = 100_000;
85
+ /** A diff's bound on its result as the runner sends it: JSON-escaped, a
86
+ * patch's control bytes take six bytes each, so patches within the byte and
87
+ * line bounds could still serialize past the bridge's frame limit (100 MiB)
88
+ * and get the runner's connection closed. A project that would pass what is
89
+ * left of it is an error, unparsed. */
90
+ export const TASK_DIFF_RESULT_BYTES = 48 * 1024 * 1024;
91
+ /** What each rendered line adds to the result beyond its own text: its
92
+ * object, keys and line numbers. */
93
+ export const TASK_DIFF_LINE_RESULT_BYTES = 96;
94
+ /** What each project's entry may take in the result besides its files: its
95
+ * id, name, path, base and error, each at its bound and JSON-escaped at
96
+ * worst, and their keys. Reserved from the result bound up front. */
97
+ export const TASK_DIFF_REPO_RESULT_BYTES = 16 * 1024;
98
+ /** A project's reported error, and its base, at most: both are the task's
99
+ * own (its Git's stderr, its remote's default branch). */
100
+ export const TASK_DIFF_ERROR_CHARS = 1024;
101
+ export const TASK_DIFF_BASE_CHARS = 256;
102
+ /** How long a diff may take, every project of it: it is killed inside its
103
+ * runtime past this, everything it started with it. */
104
+ export const TASK_DIFF_TIMEOUT_MS = 300_000;
105
+ /** Cap how many untracked files are rendered as new-file patches, so a
106
+ * pathological untracked directory that slips past .gitignore cannot
107
+ * turn one diff into thousands of patches. */
108
+ export const MAX_UNTRACKED = 200;
85
109
 
86
110
  /**
87
- * Run `git diff <range>` in `cwd` and return the raw unified-diff text.
88
- * Throws if git exits non-zero. Empty diff returns an empty string.
111
+ * `bounded CMD...` (POSIX sh): runs CMD with its output in `$tmp/out` (at
112
+ * most `$limit` bytes), its errors in `$tmp/err` (at most 4 KiB) and its
113
+ * status in `$tmp/status`. Each stream goes through a pipe whose reader
114
+ * stops at its bound, so a writer past it is stopped (SIGPIPE) and nothing
115
+ * past it is held, in memory or on disk.
89
116
  */
90
- export function runGitDiff(cwd: string, range: string): Promise<string> {
91
- return runDiffCommand(
92
- "git",
93
- ["-C", cwd, ...GIT_DIFF_ARGS, range],
94
- "git diff",
95
- {
96
- ...process.env,
97
- ...NON_INTERACTIVE_GIT_ENV,
98
- },
99
- );
100
- }
117
+ export const TASK_DIFF_BOUNDED = [
118
+ "bounded() {",
119
+ ' echo 1 >"$tmp/status"',
120
+ ' { { "$@"; echo "$?" >"$tmp/status"; } 2>&1 1>&3 3>&- | head -c 4096 >"$tmp/err"; } 3>&1 | head -c "$limit" >"$tmp/out"',
121
+ "}",
122
+ ].join("\n");
101
123
 
102
124
  /**
103
- * Run `git diff <range>` inside a running task environment.
125
+ * A task's whole diff, as one POSIX sh run in the task's environment (its
126
+ * runtime while it runs, a stopped task's sandbox otherwise; never the
127
+ * host, #cb3j0z): `$1` the bound on each producer's bytes, `$2` the
128
+ * untracked bound, `$3` the bound on the whole output, then each project's
129
+ * directory below the working directory. For each project it resolves the
130
+ * base (the remote's default branch, `origin/HEAD`, else `origin/main`:
131
+ * never the worktree's upstream, which flips to the pushed task branch and
132
+ * would hide all committed work), diffs the merge-base against the working
133
+ * tree (committed + staged + unstaged), and renders each untracked file
134
+ * (honouring .gitignore) as a new-file patch.
135
+ *
136
+ * Whatever runs for it, the task's own config drivers included, is bounded
137
+ * where it writes: its output at one byte past the bound and its errors at
138
+ * 4 KiB, each through a pipe that stops the writer once full, so nothing it
139
+ * produces is held beyond that, in memory or on disk.
104
140
  *
105
- * Running diff there keeps all Git credential behavior
106
- * inside the task sandbox instead of leaking to the host's SSH agent
107
- * (for example macOS 1Password confirmation prompts).
141
+ * Every value is a line `<kind> <base64>`, so any bytes survive whatever
142
+ * carries them: `P` a project, then either `E` an error, or `B` its base,
143
+ * `D` its tracked patches and `U` its untracked ones (or `E`). The whole
144
+ * output stops at its bound.
108
145
  */
109
- export function runGitDiffInEnvironment(
110
- environment: TaskEnvironmentExecutor,
111
- cwd: string,
112
- range: string,
113
- ): Promise<string> {
114
- return runEnvironmentGitCommand(
115
- environment,
116
- cwd,
117
- [...GIT_DIFF_ARGS, range],
118
- "task environment git diff",
119
- false,
120
- TASK_ENVIRONMENT_GIT_OUTPUT_BYTES,
121
- );
122
- }
146
+ export const TASK_DIFF_SCRIPT = [
147
+ "set -u",
148
+ 'each=$1; most=$2; cap=$3; shift 3',
149
+ 'limit=$((each + 1))',
150
+ 'tmp=$(mktemp -d "${TMPDIR:-/tmp}/uai-task-diff.XXXXXX") || exit 70',
151
+ "trap 'rm -rf \"$tmp\"' EXIT",
152
+ "root=$(pwd)",
153
+ 'frame() { printf "%s " "$1"; base64 -w0 <"$2"; printf "\\n"; }',
154
+ 'value() { printf "%s" "$2" >"$tmp/value"; frame "$1" "$tmp/value"; }',
155
+ TASK_DIFF_BOUNDED,
156
+ 'over() { [ "$(wc -c <"$tmp/out")" -gt "$each" ]; }',
157
+ "untracked() {",
158
+ ' git ls-files --others --exclude-standard -z 2>/dev/null | head -z -n "$most" \\',
159
+ ` | xargs -0 -r -n 1 sh -c 'git ${GIT_DIFF_ARGS} --no-index -- /dev/null "$1"; [ $? -le 1 ] || exit 255' untracked`,
160
+ "}",
161
+ "projects() {",
162
+ ' for slug in "$@"; do',
163
+ ' value P "$slug"',
164
+ ' if ! cd -- "$root/$slug" 2>/dev/null; then value E "not a project directory: $slug"; continue; fi',
165
+ " base=$(git rev-parse --abbrev-ref origin/HEAD 2>/dev/null) || base=",
166
+ ' [ -n "$base" ] || base=origin/main',
167
+ ' value B "$base"',
168
+ ' merge=$(git merge-base "$base" HEAD 2>/dev/null) || merge=',
169
+ ' [ -n "$merge" ] || merge=$base',
170
+ ` bounded git ${GIT_DIFF_ARGS} "$merge"`,
171
+ ' if over; then value E "the diff exceeded $each bytes"',
172
+ ' elif [ "$(cat "$tmp/status")" != 0 ]; then frame E "$tmp/err"',
173
+ " else",
174
+ ' frame D "$tmp/out"',
175
+ " bounded untracked",
176
+ ' if over; then value E "the untracked files exceeded $each bytes"',
177
+ ' elif [ "$(cat "$tmp/status")" != 0 ]; then frame E "$tmp/err"',
178
+ ' else frame U "$tmp/out"; fi',
179
+ " fi",
180
+ ' cd -- "$root"',
181
+ " done",
182
+ "}",
183
+ 'projects "$@" | head -c "$cap"',
184
+ ].join("\n");
123
185
 
124
186
  /**
125
- * Render a single untracked file as a new-file patch by diffing it against
126
- * `/dev/null` with `--no-index`. `git diff <commit>` omits untracked files
127
- * entirely, so this is how they reach the "all changes vs base" view.
128
- *
129
- * `--no-index` exits 1 when the inputs differ (the normal "there is a diff"
130
- * case for a new file) and 0 when identical — both are success here; only
131
- * code > 1 is a real error.
187
+ * The one exec of a task's diff of `slugs`, below `workspacePath`: killed
188
+ * inside the runtime at its deadline, the shell and everything it started
189
+ * (`timeout` signals their whole group), however the client that started it
190
+ * ended.
132
191
  */
133
- export function runGitDiffUntracked(
134
- cwd: string,
135
- relPath: string,
136
- ): Promise<string> {
137
- return runDiffCommand(
138
- "git",
139
- ["-C", cwd, ...GIT_DIFF_ARGS, "--no-index", "--", "/dev/null", relPath],
140
- "git diff --no-index",
141
- { ...process.env, ...NON_INTERACTIVE_GIT_ENV },
142
- true,
143
- );
192
+ export function taskDiffRequest(
193
+ workspacePath: string,
194
+ slugs: readonly string[],
195
+ ): TaskEnvironmentExecRequest {
196
+ return {
197
+ projectEnvProfile: "maintenance",
198
+ argv: [
199
+ "/usr/bin/timeout",
200
+ "--signal=KILL",
201
+ String(TASK_DIFF_TIMEOUT_MS / 1000),
202
+ "/bin/sh",
203
+ "-c",
204
+ TASK_DIFF_SCRIPT,
205
+ "uai-task-diff",
206
+ String(TASK_DIFF_PATCH_BYTES),
207
+ String(MAX_UNTRACKED),
208
+ String(TASK_DIFF_OUTPUT_BYTES),
209
+ ...slugs,
210
+ ],
211
+ cwd: workspacePath,
212
+ env: { ...RUNTIME_AUTHORITY_ENV, ...NON_INTERACTIVE_GIT_ENV },
213
+ // The client's own bound, past the runtime's.
214
+ timeoutMs: TASK_DIFF_TIMEOUT_MS + 15_000,
215
+ // The script stops at its bound; past it, the exec itself is cut.
216
+ maxOutputBytes: TASK_DIFF_OUTPUT_BYTES + 1024 * 1024,
217
+ };
144
218
  }
145
219
 
146
- /** In-environment variant of {@link runGitDiffUntracked}. */
147
- export function runGitDiffUntrackedInEnvironment(
148
- environment: TaskEnvironmentExecutor,
149
- cwd: string,
150
- relPath: string,
151
- ): Promise<string> {
152
- return runEnvironmentGitCommand(
153
- environment,
154
- cwd,
155
- [
156
- ...GIT_DIFF_ARGS,
157
- "--no-index",
158
- "--",
159
- "/dev/null",
160
- relPath,
161
- ],
162
- "task environment git diff --no-index",
163
- true,
164
- TASK_ENVIRONMENT_GIT_OUTPUT_BYTES,
165
- );
220
+ /**
221
+ * The bytes `JSON.stringify(text)` takes, counted without making it: two
222
+ * for each quote or backslash and for each control character with a short
223
+ * escape, six for any other control character or lone surrogate, and each
224
+ * other character's UTF-8 width, plus the quotes around it.
225
+ */
226
+ export function jsonEscapedBytes(text: string): number {
227
+ let bytes = 2;
228
+ for (let index = 0; index < text.length; index += 1) {
229
+ const code = text.charCodeAt(index);
230
+ if (code === 0x22 || code === 0x5c) bytes += 2;
231
+ else if (code < 0x20) {
232
+ bytes += code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d ? 2 : 6;
233
+ } else if (code < 0x80) bytes += 1;
234
+ else if (code < 0x800) bytes += 2;
235
+ else if (code >= 0xd800 && code <= 0xdbff && index + 1 < text.length && (text.charCodeAt(index + 1) & 0xfc00) === 0xdc00) {
236
+ bytes += 4;
237
+ index += 1;
238
+ } else if (code >= 0xd800 && code <= 0xdfff) bytes += 6;
239
+ else bytes += 3;
240
+ }
241
+ return bytes;
166
242
  }
167
243
 
168
- /** Best-effort read-only Git query used for base/untracked discovery. */
169
- export async function tryRunGitTextInEnvironment(
170
- environment: TaskEnvironmentExecutor,
171
- cwd: string,
172
- args: string[],
173
- ): Promise<string | null> {
174
- try {
175
- const text = await runEnvironmentGitCommand(
176
- environment,
177
- cwd,
178
- args,
179
- "task environment git query",
180
- false,
181
- TASK_ENVIRONMENT_GIT_TEXT_OUTPUT_BYTES,
182
- );
183
- const trimmed = text.trim();
184
- return trimmed.length > 0 ? trimmed : null;
185
- } catch {
186
- return null;
244
+ /** How many lines `text` holds, counted without parsing it (the last one
245
+ * counts without its newline). */
246
+ export function countDiffLines(text: string): number {
247
+ let lines = 0;
248
+ let at = text.indexOf("\n");
249
+ while (at !== -1) {
250
+ lines += 1;
251
+ at = text.indexOf("\n", at + 1);
187
252
  }
253
+ return text.length > 0 && !text.endsWith("\n") ? lines + 1 : lines;
188
254
  }
189
255
 
190
- async function runEnvironmentGitCommand(
191
- environment: TaskEnvironmentExecutor,
192
- cwd: string,
193
- args: string[],
194
- label: string,
195
- allowExitOne: boolean,
196
- maxOutputBytes: number,
197
- ): Promise<string> {
198
- const result = await environment.exec({
199
- projectEnvProfile: "maintenance",
200
- argv: ["/usr/bin/git", ...args],
201
- cwd,
202
- env: { ...RUNTIME_AUTHORITY_ENV, ...NON_INTERACTIVE_GIT_ENV },
203
- timeoutMs: TASK_ENVIRONMENT_GIT_TIMEOUT_MS,
204
- maxOutputBytes,
205
- });
206
- return requireEnvironmentGitSuccess(
207
- result,
208
- label,
209
- allowExitOne,
210
- maxOutputBytes,
211
- );
256
+ /** One project of a diff's output. */
257
+ export interface TaskDiffProjectOutput {
258
+ slug: string;
259
+ base?: string;
260
+ /** Its tracked patches, then its untracked ones. */
261
+ patches?: string;
262
+ tracked?: boolean;
263
+ untracked?: boolean;
264
+ error?: string;
212
265
  }
213
266
 
214
- function requireEnvironmentGitSuccess(
215
- result: TaskEnvironmentExecResult,
216
- label: string,
217
- allowExitOne: boolean,
218
- maxOutputBytes: number,
219
- ): string {
220
- if (result.stdoutTruncated || result.stderrTruncated) {
221
- throw new Error(`${label} exceeded ${maxOutputBytes} output bytes`);
222
- }
223
- if (result.exitCode === 0 || (allowExitOne && result.exitCode === 1)) {
224
- return Buffer.from(result.stdout).toString("utf8");
267
+ /**
268
+ * The projects of a diff's output (`TASK_DIFF_SCRIPT`), in order. Only whole
269
+ * lines count: a line the bound cut short is dropped, and its project is
270
+ * left without the value.
271
+ */
272
+ export function parseTaskDiffOutput(output: string): TaskDiffProjectOutput[] {
273
+ const projects: TaskDiffProjectOutput[] = [];
274
+ const lines = output.split("\n");
275
+ // The last element follows the last newline: whole only when empty.
276
+ lines.pop();
277
+ for (const line of lines) {
278
+ if (line.length < 2 || line[1] !== " " || !/^[A-Za-z0-9+/]*={0,2}$/.test(line.slice(2))) continue;
279
+ const value = Buffer.from(line.slice(2), "base64").toString("utf8");
280
+ const kind = line[0];
281
+ if (kind === "P") {
282
+ projects.push({ slug: value });
283
+ continue;
284
+ }
285
+ const project = projects.at(-1);
286
+ if (!project) continue;
287
+ if (kind === "B") project.base = value.slice(0, TASK_DIFF_BASE_CHARS);
288
+ else if (kind === "E") project.error = value.trim().slice(0, TASK_DIFF_ERROR_CHARS) || "the diff failed";
289
+ else if (kind === "D") {
290
+ project.patches = value;
291
+ project.tracked = true;
292
+ } else if (kind === "U") {
293
+ if (project.patches && value.length > 0 && !project.patches.endsWith("\n")) project.patches += "\n";
294
+ project.patches = (project.patches ?? "") + value;
295
+ project.untracked = true;
296
+ }
225
297
  }
226
- const stderr = Buffer.from(result.stderr)
227
- .toString("utf8")
228
- .trim()
229
- .slice(0, 4_096);
298
+ return projects;
299
+ }
300
+
301
+ /** Why a diff's exec gave nothing to parse: no exit, a failed one, or an
302
+ * output past its bound. Null when it can be parsed. */
303
+ export function taskDiffExecFailure(result: TaskEnvironmentExecResult): string | null {
304
+ if (result.stdoutTruncated || result.stderrTruncated) return "the diff exceeded its output bound";
305
+ if (result.exitCode === 0) return null;
306
+ const stderr = Buffer.from(result.stderr).toString("utf8").trim().slice(0, TASK_DIFF_ERROR_CHARS - 64);
230
307
  const outcome =
231
308
  result.exitCode === null
232
309
  ? result.signal
233
310
  ? `was terminated by ${result.signal}`
234
311
  : "did not start"
235
312
  : `exited ${result.exitCode}`;
236
- throw new Error(`${label} ${outcome}${stderr ? `: ${stderr}` : ""}`);
237
- }
238
-
239
- function runDiffCommand(
240
- command: string,
241
- args: string[],
242
- label: string,
243
- env: NodeJS.ProcessEnv,
244
- /** Treat exit code 1 as success — `git diff --no-index` returns 1 when the
245
- * compared inputs differ, which for a new-file patch is the expected case. */
246
- allowExitOne = false,
247
- ): Promise<string> {
248
- return new Promise((res, rej) => {
249
- // Force ASCII paths and pull a generous unified context so the UI
250
- // can render meaningful blocks. No color codes.
251
- const child = spawn(command, args, {
252
- env,
253
- stdio: ["ignore", "pipe", "pipe"],
254
- });
255
- let stdout = "";
256
- let stderr = "";
257
- child.stdout.on("data", (b) => {
258
- stdout += b.toString("utf8");
259
- });
260
- child.stderr.on("data", (b) => {
261
- stderr += b.toString("utf8");
262
- });
263
- child.on("error", rej);
264
- child.on("close", (code) => {
265
- if (code === 0 || (allowExitOne && code === 1)) {
266
- res(stdout);
267
- return;
268
- }
269
- rej(new Error(`${label} exited ${code}: ${stderr.trim()}`));
270
- });
271
- });
313
+ return `the task diff ${outcome}${stderr ? `: ${stderr}` : ""}`;
272
314
  }
273
315
 
274
316
  /**
@@ -24,6 +24,7 @@ import {
24
24
  taskAppContainerState,
25
25
  taskContainerCli,
26
26
  } from "./task-container-cli";
27
+ import { onOwnCredentials, whileOnOwnCredentials } from "./cloud-credential-host";
27
28
  import { runtimeAuthorityDockerExecArgs } from "./runtime-authority";
28
29
  import { RUNTIME_AUTHORITY_ENV } from "./runtime-authority";
29
30
  import { assertSafeHostTaskId } from "./task-identity";
@@ -148,7 +149,7 @@ export async function setupTaskGitIdentity(
148
149
  // 2026-08-26). The wildcard principal is deliberate: the task key is the
149
150
  // container's only signer, and the identity email may not be configured yet
150
151
  // when the key lands.
151
- const SSH_SIGNING_CONFIG =
152
+ export const SSH_SIGNING_CONFIG =
152
153
  "/usr/bin/git config --global gpg.format ssh" +
153
154
  " && /usr/bin/git config --global user.signingkey /home/node/.ssh/id_ed25519.pub" +
154
155
  " && /usr/bin/git config --global commit.gpgsign true" +
@@ -156,7 +157,7 @@ const SSH_SIGNING_CONFIG =
156
157
  ' && /usr/bin/printf \'* %s\\n\' "$(/bin/cat /home/node/.ssh/id_ed25519.pub)" > /home/node/.ssh/allowed_signers' +
157
158
  " && /usr/bin/git config --global gpg.ssh.allowedSignersFile /home/node/.ssh/allowed_signers";
158
159
 
159
- const SSH_TRANSPORT_CONFIG =
160
+ export const SSH_TRANSPORT_CONFIG =
160
161
  "/usr/bin/git config --global --unset-all 'url.https://github.com/.insteadOf' >/dev/null 2>&1 || true; " +
161
162
  "/usr/bin/git config --global --replace-all 'url.git@github.com:.insteadOf' 'https://github.com/'; " +
162
163
  '/usr/bin/git config --global core.sshCommand "ssh -o StrictHostKeyChecking=accept-new"';
@@ -275,34 +276,54 @@ interface SshEnsureInFlight {
275
276
 
276
277
  const sshEnsureInFlight = new Map<string, SshEnsureInFlight>();
277
278
  const sshDeleteInFlight = new Map<string, Promise<void>>();
279
+ /** Temporary assignment fence: cleanup seals a task, joins the older ensure,
280
+ * and deletes the copied key. A later safe assignment may explicitly reopen
281
+ * the task id. */
282
+ const sealedTaskSshEnsures = new Set<string>();
278
283
  /** Durable for this host process: an orphan id is immutable/non-reused, and
279
284
  * no later channel/recovery continuation may materialize its private key. */
280
285
  const retiredTaskSshEnsures = new Set<string>();
281
286
 
282
- /** Fence future task SSH ensures and join the exact older materialization, if
283
- * any. This is stronger than deleting the directory once: channel startup and
284
- * runtime recovery intentionally launch SSH reconciliation in the background,
285
- * and an older health probe can otherwise resume by writing the key after GC
286
- * has already checked absence. Never rejects; strict removal is the final
287
- * filesystem authority and will surface any cleanup failure to the cloud. */
288
- export async function retireTaskSshIdentityEnsures(
287
+ export async function sealTaskSshIdentityEnsures(
289
288
  taskId: string,
290
289
  ): Promise<void> {
291
290
  assertSafeHostTaskId(taskId);
292
- retiredTaskSshEnsures.add(taskId);
291
+ sealedTaskSshEnsures.add(taskId);
293
292
  for (;;) {
294
293
  const current = sshEnsureInFlight.get(taskId);
295
294
  if (!current) return;
296
295
  await current.promise.catch(() => false);
297
- // The tracked promise includes write/copy/finally-remove. Its outer caller
298
- // performs only authority bookkeeping after this point, so clearing the
299
- // matching entry is safe and avoids depending on microtask ordering.
300
296
  if (sshEnsureInFlight.get(taskId) === current) {
301
297
  sshEnsureInFlight.delete(taskId);
302
298
  }
303
299
  }
304
300
  }
305
301
 
302
+ export function openTaskSshIdentityEnsures(taskId: string): void {
303
+ assertSafeHostTaskId(taskId);
304
+ if (retiredTaskSshEnsures.has(taskId)) {
305
+ throw new Error(`task ${taskId} has been permanently retired`);
306
+ }
307
+ if (sshEnsureInFlight.has(taskId)) {
308
+ throw new Error(`task ${taskId} still has an SSH identity writer`);
309
+ }
310
+ sealedTaskSshEnsures.delete(taskId);
311
+ }
312
+
313
+ /** Fence future task SSH ensures and join the exact older materialization, if
314
+ * any. This is stronger than deleting the directory once: channel startup and
315
+ * runtime recovery intentionally launch SSH reconciliation in the background,
316
+ * and an older health probe can otherwise resume by writing the key after GC
317
+ * has already checked absence. Never rejects; strict removal is the final
318
+ * filesystem authority and will surface any cleanup failure to the cloud. */
319
+ export async function retireTaskSshIdentityEnsures(
320
+ taskId: string,
321
+ ): Promise<void> {
322
+ assertSafeHostTaskId(taskId);
323
+ retiredTaskSshEnsures.add(taskId);
324
+ await sealTaskSshIdentityEnsures(taskId);
325
+ }
326
+
306
327
  function sshPublicKeyFingerprint(publicKey: string): string | null {
307
328
  const [, encoded] = publicKey.trim().split(/\s+/, 3);
308
329
  if (!encoded) return null;
@@ -364,7 +385,25 @@ export async function ensureTaskSshIdentity(
364
385
  exec: DockerExec = defaultExec,
365
386
  deps: EnsureTaskSshIdentityDeps = {},
366
387
  ): Promise<boolean> {
367
- if (retiredTaskSshEnsures.has(taskId)) return false;
388
+ // ADR-154: the runner's own SSH key reaches a task only while the runner
389
+ // is on its own credentials; leaving them waits for a copy under way.
390
+ return (
391
+ (await whileOnOwnCredentials(() => ensureOwnTaskSshIdentity(taskId, ownerUserId, exec, deps))) ?? false
392
+ );
393
+ }
394
+
395
+ async function ensureOwnTaskSshIdentity(
396
+ taskId: string,
397
+ ownerUserId: string | null | undefined,
398
+ exec: DockerExec,
399
+ deps: EnsureTaskSshIdentityDeps,
400
+ ): Promise<boolean> {
401
+ if (
402
+ retiredTaskSshEnsures.has(taskId) ||
403
+ sealedTaskSshEnsures.has(taskId)
404
+ ) {
405
+ return false;
406
+ }
368
407
  if (ownerUserId) {
369
408
  const deleting = sshDeleteInFlight.get(ownerUserId);
370
409
  if (deleting) {
@@ -433,6 +472,9 @@ async function ensureTaskSshIdentityOnce(
433
472
  // Fast path: the DB-authoritative key is already good → re-assert only the
434
473
  // cheap signing config and skip docker cp churn.
435
474
  if (await sshKeyHealthy(taskId, expectedPublicKey, exec, environment)) {
475
+ // ADR-154: after the probe too, nothing is written once the runner has
476
+ // begun leaving its own credentials.
477
+ if (!onOwnCredentials()) return false;
436
478
  const config: [string, ...string[]] = ["/bin/sh", "-c", SSH_SIGNING_CONFIG];
437
479
  const configured = environment
438
480
  ? await environmentStep(environment, config)
@@ -440,6 +482,9 @@ async function ensureTaskSshIdentityOnce(
440
482
  return configured.status === 0;
441
483
  }
442
484
 
485
+ // ADR-154: the last hop, fenced: no key is materialized or copied once the
486
+ // runner has begun leaving its own credentials.
487
+ if (!onOwnCredentials()) return false;
443
488
  const dir = writeIdentity(taskId, ownerUserId);
444
489
  if (!dir) {
445
490
  console.log(`[ssh] task ${taskId}: no SSH signing identity for its owner`);
@@ -0,0 +1,28 @@
1
+ /**
2
+ * GitHub's REST origin for every request that carries a person's token or
3
+ * the App's own credentials (the repository picker, token revocation).
4
+ *
5
+ * An installed host always talks to https://api.github.com. Only a
6
+ * development or test launch may point UAI_GITHUB_API_URL elsewhere (the
7
+ * fake GitHub the browser e2e runs against), and then only at an http(s)
8
+ * origin with no userinfo. An installed service leaves NODE_ENV unset, so
9
+ * "not production" would let a stray override send credentials anywhere; a
10
+ * development launch is told by the markers in lib/launch-mode instead.
11
+ */
12
+
13
+ import { isDevelopmentLaunch, type LaunchEnv } from "./launch-mode";
14
+
15
+ export const GITHUB_API_ORIGIN = "https://api.github.com";
16
+
17
+ /** The origin to send GitHub credentials to. Throws on a malformed override
18
+ * in a development launch, never falls through to it otherwise. */
19
+ export function githubApiOrigin(env: LaunchEnv & { UAI_GITHUB_API_URL?: string } = process.env): string {
20
+ if (!isDevelopmentLaunch(env)) return GITHUB_API_ORIGIN;
21
+ const configured = env.UAI_GITHUB_API_URL?.trim();
22
+ if (!configured) return GITHUB_API_ORIGIN;
23
+ const url = new URL(configured);
24
+ if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) {
25
+ throw new Error("UAI_GITHUB_API_URL must be an HTTP(S) origin");
26
+ }
27
+ return url.origin;
28
+ }