@opengeni/runtime 0.2.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.
Files changed (65) hide show
  1. package/dist/chunk-2PO56VAL.js +3478 -0
  2. package/dist/chunk-2PO56VAL.js.map +1 -0
  3. package/dist/index.d.ts +912 -0
  4. package/dist/index.js +3663 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/sandbox/index.d.ts +1738 -0
  7. package/dist/sandbox/index.js +187 -0
  8. package/dist/sandbox/index.js.map +1 -0
  9. package/package.json +49 -0
  10. package/src/bundled_hashicorp_terraform_skills/LICENSE +373 -0
  11. package/src/bundled_hashicorp_terraform_skills/README.md +18 -0
  12. package/src/bundled_hashicorp_terraform_skills/UPSTREAM_GIT_SHA +1 -0
  13. package/src/bundled_hashicorp_terraform_skills/azure-verified-modules/SKILL.md +613 -0
  14. package/src/bundled_hashicorp_terraform_skills/checkov/SKILL.md +43 -0
  15. package/src/bundled_hashicorp_terraform_skills/refactor-module/SKILL.md +538 -0
  16. package/src/bundled_hashicorp_terraform_skills/social-media-marketing/SKILL.md +35 -0
  17. package/src/bundled_hashicorp_terraform_skills/terraform-search-import/SKILL.md +372 -0
  18. package/src/bundled_hashicorp_terraform_skills/terraform-search-import/references/MANUAL-IMPORT.md +113 -0
  19. package/src/bundled_hashicorp_terraform_skills/terraform-search-import/scripts/list_resources.sh +38 -0
  20. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/SKILL.md +480 -0
  21. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/api-monitoring.md +543 -0
  22. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/component-blocks.md +476 -0
  23. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/deployment-blocks.md +391 -0
  24. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/examples.md +1529 -0
  25. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/linked-stacks.md +187 -0
  26. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/troubleshooting.md +671 -0
  27. package/src/bundled_hashicorp_terraform_skills/terraform-style-guide/SKILL.md +353 -0
  28. package/src/bundled_hashicorp_terraform_skills/terraform-test/SKILL.md +451 -0
  29. package/src/bundled_hashicorp_terraform_skills/terraform-test/references/CI_CD.md +80 -0
  30. package/src/bundled_hashicorp_terraform_skills/terraform-test/references/EXAMPLES.md +314 -0
  31. package/src/bundled_hashicorp_terraform_skills/terraform-test/references/MOCK_PROVIDERS.md +171 -0
  32. package/src/codex-tool-search.ts +267 -0
  33. package/src/context-compaction.ts +538 -0
  34. package/src/history-sanitizer.ts +719 -0
  35. package/src/index.ts +3299 -0
  36. package/src/sandbox/capabilities.ts +69 -0
  37. package/src/sandbox/channel-a.ts +1031 -0
  38. package/src/sandbox/display-stack.ts +231 -0
  39. package/src/sandbox/errors.ts +34 -0
  40. package/src/sandbox/index.ts +832 -0
  41. package/src/sandbox/providers/blaxel.ts +35 -0
  42. package/src/sandbox/providers/cloudflare.ts +24 -0
  43. package/src/sandbox/providers/daytona.ts +34 -0
  44. package/src/sandbox/providers/docker.ts +17 -0
  45. package/src/sandbox/providers/e2b.ts +36 -0
  46. package/src/sandbox/providers/index.ts +107 -0
  47. package/src/sandbox/providers/local.ts +13 -0
  48. package/src/sandbox/providers/modal.ts +55 -0
  49. package/src/sandbox/providers/none.ts +13 -0
  50. package/src/sandbox/providers/runloop.ts +32 -0
  51. package/src/sandbox/providers/selfhosted.ts +96 -0
  52. package/src/sandbox/providers/types.ts +38 -0
  53. package/src/sandbox/providers/vercel.ts +29 -0
  54. package/src/sandbox/recording.ts +286 -0
  55. package/src/sandbox/routing/backend-resolver.ts +189 -0
  56. package/src/sandbox/routing/routing-session.ts +455 -0
  57. package/src/sandbox/select.ts +371 -0
  58. package/src/sandbox/selfhosted/capabilities.ts +255 -0
  59. package/src/sandbox/selfhosted/control-rpc.ts +351 -0
  60. package/src/sandbox/selfhosted/session.ts +930 -0
  61. package/src/sandbox/selfhosted/testing.ts +230 -0
  62. package/src/sandbox/stream-port.ts +185 -0
  63. package/src/sandbox/stream-token.ts +90 -0
  64. package/src/sandbox/terminal-server.ts +203 -0
  65. package/src/sandbox-computer.ts +835 -0
@@ -0,0 +1,1031 @@
1
+ // packages/runtime/src/sandbox/channel-a.ts — the Channel-A structured services
2
+ // (P4.4 / modules/08-channel-a.md §4), provider-agnostic, called API-DIRECT.
3
+ //
4
+ // THE NON-PIXEL SURFACE: file tree + read/write (the Pierre tree), git
5
+ // status/diff hunks (the Pierre diff), and a terminal exec + interactive PTY.
6
+ // Served client -> API -> box IN-PROCESS: the API resumes the box by id, builds
7
+ // ONE service around the live `session` handle for the call's lifetime, runs the
8
+ // op, returns inline JSON, and drops the handle. There is NO ownership/singleton
9
+ // here — the live handle is whatever the caller resumed; it is non-owned and
10
+ // dropped when the call returns. The same module is importable by the worker's
11
+ // agent-turn for the A1 fs.changed side-effect it produces in-process.
12
+ //
13
+ // SDK GROUNDING (the load-bearing reality — see the adversarial review in the
14
+ // module spec). Built on `session.exec(args): Promise<SandboxExecResult>` which
15
+ // returns RAW {stdout,stderr,exitCode} on the agents-core local/docker sessions
16
+ // (and Modal/the extensions providers expose the equivalent). `execCommand`
17
+ // returns a BANNER-DECORATED string (formatExecResponse) — NEVER used for
18
+ // parsing; only as a last-resort fallback when `exec` is absent, with the banner
19
+ // stripped. `readFile` returns string|Uint8Array (binary-safe). Writes go
20
+ // through `exec` (a base64 heredoc — raw + binary capable, unlike createEditor's
21
+ // apply-patch-only path which cannot do binary, C4), falling back to
22
+ // `createEditor` for text when `exec` is absent.
23
+
24
+ import type {
25
+ FsChangedPayload,
26
+ FsDeleteRequest,
27
+ FsDeleteResponse,
28
+ FsListRequest,
29
+ FsListResponse,
30
+ FsMkdirRequest,
31
+ FsMkdirResponse,
32
+ FsMoveRequest,
33
+ FsMoveResponse,
34
+ FsReadRequest,
35
+ FsReadResponse,
36
+ FsTreeNode,
37
+ FsWriteRequest,
38
+ FsWriteResponse,
39
+ GitChangedPayload,
40
+ GitCommit,
41
+ GitDiffHunk,
42
+ GitDiffLine,
43
+ GitDiffRequest,
44
+ GitDiffResponse,
45
+ GitFileDiff,
46
+ GitFileStatus,
47
+ GitFileStatusCode,
48
+ GitLogRequest,
49
+ GitLogResponse,
50
+ GitShowRequest,
51
+ GitShowResponse,
52
+ GitStatusRequest,
53
+ GitStatusResponse,
54
+ PtyCloseRequest,
55
+ PtyOpenRequest,
56
+ PtyOpenResponse,
57
+ PtyResizeRequest,
58
+ PtyWriteRequest,
59
+ SessionEventType,
60
+ SessionStructuredCapabilities,
61
+ TerminalExecRequest,
62
+ TerminalExecResponse,
63
+ } from "@opengeni/contracts";
64
+
65
+ // ── The minimal session surface Channel A consumes (a structural subset of the
66
+ // SDK's SandboxSession, all optional — capability-probed before use). ─────────
67
+ export type ChannelAExecResult = {
68
+ output?: string;
69
+ stdout?: string;
70
+ stderr?: string;
71
+ exitCode?: number | null;
72
+ sessionId?: number;
73
+ wallTimeSeconds?: number;
74
+ };
75
+ export type ChannelAExecArgs = {
76
+ cmd: string;
77
+ workdir?: string | undefined;
78
+ shell?: string | undefined;
79
+ login?: boolean | undefined;
80
+ tty?: boolean | undefined;
81
+ yieldTimeMs?: number | undefined;
82
+ maxOutputTokens?: number | undefined;
83
+ runAs?: string | undefined;
84
+ };
85
+ export type ChannelAEditor = {
86
+ createFile?(op: unknown): Promise<unknown>;
87
+ updateFile?(op: unknown): Promise<unknown>;
88
+ deleteFile?(op: unknown): Promise<unknown>;
89
+ };
90
+ export type ChannelASession = {
91
+ exec?(args: ChannelAExecArgs): Promise<ChannelAExecResult>;
92
+ execCommand?(args: ChannelAExecArgs): Promise<string>;
93
+ readFile?(args: { path: string; runAs?: string; maxBytes?: number }): Promise<string | Uint8Array>;
94
+ writeStdin?(args: { sessionId: number; chars?: string; yieldTimeMs?: number; maxOutputTokens?: number }): Promise<string>;
95
+ createEditor?(runAs?: string): ChannelAEditor;
96
+ supportsPty?(): boolean;
97
+ };
98
+
99
+ // ── Errors mapped to HTTP status at the route. ───────────────────────────────
100
+ export class ChannelAValidationError extends Error {
101
+ constructor(message: string) { super(message); this.name = "ChannelAValidationError"; }
102
+ }
103
+ export class ChannelAConflictError extends Error {
104
+ constructor(message: string) { super(message); this.name = "ChannelAConflictError"; }
105
+ }
106
+ export class ChannelANotFoundError extends Error {
107
+ constructor(message: string) { super(message); this.name = "ChannelANotFoundError"; }
108
+ }
109
+ export class ChannelAUnsupportedError extends Error {
110
+ constructor(message: string) { super(message); this.name = "ChannelAUnsupportedError"; }
111
+ }
112
+
113
+ export type ChannelAEmitter = (events: { type: SessionEventType; payload: unknown }[]) => Promise<void>;
114
+
115
+ export type SandboxChannelAServiceOptions = {
116
+ session: ChannelASession;
117
+ // The workspace-relative root the box maps "" to (the SDK normalizes against
118
+ // its own workspaceRoot, so "" is the workspace root here).
119
+ workspaceRoot?: string;
120
+ // The lease epoch the box was resumed under (paired with `revision` for cache
121
+ // invalidation — H3). 0 when ownership is off / no lease.
122
+ leaseEpoch?: number;
123
+ // The starting FS revision (monotonic; the caller may seed it from a prior
124
+ // value so it doesn't reset to 0 mid-session — H3). Defaults to 0.
125
+ revision?: number;
126
+ // A1 emitter — appendAndPublishEvents bound to the caller's db+bus. Optional:
127
+ // a pure read (fsList/fsRead/gitDiff) needs no emitter; only the mutating /
128
+ // PTY paths emit. When absent the notification is silently skipped.
129
+ emit?: ChannelAEmitter;
130
+ // runAs is omitted unless the backend supports it (modal/daytona/cloudflare);
131
+ // e2b/runloop/blaxel/vercel throw on runAs (SDK survey). Default off — the
132
+ // local/docker test backends are single-user.
133
+ runAs?: string;
134
+ };
135
+
136
+ const NUL = String.fromCharCode(0); // \0 NUL — find/porcelain/numstat -z separator
137
+ const US = String.fromCharCode(0x1f); // \x1f unit sep — git-log field separator
138
+ const RS = String.fromCharCode(0x1e); // \x1e record sep — git-log record separator
139
+
140
+ export class SandboxChannelAService {
141
+ private readonly session: ChannelASession;
142
+ private readonly workspaceRoot: string;
143
+ private readonly leaseEpoch: number;
144
+ private revision: number;
145
+ private readonly emit?: ChannelAEmitter | undefined;
146
+ private readonly runAs?: string | undefined;
147
+
148
+ constructor(opts: SandboxChannelAServiceOptions) {
149
+ this.session = opts.session;
150
+ this.workspaceRoot = opts.workspaceRoot ?? "";
151
+ this.leaseEpoch = opts.leaseEpoch ?? 0;
152
+ this.revision = opts.revision ?? 0;
153
+ this.emit = opts.emit;
154
+ this.runAs = opts.runAs;
155
+ }
156
+
157
+ /** Capability probe — the compact Channel-A projection. */
158
+ capabilities(repos: string[] = []): SessionStructuredCapabilities {
159
+ const s = this.session;
160
+ const hasExec = Boolean(s.exec || s.execCommand);
161
+ const hasFs = Boolean(s.readFile && (s.exec || s.execCommand || s.createEditor));
162
+ return {
163
+ FileSystem: { available: hasFs, readOnly: !(s.exec || s.createEditor), root: this.workspaceRoot },
164
+ Terminal: {
165
+ events: hasExec,
166
+ exec: hasExec,
167
+ pty: { available: Boolean(s.supportsPty?.() && s.writeStdin) },
168
+ },
169
+ Git: { available: hasExec, repos },
170
+ };
171
+ }
172
+
173
+ // ════════════════════════════ exec primitive ══════════════════════════════
174
+ // RAW exec — returns {stdout, stderr, exitCode}. Uses session.exec when present
175
+ // (the local/docker sessions return raw output); falls back to execCommand +
176
+ // a banner strip (last resort; banner-truncation can mangle, so exec is always
177
+ // preferred). Throws ChannelAUnsupportedError when neither exists.
178
+ private async run(args: ChannelAExecArgs): Promise<{ stdout: string; stderr: string; exitCode: number | null; sessionId?: number; wallTimeSeconds: number }> {
179
+ const withRunAs = this.runAs ? { ...args, runAs: this.runAs } : args;
180
+ if (this.session.exec) {
181
+ const r = await this.session.exec(withRunAs);
182
+ return {
183
+ stdout: r.stdout ?? r.output ?? "",
184
+ stderr: r.stderr ?? "",
185
+ exitCode: r.exitCode ?? null,
186
+ ...(typeof r.sessionId === "number" ? { sessionId: r.sessionId } : {}),
187
+ wallTimeSeconds: r.wallTimeSeconds ?? 0,
188
+ };
189
+ }
190
+ if (this.session.execCommand) {
191
+ const raw = await this.session.execCommand(withRunAs);
192
+ // The SDK's execCommand returns the formatExecResponse BANNER string. When a
193
+ // command stays running (an interactive `bash` opened with tty:true), the
194
+ // banner carries a `Process running with session ID <N>` line — the numeric
195
+ // exec-session id writeStdin() needs to drive that PTY. The exec() fast-path
196
+ // above surfaces sessionId structurally; this fallback must recover it from
197
+ // the banner or the PTY appears non-interactive (execSessionId=null ->
198
+ // pty/write 409) even on backends (Modal) whose only exec surface is
199
+ // execCommand. We DON'T close over the banner for stdout (that is stripped).
200
+ const sessionId = parseExecBannerSessionId(raw);
201
+ return {
202
+ stdout: stripExecBanner(raw),
203
+ stderr: "",
204
+ exitCode: null,
205
+ ...(sessionId !== null ? { sessionId } : {}),
206
+ wallTimeSeconds: 0,
207
+ };
208
+ }
209
+ throw new ChannelAUnsupportedError("the box does not support command execution");
210
+ }
211
+
212
+ // ════════════════════════════ FileSystem (A2) ═════════════════════════════
213
+
214
+ async fsList(req: FsListRequest): Promise<FsListResponse> {
215
+ const root = normalizeRelPath(req.path);
216
+ // A single bounded `find` (NUL-delimited) builds the whole subtree in one
217
+ // round-trip. Prefer GNU find's -printf on the Ubuntu-based images, but fall
218
+ // back to a POSIX-ish find+stat loop for unix_local on macOS/BSD.
219
+ const findRoot = root === "" ? "." : shellQuote(root);
220
+ const depthArg = Math.max(1, req.depth);
221
+ const hidden = req.includeHidden ? "" : ` -not -path '*/.*'`;
222
+ const gnuFind = `find ${findRoot} -mindepth 1 -maxdepth ${depthArg}${hidden} -printf '%y\\t%s\\t%T@\\t%m\\t%p\\0' 2>/dev/null`;
223
+ let { stdout } = await this.run({ cmd: `bash -lc ${shellQuote(gnuFind)}`, workdir: this.workspaceRoot || undefined });
224
+ if (!stdout) {
225
+ const portableFind = [
226
+ `find ${findRoot} -mindepth 1 -maxdepth ${depthArg}${hidden} -print0 2>/dev/null | while IFS= read -r -d '' p; do`,
227
+ `if [ -d "$p" ]; then t=d; size=0; elif [ -f "$p" ]; then t=f; size=$(wc -c < "$p" | tr -d ' '); elif [ -L "$p" ]; then t=l; size=0; else t=o; size=0; fi;`,
228
+ `mtime=$(date -r "$p" +%s 2>/dev/null || stat -c %Y "$p" 2>/dev/null || echo 0);`,
229
+ `mode=$(stat -f %Lp "$p" 2>/dev/null || stat -c %a "$p" 2>/dev/null || echo 0);`,
230
+ `printf '%s\\t%s\\t%s\\t%s\\t%s\\0' "$t" "$size" "$mtime" "$mode" "$p";`,
231
+ `done`,
232
+ ].join(" ");
233
+ ({ stdout } = await this.run({ cmd: `bash -lc ${shellQuote(portableFind)}`, workdir: this.workspaceRoot || undefined }));
234
+ }
235
+
236
+ const entries = stdout.split(NUL).filter((s) => s.length > 0);
237
+ const rootNode: FsTreeNode = {
238
+ name: basename(root) || (root === "" ? "" : root),
239
+ path: root,
240
+ type: "dir",
241
+ sizeBytes: null,
242
+ mtimeMs: null,
243
+ mode: null,
244
+ children: [],
245
+ truncated: false,
246
+ };
247
+ // Index nodes by path for O(1) parent attach.
248
+ const byPath = new Map<string, FsTreeNode>();
249
+ byPath.set(root, rootNode);
250
+ let count = 0;
251
+ let truncated = false;
252
+ for (const entry of entries) {
253
+ if (count >= req.maxEntries) { truncated = true; break; }
254
+ const parts = entry.split("\t");
255
+ if (parts.length < 5) continue;
256
+ const [typeChar, sizeStr, mtimeStr, modeStr, ...pathParts] = parts;
257
+ const rawPath = pathParts.join("\t");
258
+ const relPath = stripDotSlash(rawPath, root);
259
+ const node: FsTreeNode = {
260
+ name: basename(relPath),
261
+ path: relPath,
262
+ type: findTypeToNode(typeChar ?? ""),
263
+ sizeBytes: typeChar === "d" ? null : safeInt(sizeStr),
264
+ mtimeMs: mtimeToMs(mtimeStr),
265
+ mode: safeOctal(modeStr),
266
+ ...(typeChar === "d" ? { children: [] as FsTreeNode[] } : {}),
267
+ truncated: false,
268
+ };
269
+ byPath.set(relPath, node);
270
+ count++;
271
+ }
272
+ // Second pass: attach each node to its parent (parents always present
273
+ // because find emits ancestors before descendants at increasing depth).
274
+ for (const [path, node] of byPath) {
275
+ if (path === root) continue;
276
+ const parentPath = dirnameRel(path, root);
277
+ const parent = byPath.get(parentPath) ?? rootNode;
278
+ (parent.children ??= []).push(node);
279
+ }
280
+ sortTree(rootNode);
281
+ return { root: rootNode, revision: this.revision, truncated };
282
+ }
283
+
284
+ async fsRead(req: FsReadRequest): Promise<FsReadResponse> {
285
+ const path = assertSafeRelPath(req.path);
286
+ if (!this.session.readFile) {
287
+ // No native readFile: base64 the file through exec (binary-safe).
288
+ return await this.fsReadViaExec(path, req);
289
+ }
290
+ let raw: string | Uint8Array;
291
+ try {
292
+ raw = await this.session.readFile({ path: this.joinRoot(path), maxBytes: req.maxBytes, ...(this.runAs ? { runAs: this.runAs } : {}) });
293
+ } catch (error) {
294
+ // The provider's native readFile applies a REMOTE workspace-escape guard:
295
+ // a SYMLINK whose target resolves outside /workspace (e.g.
296
+ // `.config/pulse/<id>-runtime -> /tmp/pulse-…`) is rejected with
297
+ // "Sandbox path failed remote validation: workspace escape: /tmp/…". That
298
+ // raw 404 surfaced to the user. The path is still legitimately INSIDE the
299
+ // workspace (the symlink node lives there); only its target escapes. Read it
300
+ // via exec instead — `base64 <path>` follows the link and is NOT subject to
301
+ // the provider's path validation — so a symlink-to-/tmp renders cleanly
302
+ // instead of erroring. A genuine not-found falls through to a clean 404.
303
+ if (isWorkspaceEscapeError(error)) {
304
+ return await this.fsReadViaExec(path, req);
305
+ }
306
+ throw new ChannelANotFoundError(`file not found: ${path} (${error instanceof Error ? error.message : String(error)})`);
307
+ }
308
+ const bytes = typeof raw === "string" ? Buffer.from(raw, "utf8") : Buffer.from(raw);
309
+ return this.shapeRead(path, bytes, req);
310
+ }
311
+
312
+ /** Read a file by base64-ing it through exec. Binary-safe and — crucially —
313
+ * NOT subject to the provider's native-readFile workspace-escape validation,
314
+ * so it can render a symlink whose target lives outside /workspace (the link
315
+ * node itself is in-workspace). `base64 <path>` follows the symlink. */
316
+ private async fsReadViaExec(path: string, req: FsReadRequest): Promise<FsReadResponse> {
317
+ const abs = this.joinRoot(path);
318
+ const { stdout, exitCode } = await this.run({ cmd: `base64 ${shellQuote(abs)} 2>/dev/null | head -c ${Math.ceil(req.maxBytes * 1.4)}` });
319
+ if (exitCode !== null && exitCode !== 0 && stdout === "") {
320
+ // The target may be a dangling symlink or a link to a directory; surface a
321
+ // clean, typed not-found rather than a raw provider validation error.
322
+ throw new ChannelANotFoundError(`file not found: ${path}`);
323
+ }
324
+ const bytes = Buffer.from(stdout.replace(/\n/g, ""), "base64");
325
+ return this.shapeRead(path, bytes, req);
326
+ }
327
+
328
+ private shapeRead(path: string, bytes: Buffer, req: FsReadRequest): FsReadResponse {
329
+ const truncated = bytes.byteLength >= req.maxBytes;
330
+ const isBinary = sniffBinary(bytes);
331
+ const encoding = req.encoding === "base64" || isBinary ? "base64" : "utf8";
332
+ const content = encoding === "base64" ? bytes.toString("base64") : bytes.toString("utf8");
333
+ return {
334
+ path,
335
+ encoding,
336
+ content,
337
+ sizeBytes: bytes.byteLength,
338
+ truncated,
339
+ isBinary,
340
+ revision: this.revision,
341
+ };
342
+ }
343
+
344
+ async fsWrite(req: FsWriteRequest): Promise<FsWriteResponse> {
345
+ const path = assertSafeRelPath(req.path);
346
+ const abs = this.joinRoot(path);
347
+ const bytes = req.encoding === "base64" ? Buffer.from(req.content, "base64") : Buffer.from(req.content, "utf8");
348
+
349
+ if (!req.overwrite) {
350
+ const { exitCode } = await this.run({ cmd: `test -e ${shellQuote(abs)}` });
351
+ if (exitCode === 0) {
352
+ throw new ChannelAConflictError(`path exists and overwrite is false: ${path}`);
353
+ }
354
+ }
355
+ if (req.createParents) {
356
+ const dir = dirnameAbs(abs);
357
+ if (dir) await this.run({ cmd: `mkdir -p ${shellQuote(dir)}` });
358
+ }
359
+ // base64-decode heredoc — raw + binary capable, single round-trip, last-
360
+ // writer-wins (the I4 default; no read-modify-write race because we write
361
+ // the whole file). A non-existent parent with createParents:false surfaces a
362
+ // non-zero exit -> 400.
363
+ const b64 = bytes.toString("base64");
364
+ const { exitCode, stderr } = await this.run({
365
+ cmd: `printf %s ${shellQuote(b64)} | base64 -d > ${shellQuote(abs)}`,
366
+ });
367
+ if (exitCode !== null && exitCode !== 0) {
368
+ // createEditor fallback for text when exec-write failed and we have a
369
+ // text payload (binary cannot go through apply-patch).
370
+ if (req.encoding !== "base64" && this.session.createEditor) {
371
+ const ok = await this.tryEditorWrite(abs, req.content);
372
+ if (!ok) throw new ChannelAValidationError(`failed to write ${path}: ${stderr || `exit ${exitCode}`}`);
373
+ } else {
374
+ throw new ChannelAValidationError(`failed to write ${path}: ${stderr || `exit ${exitCode}`}`);
375
+ }
376
+ }
377
+ this.revision++;
378
+ await this.emitFsChanged([{ path, kind: "modified", isDir: false, sizeBytes: bytes.byteLength }], "write");
379
+ return { path, sizeBytes: bytes.byteLength, revision: this.revision };
380
+ }
381
+
382
+ private async tryEditorWrite(absPath: string, content: string): Promise<boolean> {
383
+ const editor = this.session.createEditor?.(this.runAs);
384
+ if (!editor?.createFile) return false;
385
+ try {
386
+ // The apply-patch op shape — a whole-file "create" diff (last-writer-wins).
387
+ const diff = content.split("\n").map((line) => `+${line}`).join("\n");
388
+ await editor.createFile({ type: "create_file", path: absPath, diff });
389
+ return true;
390
+ } catch {
391
+ return false;
392
+ }
393
+ }
394
+
395
+ async fsDelete(req: FsDeleteRequest): Promise<FsDeleteResponse> {
396
+ const path = assertSafeRelPath(req.path);
397
+ const abs = this.joinRoot(path);
398
+ const flag = req.recursive ? "-rf" : "-f";
399
+ const { exitCode, stderr } = await this.run({ cmd: `rm ${flag} ${shellQuote(abs)}` });
400
+ if (exitCode !== null && exitCode !== 0) {
401
+ throw new ChannelAValidationError(`failed to delete ${path}: ${stderr || `exit ${exitCode}`}`);
402
+ }
403
+ this.revision++;
404
+ await this.emitFsChanged([{ path, kind: "deleted", isDir: false, sizeBytes: null }], "write");
405
+ return { revision: this.revision };
406
+ }
407
+
408
+ async fsMove(req: FsMoveRequest): Promise<FsMoveResponse> {
409
+ const path = assertSafeRelPath(req.path);
410
+ const newPath = assertSafeRelPath(req.newPath);
411
+ const abs = this.joinRoot(path);
412
+ const newAbs = this.joinRoot(newPath);
413
+
414
+ if (!req.overwrite) {
415
+ const { exitCode } = await this.run({ cmd: `test -e ${shellQuote(newAbs)}` });
416
+ if (exitCode === 0) {
417
+ throw new ChannelAConflictError(`destination exists and overwrite is false: ${newPath}`);
418
+ }
419
+ }
420
+ if (req.createParents) {
421
+ const dir = dirnameAbs(newAbs);
422
+ if (dir) await this.run({ cmd: `mkdir -p ${shellQuote(dir)}` });
423
+ }
424
+ // -f only when overwrite — otherwise a clobber would silently succeed past
425
+ // the guard above on a race. A missing source surfaces a non-zero exit -> 400.
426
+ const flag = req.overwrite ? "-f " : "";
427
+ const { exitCode, stderr } = await this.run({
428
+ cmd: `mv ${flag}${shellQuote(abs)} ${shellQuote(newAbs)}`,
429
+ });
430
+ if (exitCode !== null && exitCode !== 0) {
431
+ throw new ChannelAValidationError(`failed to move ${path} -> ${newPath}: ${stderr || `exit ${exitCode}`}`);
432
+ }
433
+ this.revision++;
434
+ await this.emitFsChanged(
435
+ [
436
+ { path, kind: "deleted", isDir: false, sizeBytes: null },
437
+ { path: newPath, kind: "created", isDir: false, sizeBytes: null },
438
+ ],
439
+ "write",
440
+ );
441
+ return { path, newPath, revision: this.revision };
442
+ }
443
+
444
+ async fsMkdir(req: FsMkdirRequest): Promise<FsMkdirResponse> {
445
+ const path = assertSafeRelPath(req.path);
446
+ const abs = this.joinRoot(path);
447
+ // A plain mkdir on an existing path returns non-zero -> 400, matching the
448
+ // write-on-existing semantics; -p makes the create idempotent + builds parents.
449
+ const flag = req.recursive ? "-p " : "";
450
+ const { exitCode, stderr } = await this.run({ cmd: `mkdir ${flag}${shellQuote(abs)}` });
451
+ if (exitCode !== null && exitCode !== 0) {
452
+ throw new ChannelAValidationError(`failed to mkdir ${path}: ${stderr || `exit ${exitCode}`}`);
453
+ }
454
+ this.revision++;
455
+ await this.emitFsChanged([{ path, kind: "created", isDir: true, sizeBytes: null }], "write");
456
+ return { path, revision: this.revision };
457
+ }
458
+
459
+ // ════════════════════════════ Git (A2, read-only) ═════════════════════════
460
+
461
+ async gitStatus(req: GitStatusRequest): Promise<GitStatusResponse> {
462
+ const repo = this.repoWorkdir(req.path);
463
+ const inside = await this.run({ cmd: "git rev-parse --is-inside-work-tree 2>/dev/null", workdir: repo });
464
+ if (inside.stdout.trim() !== "true") {
465
+ return { isRepo: false, head: null, detached: false, upstream: null, ahead: 0, behind: 0, files: [], revision: this.revision };
466
+ }
467
+ const { stdout } = await this.run({ cmd: "git status --porcelain=v2 --branch -z", workdir: repo });
468
+ return { ...parsePorcelainV2(stdout), revision: this.revision };
469
+ }
470
+
471
+ async gitDiff(req: GitDiffRequest): Promise<GitDiffResponse> {
472
+ const repo = this.repoWorkdir(req.path);
473
+ const ctx = req.contextLines;
474
+ // Selector precedence: refs > staged > worktree.
475
+ let range = "";
476
+ if (req.fromRef && req.toRef) range = `${shellQuote(req.fromRef)} ${shellQuote(req.toRef)}`;
477
+ else if (req.fromRef) range = `${shellQuote(req.fromRef)}`;
478
+ else if (req.staged) range = "--cached";
479
+ const pathspec = req.pathspec.length ? ` -- ${req.pathspec.map(shellQuote).join(" ")}` : "";
480
+
481
+ // Pass 1: numstat (stats + binary detection). -z gives NUL-separated fields;
482
+ // a rename emits old\0new for that record's path fields.
483
+ const numstat = await this.run({ cmd: `git -c core.quotePath=false diff --no-color -z --numstat ${range}${pathspec}`.trim(), workdir: repo });
484
+ const stats = parseNumstatZ(numstat.stdout);
485
+
486
+ const files: GitFileDiff[] = [];
487
+ for (const stat of stats) {
488
+ const target = stat.newPath;
489
+ const fileStatus: GitFileStatusCode = stat.binary ? "modified" : "modified";
490
+ if (stat.binary) {
491
+ files.push({
492
+ path: target,
493
+ oldPath: stat.oldPath,
494
+ status: fileStatus,
495
+ isBinary: true,
496
+ isImage: isImagePath(target),
497
+ additions: 0,
498
+ deletions: 0,
499
+ hunks: [],
500
+ truncated: false,
501
+ });
502
+ continue;
503
+ }
504
+ // Pass 2: the per-file unified patch -> hunks.
505
+ const patch = await this.run({
506
+ cmd: `git -c core.quotePath=false diff --no-color -U${ctx} ${range} -- ${shellQuote(target)}`.trim(),
507
+ workdir: repo,
508
+ });
509
+ const oversized = Buffer.byteLength(patch.stdout, "utf8") > req.maxBytesPerFile;
510
+ const parsed = oversized ? { hunks: [] as GitDiffHunk[], status: "modified" as GitFileStatusCode } : parseUnifiedPatch(patch.stdout);
511
+ files.push({
512
+ path: target,
513
+ oldPath: stat.oldPath,
514
+ status: parsed.status,
515
+ isBinary: false,
516
+ isImage: isImagePath(target),
517
+ additions: stat.additions,
518
+ deletions: stat.deletions,
519
+ hunks: parsed.hunks,
520
+ truncated: oversized,
521
+ });
522
+ }
523
+ return { files, revision: this.revision };
524
+ }
525
+
526
+ async gitLog(req: GitLogRequest): Promise<GitLogResponse> {
527
+ const repo = this.repoWorkdir(req.path);
528
+ const fmt = `%H${US}%h${US}%P${US}%an${US}%ae${US}%at${US}%cn${US}%ce${US}%ct${US}%s${US}%b${RS}`;
529
+ const pathspec = req.pathspec.length ? ` -- ${req.pathspec.map(shellQuote).join(" ")}` : "";
530
+ const { stdout, exitCode } = await this.run({
531
+ cmd: `git log --format=${shellQuote(fmt)} -n${req.maxCount + 1} --skip=${req.skip} ${shellQuote(req.ref)}${pathspec}`,
532
+ workdir: repo,
533
+ });
534
+ if (exitCode !== null && exitCode !== 0) {
535
+ return { commits: [], hasMore: false };
536
+ }
537
+ const records = stdout.split(RS).map((r) => r.replace(/^\n/, "")).filter((r) => r.trim().length > 0);
538
+ const commits: GitCommit[] = [];
539
+ for (const rec of records.slice(0, req.maxCount)) {
540
+ const f = rec.split(US);
541
+ if (f.length < 11) continue;
542
+ commits.push({
543
+ sha: f[0]!,
544
+ shortSha: f[1]!,
545
+ parents: (f[2] ?? "").trim() ? f[2]!.trim().split(" ") : [],
546
+ author: { name: f[3]!, email: f[4]!, timestamp: safeInt(f[5]) ?? 0 },
547
+ committer: { name: f[6]!, email: f[7]!, timestamp: safeInt(f[8]) ?? 0 },
548
+ subject: f[9]!,
549
+ body: f.slice(10).join(US),
550
+ refs: [],
551
+ });
552
+ }
553
+ return { commits, hasMore: records.length > req.maxCount };
554
+ }
555
+
556
+ async gitShow(req: GitShowRequest): Promise<GitShowResponse> {
557
+ const repo = this.repoWorkdir(req.path);
558
+ if (req.filePath) {
559
+ // Raw blob mode: ref:filePath -> bytes.
560
+ const { stdout, exitCode } = await this.run({
561
+ cmd: `git cat-file blob ${shellQuote(`${req.ref}:${req.filePath}`)} 2>/dev/null | base64`,
562
+ workdir: repo,
563
+ });
564
+ if (exitCode !== null && exitCode !== 0 && stdout.trim() === "") {
565
+ throw new ChannelANotFoundError(`blob not found: ${req.ref}:${req.filePath}`);
566
+ }
567
+ const bytes = Buffer.from(stdout.replace(/\n/g, ""), "base64");
568
+ const truncated = bytes.byteLength > req.maxBytesPerFile;
569
+ const clamped = truncated ? bytes.subarray(0, req.maxBytesPerFile) : bytes;
570
+ const isBinary = sniffBinary(clamped);
571
+ const encoding = req.encoding === "base64" || isBinary ? "base64" : "utf8";
572
+ return {
573
+ commit: null,
574
+ files: [],
575
+ blob: { content: encoding === "base64" ? clamped.toString("base64") : clamped.toString("utf8"), encoding, sizeBytes: clamped.byteLength, truncated },
576
+ revision: this.revision,
577
+ };
578
+ }
579
+ // Commit mode: metadata + diff vs first parent.
580
+ const log = await this.gitLog({ path: req.path, ref: req.ref, maxCount: 1, skip: 0, pathspec: [] });
581
+ const commit = log.commits[0] ?? null;
582
+ const diff = await this.gitDiff({ path: req.path, staged: false, fromRef: `${req.ref}^`, toRef: req.ref, pathspec: [], contextLines: 3, maxBytesPerFile: req.maxBytesPerFile });
583
+ return { commit, files: diff.files, blob: null, revision: this.revision };
584
+ }
585
+
586
+ /** Detect repo roots within the workspace (for the Git.repos capability). */
587
+ async detectRepos(): Promise<string[]> {
588
+ try {
589
+ const { stdout } = await this.run({ cmd: `find . -maxdepth 3 -name .git -type d 2>/dev/null`, workdir: this.workspaceRoot || undefined });
590
+ return stdout.split("\n").map((l) => l.trim()).filter(Boolean).map((g) => dirnameAbs(stripDotSlash(g, "")) || "");
591
+ } catch {
592
+ return [];
593
+ }
594
+ }
595
+
596
+ // ════════════════════════ Terminal exec + PTY (A2) ════════════════════════
597
+
598
+ /** Run a bounded command, return buffered stdout/stderr + exit code inline. The
599
+ * long-running tail (when the process hasn't exited within timeoutMs) keeps
600
+ * running in-box; if emitStream is set the buffered output is also published as
601
+ * the agent firehose so other viewers see it. */
602
+ async terminalExec(req: TerminalExecRequest): Promise<TerminalExecResponse> {
603
+ const r = await this.run({
604
+ cmd: req.command,
605
+ workdir: this.repoWorkdir(req.cwd),
606
+ yieldTimeMs: req.timeoutMs,
607
+ });
608
+ const running = r.exitCode === null && typeof r.sessionId === "number";
609
+ if (req.emitStream && (r.stdout || r.stderr)) {
610
+ const events: { type: SessionEventType; payload: unknown }[] = [];
611
+ const commandId = crypto.randomUUID();
612
+ if (r.stdout) events.push({ type: "sandbox.command.output.delta", payload: { stream: "stdout", chunk: r.stdout, commandId, seq: 0 } });
613
+ if (r.stderr) events.push({ type: "sandbox.command.output.delta", payload: { stream: "stderr", chunk: r.stderr, commandId, seq: 1 } });
614
+ await this.emitEvents(events);
615
+ }
616
+ return {
617
+ stdout: r.stdout,
618
+ stderr: r.stderr,
619
+ exitCode: r.exitCode,
620
+ running,
621
+ wallTimeSeconds: r.wallTimeSeconds,
622
+ };
623
+ }
624
+
625
+ /** Open an interactive PTY: exec the shell with tty:true, yielding the numeric
626
+ * exec-session id the caller persists (ptyId<->execSessionId) so subsequent
627
+ * writeStdin can drive it. Returns the supportsInput gate (false when the
628
+ * backend has no writeStdin). The caller emits terminal.pty.started after it
629
+ * persists the row. */
630
+ async ptyOpen(req: PtyOpenRequest, ptyId: string): Promise<{ response: PtyOpenResponse; execSessionId: number | null; shell: string; initialOutput: string }> {
631
+ const supportsInput = Boolean(this.session.supportsPty?.() && this.session.writeStdin);
632
+ const shell = req.shell ?? "/bin/bash";
633
+ const r = await this.run({
634
+ cmd: shell,
635
+ workdir: this.repoWorkdir(req.cwd),
636
+ tty: true,
637
+ login: true,
638
+ yieldTimeMs: 250,
639
+ });
640
+ return {
641
+ response: { ptyId, streamVia: "sse-events", supportsInput },
642
+ execSessionId: typeof r.sessionId === "number" ? r.sessionId : null,
643
+ shell,
644
+ initialOutput: r.stdout,
645
+ };
646
+ }
647
+
648
+ /** Drive an open PTY's stdin. Returns the drained output (the caller publishes
649
+ * it as terminal.pty.output.delta). Throws ChannelAUnsupportedError when the
650
+ * backend has no writeStdin. */
651
+ async ptyWrite(_req: PtyWriteRequest, execSessionId: number, data: string): Promise<string> {
652
+ if (!this.session.writeStdin) {
653
+ throw new ChannelAUnsupportedError("interactive terminal unsupported on this backend");
654
+ }
655
+ const out = await this.session.writeStdin({ sessionId: execSessionId, chars: data, yieldTimeMs: 250 });
656
+ // The Modal exec surface reports a vanished exec-session as a NON-throwing
657
+ // string ("write_stdin failed: session not found: N") that we used to stream
658
+ // verbatim into the terminal. That happens when the persisted exec-session no
659
+ // longer exists on the live box — historically the box-mismatch (resume_state
660
+ // pointing at a rival box; fixed at the lease layer), or a genuine box
661
+ // rollover after the PTY opened. Surface it as a typed CONFLICT so the route
662
+ // returns 409 and the client cleanly RE-OPENS the PTY against the live box,
663
+ // instead of writing a raw "session not found: 1" into the user's xterm.
664
+ if (isExecSessionLostBanner(out, execSessionId)) {
665
+ throw new ChannelAConflictError("pty session lost on the live box; reopen the terminal");
666
+ }
667
+ return stripExecBanner(out);
668
+ }
669
+
670
+ /** Resize an open PTY (SIGWINCH via stty against the exec-session). The SDK has
671
+ * no resize method; stty in the same tty session updates the geometry. */
672
+ async ptyResize(req: PtyResizeRequest, execSessionId: number): Promise<void> {
673
+ if (!this.session.writeStdin) return;
674
+ // Send a stty in-band on the same pty session.
675
+ await this.session.writeStdin({ sessionId: execSessionId, chars: `stty cols ${req.cols} rows ${req.rows}\n`, yieldTimeMs: 50 });
676
+ }
677
+
678
+ /** Close an open PTY: write exit/EOF. The caller marks the row closed + emits
679
+ * terminal.pty.exited. */
680
+ async ptyClose(_req: PtyCloseRequest, execSessionId: number | null): Promise<void> {
681
+ if (execSessionId !== null && this.session.writeStdin) {
682
+ try {
683
+ await this.session.writeStdin({ sessionId: execSessionId, chars: "", yieldTimeMs: 50 }); // EOF
684
+ } catch {
685
+ // best-effort; the row is marked closed regardless.
686
+ }
687
+ }
688
+ }
689
+
690
+ // ──────────────────────────── helpers ──────────────────────────────────────
691
+
692
+ /** The current FS revision (for the caller to persist/seed). */
693
+ currentRevision(): number { return this.revision; }
694
+
695
+ private joinRoot(rel: string): string {
696
+ if (!this.workspaceRoot) return rel === "" ? "." : rel;
697
+ return rel === "" ? this.workspaceRoot : `${this.workspaceRoot}/${rel}`;
698
+ }
699
+
700
+ private repoWorkdir(rel: string): string | undefined {
701
+ const safe = normalizeRelPath(rel);
702
+ const joined = this.joinRoot(safe);
703
+ return joined === "." ? this.workspaceRoot || undefined : joined;
704
+ }
705
+
706
+ private async emitEvents(events: { type: SessionEventType; payload: unknown }[]): Promise<void> {
707
+ if (!this.emit || events.length === 0) return;
708
+ try { await this.emit(events); } catch { /* durable spine retries; not fatal */ }
709
+ }
710
+
711
+ private async emitFsChanged(changes: FsChangedPayload["changes"], source: FsChangedPayload["source"]): Promise<void> {
712
+ const payload: FsChangedPayload = { changes, source, revision: this.revision, leaseEpoch: this.leaseEpoch };
713
+ await this.emitEvents([{ type: "fs.changed", payload }]);
714
+ }
715
+
716
+ /** Re-probe git after a mutation and emit git.changed (best-effort, used by the
717
+ * worker agent-turn side after FS-mutating tools). */
718
+ async emitGitChanged(repoPath: string, reason: GitChangedPayload["reason"]): Promise<void> {
719
+ try {
720
+ const status = await this.gitStatus({ path: repoPath });
721
+ const payload: GitChangedPayload = {
722
+ head: status.head,
723
+ dirty: status.files.length > 0,
724
+ ahead: status.ahead,
725
+ behind: status.behind,
726
+ changedFileCount: status.files.length,
727
+ reason,
728
+ revision: this.revision,
729
+ leaseEpoch: this.leaseEpoch,
730
+ };
731
+ await this.emitEvents([{ type: "git.changed", payload }]);
732
+ } catch {
733
+ // non-repo / git absent — no notification.
734
+ }
735
+ }
736
+ }
737
+
738
+ // ════════════════════════════ pure parsers/helpers ══════════════════════════
739
+
740
+ // Strip the formatExecResponse banner (Chunk ID / Wall time / Process … / Output:)
741
+ // — only used when exec() is absent and we fall back to execCommand's string.
742
+ export function stripExecBanner(raw: string): string {
743
+ const marker = raw.indexOf("\nOutput:\n");
744
+ if (marker >= 0) return raw.slice(marker + "\nOutput:\n".length);
745
+ if (raw.startsWith("Output:\n")) return raw.slice("Output:\n".length);
746
+ return raw;
747
+ }
748
+
749
+ // Detect the provider's native-readFile workspace-escape rejection — a symlink
750
+ // whose target resolves outside the sandbox root. Modal phrases it "Sandbox path
751
+ // failed remote validation: workspace escape: <target>"; we match loosely so a
752
+ // wording tweak still classifies it. Used to fall the read back onto the exec
753
+ // path (which follows the link and isn't path-validated) instead of 404-ing.
754
+ export function isWorkspaceEscapeError(error: unknown): boolean {
755
+ const msg = error instanceof Error ? error.message : String(error ?? "");
756
+ const lower = msg.toLowerCase();
757
+ return lower.includes("workspace escape") || (lower.includes("remote validation") && lower.includes("escape"));
758
+ }
759
+
760
+ // Detect the Modal "the exec-session you're writing to no longer exists" banner.
761
+ // writeStdin reports a vanished session as a non-throwing string of the shape
762
+ // `write_stdin failed: session not found: <N>` (it does NOT raise). We treat that
763
+ // as a lost PTY (the box rolled over / was re-created since the open) so the
764
+ // caller surfaces a clean reconnect instead of writing the raw failure into
765
+ // xterm. Matched loosely (`session not found`) with the id when present so a
766
+ // future wording tweak still classifies it; the command's own output cannot spoof
767
+ // it because the SDK emits this as the whole writeStdin return, not user output.
768
+ export function isExecSessionLostBanner(out: string, execSessionId: number): boolean {
769
+ if (!out) return false;
770
+ const lower = out.toLowerCase();
771
+ if (!lower.includes("session not found")) return false;
772
+ // When the id is present require it to match ours; when absent, the generic
773
+ // "session not found" still classifies (it is never legitimate stdout here).
774
+ return lower.includes(`session not found: ${execSessionId}`) || !/session not found:\s*\d+/.test(lower);
775
+ }
776
+
777
+ // Recover the numeric exec-session id the SDK embeds in a formatExecResponse
778
+ // banner for a STILL-RUNNING process (`Process running with session ID <N>`).
779
+ // A finished command emits `Process exited with code <N>` instead (no session
780
+ // id) — that yields null. Only the banner region (before the `Output:` marker)
781
+ // is scanned so a session-id-looking line in the command's own output can't
782
+ // spoof it. This is what makes the interactive PTY work on backends whose only
783
+ // exec surface is execCommand (Modal): without it ptyOpen reports execSessionId
784
+ // = null and every pty/write 409s ("interactive terminal unsupported").
785
+ export function parseExecBannerSessionId(raw: string): number | null {
786
+ const outputIdx = raw.indexOf("\nOutput:\n");
787
+ const banner = outputIdx >= 0 ? raw.slice(0, outputIdx) : raw.startsWith("Output:\n") ? "" : raw;
788
+ const match = banner.match(/Process running with session ID (\d+)/);
789
+ if (!match) return null;
790
+ const n = Number.parseInt(match[1]!, 10);
791
+ return Number.isFinite(n) ? n : null;
792
+ }
793
+
794
+ function sniffBinary(bytes: Buffer): boolean {
795
+ const n = Math.min(bytes.byteLength, 8192);
796
+ for (let i = 0; i < n; i++) if (bytes[i] === 0) return true;
797
+ return false;
798
+ }
799
+
800
+ function normalizeRelPath(p: string): string {
801
+ const trimmed = (p ?? "").replace(/^\/+/, "").replace(/\/+$/, "");
802
+ return trimmed;
803
+ }
804
+
805
+ // Reject path traversal / absolute paths (case 4); the box normalizes against
806
+ // the workspace root, so a leading slash or `..` is a 400.
807
+ export function assertSafeRelPath(p: string): string {
808
+ const norm = normalizeRelPath(p);
809
+ if (norm === "") throw new ChannelAValidationError("path is required");
810
+ if (p.startsWith("/")) throw new ChannelAValidationError(`absolute paths are not allowed: ${p}`);
811
+ if (norm.split("/").some((seg) => seg === "..")) throw new ChannelAValidationError(`path traversal is not allowed: ${p}`);
812
+ return norm;
813
+ }
814
+
815
+ function shellQuote(s: string): string {
816
+ return `'${s.replace(/'/g, `'\\''`)}'`;
817
+ }
818
+
819
+ function basename(p: string): string {
820
+ const parts = p.split("/").filter(Boolean);
821
+ return parts.length ? parts[parts.length - 1]! : "";
822
+ }
823
+
824
+ function dirnameAbs(p: string): string {
825
+ const idx = p.lastIndexOf("/");
826
+ return idx > 0 ? p.slice(0, idx) : "";
827
+ }
828
+
829
+ function dirnameRel(p: string, root: string): string {
830
+ const idx = p.lastIndexOf("/");
831
+ if (idx < 0) return root;
832
+ return p.slice(0, idx);
833
+ }
834
+
835
+ function stripDotSlash(rawPath: string, root: string): string {
836
+ let p = rawPath.startsWith("./") ? rawPath.slice(2) : rawPath;
837
+ p = p.replace(/^\/+/, "");
838
+ // find run with workdir=root and findRoot="." gives paths relative to root,
839
+ // but if root is non-empty the relPath should still be workspace-relative.
840
+ if (root && !p.startsWith(`${root}/`) && p !== root) {
841
+ return root ? `${root}/${p}` : p;
842
+ }
843
+ return p;
844
+ }
845
+
846
+ function findTypeToNode(t: string): FsTreeNode["type"] {
847
+ if (t === "d") return "dir";
848
+ if (t === "f") return "file";
849
+ if (t === "l") return "symlink";
850
+ return "other";
851
+ }
852
+
853
+ function safeInt(s: string | undefined): number | null {
854
+ if (s === undefined) return null;
855
+ const n = Number.parseInt(s, 10);
856
+ return Number.isFinite(n) ? n : null;
857
+ }
858
+
859
+ function safeOctal(s: string | undefined): number | null {
860
+ if (s === undefined) return null;
861
+ const n = Number.parseInt(s, 8);
862
+ return Number.isFinite(n) ? n : null;
863
+ }
864
+
865
+ function mtimeToMs(s: string | undefined): number | null {
866
+ if (s === undefined) return null;
867
+ const f = Number.parseFloat(s);
868
+ return Number.isFinite(f) ? Math.round(f * 1000) : null;
869
+ }
870
+
871
+ function sortTree(node: FsTreeNode): void {
872
+ if (!node.children) return;
873
+ node.children.sort((a, b) => {
874
+ if (a.type === "dir" && b.type !== "dir") return -1;
875
+ if (a.type !== "dir" && b.type === "dir") return 1;
876
+ return a.name.localeCompare(b.name);
877
+ });
878
+ for (const child of node.children) sortTree(child);
879
+ }
880
+
881
+ function isImagePath(p: string): boolean {
882
+ return /\.(png|jpe?g|gif|webp|bmp|ico|svg|tiff?)$/i.test(p);
883
+ }
884
+
885
+ // ── git status --porcelain=v2 --branch -z parser ────────────────────────────
886
+ export function parsePorcelainV2(z: string): Omit<GitStatusResponse, "revision"> {
887
+ const records = z.split(NUL);
888
+ let head: string | null = null;
889
+ let upstream: string | null = null;
890
+ let detached = false;
891
+ let ahead = 0;
892
+ let behind = 0;
893
+ const files: GitFileStatus[] = [];
894
+ for (let i = 0; i < records.length; i++) {
895
+ const rec = records[i]!;
896
+ if (rec === "") continue;
897
+ if (rec.startsWith("# branch.head ")) {
898
+ const v = rec.slice("# branch.head ".length);
899
+ if (v === "(detached)") { detached = true; head = null; } else head = v;
900
+ } else if (rec.startsWith("# branch.upstream ")) {
901
+ upstream = rec.slice("# branch.upstream ".length);
902
+ } else if (rec.startsWith("# branch.ab ")) {
903
+ const m = rec.slice("# branch.ab ".length).match(/\+(\d+)\s+-(\d+)/);
904
+ if (m) { ahead = Number(m[1]); behind = Number(m[2]); }
905
+ } else if (rec.startsWith("1 ")) {
906
+ // 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
907
+ const fields = rec.split(" ");
908
+ const xy = fields[1] ?? "..";
909
+ const path = fields.slice(8).join(" ");
910
+ files.push(statusFromXY(xy, path, null));
911
+ } else if (rec.startsWith("2 ")) {
912
+ // 2 <XY> ... <Xscore> <path>\0<origPath> — the origPath is the NEXT NUL rec
913
+ const fields = rec.split(" ");
914
+ const xy = fields[1] ?? "..";
915
+ const path = fields.slice(9).join(" ");
916
+ const oldPath = records[i + 1] ?? null;
917
+ i++; // consume the origPath record
918
+ files.push(statusFromXY(xy, path, oldPath));
919
+ } else if (rec.startsWith("u ")) {
920
+ const fields = rec.split(" ");
921
+ const path = fields.slice(10).join(" ");
922
+ files.push({ path, oldPath: null, index: "conflicted", worktree: "conflicted", isConflicted: true });
923
+ } else if (rec.startsWith("? ")) {
924
+ files.push({ path: rec.slice(2), oldPath: null, index: null, worktree: "untracked", isConflicted: false });
925
+ } else if (rec.startsWith("! ")) {
926
+ files.push({ path: rec.slice(2), oldPath: null, index: null, worktree: "ignored", isConflicted: false });
927
+ }
928
+ }
929
+ return { isRepo: true, head, detached, upstream, ahead, behind, files };
930
+ }
931
+
932
+ function xyCode(c: string): GitFileStatusCode | null {
933
+ switch (c) {
934
+ case "A": return "added";
935
+ case "M": return "modified";
936
+ case "D": return "deleted";
937
+ case "R": return "renamed";
938
+ case "C": return "copied";
939
+ case "T": return "typechange";
940
+ case "U": return "conflicted";
941
+ case ".": return null;
942
+ default: return null;
943
+ }
944
+ }
945
+
946
+ function statusFromXY(xy: string, path: string, oldPath: string | null): GitFileStatus {
947
+ const x = xy[0] ?? ".";
948
+ const y = xy[1] ?? ".";
949
+ return {
950
+ path,
951
+ oldPath,
952
+ index: xyCode(x),
953
+ worktree: xyCode(y),
954
+ isConflicted: x === "U" || y === "U",
955
+ };
956
+ }
957
+
958
+ // ── numstat -z parser (additions/deletions/binary + rename old\0new) ─────────
959
+ export type NumstatEntry = { additions: number; deletions: number; binary: boolean; oldPath: string | null; newPath: string };
960
+ export function parseNumstatZ(z: string): NumstatEntry[] {
961
+ const fields = z.split(NUL);
962
+ const out: NumstatEntry[] = [];
963
+ let i = 0;
964
+ while (i < fields.length) {
965
+ const head = fields[i]!;
966
+ if (head === "") { i++; continue; }
967
+ // "<add>\t<del>\t<path>" OR for a rename "<add>\t<del>\t" then old\0new follow.
968
+ const m = head.match(/^(\d+|-)\t(\d+|-)\t(.*)$/s);
969
+ if (!m) { i++; continue; }
970
+ const addStr = m[1]!;
971
+ const delStr = m[2]!;
972
+ const pathPart = m[3]!;
973
+ const binary = addStr === "-" && delStr === "-";
974
+ if (pathPart === "") {
975
+ // rename: the next two NUL fields are old, new
976
+ const oldPath = fields[i + 1] ?? null;
977
+ const newPath = fields[i + 2] ?? "";
978
+ out.push({ additions: binary ? 0 : Number(addStr), deletions: binary ? 0 : Number(delStr), binary, oldPath, newPath });
979
+ i += 3;
980
+ } else {
981
+ out.push({ additions: binary ? 0 : Number(addStr), deletions: binary ? 0 : Number(delStr), binary, oldPath: null, newPath: pathPart });
982
+ i++;
983
+ }
984
+ }
985
+ return out;
986
+ }
987
+
988
+ // ── unified-diff parser -> GitDiffHunk[] (the Pierre-diff shape) ─────────────
989
+ export function parseUnifiedPatch(patch: string): { hunks: GitDiffHunk[]; status: GitFileStatusCode } {
990
+ const lines = patch.split("\n");
991
+ const hunks: GitDiffHunk[] = [];
992
+ let status: GitFileStatusCode = "modified";
993
+ let current: GitDiffHunk | null = null;
994
+ let oldNo = 0;
995
+ let newNo = 0;
996
+ for (const line of lines) {
997
+ if (line.startsWith("new file mode")) status = "added";
998
+ else if (line.startsWith("deleted file mode")) status = "deleted";
999
+ else if (line.startsWith("rename from") || line.startsWith("rename to")) status = "renamed";
1000
+ if (line.startsWith("@@")) {
1001
+ const m = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/);
1002
+ if (m) {
1003
+ const oldStart = Number(m[1]);
1004
+ const oldLines = m[2] !== undefined ? Number(m[2]) : 1;
1005
+ const newStart = Number(m[3]);
1006
+ const newLines = m[4] !== undefined ? Number(m[4]) : 1;
1007
+ current = { oldStart, oldLines, newStart, newLines, header: (m[5] ?? "").trim(), lines: [] };
1008
+ hunks.push(current);
1009
+ oldNo = oldStart;
1010
+ newNo = newStart;
1011
+ }
1012
+ continue;
1013
+ }
1014
+ if (!current) continue;
1015
+ if (line.startsWith("\\")) continue; // ""
1016
+ const marker = line[0];
1017
+ const text = line.slice(1);
1018
+ if (marker === "+") {
1019
+ current.lines.push({ type: "add", oldNo: null, newNo, text });
1020
+ newNo++;
1021
+ } else if (marker === "-") {
1022
+ current.lines.push({ type: "del", oldNo, newNo: null, text });
1023
+ oldNo++;
1024
+ } else if (marker === " ") {
1025
+ current.lines.push({ type: "context", oldNo, newNo, text });
1026
+ oldNo++;
1027
+ newNo++;
1028
+ }
1029
+ }
1030
+ return { hunks, status };
1031
+ }