@runuai/host 0.2.8 → 0.4.0

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.
@@ -0,0 +1,223 @@
1
+ /**
2
+ * In-container `uai` CLI materialisation (ADR-048).
3
+ *
4
+ * At task-up the host writes two files into the task workspace (bind-mounted at
5
+ * /workspace, like skills — no docker cp/exec needed):
6
+ * - `.uai/cli.mjs` — the self-contained agent CLI (Node ESM, fetch-only).
7
+ * - `.uai/uai.json` — { apiUrl, token, permissions } the CLI reads.
8
+ *
9
+ * The token is minted here (host-side) carrying the task id, its owner user, and
10
+ * the UNION of the roster's permissions; the cloud verifies it. Agents invoke it
11
+ * as `node /workspace/.uai/cli.mjs <cmd>` (surfaced in the system preamble).
12
+ */
13
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { resolve } from "node:path";
15
+
16
+ import { env, taskWorkspaceDir } from "./env";
17
+ import { signTaskToken } from "./task-token";
18
+ import type { RosterAgent } from "./agents/types";
19
+
20
+ /** Container path of the CLI — what the preamble tells agents to run. */
21
+ export const CONTAINER_CLI_PATH = "/workspace/.uai/cli.mjs";
22
+
23
+ // The task's cli secret lives HOST-PRIVATE (never the container), so it survives
24
+ // host restarts without a DB migration. The cloud re-sends it on each task-up.
25
+ function taskSecretPath(taskId: string): string {
26
+ return resolve(env.dataDir, "task-secrets", taskId);
27
+ }
28
+
29
+ /** Persist the cloud-issued per-task cli secret (ADR-048). Best-effort. */
30
+ export function storeTaskCliSecret(taskId: string, secret: string): void {
31
+ try {
32
+ mkdirSync(resolve(env.dataDir, "task-secrets"), { recursive: true });
33
+ writeFileSync(taskSecretPath(taskId), secret, { mode: 0o600 });
34
+ } catch {
35
+ // best-effort — the CLI is simply unavailable if we can't persist it
36
+ }
37
+ }
38
+
39
+ /** Read the per-task cli secret, or null if we don't have it. */
40
+ export function loadTaskCliSecret(taskId: string): string | null {
41
+ try {
42
+ return readFileSync(taskSecretPath(taskId), "utf8").trim() || null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /** The union of a roster's permissions (ADR-048) — the task's effective powers. */
49
+ export function rosterPermissions(roster: RosterAgent[]): string[] {
50
+ return Array.from(new Set(roster.flatMap((a) => a.permissions ?? [])));
51
+ }
52
+
53
+ /** Derive the cloud HTTPS API base from the host's cloud WSS url. */
54
+ export function apiUrlFromCloudUrl(cloudUrl: string | undefined): string | null {
55
+ if (!cloudUrl) return null;
56
+ try {
57
+ const u = new URL(cloudUrl);
58
+ const proto = u.protocol === "wss:" ? "https:" : u.protocol === "ws:" ? "http:" : u.protocol;
59
+ return `${proto}//${u.host}`;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Write the CLI script + shared config (apiUrl only — NO token) into the task
67
+ * workspace. Each agent's OWN token is injected per-agent via its docker exec
68
+ * env (agentCliEnv), so no shared file ever holds a token. Best-effort +
69
+ * idempotent. No-op (false) if there's no api url or the roster has zero
70
+ * permissions — nothing the CLI could do.
71
+ */
72
+ export function writeAgentCli(
73
+ taskId: string,
74
+ roster: RosterAgent[],
75
+ apiUrl: string | null,
76
+ ): boolean {
77
+ if (!apiUrl || rosterPermissions(roster).length === 0) return false;
78
+ try {
79
+ const dir = resolve(taskWorkspaceDir(taskId), ".uai");
80
+ mkdirSync(dir, { recursive: true });
81
+ writeFileSync(resolve(dir, "uai.json"), `${JSON.stringify({ apiUrl }, null, 2)}\n`);
82
+ const cliPath = resolve(dir, "cli.mjs");
83
+ writeFileSync(cliPath, CLI_SOURCE);
84
+ try {
85
+ chmodSync(cliPath, 0o755);
86
+ } catch {
87
+ // best-effort
88
+ }
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Per-agent env for the `docker exec` that spawns one agent (ADR-048): its OWN
97
+ * task token (carrying only ITS permissions) + the API url. Empty when the agent
98
+ * has no permissions (or no owner/url) — its `uai` CLI then reports "not
99
+ * available" rather than borrowing anyone else's powers.
100
+ */
101
+ export function agentCliEnv(
102
+ taskId: string,
103
+ agent: RosterAgent,
104
+ ownerUserId: string | null,
105
+ apiUrl: string | null,
106
+ cliSecret: string | null,
107
+ ): Record<string, string> {
108
+ const permissions = agent.permissions ?? [];
109
+ if (!ownerUserId || !apiUrl || !cliSecret || permissions.length === 0) return {};
110
+ return {
111
+ UAI_TASK_TOKEN: signTaskToken(
112
+ { taskId, userId: ownerUserId, permissions, agentId: agent.id },
113
+ cliSecret,
114
+ ),
115
+ UAI_API_URL: apiUrl,
116
+ };
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // The CLI itself — kept as a self-contained ESM string so the host writes ONE
121
+ // file with no dependencies (Node 22 in the image provides global fetch).
122
+ // ---------------------------------------------------------------------------
123
+
124
+ const CLI_SOURCE = `#!/usr/bin/env node
125
+ // uai — in-task agent CLI (ADR-048). Talks to the cloud agent API as the task.
126
+ import { readFileSync } from "node:fs";
127
+ import { dirname, join } from "node:path";
128
+ import { fileURLToPath } from "node:url";
129
+
130
+ const here = dirname(fileURLToPath(import.meta.url));
131
+ let cfg = {};
132
+ try { cfg = JSON.parse(readFileSync(join(here, "uai.json"), "utf8")); } catch {}
133
+ // Per-agent token comes from the env (each agent's docker exec carries only its
134
+ // own — ADR-048); apiUrl from env or the shared config file.
135
+ const TOKEN = process.env.UAI_TASK_TOKEN || cfg.token;
136
+ const API_URL = process.env.UAI_API_URL || cfg.apiUrl;
137
+ if (!TOKEN || !API_URL) {
138
+ console.error("uai: not available for this agent — no permissions granted (or not inside a task).");
139
+ process.exit(1);
140
+ }
141
+
142
+ function parseFlags(args) {
143
+ const flags = {}; const rest = [];
144
+ for (let i = 0; i < args.length; i++) {
145
+ const a = args[i];
146
+ if (a.startsWith("--")) { const k = a.slice(2); const v = args[i + 1] && !args[i + 1].startsWith("--") ? args[++i] : "true"; flags[k] = v; }
147
+ else rest.push(a);
148
+ }
149
+ return { flags, rest };
150
+ }
151
+
152
+ async function api(method, path, body) {
153
+ const res = await fetch(API_URL + path, {
154
+ method,
155
+ headers: { authorization: "Bearer " + TOKEN, "content-type": "application/json" },
156
+ body: body ? JSON.stringify(body) : undefined,
157
+ });
158
+ const text = await res.text();
159
+ let json; try { json = JSON.parse(text); } catch { json = text; }
160
+ if (!res.ok) {
161
+ const msg = (json && json.error && json.error.message) || text || res.statusText;
162
+ console.error("uai: " + res.status + " " + msg);
163
+ process.exit(1);
164
+ }
165
+ return json;
166
+ }
167
+ function out(v) { console.log(typeof v === "string" ? v : JSON.stringify(v, null, 2)); }
168
+
169
+ const argv = process.argv.slice(2);
170
+ const [group, action, ...rest] = argv;
171
+ const { flags, rest: pos } = parseFlags(rest);
172
+
173
+ async function main() {
174
+ const key = group + " " + (action || "");
175
+ switch (key) {
176
+ case "projects list": out((await api("GET", "/api/agent/projects")).projects); break;
177
+ case "people list": out(await api("GET", "/api/agent/people")); break;
178
+ case "task list": out((await api("GET", "/api/agent/tasks")).tasks); break;
179
+ case "memory search": {
180
+ const q = pos.join(" ") || flags.q || "";
181
+ const author = flags.as ? "&author=" + encodeURIComponent(flags.as) : "";
182
+ out((await api("GET", "/api/agent/memory?q=" + encodeURIComponent(q) + author)).memories);
183
+ break;
184
+ }
185
+ case "memory save": {
186
+ const text = pos.join(" ") || flags.text || "";
187
+ if (!text) { console.error("uai: memory save needs text"); process.exit(1); }
188
+ out((await api("POST", "/api/agent/memory", {
189
+ text,
190
+ author: flags.as || null,
191
+ projectId: flags.project || null,
192
+ tags: flags.tags ? String(flags.tags).split(",").map((s) => s.trim()).filter(Boolean) : [],
193
+ })).memory);
194
+ break;
195
+ }
196
+ case "task create": {
197
+ out((await api("POST", "/api/agent/tasks", {
198
+ name: flags.name,
199
+ prompt: flags.prompt,
200
+ projects: flags.projects ? String(flags.projects).split(",").map((s) => s.trim()).filter(Boolean) : [],
201
+ team: flags.team || null,
202
+ agents: flags.agents ? String(flags.agents).split(",").map((s) => s.trim()).filter(Boolean) : [],
203
+ parent: flags.parent || null,
204
+ })).task);
205
+ break;
206
+ }
207
+ case "whoami ": case "whoami undefined": out({ apiUrl: API_URL }); break;
208
+ default:
209
+ console.error([
210
+ "uai — agent CLI. Commands:",
211
+ " uai projects list",
212
+ " uai people list",
213
+ " uai task list",
214
+ " uai task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle]",
215
+ " uai memory search <query>",
216
+ " uai memory save <text> [--project id] [--tags a,b]",
217
+ " uai whoami",
218
+ ].join("\\n"));
219
+ process.exit(argv.length ? 1 : 0);
220
+ }
221
+ }
222
+ main().catch((e) => { console.error("uai: " + (e && e.message ? e.message : e)); process.exit(1); });
223
+ `;
@@ -200,6 +200,7 @@ export class ClaudeSession implements AgentSession {
200
200
  agent: RosterAgent;
201
201
  containerName: string;
202
202
  systemPreamble: string;
203
+ agentEnv?: Record<string, string>;
203
204
  }) {
204
205
  this.agentId = args.agent.id;
205
206
 
@@ -234,6 +235,7 @@ export class ClaudeSession implements AgentSession {
234
235
  "claude",
235
236
  cliArgs,
236
237
  ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
238
+ args.agentEnv ?? {},
237
239
  );
238
240
  this.proc = new LineProcess({
239
241
  command,
@@ -338,6 +340,14 @@ register({
338
340
  defaultModel: CLAUDE_DEFAULT_MODEL,
339
341
  supportedEfforts: () => [...CLAUDE_EFFORTS],
340
342
  defaultEffort: CLAUDE_DEFAULT_EFFORT,
341
- create: async ({ agent, containerName, systemPreamble }) =>
342
- new ClaudeSession({ agent, containerName, systemPreamble }),
343
+ // Usable only when a Claude credential is in the env (injected into task
344
+ // containers at task-up). Gates advertisement (ADR-044 P2).
345
+ available: () =>
346
+ Boolean(
347
+ process.env.CLAUDE_CODE_OAUTH_TOKEN ||
348
+ process.env.ANTHROPIC_API_KEY ||
349
+ process.env.ANTHROPIC_AUTH_TOKEN,
350
+ ),
351
+ create: async ({ agent, containerName, systemPreamble, agentEnv }) =>
352
+ new ClaudeSession({ agent, containerName, systemPreamble, agentEnv }),
343
353
  });
@@ -28,6 +28,10 @@
28
28
  * namespaces inside it anyway.
29
29
  */
30
30
 
31
+ import { existsSync } from "node:fs";
32
+ import { homedir } from "node:os";
33
+ import { join } from "node:path";
34
+
31
35
  import { newId } from "../ulid";
32
36
  import { dockerExecArgs, LineProcess } from "./proc";
33
37
  import { register } from "./registry";
@@ -223,6 +227,7 @@ export class CodexSession implements AgentSession {
223
227
  agent: RosterAgent;
224
228
  containerName: string;
225
229
  systemPreamble: string;
230
+ agentEnv?: Record<string, string>;
226
231
  }) {
227
232
  this.agentId = args.agent.id;
228
233
  this.systemPreamble = args.systemPreamble;
@@ -243,6 +248,8 @@ export class CodexSession implements AgentSession {
243
248
  args.containerName,
244
249
  "codex",
245
250
  codexArgs,
251
+ [],
252
+ args.agentEnv ?? {},
246
253
  );
247
254
  this.proc = new LineProcess({
248
255
  command,
@@ -517,6 +524,13 @@ register({
517
524
  defaultModel: CODEX_DEFAULT_MODEL,
518
525
  supportedEfforts: () => [...CODEX_EFFORTS],
519
526
  defaultEffort: CODEX_DEFAULT_EFFORT,
520
- create: async ({ agent, containerName, systemPreamble }) =>
521
- new CodexSession({ agent, containerName, systemPreamble }),
527
+ // Usable only when the owner's ~/.codex login exists — task-up copies it into
528
+ // each container from UAI_OWNER_HOME (default $HOME). Gates advertisement
529
+ // (ADR-044 P2).
530
+ available: () =>
531
+ existsSync(
532
+ join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".codex", "auth.json"),
533
+ ),
534
+ create: async ({ agent, containerName, systemPreamble, agentEnv }) =>
535
+ new CodexSession({ agent, containerName, systemPreamble, agentEnv }),
522
536
  });
@@ -160,11 +160,20 @@ export function dockerExecArgs(
160
160
  * cloud (ADR-015). Only vars actually set on the host are forwarded.
161
161
  */
162
162
  passEnv: string[] = [],
163
+ /**
164
+ * Explicit per-exec env values (`-e NAME=value`) — used for per-agent values
165
+ * that don't live in the host's own env, e.g. the agent's task-scoped
166
+ * `UAI_TASK_TOKEN` (ADR-048). Values are argv, never a shell string.
167
+ */
168
+ explicitEnv: Record<string, string> = {},
163
169
  ): { command: string; args: string[] } {
164
170
  const envArgs: string[] = [];
165
171
  for (const name of passEnv) {
166
172
  if (process.env[name]) envArgs.push("-e", name);
167
173
  }
174
+ for (const [name, value] of Object.entries(explicitEnv)) {
175
+ envArgs.push("-e", `${name}=${value}`);
176
+ }
168
177
  return {
169
178
  command: "docker",
170
179
  args: ["exec", "-i", ...envArgs, containerName, cli, ...cliArgs],
@@ -32,6 +32,14 @@ export interface RegisteredAdapter {
32
32
  supportedEfforts(): string[];
33
33
  /** Preferred effort when the user doesn't pick one. */
34
34
  defaultEffort?: string;
35
+ /**
36
+ * Whether this kind is usable on THIS host right now — i.e. its credentials
37
+ * are present (ADR-044 P2). Gates advertisement: an unavailable kind is left
38
+ * out of `capabilities()` so the cloud's task picker doesn't offer an LLM the
39
+ * host can't actually run. Omit (or return true) to always advertise; the
40
+ * session factory stays permissive regardless.
41
+ */
42
+ available?: () => boolean;
35
43
  /** Builds an AgentSession for a roster entry of this kind. */
36
44
  create: AgentSessionFactory["create"];
37
45
  }
@@ -76,7 +84,9 @@ export function factoryFor(kind: string): AgentSessionFactory | undefined {
76
84
  * Each adapter's `supportedModels()` / `supportedEfforts()` is evaluated here.
77
85
  */
78
86
  export function capabilities(): AgentKindCapability[] {
79
- return list().map((adapter) => {
87
+ return list()
88
+ .filter((adapter) => adapter.available?.() ?? true)
89
+ .map((adapter) => {
80
90
  const out: AgentKindCapability = {
81
91
  kind: adapter.kind,
82
92
  label: adapter.label,
@@ -26,6 +26,19 @@ export type AgentKind = z.infer<typeof AgentKind>;
26
26
  * `@<id>` mention token. `kind` selects the adapter; `model` is passed
27
27
  * through to the CLI. There is no `role` field. `initialPrompt` (if set)
28
28
  * drives this agent's first turn at container-ready. */
29
+ /** Reference material (a link or inline document) materialised onto an agent
30
+ * from its persona + team (ADR-046). The host writes these to a per-agent
31
+ * SKILL.md at session start. */
32
+ export const RosterSkill = z.object({
33
+ name: z.string().min(1),
34
+ type: z.string().min(1), // 'link' | 'document' | 'package' (ADR-047)
35
+ value: z.string(),
36
+ // ADR-047 package skills: 'git' | 'cli' install mode + (git) repo subpath.
37
+ source: z.string().nullable().optional(),
38
+ subpath: z.string().nullable().optional(),
39
+ });
40
+ export type RosterSkill = z.infer<typeof RosterSkill>;
41
+
29
42
  export const RosterAgent = z.object({
30
43
  id: z
31
44
  .string()
@@ -37,6 +50,8 @@ export const RosterAgent = z.object({
37
50
  effort: z.string().min(1).optional(), // CLI reasoning level, passed through
38
51
  defaultPrompt: z.string().optional(), // persona
39
52
  initialPrompt: z.string().optional(), // first-turn instructions, this task only
53
+ skills: z.array(RosterSkill).optional(), // ADR-046 reference material
54
+ permissions: z.array(z.string()).optional(), // ADR-048 `uai` CLI capabilities
40
55
  });
41
56
  export type RosterAgent = z.infer<typeof RosterAgent>;
42
57
 
@@ -129,5 +144,8 @@ export interface AgentSessionFactory {
129
144
  containerName: string;
130
145
  /** Initial briefing — project.defaultPrompt — sent on session start. */
131
146
  systemPreamble: string;
147
+ /** ADR-048: extra per-agent env for the `docker exec` (e.g. this agent's
148
+ * own UAI_TASK_TOKEN so its `uai` CLI carries only its own permissions). */
149
+ agentEnv?: Record<string, string>;
132
150
  }): Promise<AgentSession>;
133
151
  }
package/lib/command-db.ts CHANGED
@@ -135,12 +135,12 @@ function insertTask(db: Database.Database, task: CommandTaskRow): void {
135
135
  id, owner_user_id, host_id, name, slug, branch, status,
136
136
  global_context, reviewer_order, agents, pr_context,
137
137
  worktree_path, compose_project, code_server_port, preview_ports,
138
- locked_at, started_at, ended_at
138
+ preview_env, locked_at, started_at, ended_at
139
139
  ) VALUES (
140
140
  @id, @owner_user_id, @host_id, @name, @slug, @branch, @status,
141
141
  @global_context, @reviewer_order, @agents, @pr_context,
142
142
  @worktree_path, @compose_project, @code_server_port, @preview_ports,
143
- @locked_at, @started_at, @ended_at
143
+ @preview_env, @locked_at, @started_at, @ended_at
144
144
  )`,
145
145
  ).run(task);
146
146
  }
@@ -166,6 +166,7 @@ interface CommandTaskRow {
166
166
  compose_project: string | null;
167
167
  code_server_port: number | null;
168
168
  preview_ports: string;
169
+ preview_env: string | null;
169
170
  locked_at: number | null;
170
171
  started_at: number | null;
171
172
  ended_at: number | null;
@@ -193,6 +194,7 @@ function withRuntime(
193
194
  compose_project: runtime?.composeProject ?? null,
194
195
  code_server_port: runtime?.codeServerPort ?? null,
195
196
  preview_ports: runtime?.previewPorts ?? "[]",
197
+ preview_env: task.previewEnv ? JSON.stringify(task.previewEnv) : null,
196
198
  locked_at: runtime?.lockedAt ?? null,
197
199
  started_at: runtime?.startedAt ?? null,
198
200
  ended_at: runtime?.endedAt ?? null,
@@ -216,6 +218,7 @@ function runtimeTask(taskId: string, runtime: HostTask | null): CommandTaskRow {
216
218
  compose_project: runtime?.composeProject ?? null,
217
219
  code_server_port: runtime?.codeServerPort ?? null,
218
220
  preview_ports: runtime?.previewPorts ?? "[]",
221
+ preview_env: null,
219
222
  locked_at: runtime?.lockedAt ?? null,
220
223
  started_at: runtime?.startedAt ?? null,
221
224
  ended_at: runtime?.endedAt ?? null,
@@ -262,6 +265,7 @@ CREATE TABLE IF NOT EXISTS uai_tasks (
262
265
  compose_project text,
263
266
  code_server_port integer,
264
267
  preview_ports text NOT NULL DEFAULT '[]',
268
+ preview_env text,
265
269
  pr_url text,
266
270
  locked_at integer,
267
271
  started_at integer,
package/lib/git-diff.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Parse `git diff` unified output into structured per-file patches.
3
3
  *
4
- * Input is whatever `git diff origin/<base>..HEAD` produces multi-file,
5
- * with headers, mode/rename/binary annotations, and `@@` hunks. The
6
- * output is a list of `DiffFile`s the UI can render per-file with
4
+ * Input is whatever `git diff <merge-base>` (committed + uncommitted) plus
5
+ * appended `--no-index` new-file patches for untracked files produce —
6
+ * multi-file, with headers, mode/rename/binary annotations, and `@@` hunks.
7
+ * The output is a list of `DiffFile`s the UI can render per-file with
7
8
  * collapse/expand and syntax-highlighted hunks.
8
9
  *
9
10
  * Hand-rolled rather than pulled in as a dep — the unified-diff grammar
@@ -98,34 +99,84 @@ export function runGitDiffInContainer(
98
99
  containerName: string,
99
100
  cwd: string,
100
101
  range: string,
102
+ ): Promise<string> {
103
+ return runDiffCommand(
104
+ "docker",
105
+ [...dockerExecPrefix(containerName, cwd), "git", ...GIT_DIFF_ARGS, range],
106
+ "docker exec git diff",
107
+ process.env,
108
+ );
109
+ }
110
+
111
+ /**
112
+ * Render a single untracked file as a new-file patch by diffing it against
113
+ * `/dev/null` with `--no-index`. `git diff <commit>` omits untracked files
114
+ * entirely, so this is how they reach the "all changes vs base" view.
115
+ *
116
+ * `--no-index` exits 1 when the inputs differ (the normal "there is a diff"
117
+ * case for a new file) and 0 when identical — both are success here; only
118
+ * code > 1 is a real error.
119
+ */
120
+ export function runGitDiffUntracked(
121
+ cwd: string,
122
+ relPath: string,
123
+ ): Promise<string> {
124
+ return runDiffCommand(
125
+ "git",
126
+ ["-C", cwd, ...GIT_DIFF_ARGS, "--no-index", "--", "/dev/null", relPath],
127
+ "git diff --no-index",
128
+ { ...process.env, ...NON_INTERACTIVE_GIT_ENV },
129
+ true,
130
+ );
131
+ }
132
+
133
+ /** In-container variant of {@link runGitDiffUntracked}. */
134
+ export function runGitDiffUntrackedInContainer(
135
+ containerName: string,
136
+ cwd: string,
137
+ relPath: string,
101
138
  ): Promise<string> {
102
139
  return runDiffCommand(
103
140
  "docker",
104
141
  [
105
- "exec",
106
- "-e",
107
- `GIT_TERMINAL_PROMPT=${NON_INTERACTIVE_GIT_ENV.GIT_TERMINAL_PROMPT}`,
108
- "-e",
109
- `GCM_INTERACTIVE=${NON_INTERACTIVE_GIT_ENV.GCM_INTERACTIVE}`,
110
- "-e",
111
- `GIT_SSH_COMMAND=${NON_INTERACTIVE_GIT_ENV.GIT_SSH_COMMAND}`,
112
- "-w",
113
- cwd,
114
- containerName,
142
+ ...dockerExecPrefix(containerName, cwd),
115
143
  "git",
116
144
  ...GIT_DIFF_ARGS,
117
- range,
145
+ "--no-index",
146
+ "--",
147
+ "/dev/null",
148
+ relPath,
118
149
  ],
119
- "docker exec git diff",
150
+ "docker exec git diff --no-index",
120
151
  process.env,
152
+ true,
121
153
  );
122
154
  }
123
155
 
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",
167
+ cwd,
168
+ containerName,
169
+ ];
170
+ }
171
+
124
172
  function runDiffCommand(
125
173
  command: string,
126
174
  args: string[],
127
175
  label: string,
128
176
  env: NodeJS.ProcessEnv,
177
+ /** Treat exit code 1 as success — `git diff --no-index` returns 1 when the
178
+ * compared inputs differ, which for a new-file patch is the expected case. */
179
+ allowExitOne = false,
129
180
  ): Promise<string> {
130
181
  return new Promise((res, rej) => {
131
182
  // Force ASCII paths and pull a generous unified context so the UI
@@ -144,7 +195,7 @@ function runDiffCommand(
144
195
  });
145
196
  child.on("error", rej);
146
197
  child.on("close", (code) => {
147
- if (code === 0) {
198
+ if (code === 0 || (allowExitOne && code === 1)) {
148
199
  res(stdout);
149
200
  return;
150
201
  }