@runuai/host 0.9.14 → 0.9.43

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 +3051 -200
  53. package/lib/preview-sidecar.ts +68 -14
  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 +956 -0
  70. package/lib/task-environment/index.ts +364 -0
  71. package/lib/task-environment/legacy-adoption.ts +459 -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,44 @@
1
+ /**
2
+ * Best-effort task.status frames have no acknowledgement, but they must at
3
+ * least survive bridge disconnects within this host process. The local task
4
+ * mirror remains the authority at flush time so a later Resume wins over an
5
+ * older queued stop.
6
+ */
7
+ export class StoppedTaskStatusQueue {
8
+ private readonly pending = new Set<string>();
9
+
10
+ constructor(
11
+ private readonly statusForTask: (
12
+ taskId: string,
13
+ ) => string | null | undefined,
14
+ ) {}
15
+
16
+ enqueue(taskId: string): void {
17
+ this.pending.add(taskId);
18
+ }
19
+
20
+ /** A failed taskUp may have stopped a legacy runtime before recreation lost
21
+ * Docker. Queue the physical truth after the command result is published. */
22
+ enqueueStoppedTaskUp(command: string, args: readonly unknown[]): boolean {
23
+ if (command !== "taskUp") return false;
24
+ const input = args[0];
25
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
26
+ return false;
27
+ }
28
+ const task = (input as { task?: unknown }).task;
29
+ if (!task || typeof task !== "object" || Array.isArray(task)) return false;
30
+ const taskId = (task as { id?: unknown }).id;
31
+ if (typeof taskId !== "string" || this.statusForTask(taskId) !== "stopped") {
32
+ return false;
33
+ }
34
+ this.enqueue(taskId);
35
+ return true;
36
+ }
37
+
38
+ flush(publish: (taskId: string) => void): void {
39
+ for (const taskId of [...this.pending]) {
40
+ if (this.statusForTask(taskId) === "stopped") publish(taskId);
41
+ this.pending.delete(taskId);
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Backend-aware CLI access to TASK app containers for the auxiliary
3
+ * subsystems that predate the TaskEnvironment provider (GitHub credential
4
+ * injection, git identity, Codex reinjection, skills). Found live 2026-08-16:
5
+ * these spawned the literal `docker` binary with Compose replica names, so on
6
+ * a Docker-free Apple host every agent session start died at the credential
7
+ * step with spawn ENOENT.
8
+ *
9
+ * The exec/cp verbs are flag-compatible across Docker and the bundled Apple
10
+ * CLI, so most callers only need the command + container-name translation
11
+ * here. Verbs that differ (ps/rm/inventory) get explicit helpers.
12
+ */
13
+
14
+ import { spawn as nodeSpawn } from "node:child_process";
15
+
16
+ import {
17
+ appleContainerRuntimeBinding,
18
+ pinnedContainerRuntimeProvider,
19
+ } from "./container-runtime";
20
+ import { dockerCli, type DockerResult } from "./docker-exec";
21
+ import { assertSafeHostTaskId } from "./task-identity";
22
+
23
+ export interface TaskContainerBackend {
24
+ command: string;
25
+ apple: boolean;
26
+ }
27
+
28
+ /** The pin, not the readiness-gated identity: session ensure and recovery
29
+ * both run while the public state can still be `checking`. */
30
+ export function taskContainerBackend(): TaskContainerBackend {
31
+ if (pinnedContainerRuntimeProvider() === "apple-container") {
32
+ const binding = appleContainerRuntimeBinding();
33
+ if (binding !== null) {
34
+ return { command: binding.containerCliPath, apple: true };
35
+ }
36
+ }
37
+ return { command: "docker", apple: false };
38
+ }
39
+
40
+ /** The app container's name under the selected backend: Compose replica
41
+ * naming for Docker, the provider's fixed name for Apple (must match
42
+ * appleTaskContainerName in task-environment/apple-container.ts). */
43
+ export function taskAppContainerName(taskId: string): string {
44
+ assertSafeHostTaskId(taskId);
45
+ const lowered = taskId.toLowerCase();
46
+ return taskContainerBackend().apple
47
+ ? `task-${lowered}-app`
48
+ : `task-${lowered}-app-1`;
49
+ }
50
+
51
+ /** dockerCli-compatible runner that targets the selected backend. Every kill
52
+ * (deadline or overflow) forces settlement within a bounded grace. */
53
+ export function taskContainerCli(
54
+ args: string[],
55
+ opts: {
56
+ input?: string;
57
+ timeoutMs?: number;
58
+ maxOutputBytes?: number;
59
+ } = {},
60
+ ): Promise<DockerResult> {
61
+ const backend = taskContainerBackend();
62
+ if (!backend.apple) return dockerCli(args, opts);
63
+ return new Promise<DockerResult>((resolve) => {
64
+ const child = nodeSpawn(backend.command, args, {
65
+ stdio: ["pipe", "pipe", "pipe"],
66
+ });
67
+ let stdout = "";
68
+ let stderr = "";
69
+ let settled = false;
70
+ const finish = (status: number | null): void => {
71
+ if (settled) return;
72
+ settled = true;
73
+ clearTimeout(timer);
74
+ resolve({ status, stdout, stderr });
75
+ };
76
+ const killAndSettle = (): void => {
77
+ child.kill("SIGKILL");
78
+ const settle = setTimeout(() => finish(null), 5_000);
79
+ settle.unref?.();
80
+ };
81
+ const timer = setTimeout(killAndSettle, opts.timeoutMs ?? 30_000);
82
+ timer.unref?.();
83
+ child.stdout.setEncoding("utf8");
84
+ child.stderr.setEncoding("utf8");
85
+ child.stdout.on("data", (chunk: string) => {
86
+ stdout += chunk;
87
+ if (
88
+ opts.maxOutputBytes !== undefined &&
89
+ stdout.length + stderr.length > opts.maxOutputBytes
90
+ ) {
91
+ killAndSettle();
92
+ }
93
+ });
94
+ child.stderr.on("data", (chunk: string) => {
95
+ stderr += chunk;
96
+ if (
97
+ opts.maxOutputBytes !== undefined &&
98
+ stdout.length + stderr.length > opts.maxOutputBytes
99
+ ) {
100
+ killAndSettle();
101
+ }
102
+ });
103
+ child.stdin.on("error", () => {});
104
+ if (opts.input !== undefined) child.stdin.write(opts.input);
105
+ child.stdin.end();
106
+ child.on("error", () => finish(null));
107
+ child.on("close", (code) => finish(code));
108
+ });
109
+ }
110
+
111
+ export type TaskContainerState =
112
+ | { kind: "running" }
113
+ | { kind: "stopped"; state: string }
114
+ | { kind: "absent" }
115
+ | { kind: "unknown"; detail: string };
116
+
117
+ /** Backend-aware state probe for one exact task app container. Absence is
118
+ * proven only by the complete not-found error naming the container (Apple)
119
+ * or an empty exact-name filter answer (Docker); anything else is unknown. */
120
+ export async function taskAppContainerState(
121
+ containerName: string,
122
+ run: typeof taskContainerCli = taskContainerCli,
123
+ ): Promise<TaskContainerState> {
124
+ const backend = taskContainerBackend();
125
+ if (backend.apple) {
126
+ const inspected = await run(["inspect", containerName], {
127
+ timeoutMs: 30_000,
128
+ maxOutputBytes: 1024 * 1024,
129
+ });
130
+ if (inspected.status !== 0) {
131
+ if (
132
+ inspected.stdout.trim() === "" &&
133
+ inspected.stderr
134
+ .split("\n")
135
+ .some(
136
+ (line) =>
137
+ line.trim() === `Error: container not found: ${containerName}`,
138
+ )
139
+ ) {
140
+ return { kind: "absent" };
141
+ }
142
+ return {
143
+ kind: "unknown",
144
+ detail: inspected.stderr.trim().slice(0, 300) || "inspect failed",
145
+ };
146
+ }
147
+ try {
148
+ const parsed = JSON.parse(inspected.stdout) as Array<{
149
+ id?: unknown;
150
+ status?: { state?: unknown };
151
+ }>;
152
+ if (
153
+ !Array.isArray(parsed) ||
154
+ parsed.length !== 1 ||
155
+ parsed[0]?.id !== containerName
156
+ ) {
157
+ return { kind: "unknown", detail: "inspect shape is invalid" };
158
+ }
159
+ const state = parsed[0]?.status?.state;
160
+ if (typeof state !== "string") {
161
+ return { kind: "unknown", detail: "inspect state is missing" };
162
+ }
163
+ return state === "running"
164
+ ? { kind: "running" }
165
+ : { kind: "stopped", state };
166
+ } catch {
167
+ return { kind: "unknown", detail: "inspect output is not JSON" };
168
+ }
169
+ }
170
+ const listed = await run(
171
+ [
172
+ "ps",
173
+ "--all",
174
+ "--filter",
175
+ `name=^/${containerName}$`,
176
+ "--format",
177
+ "{{.State}}",
178
+ ],
179
+ { timeoutMs: 30_000, maxOutputBytes: 64 * 1024 },
180
+ );
181
+ if (listed.status !== 0) {
182
+ return {
183
+ kind: "unknown",
184
+ detail: listed.stderr.trim().slice(0, 300) || "docker ps failed",
185
+ };
186
+ }
187
+ const states = listed.stdout
188
+ .split(/\r?\n/)
189
+ .map((line) => line.trim())
190
+ .filter(Boolean);
191
+ if (states.length === 0) return { kind: "absent" };
192
+ if (states.length !== 1) {
193
+ return { kind: "unknown", detail: `${states.length} containers matched` };
194
+ }
195
+ return states[0] === "running"
196
+ ? { kind: "running" }
197
+ : { kind: "stopped", state: states[0]! };
198
+ }
199
+
200
+ /** Force-remove one exact task app container and prove its absence. */
201
+ export async function removeTaskAppContainer(
202
+ containerName: string,
203
+ run: typeof taskContainerCli = taskContainerCli,
204
+ ): Promise<void> {
205
+ const backend = taskContainerBackend();
206
+ const removed = await run(
207
+ backend.apple
208
+ ? ["delete", "--force", containerName]
209
+ : ["rm", "--force", containerName],
210
+ { timeoutMs: 60_000, maxOutputBytes: 64 * 1024 },
211
+ );
212
+ if (removed.status !== 0 && backend.apple) {
213
+ // Deleting an already-absent container is success; anything else is not.
214
+ const state = await taskAppContainerState(containerName, run);
215
+ if (state.kind === "absent") return;
216
+ throw new Error(
217
+ `remove ${containerName} failed: ${removed.stderr.trim().slice(0, 300)}`,
218
+ );
219
+ }
220
+ const after = await taskAppContainerState(containerName, run);
221
+ if (after.kind !== "absent") {
222
+ throw new Error(`remove ${containerName} was not verified`);
223
+ }
224
+ }
225
+
226
+ /** Names of RUNNING task app containers under the selected backend. */
227
+ export async function listRunningTaskAppContainerNames(
228
+ run: typeof taskContainerCli = taskContainerCli,
229
+ ): Promise<string[] | null> {
230
+ const backend = taskContainerBackend();
231
+ if (backend.apple) {
232
+ const listed = await run(["list", "--format", "json"], {
233
+ timeoutMs: 30_000,
234
+ maxOutputBytes: 8 * 1024 * 1024,
235
+ });
236
+ if (listed.status !== 0) return null;
237
+ try {
238
+ const parsed = JSON.parse(listed.stdout) as Array<{
239
+ configuration?: { id?: unknown };
240
+ status?: { state?: unknown };
241
+ }>;
242
+ if (!Array.isArray(parsed)) return null;
243
+ // `container list` without --all already returns running containers,
244
+ // but the state check keeps this honest against CLI behavior drift.
245
+ return parsed
246
+ .filter(
247
+ (item) =>
248
+ item?.status?.state === undefined ||
249
+ item?.status?.state === "running",
250
+ )
251
+ .map((item) => item?.configuration?.id)
252
+ .filter(
253
+ (id): id is string =>
254
+ typeof id === "string" && /^task-[a-z0-9][a-z0-9_-]*-app$/.test(id),
255
+ );
256
+ } catch {
257
+ return null;
258
+ }
259
+ }
260
+ const listed = await run(
261
+ ["ps", "--filter", "status=running", "--format", "{{.Names}}"],
262
+ { timeoutMs: 30_000, maxOutputBytes: 1024 * 1024 },
263
+ );
264
+ if (listed.status !== 0) return null;
265
+ return listed.stdout
266
+ .split(/\r?\n/)
267
+ .map((line) => line.trim())
268
+ .filter(Boolean);
269
+ }
package/lib/task-diff.ts CHANGED
@@ -1,67 +1,89 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { spawnSync } from "node:child_process";
3
- import { join } from "node:path";
3
+ import { join, posix } from "node:path";
4
4
 
5
5
  import type { TaskDiffInput, TaskDiffResult } from "../src/protocol";
6
6
  import { taskWorkspaceDir } from "./env";
7
7
  import {
8
8
  parseGitDiff,
9
9
  runGitDiff,
10
- runGitDiffInContainer,
10
+ runGitDiffInEnvironment,
11
11
  runGitDiffUntracked,
12
- runGitDiffUntrackedInContainer,
12
+ runGitDiffUntrackedInEnvironment,
13
+ tryRunGitTextInEnvironment,
13
14
  } from "./git-diff";
14
- import { getHostTask } from "./runtime-state";
15
+ import type { TaskEnvironmentHandle } from "./task-environment/types";
15
16
 
16
17
  // Cap how many untracked files we render as new-file patches. Each is a
17
- // separate (in-container) git spawn, so a pathological untracked dir that
18
+ // separate in-environment Git execution, so a pathological untracked dir that
18
19
  // slips past .gitignore shouldn't turn the diff into thousands of execs.
19
20
  const MAX_UNTRACKED = 200;
20
21
 
22
+ export type TaskDiffEnvironment = Pick<
23
+ TaskEnvironmentHandle,
24
+ "descriptor" | "exec"
25
+ >;
26
+
21
27
  export async function buildTaskDiff(
22
28
  input: TaskDiffInput,
29
+ environment: TaskDiffEnvironment | null = null,
23
30
  ): Promise<TaskDiffResult> {
31
+ if (
32
+ environment !== null &&
33
+ environment.descriptor.taskId !== input.taskId
34
+ ) {
35
+ throw new Error("task diff environment belongs to a different task");
36
+ }
24
37
  const workspaceRoot = taskWorkspaceDir(input.taskId);
25
- const runtime = getHostTask(input.taskId);
26
- const containerName =
27
- runtime?.statusMirror === "running" && runtime.composeProject
28
- ? `${runtime.composeProject}-app-1`
29
- : null;
30
38
  const out: TaskDiffResult["repos"] = [];
31
39
 
32
40
  for (const project of input.projects) {
33
41
  const cwd = join(workspaceRoot, project.slug);
34
- const containerCwd = `/workspace/${project.slug}`;
35
- if (!containerName && !existsSync(cwd)) continue;
42
+ const environmentCwd = environment
43
+ ? posix.join(environment.descriptor.workspacePath, project.slug)
44
+ : null;
45
+ if (!environment && !existsSync(cwd)) continue;
36
46
 
37
47
  // Base branch is the remote's default branch (where task branches are cut
38
48
  // from at task-up) — NOT the worktree's @{upstream}, which flips to
39
49
  // origin/<task-branch> once the branch is pushed and would then hide all
40
50
  // committed work (see resolveBase).
41
- const base = resolveBase(containerName, cwd, containerCwd, project.slug);
51
+ const base = await resolveBase(environment, cwd, environmentCwd);
42
52
  // Diff the whole branch against base, including UNCOMMITTED work: compare
43
53
  // the merge-base (where the branch diverged) to the WORKING TREE, not HEAD.
44
54
  // The merge-base keeps base's own later commits from showing up reversed if
45
55
  // it advanced; falling back to `base` itself if merge-base can't resolve.
46
56
  const mergeBase =
47
- runGitText(containerName, cwd, containerCwd, [
57
+ (await runGitText(environment, cwd, environmentCwd, [
48
58
  "merge-base",
49
59
  base,
50
60
  "HEAD",
51
- ]) ?? base;
61
+ ])) ?? base;
52
62
  try {
53
63
  // Tracked changes (committed + staged + unstaged): a commit-ish vs the
54
64
  // working tree, so omitting `..HEAD` is the point.
55
- let text = containerName
56
- ? await runGitDiffInContainer(containerName, containerCwd, mergeBase)
65
+ let text = environment
66
+ ? await runGitDiffInEnvironment(
67
+ environment,
68
+ environmentCwd!,
69
+ mergeBase,
70
+ )
57
71
  : await runGitDiff(cwd, mergeBase);
58
72
 
59
73
  // Untracked files: `git diff <commit>` skips them, so list (honouring
60
74
  // .gitignore) and append each as a /dev/null new-file patch.
61
- const untracked = listUntracked(containerName, cwd, containerCwd);
75
+ const untracked = await listUntracked(
76
+ environment,
77
+ cwd,
78
+ environmentCwd,
79
+ );
62
80
  for (const file of untracked) {
63
- const patch = containerName
64
- ? await runGitDiffUntrackedInContainer(containerName, containerCwd, file)
81
+ const patch = environment
82
+ ? await runGitDiffUntrackedInEnvironment(
83
+ environment,
84
+ environmentCwd!,
85
+ file,
86
+ )
65
87
  : await runGitDiffUntracked(cwd, file);
66
88
  if (patch.trim().length === 0) continue;
67
89
  if (text.length > 0 && !text.endsWith("\n")) text += "\n";
@@ -93,7 +115,7 @@ export async function buildTaskDiff(
93
115
  /**
94
116
  * Resolve the diff base for a worktree: the remote's default branch
95
117
  * (`origin/HEAD`, set at task-up via `remote set-head`), falling back to
96
- * `origin/main`. Runs git in-container when the task is live so credential
118
+ * `origin/main`. Runs Git in-environment when the task is live so credential
97
119
  * behavior stays inside the sandbox (see git-diff).
98
120
  *
99
121
  * We deliberately do NOT use the worktree's `@{upstream}`. At task-up it is
@@ -102,16 +124,15 @@ export async function buildTaskDiff(
102
124
  * `merge-base(upstream, HEAD) ≈ HEAD`, so the diff would show only uncommitted
103
125
  * work instead of the whole branch.
104
126
  */
105
- function resolveBase(
106
- containerName: string | null,
127
+ async function resolveBase(
128
+ environment: TaskDiffEnvironment | null,
107
129
  hostCwd: string,
108
- containerCwd: string,
109
- _slug: string,
110
- ): string {
111
- const head = runGitText(
112
- containerName,
130
+ environmentCwd: string | null,
131
+ ): Promise<string> {
132
+ const head = await runGitText(
133
+ environment,
113
134
  hostCwd,
114
- containerCwd,
135
+ environmentCwd,
115
136
  ["rev-parse", "--abbrev-ref", "origin/HEAD"],
116
137
  );
117
138
  if (head) return head;
@@ -127,12 +148,12 @@ function stripOriginPrefix(ref: string): string {
127
148
  * List untracked files in the worktree, honouring .gitignore
128
149
  * (`--exclude-standard`), capped at {@link MAX_UNTRACKED}. Empty on error.
129
150
  */
130
- function listUntracked(
131
- containerName: string | null,
151
+ async function listUntracked(
152
+ environment: TaskDiffEnvironment | null,
132
153
  hostCwd: string,
133
- containerCwd: string,
134
- ): string[] {
135
- const raw = runGitText(containerName, hostCwd, containerCwd, [
154
+ environmentCwd: string | null,
155
+ ): Promise<string[]> {
156
+ const raw = await runGitText(environment, hostCwd, environmentCwd, [
136
157
  "ls-files",
137
158
  "--others",
138
159
  "--exclude-standard",
@@ -145,23 +166,22 @@ function listUntracked(
145
166
  }
146
167
 
147
168
  /**
148
- * Run a read-only git command (host or in-container) and return trimmed
169
+ * Run a read-only Git command (host or in-environment) and return trimmed
149
170
  * stdout, or null on any failure. Used for base-branch resolution where a
150
171
  * missing upstream is expected, not exceptional.
151
172
  */
152
- function runGitText(
153
- containerName: string | null,
173
+ async function runGitText(
174
+ environment: TaskDiffEnvironment | null,
154
175
  hostCwd: string,
155
- containerCwd: string,
176
+ environmentCwd: string | null,
156
177
  args: string[],
157
- ): string | null {
158
- const result = containerName
159
- ? spawnSync(
160
- "docker",
161
- ["exec", "-w", containerCwd, containerName, "git", ...args],
162
- { encoding: "utf8" },
163
- )
164
- : spawnSync("git", ["-C", hostCwd, ...args], { encoding: "utf8" });
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
+ });
165
185
  if (result.status !== 0) return null;
166
186
  const text = (result.stdout ?? "").trim();
167
187
  return text.length > 0 ? text : null;