@runuai/host 0.9.14 → 0.9.42

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 (97) hide show
  1. package/README.md +22 -5
  2. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  3. package/db/migrations/0015_host_settings.sql +9 -0
  4. package/db/migrations/0016_task_environment.sql +2 -0
  5. package/db/migrations/meta/_journal.json +21 -0
  6. package/db/schema.ts +80 -30
  7. package/images/standard/Dockerfile +36 -10
  8. package/images/standard/README.md +63 -18
  9. package/images/standard/container/corepack-version +1 -0
  10. package/images/standard/container/uai-init +308 -38
  11. package/images/standard/container/uai-materialize-runtimes +1527 -0
  12. package/lib/agent-cli.ts +33 -2
  13. package/lib/agent.ts +46 -7
  14. package/lib/agents/claude.ts +13 -8
  15. package/lib/agents/codex.ts +11 -6
  16. package/lib/agents/cursor.ts +39 -29
  17. package/lib/agents/durable-proc.ts +20 -27
  18. package/lib/agents/factory.ts +9 -25
  19. package/lib/agents/grok.ts +43 -30
  20. package/lib/agents/kimi.ts +44 -29
  21. package/lib/agents/opencode.ts +43 -31
  22. package/lib/agents/proc.ts +149 -114
  23. package/lib/agents/transport.ts +62 -50
  24. package/lib/agents/types.ts +6 -4
  25. package/lib/apple-runtime-recycle.ts +236 -0
  26. package/lib/apple-uninstall-teardown.ts +224 -0
  27. package/lib/browser-testing.ts +233 -93
  28. package/lib/codex-auth.ts +40 -6
  29. package/lib/command-db.ts +20 -0
  30. package/lib/container-runtime.ts +1338 -0
  31. package/lib/db.ts +1 -0
  32. package/lib/docker-exec.ts +87 -5
  33. package/lib/engine-accounts.ts +68 -5
  34. package/lib/engine-login.ts +1952 -0
  35. package/lib/enrollment-state.ts +251 -0
  36. package/lib/env-file.ts +155 -0
  37. package/lib/env.ts +4 -0
  38. package/lib/git-diff.ts +98 -32
  39. package/lib/git-identity.ts +199 -87
  40. package/lib/github-tokens.ts +202 -91
  41. package/lib/host-cloud-url.ts +62 -0
  42. package/lib/host-config.ts +279 -0
  43. package/lib/host-logs.ts +962 -0
  44. package/lib/keyed-promise-tail.ts +23 -0
  45. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  46. package/lib/managed-activation-watcher.ts +72 -0
  47. package/lib/managed-install-owner-watcher.ts +55 -0
  48. package/lib/managed-operation-drain.ts +49 -0
  49. package/lib/managed-runtime.ts +3644 -0
  50. package/lib/managed-update-scheduler.ts +125 -0
  51. package/lib/mcp-gateway.ts +450 -23
  52. package/lib/orchestrator.ts +3060 -218
  53. package/lib/preview-sidecar.ts +57 -13
  54. package/lib/release-manifest.ts +708 -0
  55. package/lib/release-trust.ts +28 -0
  56. package/lib/runtime-activation-tail.ts +232 -0
  57. package/lib/runtime-archive.ts +1086 -0
  58. package/lib/runtime-authority.ts +79 -0
  59. package/lib/runtime-guard.ts +36 -0
  60. package/lib/runtime-provider-state.ts +169 -0
  61. package/lib/runtime-state.ts +232 -12
  62. package/lib/skills.ts +24 -3
  63. package/lib/ssh.ts +18 -0
  64. package/lib/standard-image.ts +1104 -141
  65. package/lib/stopped-task-status-queue.ts +44 -0
  66. package/lib/task-container-cli.ts +269 -0
  67. package/lib/task-diff.ts +66 -46
  68. package/lib/task-environment/apple-container.ts +757 -0
  69. package/lib/task-environment/docker.ts +945 -0
  70. package/lib/task-environment/index.ts +364 -0
  71. package/lib/task-environment/legacy-adoption.ts +443 -0
  72. package/lib/task-environment/registry.ts +58 -0
  73. package/lib/task-environment/types.ts +408 -0
  74. package/lib/task-identity.ts +19 -0
  75. package/lib/task-inventory.ts +585 -0
  76. package/lib/tunnel-registry.ts +135 -19
  77. package/lib/tunnel-runtime.ts +235 -0
  78. package/package.json +1 -1
  79. package/scripts/agent/_common.sh +123 -3
  80. package/scripts/agent/task-down.sh +146 -38
  81. package/scripts/agent/task-status.sh +19 -3
  82. package/scripts/agent/task-up.sh +1405 -107
  83. package/scripts/install/darwin.ts +848 -50
  84. package/scripts/install/linux.ts +838 -35
  85. package/scripts/install/types.ts +43 -0
  86. package/scripts/install/util.ts +215 -8
  87. package/scripts/install/win.ts +12 -0
  88. package/src/apple-tunnel-route.ts +104 -0
  89. package/src/cli.ts +1464 -72
  90. package/src/event-outbox.ts +83 -4
  91. package/src/index.ts +766 -42
  92. package/src/main.ts +1398 -255
  93. package/src/paths.ts +17 -1
  94. package/src/protocol.ts +695 -1
  95. package/src/runtime-bootstrap.ts +165 -0
  96. package/src/ui/server.ts +46 -10
  97. package/src/ui/types.ts +37 -0
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Crash-safe local state for first-time host enrollment (ADR-099).
3
+ *
4
+ * A host chooses its permanent id/secret before redeeming a one-time cloud
5
+ * token. Persist that proposal before the request so a lost response or a
6
+ * process crash can replay the exact same identity. The raw enrollment token
7
+ * is never written to disk; only its sha256 fingerprint is retained.
8
+ */
9
+
10
+ import { createHash, randomBytes } from "node:crypto";
11
+ import {
12
+ chmodSync,
13
+ closeSync,
14
+ existsSync,
15
+ fsyncSync,
16
+ linkSync,
17
+ lstatSync,
18
+ mkdirSync,
19
+ openSync,
20
+ readFileSync,
21
+ unlinkSync,
22
+ writeFileSync,
23
+ } from "node:fs";
24
+ import { basename, dirname, join } from "node:path";
25
+
26
+ import { writeEnvValuesAtomic } from "./env-file";
27
+ import { parseHostCloudEndpoint } from "./host-cloud-url";
28
+
29
+ const HOST_ID = /^[a-z0-9_-]{1,64}$/;
30
+ const SHA256 = /^[a-f0-9]{64}$/;
31
+
32
+ export { writeEnvValuesAtomic } from "./env-file";
33
+
34
+ export interface PendingEnrollment {
35
+ readonly schemaVersion: 1;
36
+ readonly enrollmentTokenSha256: string;
37
+ readonly hostId: string;
38
+ readonly hostSecret: string;
39
+ readonly cloudUrl: string;
40
+ readonly hostName: string;
41
+ readonly createdAt: number;
42
+ }
43
+
44
+ export function pendingEnrollmentPath(uaiHome: string): string {
45
+ return join(uaiHome, ".enrollment-pending.json");
46
+ }
47
+
48
+ export function enrollmentTokenSha256(token: string): string {
49
+ return createHash("sha256").update(token).digest("hex");
50
+ }
51
+
52
+ export function pendingEnrollmentMatches(
53
+ pending: PendingEnrollment,
54
+ enrollmentToken: string,
55
+ cloudUrl: string,
56
+ ): boolean {
57
+ return (
58
+ pending.enrollmentTokenSha256 === enrollmentTokenSha256(enrollmentToken) &&
59
+ pending.cloudUrl === cloudUrl
60
+ );
61
+ }
62
+
63
+ export function readPendingEnrollment(file: string): PendingEnrollment | null {
64
+ if (!existsSync(file)) return null;
65
+ const stat = lstatSync(file);
66
+ if (!stat.isFile() || stat.isSymbolicLink()) {
67
+ throw new Error("pending enrollment state must be a regular file");
68
+ }
69
+ let parsed: unknown;
70
+ try {
71
+ parsed = JSON.parse(readFileSync(file, "utf8"));
72
+ } catch {
73
+ throw new Error("pending enrollment state is malformed");
74
+ }
75
+ return validatePendingEnrollment(parsed);
76
+ }
77
+
78
+ /**
79
+ * Create the immutable pending proposal without an overwrite race. A complete
80
+ * 0600 temp file is fsync'd and hard-linked into place, so the durable path is
81
+ * never observable with partial JSON. If another process won, return its
82
+ * strictly validated proposal instead.
83
+ */
84
+ export function ensurePendingEnrollment(
85
+ file: string,
86
+ candidate: PendingEnrollment,
87
+ ): { pending: PendingEnrollment; created: boolean } {
88
+ const normalized = validatePendingEnrollment(candidate);
89
+ const parent = dirname(file);
90
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
91
+
92
+ const existing = readPendingEnrollment(file);
93
+ if (existing) return { pending: existing, created: false };
94
+
95
+ const temporary = join(
96
+ parent,
97
+ `.${basename(file)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`,
98
+ );
99
+ try {
100
+ writeDurableFile(temporary, `${JSON.stringify(normalized, null, 2)}\n`, true);
101
+ try {
102
+ linkSync(temporary, file);
103
+ fsyncDirectory(parent);
104
+ return { pending: normalized, created: true };
105
+ } catch (error) {
106
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
107
+ const winner = readPendingEnrollment(file);
108
+ if (!winner) throw new Error("pending enrollment winner disappeared");
109
+ return { pending: winner, created: false };
110
+ }
111
+ } finally {
112
+ try {
113
+ unlinkSync(temporary);
114
+ } catch (error) {
115
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Commit the exact pending identity to dotenv, then remove the journal. The
122
+ * ordering is intentional: a crash can leave both files, but can never leave
123
+ * neither. A later invocation can recognize and remove the harmless journal.
124
+ */
125
+ export function commitPendingEnrollment(
126
+ pendingFile: string,
127
+ envFile: string,
128
+ expected: PendingEnrollment,
129
+ ): void {
130
+ const current = readPendingEnrollment(pendingFile);
131
+ if (!current || JSON.stringify(current) !== JSON.stringify(expected)) {
132
+ throw new Error("pending enrollment state changed before commit");
133
+ }
134
+ writeEnvValuesAtomic(envFile, {
135
+ UAI_HOST_ID: expected.hostId,
136
+ UAI_HOST_TOKEN: expected.hostSecret,
137
+ UAI_CLOUD_URL: expected.cloudUrl,
138
+ });
139
+ removePendingEnrollment(pendingFile);
140
+ }
141
+
142
+ export function removeCommittedPendingEnrollment(
143
+ file: string,
144
+ identity: { hostId: string; hostSecret: string },
145
+ ): boolean {
146
+ const pending = readPendingEnrollment(file);
147
+ if (!pending) return false;
148
+ if (
149
+ pending.hostId !== identity.hostId ||
150
+ pending.hostSecret !== identity.hostSecret
151
+ ) {
152
+ return false;
153
+ }
154
+ removePendingEnrollment(file);
155
+ return true;
156
+ }
157
+
158
+ /** Deliberate operator reset used only by the existing `setup --force` path. */
159
+ export function discardPendingEnrollment(file: string): void {
160
+ removePendingEnrollment(file);
161
+ }
162
+
163
+ function removePendingEnrollment(file: string): void {
164
+ try {
165
+ unlinkSync(file);
166
+ fsyncDirectory(dirname(file));
167
+ } catch (error) {
168
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
169
+ }
170
+ }
171
+
172
+ function writeDurableFile(file: string, body: string, exclusive: boolean): void {
173
+ const descriptor = openSync(file, exclusive ? "wx" : "w", 0o600);
174
+ try {
175
+ writeFileSync(descriptor, body, "utf8");
176
+ fsyncSync(descriptor);
177
+ } finally {
178
+ closeSync(descriptor);
179
+ }
180
+ chmodSync(file, 0o600);
181
+ }
182
+
183
+ function fsyncDirectory(directory: string): void {
184
+ let descriptor: number | undefined;
185
+ try {
186
+ descriptor = openSync(directory, "r");
187
+ fsyncSync(descriptor);
188
+ } catch (error) {
189
+ // Some platforms/filesystems do not permit fsync on a directory. The file
190
+ // itself was still fsync'd before rename/link; do not make enrollment
191
+ // unusable solely because the directory durability primitive is absent.
192
+ const code = (error as NodeJS.ErrnoException).code;
193
+ if (code !== "EINVAL" && code !== "ENOTSUP" && code !== "EISDIR") throw error;
194
+ } finally {
195
+ if (descriptor !== undefined) closeSync(descriptor);
196
+ }
197
+ }
198
+
199
+ function validatePendingEnrollment(value: unknown): PendingEnrollment {
200
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
201
+ throw new Error("pending enrollment state must be an object");
202
+ }
203
+ const object = value as Record<string, unknown>;
204
+ const expected = [
205
+ "schemaVersion",
206
+ "enrollmentTokenSha256",
207
+ "hostId",
208
+ "hostSecret",
209
+ "cloudUrl",
210
+ "hostName",
211
+ "createdAt",
212
+ ];
213
+ if (
214
+ Object.keys(object).sort().join("\0") !== expected.sort().join("\0") ||
215
+ object.schemaVersion !== 1 ||
216
+ typeof object.enrollmentTokenSha256 !== "string" ||
217
+ !SHA256.test(object.enrollmentTokenSha256) ||
218
+ typeof object.hostId !== "string" ||
219
+ !HOST_ID.test(object.hostId) ||
220
+ typeof object.hostSecret !== "string" ||
221
+ !SHA256.test(object.hostSecret) ||
222
+ typeof object.cloudUrl !== "string" ||
223
+ object.cloudUrl.length < 1 ||
224
+ object.cloudUrl.length > 2_048 ||
225
+ typeof object.hostName !== "string" ||
226
+ object.hostName.length > 128 ||
227
+ typeof object.createdAt !== "number" ||
228
+ !Number.isSafeInteger(object.createdAt) ||
229
+ object.createdAt <= 0
230
+ ) {
231
+ throw new Error("pending enrollment state is invalid");
232
+ }
233
+ let canonicalCloudUrl: string;
234
+ try {
235
+ canonicalCloudUrl = parseHostCloudEndpoint(object.cloudUrl).bridgeUrl;
236
+ } catch {
237
+ throw new Error("pending enrollment state is invalid");
238
+ }
239
+ if (canonicalCloudUrl !== object.cloudUrl) {
240
+ throw new Error("pending enrollment state is invalid");
241
+ }
242
+ return {
243
+ schemaVersion: 1,
244
+ enrollmentTokenSha256: object.enrollmentTokenSha256,
245
+ hostId: object.hostId,
246
+ hostSecret: object.hostSecret,
247
+ cloudUrl: object.cloudUrl,
248
+ hostName: object.hostName,
249
+ createdAt: object.createdAt,
250
+ };
251
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Durable `.env.local` updates shared by every host-side settings writer.
3
+ *
4
+ * A mutation is a read/merge/fsync/rename operation, so concurrent writers
5
+ * must serialize the entire transition rather than only the final rename.
6
+ * `serializeEnvFileMutation` is the process-wide boundary used by remote host
7
+ * config today and interactive engine-login persistence later (ADR-100).
8
+ */
9
+
10
+ import { randomBytes } from "node:crypto";
11
+ import {
12
+ chmodSync,
13
+ closeSync,
14
+ existsSync,
15
+ fsyncSync,
16
+ mkdirSync,
17
+ openSync,
18
+ readFileSync,
19
+ renameSync,
20
+ unlinkSync,
21
+ writeFileSync,
22
+ } from "node:fs";
23
+ import { basename, dirname, resolve } from "node:path";
24
+
25
+ const ENV_KEY = /^[A-Z][A-Z0-9_]*$/;
26
+ const mutationTails = new Map<string, Promise<void>>();
27
+
28
+ export interface EnvFileSnapshot {
29
+ readonly exists: boolean;
30
+ readonly body: string;
31
+ }
32
+
33
+ /**
34
+ * Queue one complete mutation for an env file. A failed predecessor never
35
+ * poisons the queue, and the tail is removed once the last waiter settles.
36
+ */
37
+ export function serializeEnvFileMutation<T>(
38
+ file: string,
39
+ operation: () => T | Promise<T>,
40
+ ): Promise<T> {
41
+ const key = resolve(file);
42
+ const predecessor = mutationTails.get(key) ?? Promise.resolve();
43
+ const running = predecessor.catch(() => undefined).then(operation);
44
+ const settled = running.then(
45
+ () => undefined,
46
+ () => undefined,
47
+ );
48
+ mutationTails.set(key, settled);
49
+ void settled.finally(() => {
50
+ if (mutationTails.get(key) === settled) mutationTails.delete(key);
51
+ });
52
+ return running;
53
+ }
54
+
55
+ /** Atomically merge all supplied values into one durable 0600 dotenv file. */
56
+ export function writeEnvValuesAtomic(
57
+ file: string,
58
+ updates: Readonly<Record<string, string>>,
59
+ ): void {
60
+ const entries = Object.entries(updates);
61
+ if (entries.length === 0) return;
62
+ for (const [key, value] of entries) {
63
+ if (!ENV_KEY.test(key)) throw new Error(`invalid environment key: ${key}`);
64
+ if (value.includes("\0") || value.includes("\n") || value.includes("\r")) {
65
+ throw new Error(`environment value for ${key} contains a line break`);
66
+ }
67
+ }
68
+
69
+ const keys = new Set(entries.map(([key]) => key));
70
+ const current = existsSync(file) ? readFileSync(file, "utf8") : "";
71
+ const kept = current
72
+ .split(/\r?\n/)
73
+ .filter((line, index, lines) => {
74
+ if (index === lines.length - 1 && line === "") return false;
75
+ const equals = line.indexOf("=");
76
+ return equals <= 0 || !keys.has(line.slice(0, equals));
77
+ });
78
+ for (const [key, value] of entries) kept.push(`${key}=${value}`);
79
+ replaceEnvFileAtomic(file, `${kept.join("\n")}\n`);
80
+ }
81
+
82
+ /** Capture the exact bytes needed to roll back a multi-store config write. */
83
+ export function readEnvFileSnapshot(file: string): EnvFileSnapshot {
84
+ return existsSync(file)
85
+ ? { exists: true, body: readFileSync(file, "utf8") }
86
+ : { exists: false, body: "" };
87
+ }
88
+
89
+ /** Restore a prior snapshot durably. Successful replacements remain 0600. */
90
+ export function restoreEnvFileSnapshotAtomic(
91
+ file: string,
92
+ snapshot: EnvFileSnapshot,
93
+ ): void {
94
+ if (snapshot.exists) {
95
+ replaceEnvFileAtomic(file, snapshot.body);
96
+ return;
97
+ }
98
+ try {
99
+ unlinkSync(file);
100
+ fsyncDirectory(dirname(file));
101
+ } catch (error) {
102
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
103
+ }
104
+ }
105
+
106
+ function replaceEnvFileAtomic(file: string, body: string): void {
107
+ const parent = dirname(file);
108
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
109
+ const temporary = joinTemporary(parent, basename(file));
110
+ try {
111
+ writeDurableFile(temporary, body);
112
+ renameSync(temporary, file);
113
+ chmodSync(file, 0o600);
114
+ fsyncDirectory(parent);
115
+ } finally {
116
+ try {
117
+ unlinkSync(temporary);
118
+ } catch (error) {
119
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
120
+ }
121
+ }
122
+ }
123
+
124
+ function joinTemporary(parent: string, name: string): string {
125
+ return resolve(
126
+ parent,
127
+ `.${name}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`,
128
+ );
129
+ }
130
+
131
+ function writeDurableFile(file: string, body: string): void {
132
+ const descriptor = openSync(file, "wx", 0o600);
133
+ try {
134
+ writeFileSync(descriptor, body, "utf8");
135
+ fsyncSync(descriptor);
136
+ } finally {
137
+ closeSync(descriptor);
138
+ }
139
+ chmodSync(file, 0o600);
140
+ }
141
+
142
+ function fsyncDirectory(directory: string): void {
143
+ let descriptor: number | undefined;
144
+ try {
145
+ descriptor = openSync(directory, "r");
146
+ fsyncSync(descriptor);
147
+ } catch (error) {
148
+ const code = (error as NodeJS.ErrnoException).code;
149
+ if (code !== "EINVAL" && code !== "ENOTSUP" && code !== "EISDIR") {
150
+ throw error;
151
+ }
152
+ } finally {
153
+ if (descriptor !== undefined) closeSync(descriptor);
154
+ }
155
+ }
package/lib/env.ts CHANGED
@@ -20,6 +20,10 @@ const schema = z.object({
20
20
  UAI_HOST_BRIDGE_PORT: z.coerce.number().int().min(1).max(65535).default(8789),
21
21
  UAI_CLOUD_URL: z.string().min(1).optional(),
22
22
  UAI_HOST_ID: z.string().min(1).optional(),
23
+ // ADR-101: container-runtime.ts validates this separately. Keeping the raw
24
+ // value here prevents one misspelled runtime preference from making the
25
+ // schema fallback discard every otherwise-valid host setting.
26
+ UAI_RUNTIME: z.string().optional(),
23
27
  // Host master key file (ADR-027) — 32 bytes, AES-256-GCM for the host
24
28
  // secret store. Defaults to $UAI_DATA_DIR/host-master.key; generated on
25
29
  // first use.
package/lib/git-diff.ts CHANGED
@@ -13,6 +13,12 @@
13
13
  */
14
14
  import { spawn } from "node:child_process";
15
15
 
16
+ import { RUNTIME_AUTHORITY_ENV } from "./runtime-authority";
17
+ import type {
18
+ TaskEnvironmentExecResult,
19
+ TaskEnvironmentHandle,
20
+ } from "./task-environment/types";
21
+
16
22
  export type DiffStatus =
17
23
  | "added"
18
24
  | "deleted"
@@ -71,6 +77,12 @@ const NON_INTERACTIVE_GIT_ENV = {
71
77
  GIT_SSH_COMMAND: "ssh -o BatchMode=yes",
72
78
  };
73
79
 
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">;
85
+
74
86
  /**
75
87
  * Run `git diff <range>` in `cwd` and return the raw unified-diff text.
76
88
  * Throws if git exits non-zero. Empty diff returns an empty string.
@@ -88,23 +100,24 @@ export function runGitDiff(cwd: string, range: string): Promise<string> {
88
100
  }
89
101
 
90
102
  /**
91
- * Run `git diff <range>` inside a running task container.
103
+ * Run `git diff <range>` inside a running task environment.
92
104
  *
93
- * Active task worktrees are bind-mounted into the container at
94
- * `/workspace`. Running diff there keeps all Git credential behavior
105
+ * Running diff there keeps all Git credential behavior
95
106
  * inside the task sandbox instead of leaking to the host's SSH agent
96
107
  * (for example macOS 1Password confirmation prompts).
97
108
  */
98
- export function runGitDiffInContainer(
99
- containerName: string,
109
+ export function runGitDiffInEnvironment(
110
+ environment: TaskEnvironmentExecutor,
100
111
  cwd: string,
101
112
  range: string,
102
113
  ): Promise<string> {
103
- return runDiffCommand(
104
- "docker",
105
- [...dockerExecPrefix(containerName, cwd), "git", ...GIT_DIFF_ARGS, range],
106
- "docker exec git diff",
107
- process.env,
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,
108
121
  );
109
122
  }
110
123
 
@@ -130,43 +143,96 @@ export function runGitDiffUntracked(
130
143
  );
131
144
  }
132
145
 
133
- /** In-container variant of {@link runGitDiffUntracked}. */
134
- export function runGitDiffUntrackedInContainer(
135
- containerName: string,
146
+ /** In-environment variant of {@link runGitDiffUntracked}. */
147
+ export function runGitDiffUntrackedInEnvironment(
148
+ environment: TaskEnvironmentExecutor,
136
149
  cwd: string,
137
150
  relPath: string,
138
151
  ): Promise<string> {
139
- return runDiffCommand(
140
- "docker",
152
+ return runEnvironmentGitCommand(
153
+ environment,
154
+ cwd,
141
155
  [
142
- ...dockerExecPrefix(containerName, cwd),
143
- "git",
144
156
  ...GIT_DIFF_ARGS,
145
157
  "--no-index",
146
158
  "--",
147
159
  "/dev/null",
148
160
  relPath,
149
161
  ],
150
- "docker exec git diff --no-index",
151
- process.env,
162
+ "task environment git diff --no-index",
152
163
  true,
164
+ TASK_ENVIRONMENT_GIT_OUTPUT_BYTES,
153
165
  );
154
166
  }
155
167
 
156
- /** `docker exec` argv prefix with the non-interactive git env + working dir. */
157
- function dockerExecPrefix(containerName: string, cwd: string): string[] {
158
- return [
159
- "exec",
160
- "-e",
161
- `GIT_TERMINAL_PROMPT=${NON_INTERACTIVE_GIT_ENV.GIT_TERMINAL_PROMPT}`,
162
- "-e",
163
- `GCM_INTERACTIVE=${NON_INTERACTIVE_GIT_ENV.GCM_INTERACTIVE}`,
164
- "-e",
165
- `GIT_SSH_COMMAND=${NON_INTERACTIVE_GIT_ENV.GIT_SSH_COMMAND}`,
166
- "-w",
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;
187
+ }
188
+ }
189
+
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
+ argv: ["/usr/bin/git", ...args],
167
200
  cwd,
168
- containerName,
169
- ];
201
+ env: { ...RUNTIME_AUTHORITY_ENV, ...NON_INTERACTIVE_GIT_ENV },
202
+ timeoutMs: TASK_ENVIRONMENT_GIT_TIMEOUT_MS,
203
+ maxOutputBytes,
204
+ });
205
+ return requireEnvironmentGitSuccess(
206
+ result,
207
+ label,
208
+ allowExitOne,
209
+ maxOutputBytes,
210
+ );
211
+ }
212
+
213
+ function requireEnvironmentGitSuccess(
214
+ result: TaskEnvironmentExecResult,
215
+ label: string,
216
+ allowExitOne: boolean,
217
+ maxOutputBytes: number,
218
+ ): string {
219
+ if (result.stdoutTruncated || result.stderrTruncated) {
220
+ throw new Error(`${label} exceeded ${maxOutputBytes} output bytes`);
221
+ }
222
+ if (result.exitCode === 0 || (allowExitOne && result.exitCode === 1)) {
223
+ return Buffer.from(result.stdout).toString("utf8");
224
+ }
225
+ const stderr = Buffer.from(result.stderr)
226
+ .toString("utf8")
227
+ .trim()
228
+ .slice(0, 4_096);
229
+ const outcome =
230
+ result.exitCode === null
231
+ ? result.signal
232
+ ? `was terminated by ${result.signal}`
233
+ : "did not start"
234
+ : `exited ${result.exitCode}`;
235
+ throw new Error(`${label} ${outcome}${stderr ? `: ${stderr}` : ""}`);
170
236
  }
171
237
 
172
238
  function runDiffCommand(