@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
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
*
|
|
88
|
-
*
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
):
|
|
137
|
-
return
|
|
138
|
-
"
|
|
139
|
-
[
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
/**
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
/**
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/lib/git-identity.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|