@opengeni/runtime 0.7.1 → 0.13.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 (61) hide show
  1. package/THIRD_PARTY_NOTICES +32 -0
  2. package/dist/agent-UMUIWWD2.js +8 -0
  3. package/dist/agent-UMUIWWD2.js.map +1 -0
  4. package/dist/chunk-4YJ5Z52Z.js +1789 -0
  5. package/dist/chunk-4YJ5Z52Z.js.map +1 -0
  6. package/dist/chunk-65UDUFUO.js +22133 -0
  7. package/dist/chunk-65UDUFUO.js.map +1 -0
  8. package/dist/chunk-DPULLAJG.js +215 -0
  9. package/dist/chunk-DPULLAJG.js.map +1 -0
  10. package/dist/chunk-HAN5D27J.js +292 -0
  11. package/dist/chunk-HAN5D27J.js.map +1 -0
  12. package/dist/chunk-K4V4QYCL.js +37683 -0
  13. package/dist/chunk-K4V4QYCL.js.map +1 -0
  14. package/dist/chunk-PZ5AY32C.js +10 -0
  15. package/dist/chunk-PZ5AY32C.js.map +1 -0
  16. package/dist/{index-DIiiwQY2.d.ts → index-DGUrY_yR.d.ts} +171 -18
  17. package/dist/index.d.ts +404 -125
  18. package/dist/index.js +8857 -412
  19. package/dist/index.js.map +1 -1
  20. package/dist/localSources-T3HZ6KY4.js +15 -0
  21. package/dist/localSources-T3HZ6KY4.js.map +1 -0
  22. package/dist/mcp-network.d.ts +78 -0
  23. package/dist/mcp-network.js +62 -0
  24. package/dist/mcp-network.js.map +1 -0
  25. package/dist/sandbox/index.d.ts +1 -1
  26. package/dist/sandbox/index.js +42 -5
  27. package/dist/skill-library.d.ts +74 -0
  28. package/dist/skill-library.js +18 -0
  29. package/dist/skill-library.js.map +1 -0
  30. package/package.json +18 -5
  31. package/src/bundled_hashicorp_terraform_skills/README.md +3 -3
  32. package/src/bundled_hashicorp_terraform_skills/checkov/SKILL.md +1 -1
  33. package/src/codex-tool-search.ts +20 -1
  34. package/src/context-compaction.ts +421 -24
  35. package/src/history-sanitizer.ts +14 -5
  36. package/src/index.ts +1806 -347
  37. package/src/mcp-network.ts +330 -0
  38. package/src/sandbox/channel-a.ts +526 -113
  39. package/src/sandbox/display-stack.ts +860 -23
  40. package/src/sandbox/exec-banner.ts +67 -0
  41. package/src/sandbox/index.ts +43 -2
  42. package/src/sandbox/providers/modal.ts +29 -2
  43. package/src/sandbox/routing/routing-session.ts +10 -2
  44. package/src/sandbox/run-credentials.ts +818 -0
  45. package/src/sandbox/select.ts +3 -3
  46. package/src/sandbox/selfhosted/capabilities.ts +1 -1
  47. package/src/sandbox/selfhosted/op-stream.ts +40 -0
  48. package/src/sandbox/selfhosted/op-testing.ts +50 -3
  49. package/src/sandbox/selfhosted/session.ts +17 -4
  50. package/src/sandbox/selfhosted/testing.ts +1 -1
  51. package/src/sandbox/stream-port.ts +1 -1
  52. package/src/sandbox/stream-token.ts +1 -1
  53. package/src/sandbox/terminal-server.ts +6 -2
  54. package/src/sandbox/toolspace-token.ts +147 -0
  55. package/src/sandbox/turn-tool-cancellation.ts +940 -0
  56. package/src/sandbox-computer.ts +3 -1
  57. package/src/skill-library.ts +323 -0
  58. package/src/usage-telemetry.ts +495 -51
  59. package/dist/chunk-RYDCVTMW.js +0 -5531
  60. package/dist/chunk-RYDCVTMW.js.map +0 -1
  61. /package/src/{bundled_hashicorp_terraform_skills → bundled_skill_library}/azure-verified-modules/SKILL.md +0 -0
@@ -1,5 +1,4 @@
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.
1
+ // Provider-agnostic structured sandbox services, called API-direct.
3
2
  //
4
3
  // THE NON-PIXEL SURFACE: file tree + read/write (the Pierre tree), git
5
4
  // status/diff hunks (the Pierre diff), and a terminal exec + interactive PTY.
@@ -8,10 +7,9 @@
8
7
  // op, returns inline JSON, and drops the handle. There is NO ownership/singleton
9
8
  // here — the live handle is whatever the caller resumed; it is non-owned and
10
9
  // 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.
10
+ // agent turn for the fs.changed side effect it produces in-process.
12
11
  //
13
- // SDK GROUNDING (the load-bearing reality — see the adversarial review in the
14
- // module spec). Built on `session.exec(args): Promise<SandboxExecResult>` which
12
+ // Built on `session.exec(args): Promise<SandboxExecResult>`, which
15
13
  // returns RAW {stdout,stderr,exitCode} on the agents-core local/docker sessions
16
14
  // (and Modal/the extensions providers expose the equivalent). `execCommand`
17
15
  // returns a BANNER-DECORATED string (formatExecResponse) — NEVER used for
@@ -60,6 +58,9 @@ import type {
60
58
  TerminalExecRequest,
61
59
  TerminalExecResponse,
62
60
  } from "@opengeni/contracts";
61
+ import { parseExecBannerExitCode, parseExecBannerSessionId } from "./exec-banner";
62
+
63
+ export { parseExecBannerExitCode, parseExecBannerSessionId } from "./exec-banner";
63
64
 
64
65
  // ── The minimal session surface Channel A consumes (a structural subset of the
65
66
  // SDK's SandboxSession, all optional — capability-probed before use). ─────────
@@ -111,6 +112,15 @@ export class ChannelAValidationError extends Error {
111
112
  this.name = "ChannelAValidationError";
112
113
  }
113
114
  }
115
+ /** A structurally valid Channel-A request could not be completed because the
116
+ * sandbox/provider control plane was temporarily unavailable. Callers may retry
117
+ * this class; it must never be presented as a bad user path. */
118
+ export class ChannelAUnavailableError extends Error {
119
+ constructor(message: string) {
120
+ super(message);
121
+ this.name = "ChannelAUnavailableError";
122
+ }
123
+ }
114
124
  export class ChannelAConflictError extends Error {
115
125
  constructor(message: string) {
116
126
  super(message);
@@ -170,6 +180,11 @@ const REPOSITORY_DISCOVERY_TRUNCATED_SENTINEL = "__OPENGENI_REPOSITORY_DISCOVERY
170
180
  const REPOSITORY_DISCOVERY_STATUS_PREFIX = "__OPENGENI_REPOSITORY_DISCOVERY_STATUS__:";
171
181
 
172
182
  const NUL = String.fromCharCode(0); // \0 NUL — find/porcelain/numstat -z separator
183
+ const FS_LIST_TRUNCATED_MARKER = "__OPENGENI_FS_LIST_TRUNCATED__";
184
+ // Both the local and Modal SDK sessions retain at most 1 MiB per active output
185
+ // stream. Leave ample framing/translation headroom and emit an explicit marker
186
+ // instead of allowing the SDK to drop the tree's leading parent records.
187
+ const FS_LIST_MAX_OUTPUT_BYTES = 768 * 1024;
173
188
  const US = String.fromCharCode(0x1f); // \x1f unit sep — git-log field separator
174
189
  const RS = String.fromCharCode(0x1e); // \x1e record sep — git-log record separator
175
190
  const SELFHOSTED_VIRTUAL_ROOT = "/workspace";
@@ -248,7 +263,7 @@ export class SandboxChannelAService {
248
263
  return {
249
264
  stdout: stripExecBanner(raw),
250
265
  stderr: "",
251
- exitCode: null,
266
+ exitCode: parseExecBannerExitCode(raw),
252
267
  ...(sessionId !== null ? { sessionId } : {}),
253
268
  wallTimeSeconds: 0,
254
269
  };
@@ -259,34 +274,102 @@ export class SandboxChannelAService {
259
274
  // ════════════════════════════ FileSystem (A2) ═════════════════════════════
260
275
 
261
276
  async fsList(req: FsListRequest): Promise<FsListResponse> {
262
- const root = normalizeRelPath(req.path);
263
- // A single bounded `find` (NUL-delimited) builds the whole subtree in one
264
- // round-trip. Prefer GNU find's -printf on the Ubuntu-based images, but fall
265
- // back to a POSIX-ish find+stat loop for unix_local on macOS/BSD.
266
- const findRoot = root === "" ? "." : shellQuote(root);
277
+ return await this.fsListInternal(req, []);
278
+ }
279
+
280
+ /**
281
+ * Worker-only fast path for a bounded whole-workspace index. Matching
282
+ * directory nodes are emitted but pruned by `find` before descent, so residue
283
+ * such as node_modules cannot consume the result budget. The public fs/list
284
+ * wire remains unchanged; interactive callers still expand every directory
285
+ * they explicitly request.
286
+ */
287
+ async fsListPruned(
288
+ req: FsListRequest,
289
+ pruneDirectoryNames: readonly string[],
290
+ ): Promise<FsListResponse> {
291
+ return await this.fsListInternal(req, pruneDirectoryNames);
292
+ }
293
+
294
+ private async fsListInternal(
295
+ req: FsListRequest,
296
+ pruneDirectoryNames: readonly string[],
297
+ ): Promise<FsListResponse> {
298
+ const root = assertSafeRelPathOrRoot(req.path);
299
+ const pruneNames = [...new Set(pruneDirectoryNames)].map(assertSafePruneDirectoryName);
300
+ // A single bounded command (NUL-delimited) builds the whole subtree in one
301
+ // round-trip. Prefer GNU find's -printf on the Ubuntu-based images; on
302
+ // macOS/BSD, use a depth-bounded Bash glob walker because their find has no
303
+ // -mindepth/-maxdepth/-printf contract.
304
+ const findRoot = ".";
267
305
  const depthArg = Math.max(1, req.depth);
268
- const hidden = req.includeHidden ? "" : ` -not -path '*/.*'`;
269
- const gnuFind = `find ${findRoot} -mindepth 1 -maxdepth ${depthArg}${hidden} -printf '%y\\t%s\\t%T@\\t%m\\t%p\\0' 2>/dev/null`;
270
- let { stdout } = await this.run({
271
- cmd: `bash -lc ${shellQuote(gnuFind)}`,
272
- workdir: this.workspaceRoot || undefined,
306
+ const maxCommandEntries = req.maxEntries + 1;
307
+ const gnuPrint = `-printf '%y\\t%s\\t%T@\\t%m\\t%p\\0'`;
308
+ const gnuSelector = pruneNames.length
309
+ ? `\\( -type d \\( ${pruneNames.map((name) => `-name ${shellQuote(name)}`).join(" -o ")} \\) ${gnuPrint} -prune \\) -o ${gnuPrint}`
310
+ : gnuPrint;
311
+ const gnuVisibleSelector = req.includeHidden
312
+ ? gnuSelector
313
+ : `\\( -path '*/.*' -prune \\) -o \\( ${gnuSelector} \\)`;
314
+ const gnuFind = `find ${findRoot} -mindepth 1 -maxdepth ${depthArg} \\( ${gnuVisibleSelector} \\) 2>/dev/null`;
315
+ const portableHiddenGuard = req.includeHidden
316
+ ? ""
317
+ : `if [[ "$base" == .* ]]; then continue; fi;`;
318
+ const portablePruneCase = pruneNames.length
319
+ ? `case "$base" in ${pruneNames.map(shellQuote).join("|")}) pruned=1 ;; *) pruned=0 ;; esac;`
320
+ : "pruned=0;";
321
+ const portableWalk = [
322
+ "shopt -s nullglob dotglob; count=0; stop=0;",
323
+ 'walk() { local dir="$1" level="$2" p base t size mtime mode pruned;',
324
+ 'for p in "$dir"/*; do [ "$stop" -eq 1 ] && return; base=${p##*/};',
325
+ portableHiddenGuard,
326
+ `if [ -L "$p" ]; then t=l; size=0; elif [ -d "$p" ]; then t=d; size=0; elif [ -f "$p" ]; then t=f; size=$(wc -c < "$p" | tr -d ' '); else t=o; size=0; fi;`,
327
+ `mtime=$(date -r "$p" +%s 2>/dev/null || stat -c %Y "$p" 2>/dev/null || echo 0);`,
328
+ `mode=$(stat -f %Lp "$p" 2>/dev/null || stat -c %a "$p" 2>/dev/null || echo 0);`,
329
+ `printf '%s\\t%s\\t%s\\t%s\\t%s\\0' "$t" "$size" "$mtime" "$mode" "$p";`,
330
+ `count=$((count + 1)); if [ "$count" -ge ${maxCommandEntries} ]; then stop=1; return; fi;`,
331
+ portablePruneCase,
332
+ `if [ "$t" = d ] && [ "$pruned" -eq 0 ] && [ "$level" -lt ${depthArg} ]; then walk "$p" $((level + 1)); fi;`,
333
+ "done; }; walk . 1",
334
+ ].join(" ");
335
+ // Capability selection lives inside the confined command. Even a BSD/macOS
336
+ // box therefore pays exactly one provider round-trip, and an empty GNU
337
+ // listing cannot be mistaken for a failed capability probe.
338
+ const rawFindCommand = [
339
+ `if find --version >/dev/null 2>&1 && head -z -n 0 </dev/null >/dev/null 2>&1; then`,
340
+ `${gnuFind};`,
341
+ "else",
342
+ `${portableWalk};`,
343
+ "fi",
344
+ ].join(" ");
345
+ const boundedOutput = [
346
+ "LC_ALL=C; bytes=0; records=0; truncated=0;",
347
+ "while IFS= read -r -d '' record; do",
348
+ "record_bytes=${#record};",
349
+ `if [ "$records" -ge ${req.maxEntries} ] || [ $((bytes + record_bytes + 1)) -gt ${FS_LIST_MAX_OUTPUT_BYTES} ]; then truncated=1; break; fi;`,
350
+ "printf '%s\\0' \"$record\"; bytes=$((bytes + record_bytes + 1)); records=$((records + 1));",
351
+ "done;",
352
+ `if [ "$truncated" -eq 1 ]; then printf '%s\\0' ${shellQuote(FS_LIST_TRUNCATED_MARKER)}; fi`,
353
+ ].join(" ");
354
+ const findCommand = [
355
+ `{ ${rawFindCommand}; } | { ${boundedOutput}; };`,
356
+ "producer_status=${PIPESTATUS[0]};",
357
+ `if [ "$producer_status" -ne 0 ] && [ "$producer_status" -ne 141 ]; then printf '%s\\0' ${shellQuote(FS_LIST_TRUNCATED_MARKER)}; fi`,
358
+ ].join(" ");
359
+ const { stdout, exitCode } = await this.runInConfinedDirectory(root, {
360
+ cmd: internalBashCommand(findCommand),
361
+ yieldTimeMs: 10_000,
362
+ maxOutputTokens: Math.ceil(FS_LIST_MAX_OUTPUT_BYTES / 4) + 1_024,
273
363
  });
274
- if (!stdout) {
275
- const portableFind = [
276
- `find ${findRoot} -mindepth 1 -maxdepth ${depthArg}${hidden} -print0 2>/dev/null | while IFS= read -r -d '' p; do`,
277
- `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;`,
278
- `mtime=$(date -r "$p" +%s 2>/dev/null || stat -c %Y "$p" 2>/dev/null || echo 0);`,
279
- `mode=$(stat -f %Lp "$p" 2>/dev/null || stat -c %a "$p" 2>/dev/null || echo 0);`,
280
- `printf '%s\\t%s\\t%s\\t%s\\t%s\\0' "$t" "$size" "$mtime" "$mode" "$p";`,
281
- `done`,
282
- ].join(" ");
283
- ({ stdout } = await this.run({
284
- cmd: `bash -lc ${shellQuote(portableFind)}`,
285
- workdir: this.workspaceRoot || undefined,
286
- }));
364
+ if (exitCode !== 0) {
365
+ throw new ChannelAUnavailableError(
366
+ "Workspace files are temporarily unavailable. Retry the file list.",
367
+ );
287
368
  }
288
369
 
289
370
  const entries = stdout.split(NUL).filter((s) => s.length > 0);
371
+ const transportTruncated = entries.at(-1) === FS_LIST_TRUNCATED_MARKER;
372
+ if (transportTruncated) entries.pop();
290
373
  const rootNode: FsTreeNode = {
291
374
  name: basename(root) || (root === "" ? "" : root),
292
375
  path: root,
@@ -301,7 +384,7 @@ export class SandboxChannelAService {
301
384
  const byPath = new Map<string, FsTreeNode>();
302
385
  byPath.set(root, rootNode);
303
386
  let count = 0;
304
- let truncated = false;
387
+ let truncated = transportTruncated;
305
388
  for (const entry of entries) {
306
389
  if (count >= req.maxEntries) {
307
390
  truncated = true;
@@ -340,7 +423,8 @@ export class SandboxChannelAService {
340
423
  async fsRead(req: FsReadRequest): Promise<FsReadResponse> {
341
424
  const path = assertSafeRelPath(req.path);
342
425
  if (!this.session.readFile) {
343
- // No native readFile: base64 the file through exec (binary-safe).
426
+ // No native readFile: open the file once, prove that exact descriptor is
427
+ // rooted beneath the workspace, then base64 it through exec.
344
428
  return await this.fsReadViaExec(path, req);
345
429
  }
346
430
  let raw: string | Uint8Array;
@@ -351,41 +435,152 @@ export class SandboxChannelAService {
351
435
  ...(this.runAs ? { runAs: this.runAs } : {}),
352
436
  });
353
437
  } catch (error) {
354
- // The provider's native readFile applies a REMOTE workspace-escape guard:
355
- // a SYMLINK whose target resolves outside /workspace (e.g.
356
- // `.config/pulse/<id>-runtime -> /tmp/pulse-…`) is rejected with
357
- // "Sandbox path failed remote validation: workspace escape: /tmp/…". That
358
- // raw 404 surfaced to the user. The path is still legitimately INSIDE the
359
- // workspace (the symlink node lives there); only its target escapes. Read it
360
- // via exec instead — `base64 <path>` follows the link and is NOT subject to
361
- // the provider's path validation — so a symlink-to-/tmp renders cleanly
362
- // instead of erroring. A genuine not-found falls through to a clean 404.
438
+ // A provider guard is the final race-safe check after our preflight. Never
439
+ // bypass it through exec: that would turn an in-workspace symlink into an
440
+ // arbitrary read outside the workspace root.
363
441
  if (isWorkspaceEscapeError(error)) {
442
+ throw new ChannelAValidationError(`path resolves outside workspace: ${path}`);
443
+ }
444
+ if (isDefinitePathNotFoundError(error)) {
445
+ throw new ChannelANotFoundError(`file not found: ${path}`);
446
+ }
447
+ // A native provider read can fail while exec on the same live box remains
448
+ // healthy. The descriptor path below is equally confined and binary-safe,
449
+ // so use it as a single recovery path. Unknown provider failures must never
450
+ // be downgraded into a false 404.
451
+ if (this.session.exec || this.session.execCommand) {
364
452
  return await this.fsReadViaExec(path, req);
365
453
  }
366
- throw new ChannelANotFoundError(
367
- `file not found: ${path} (${error instanceof Error ? error.message : String(error)})`,
454
+ throw new ChannelAUnavailableError(
455
+ "Workspace files are temporarily unavailable. Retry the file read.",
368
456
  );
369
457
  }
370
458
  const bytes = typeof raw === "string" ? Buffer.from(raw, "utf8") : Buffer.from(raw);
371
459
  return this.shapeRead(path, bytes, req);
372
460
  }
373
461
 
374
- /** Read a file by base64-ing it through exec. Binary-safe and — crucially —
375
- * NOT subject to the provider's native-readFile workspace-escape validation,
376
- * so it can render a symlink whose target lives outside /workspace (the link
377
- * node itself is in-workspace). `base64 <path>` follows the symlink. */
462
+ private async assertConfinedExistingDirectory(path: string): Promise<void> {
463
+ const root = this.workspaceRoot || ".";
464
+ const abs = this.joinRoot(path);
465
+ const rejectLink = path
466
+ ? `test ! -L ${shellQuote(abs)} || { printf '__OPENGENI_FS_SYMLINK__'; exit 68; }`
467
+ : ":";
468
+ const script = [
469
+ `root=$(realpath -e -- ${shellQuote(root)}) || { printf '__OPENGENI_FS_NOT_FOUND__'; exit 66; }`,
470
+ rejectLink,
471
+ `target=$(realpath -e -- ${shellQuote(abs)}) || { printf '__OPENGENI_FS_NOT_FOUND__'; exit 66; }`,
472
+ `case "$target" in "$root"|"$root"/*) ;; *) printf '__OPENGENI_FS_ESCAPE__'; exit 67 ;; esac`,
473
+ `test -d "$target" || { printf '__OPENGENI_FS_NOT_FOUND__'; exit 66; }`,
474
+ `printf '__OPENGENI_FS_CONFINED_OK__'`,
475
+ ].join("; ");
476
+ const result = await this.run({ cmd: internalBashCommand(script) });
477
+ this.assertConfinementResult(result, path || ".", "directory");
478
+ }
479
+
480
+ private async assertConfinedMutationParent(
481
+ path: string,
482
+ options: { allowMissingParents: boolean; rejectFinalSymlink: boolean },
483
+ ): Promise<void> {
484
+ const root = this.workspaceRoot || ".";
485
+ const abs = this.joinRoot(path);
486
+ const rejectLink = options.rejectFinalSymlink
487
+ ? `test ! -L ${shellQuote(abs)} || { printf '__OPENGENI_FS_SYMLINK__'; exit 68; }`
488
+ : ":";
489
+ const locateParent = options.allowMissingParents
490
+ ? 'probe="$parent"; while [ ! -e "$probe" ] && [ ! -L "$probe" ]; do next=$(dirname -- "$probe"); [ "$next" != "$probe" ] || break; probe="$next"; done'
491
+ : 'probe="$parent"';
492
+ const requireParent = options.allowMissingParents
493
+ ? ":"
494
+ : 'test -d "$parent" || { printf "__OPENGENI_FS_NOT_FOUND__"; exit 66; }';
495
+ const script = [
496
+ `root=$(realpath -e -- ${shellQuote(root)}) || { printf '__OPENGENI_FS_NOT_FOUND__'; exit 66; }`,
497
+ rejectLink,
498
+ `parent=$(dirname -- ${shellQuote(abs)})`,
499
+ locateParent,
500
+ `target=$(realpath -e -- "$probe") || { printf '__OPENGENI_FS_NOT_FOUND__'; exit 66; }`,
501
+ `case "$target" in "$root"|"$root"/*) ;; *) printf '__OPENGENI_FS_ESCAPE__'; exit 67 ;; esac`,
502
+ requireParent,
503
+ `printf '__OPENGENI_FS_CONFINED_OK__'`,
504
+ ].join("; ");
505
+ const result = await this.run({ cmd: internalBashCommand(script) });
506
+ this.assertConfinementResult(result, path, "mutation");
507
+ }
508
+
509
+ private assertConfinementResult(
510
+ result: { stdout: string; exitCode: number | null; sessionId?: number },
511
+ path: string,
512
+ kind: "directory" | "mutation",
513
+ ): void {
514
+ if (result.sessionId !== undefined) {
515
+ throw new ChannelAUnavailableError(
516
+ "Workspace files are temporarily unavailable. Retry the operation.",
517
+ );
518
+ }
519
+ if (result.stdout.includes("__OPENGENI_FS_CONFINED_OK__")) return;
520
+ if (result.stdout.includes("__OPENGENI_FS_ESCAPE__") || result.exitCode === 67) {
521
+ throw new ChannelAValidationError(`path resolves outside workspace: ${path}`);
522
+ }
523
+ if (result.stdout.includes("__OPENGENI_FS_SYMLINK__") || result.exitCode === 68) {
524
+ throw new ChannelAValidationError(`${kind} path must not be a symbolic link: ${path}`);
525
+ }
526
+ if (result.stdout.includes("__OPENGENI_FS_NOT_FOUND__") || result.exitCode === 66) {
527
+ throw new ChannelANotFoundError(`${kind} path not found: ${path}`);
528
+ }
529
+ throw new ChannelAUnavailableError(
530
+ "Workspace files are temporarily unavailable. Retry the operation.",
531
+ );
532
+ }
533
+
534
+ /** Binary-safe fallback for sessions without native reads. The file is opened
535
+ * before checking `/proc/.../fd/3`, so a symlink swap cannot redirect the
536
+ * subsequent read after confinement has been established. */
378
537
  private async fsReadViaExec(path: string, req: FsReadRequest): Promise<FsReadResponse> {
538
+ const root = this.workspaceRoot || ".";
379
539
  const abs = this.joinRoot(path);
380
- const { stdout, exitCode } = await this.run({
381
- cmd: `base64 ${shellQuote(abs)} 2>/dev/null | head -c ${Math.ceil(req.maxBytes * 1.4)}`,
382
- });
383
- if (exitCode !== null && exitCode !== 0 && stdout === "") {
384
- // The target may be a dangling symlink or a link to a directory; surface a
385
- // clean, typed not-found rather than a raw provider validation error.
540
+ const script = [
541
+ `root=$(realpath -e -- ${shellQuote(root)}) || { printf '__OPENGENI_FS_NOT_FOUND__'; exit 66; }`,
542
+ `exec 3<${shellQuote(abs)} || { printf '__OPENGENI_FS_NOT_FOUND__'; exit 66; }`,
543
+ `target=$(readlink -f -- "/proc/$$/fd/3") || { printf '__OPENGENI_FS_NOT_FOUND__'; exit 66; }`,
544
+ `case "$target" in "$root"|"$root"/*) ;; *) printf '__OPENGENI_FS_ESCAPE__'; exit 67 ;; esac`,
545
+ `test -f "$target" || { printf '__OPENGENI_FS_NOT_FOUND__'; exit 66; }`,
546
+ `printf '__OPENGENI_FS_READ_OK__\\n'`,
547
+ `head -c ${req.maxBytes} <&3 | base64 | tr -d '\\n'`,
548
+ ].join("; ");
549
+ let result: Awaited<ReturnType<SandboxChannelAService["run"]>>;
550
+ try {
551
+ result = await this.run({
552
+ cmd: internalBashCommand(script),
553
+ maxOutputTokens: Math.ceil((req.maxBytes * 4) / 3) + 1_024,
554
+ });
555
+ } catch (error) {
556
+ if (error instanceof ChannelAUnsupportedError) throw error;
557
+ throw new ChannelAUnavailableError(
558
+ "Workspace files are temporarily unavailable. Retry the file read.",
559
+ );
560
+ }
561
+ const { stdout, exitCode, sessionId } = result;
562
+ if (sessionId !== undefined) {
563
+ throw new ChannelAUnavailableError(
564
+ "Workspace files are temporarily unavailable. Retry the file read.",
565
+ );
566
+ }
567
+ if (stdout.includes("__OPENGENI_FS_ESCAPE__") || exitCode === 67) {
568
+ throw new ChannelAValidationError(`path resolves outside workspace: ${path}`);
569
+ }
570
+ if (stdout.includes("__OPENGENI_FS_NOT_FOUND__") || exitCode === 66) {
386
571
  throw new ChannelANotFoundError(`file not found: ${path}`);
387
572
  }
388
- const bytes = Buffer.from(stdout.replace(/\n/g, ""), "base64");
573
+ const prefix = "__OPENGENI_FS_READ_OK__\n";
574
+ const successIndex = stdout.indexOf(prefix);
575
+ if (successIndex < 0 || (exitCode !== null && exitCode !== 0)) {
576
+ throw new ChannelAUnavailableError(
577
+ "Workspace files are temporarily unavailable. Retry the file read.",
578
+ );
579
+ }
580
+ const bytes = Buffer.from(
581
+ stdout.slice(successIndex + prefix.length).replace(/\n/g, ""),
582
+ "base64",
583
+ );
389
584
  return this.shapeRead(path, bytes, req);
390
585
  }
391
586
 
@@ -413,8 +608,15 @@ export class SandboxChannelAService {
413
608
  ? Buffer.from(req.content, "base64")
414
609
  : Buffer.from(req.content, "utf8");
415
610
 
611
+ await this.assertConfinedMutationParent(path, {
612
+ allowMissingParents: req.createParents,
613
+ rejectFinalSymlink: true,
614
+ });
615
+
416
616
  if (!req.overwrite) {
417
- const { exitCode } = await this.run({ cmd: `test -e ${shellQuote(abs)}` });
617
+ const { exitCode } = await this.run({
618
+ cmd: `test -e ${shellQuote(abs)} || test -L ${shellQuote(abs)}`,
619
+ });
418
620
  if (exitCode === 0) {
419
621
  throw new ChannelAConflictError(`path exists and overwrite is false: ${path}`);
420
622
  }
@@ -422,6 +624,10 @@ export class SandboxChannelAService {
422
624
  if (req.createParents) {
423
625
  const dir = dirnameAbs(abs);
424
626
  if (dir) await this.run({ cmd: `mkdir -p ${shellQuote(dir)}` });
627
+ await this.assertConfinedMutationParent(path, {
628
+ allowMissingParents: false,
629
+ rejectFinalSymlink: true,
630
+ });
425
631
  }
426
632
  // base64-decode heredoc — raw + binary capable, single round-trip, last-
427
633
  // writer-wins (the I4 default; no read-modify-write race because we write
@@ -473,6 +679,10 @@ export class SandboxChannelAService {
473
679
  async fsDelete(req: FsDeleteRequest): Promise<FsDeleteResponse> {
474
680
  const path = assertSafeRelPath(req.path);
475
681
  const abs = this.joinRoot(path);
682
+ await this.assertConfinedMutationParent(path, {
683
+ allowMissingParents: false,
684
+ rejectFinalSymlink: false,
685
+ });
476
686
  const flag = req.recursive ? "-rf" : "-f";
477
687
  const { exitCode, stderr } = await this.run({ cmd: `rm ${flag} ${shellQuote(abs)}` });
478
688
  if (exitCode !== null && exitCode !== 0) {
@@ -491,8 +701,19 @@ export class SandboxChannelAService {
491
701
  const abs = this.joinRoot(path);
492
702
  const newAbs = this.joinRoot(newPath);
493
703
 
704
+ await this.assertConfinedMutationParent(path, {
705
+ allowMissingParents: false,
706
+ rejectFinalSymlink: false,
707
+ });
708
+ await this.assertConfinedMutationParent(newPath, {
709
+ allowMissingParents: req.createParents,
710
+ rejectFinalSymlink: true,
711
+ });
712
+
494
713
  if (!req.overwrite) {
495
- const { exitCode } = await this.run({ cmd: `test -e ${shellQuote(newAbs)}` });
714
+ const { exitCode } = await this.run({
715
+ cmd: `test -e ${shellQuote(newAbs)} || test -L ${shellQuote(newAbs)}`,
716
+ });
496
717
  if (exitCode === 0) {
497
718
  throw new ChannelAConflictError(`destination exists and overwrite is false: ${newPath}`);
498
719
  }
@@ -500,6 +721,10 @@ export class SandboxChannelAService {
500
721
  if (req.createParents) {
501
722
  const dir = dirnameAbs(newAbs);
502
723
  if (dir) await this.run({ cmd: `mkdir -p ${shellQuote(dir)}` });
724
+ await this.assertConfinedMutationParent(newPath, {
725
+ allowMissingParents: false,
726
+ rejectFinalSymlink: true,
727
+ });
503
728
  }
504
729
  // -f only when overwrite — otherwise a clobber would silently succeed past
505
730
  // the guard above on a race. A missing source surfaces a non-zero exit -> 400.
@@ -526,6 +751,10 @@ export class SandboxChannelAService {
526
751
  async fsMkdir(req: FsMkdirRequest): Promise<FsMkdirResponse> {
527
752
  const path = assertSafeRelPath(req.path);
528
753
  const abs = this.joinRoot(path);
754
+ await this.assertConfinedMutationParent(path, {
755
+ allowMissingParents: req.recursive,
756
+ rejectFinalSymlink: true,
757
+ });
529
758
  // A plain mkdir on an existing path returns non-zero -> 400, matching the
530
759
  // write-on-existing semantics; -p makes the create idempotent + builds parents.
531
760
  const flag = req.recursive ? "-p " : "";
@@ -533,6 +762,7 @@ export class SandboxChannelAService {
533
762
  if (exitCode !== null && exitCode !== 0) {
534
763
  throw new ChannelAValidationError(`failed to mkdir ${path}: ${stderr || `exit ${exitCode}`}`);
535
764
  }
765
+ await this.assertConfinedExistingDirectory(path);
536
766
  this.revision++;
537
767
  await this.emitFsChanged([{ path, kind: "created", isDir: true, sizeBytes: null }], "write");
538
768
  return { path, revision: this.revision };
@@ -541,10 +771,9 @@ export class SandboxChannelAService {
541
771
  // ════════════════════════════ Git (A2, read-only) ═════════════════════════
542
772
 
543
773
  async gitStatus(req: GitStatusRequest): Promise<GitStatusResponse> {
544
- const repo = this.repoWorkdir(req.path);
545
- const inside = await this.run({
774
+ const repo = assertSafeRelPathOrRoot(req.path);
775
+ const inside = await this.runInConfinedDirectory(repo, {
546
776
  cmd: "git rev-parse --is-inside-work-tree 2>/dev/null",
547
- workdir: repo,
548
777
  });
549
778
  if (inside.stdout.trim() !== "true") {
550
779
  return {
@@ -558,15 +787,14 @@ export class SandboxChannelAService {
558
787
  revision: this.revision,
559
788
  };
560
789
  }
561
- const { stdout } = await this.run({
790
+ const { stdout } = await this.runInConfinedDirectory(repo, {
562
791
  cmd: "git status --porcelain=v2 --branch -z",
563
- workdir: repo,
564
792
  });
565
793
  return { ...parsePorcelainV2(stdout), revision: this.revision };
566
794
  }
567
795
 
568
796
  async gitDiff(req: GitDiffRequest): Promise<GitDiffResponse> {
569
- const repo = this.repoWorkdir(req.path);
797
+ const repo = assertSafeRelPathOrRoot(req.path);
570
798
  const ctx = req.contextLines;
571
799
  // Selector precedence: refs > staged > worktree.
572
800
  let range = "";
@@ -577,9 +805,8 @@ export class SandboxChannelAService {
577
805
 
578
806
  // Pass 1: numstat (stats + binary detection). -z gives NUL-separated fields;
579
807
  // a rename emits old\0new for that record's path fields.
580
- const numstat = await this.run({
808
+ const numstat = await this.runInConfinedDirectory(repo, {
581
809
  cmd: `git -c core.quotePath=false diff --no-color -z --numstat ${range}${pathspec}`.trim(),
582
- workdir: repo,
583
810
  });
584
811
  const stats = parseNumstatZ(numstat.stdout);
585
812
 
@@ -602,9 +829,8 @@ export class SandboxChannelAService {
602
829
  continue;
603
830
  }
604
831
  // Pass 2: the per-file unified patch -> hunks.
605
- const patch = await this.run({
832
+ const patch = await this.runInConfinedDirectory(repo, {
606
833
  cmd: `git -c core.quotePath=false diff --no-color -U${ctx} ${range} -- ${shellQuote(target)}`.trim(),
607
- workdir: repo,
608
834
  });
609
835
  const oversized = Buffer.byteLength(patch.stdout, "utf8") > req.maxBytesPerFile;
610
836
  const parsed = oversized
@@ -622,16 +848,100 @@ export class SandboxChannelAService {
622
848
  truncated: oversized,
623
849
  });
624
850
  }
851
+
852
+ if (req.includeUntracked) {
853
+ // Native `git diff` deliberately omits untracked files, but a workspace
854
+ // review cannot: an untracked-only turn is still a real change surface.
855
+ // Ask Git for the exact NUL-delimited set, then synthesize an added-file
856
+ // hunk from a bounded read. The explicit request flag keeps commit/staged
857
+ // consumers on native Git semantics.
858
+ const untracked = await this.runInConfinedDirectory(repo, {
859
+ cmd: `git -c core.quotePath=false ls-files --others --exclude-standard -z${pathspec}`,
860
+ });
861
+ for (const target of untracked.stdout.split(NUL).filter(Boolean)) {
862
+ const fileArg = target.startsWith("./") ? target : `./${target}`;
863
+ const read = await this.runInConfinedDirectory(repo, {
864
+ cmd: [
865
+ `file=${shellQuote(fileArg)}`,
866
+ 'if [ -L "$file" ]; then printf "L\\n"; readlink -z -- "$file" | base64 | tr -d "\\n"; exit; fi',
867
+ 'size=$(wc -c < "$file") || exit 66',
868
+ 'line_count=$(wc -l < "$file") || exit 66',
869
+ 'if [ "$size" -gt 0 ]; then last_byte=$(tail -c 1 "$file" | od -An -tu1 | tr -d " \\n"); if [ "$last_byte" != "10" ]; then line_count=$((line_count + 1)); fi; fi',
870
+ 'printf "F\\t%s\\t%s\\n" "$size" "$line_count"',
871
+ `head -c ${req.maxBytesPerFile + 1} "$file" | base64 | tr -d "\\n"`,
872
+ ].join("; "),
873
+ });
874
+ if (read.exitCode !== null && read.exitCode !== 0) continue;
875
+
876
+ const lineBreak = read.stdout.indexOf("\n");
877
+ if (lineBreak < 0) continue;
878
+ const header = read.stdout.slice(0, lineBreak);
879
+ const encoded = read.stdout.slice(lineBreak + 1).replace(/\s/g, "");
880
+ let sampled = Buffer.from(encoded, "base64");
881
+ let sizeBytes: number;
882
+ let lineCount: number;
883
+ if (header === "L") {
884
+ // GNU readlink -z preserves every byte (including trailing newlines)
885
+ // and adds one NUL framing byte. The diff shows the link destination
886
+ // itself, matching Git's symlink-blob semantics, never target content.
887
+ if (sampled.at(-1) !== 0) continue;
888
+ sampled = sampled.subarray(0, -1);
889
+ sizeBytes = sampled.length;
890
+ lineCount = addedLines(sampled).length;
891
+ } else {
892
+ const [kind, sizeRaw, lineCountRaw] = header.split("\t");
893
+ if (kind !== "F") continue;
894
+ const parsedSize = safeInt(sizeRaw);
895
+ const parsedLineCount = safeInt(lineCountRaw);
896
+ if (
897
+ parsedSize === null ||
898
+ parsedSize < 0 ||
899
+ parsedLineCount === null ||
900
+ parsedLineCount < 0
901
+ ) {
902
+ continue;
903
+ }
904
+ sizeBytes = parsedSize;
905
+ lineCount = parsedLineCount;
906
+ }
907
+ const truncated = sizeBytes > req.maxBytesPerFile || sampled.length > req.maxBytesPerFile;
908
+ const bytes = truncated ? sampled.subarray(0, req.maxBytesPerFile) : sampled;
909
+ const isBinary = sniffBinary(bytes);
910
+ const hunkLines = !isBinary && !truncated ? addedLines(bytes) : [];
911
+ files.push({
912
+ path: target,
913
+ oldPath: null,
914
+ status: "untracked",
915
+ isBinary,
916
+ isImage: isImagePath(target),
917
+ additions: isBinary ? 0 : lineCount,
918
+ deletions: 0,
919
+ hunks:
920
+ hunkLines.length > 0
921
+ ? [
922
+ {
923
+ oldStart: 0,
924
+ oldLines: 0,
925
+ newStart: 1,
926
+ newLines: hunkLines.length,
927
+ header: `@@ -0,0 +1,${hunkLines.length} @@`,
928
+ lines: hunkLines,
929
+ },
930
+ ]
931
+ : [],
932
+ truncated,
933
+ });
934
+ }
935
+ }
625
936
  return { files, revision: this.revision };
626
937
  }
627
938
 
628
939
  async gitLog(req: GitLogRequest): Promise<GitLogResponse> {
629
- const repo = this.repoWorkdir(req.path);
940
+ const repo = assertSafeRelPathOrRoot(req.path);
630
941
  const fmt = `%H${US}%h${US}%P${US}%an${US}%ae${US}%at${US}%cn${US}%ce${US}%ct${US}%s${US}%b${RS}`;
631
942
  const pathspec = req.pathspec.length ? ` -- ${req.pathspec.map(shellQuote).join(" ")}` : "";
632
- const { stdout, exitCode } = await this.run({
943
+ const { stdout, exitCode } = await this.runInConfinedDirectory(repo, {
633
944
  cmd: `git log --format=${shellQuote(fmt)} -n${req.maxCount + 1} --skip=${req.skip} ${shellQuote(req.ref)}${pathspec}`,
634
- workdir: repo,
635
945
  });
636
946
  if (exitCode !== null && exitCode !== 0) {
637
947
  return { commits: [], hasMore: false };
@@ -659,12 +969,11 @@ export class SandboxChannelAService {
659
969
  }
660
970
 
661
971
  async gitShow(req: GitShowRequest): Promise<GitShowResponse> {
662
- const repo = this.repoWorkdir(req.path);
972
+ const repo = assertSafeRelPathOrRoot(req.path);
663
973
  if (req.filePath) {
664
974
  // Raw blob mode: ref:filePath -> bytes.
665
- const { stdout, exitCode } = await this.run({
975
+ const { stdout, exitCode } = await this.runInConfinedDirectory(repo, {
666
976
  cmd: `git cat-file blob ${shellQuote(`${req.ref}:${req.filePath}`)} 2>/dev/null | base64`,
667
- workdir: repo,
668
977
  });
669
978
  if (exitCode !== null && exitCode !== 0 && stdout.trim() === "") {
670
979
  throw new ChannelANotFoundError(`blob not found: ${req.ref}:${req.filePath}`);
@@ -698,6 +1007,7 @@ export class SandboxChannelAService {
698
1007
  const diff = await this.gitDiff({
699
1008
  path: req.path,
700
1009
  staged: false,
1010
+ includeUntracked: false,
701
1011
  fromRef: `${req.ref}^`,
702
1012
  toRef: req.ref,
703
1013
  pathspec: [],
@@ -710,9 +1020,10 @@ export class SandboxChannelAService {
710
1020
  /**
711
1021
  * Detect repo roots within every supported workspace layout.
712
1022
  *
713
- * The platform seeds GitHub repositories at
714
- * `repos/<owner>/<repo>`, which puts the `.git` marker at depth four from the
715
- * workspace root. Do not reintroduce a fixed maxdepth here. Traversal is
1023
+ * The platform normally seeds repositories at
1024
+ * `repos/<encoded-host>/<owner>/<repo>`, while explicit mount paths and
1025
+ * connected-machine layouts can be deeper. Do not reintroduce a fixed
1026
+ * maxdepth here. Traversal is
716
1027
  * bounded instead by pruning known machine/build residue, a wall-clock
717
1028
  * timeout, and a result limit. `.git` may be either a directory (ordinary
718
1029
  * clone/submodule) or a file (linked worktree).
@@ -769,7 +1080,7 @@ export class SandboxChannelAService {
769
1080
  'exit "$status"',
770
1081
  ].join("\n");
771
1082
  const { stdout } = await this.run({
772
- cmd: `bash -lc ${shellQuote(command)}`,
1083
+ cmd: internalBashCommand(command),
773
1084
  workdir: this.workspaceRoot || undefined,
774
1085
  yieldTimeMs: 20_000,
775
1086
  // At most 256 Unix paths (PATH_MAX each) plus the status trailer. This
@@ -950,8 +1261,81 @@ export class SandboxChannelAService {
950
1261
  return rel === "" ? this.workspaceRoot : `${this.workspaceRoot}/${rel}`;
951
1262
  }
952
1263
 
1264
+ /** Validate and enter an FS/Git directory in the same remote command that
1265
+ * performs the operation. This preserves confinement without adding a full
1266
+ * provider round-trip to every tree expansion or Git query. */
1267
+ private async runInConfinedDirectory(
1268
+ rel: string,
1269
+ args: ChannelAExecArgs,
1270
+ ): Promise<{
1271
+ stdout: string;
1272
+ stderr: string;
1273
+ exitCode: number | null;
1274
+ sessionId?: number;
1275
+ wallTimeSeconds: number;
1276
+ }> {
1277
+ const safe = assertSafeRelPathOrRoot(rel);
1278
+ const root = this.workspaceRoot || ".";
1279
+ const abs = this.joinRoot(safe);
1280
+ const rejectLink = safe
1281
+ ? `test ! -L ${shellQuote(abs)} || { printf '__OPENGENI_FS_SYMLINK__'; exit 68; }`
1282
+ : ":";
1283
+ const script = [
1284
+ `root=$(realpath -e -- ${shellQuote(root)}) || { printf '__OPENGENI_FS_NOT_FOUND__'; exit 66; }`,
1285
+ rejectLink,
1286
+ `target=$(realpath -e -- ${shellQuote(abs)}) || { printf '__OPENGENI_FS_NOT_FOUND__'; exit 66; }`,
1287
+ `case "$target" in "$root"|"$root"/*) ;; *) printf '__OPENGENI_FS_ESCAPE__'; exit 67 ;; esac`,
1288
+ `test -d "$target" || { printf '__OPENGENI_FS_NOT_FOUND__'; exit 66; }`,
1289
+ `cd -P -- "$target"`,
1290
+ `printf '__OPENGENI_FS_CONFINED_OK__\\n'`,
1291
+ args.cmd,
1292
+ ].join("; ");
1293
+ const successPrefix = "__OPENGENI_FS_CONFINED_OK__\n";
1294
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1295
+ let result: Awaited<ReturnType<SandboxChannelAService["run"]>>;
1296
+ try {
1297
+ result = await this.run({
1298
+ ...args,
1299
+ cmd: internalBashCommand(script),
1300
+ workdir: undefined,
1301
+ });
1302
+ } catch (error) {
1303
+ if (error instanceof ChannelAUnsupportedError) throw error;
1304
+ if (attempt === 0) continue;
1305
+ throw new ChannelAUnavailableError(
1306
+ "Workspace files are temporarily unavailable. Retry the operation.",
1307
+ );
1308
+ }
1309
+ if (result.sessionId !== undefined) {
1310
+ throw new ChannelAUnavailableError(
1311
+ "Workspace files are temporarily unavailable. Retry after the current file operation finishes.",
1312
+ );
1313
+ }
1314
+ const successIndex = result.stdout.indexOf(successPrefix);
1315
+ if (successIndex >= 0) {
1316
+ return { ...result, stdout: result.stdout.slice(successIndex + successPrefix.length) };
1317
+ }
1318
+ if (result.stdout.includes("__OPENGENI_FS_ESCAPE__") || result.exitCode === 67) {
1319
+ throw new ChannelAValidationError(`path resolves outside workspace: ${safe || "."}`);
1320
+ }
1321
+ if (result.stdout.includes("__OPENGENI_FS_SYMLINK__") || result.exitCode === 68) {
1322
+ throw new ChannelAValidationError(`directory path must not be a symbolic link: ${safe}`);
1323
+ }
1324
+ if (result.stdout.includes("__OPENGENI_FS_NOT_FOUND__") || result.exitCode === 66) {
1325
+ throw new ChannelANotFoundError(`directory path not found: ${safe || "."}`);
1326
+ }
1327
+ // Every operation using this helper is read-only. A completed result with
1328
+ // no trusted marker can be retried once, but is never downgraded into a
1329
+ // user-input error.
1330
+ if (attempt === 0) continue;
1331
+ }
1332
+ throw new ChannelAUnavailableError(
1333
+ "Workspace files are temporarily unavailable. Retry the operation.",
1334
+ );
1335
+ }
1336
+
953
1337
  private repoWorkdir(rel: string): string | undefined {
954
- const safe = normalizeRelPath(rel);
1338
+ const safe = assertSafeRelPathOrRoot(rel);
955
1339
  const joined = this.joinRoot(safe);
956
1340
  return joined === "." ? this.workspaceRoot || undefined : joined;
957
1341
  }
@@ -1024,51 +1408,49 @@ export function stripExecBanner(raw: string): string {
1024
1408
  // Detect the provider's native-readFile workspace-escape rejection — a symlink
1025
1409
  // whose target resolves outside the sandbox root. Modal phrases it "Sandbox path
1026
1410
  // failed remote validation: workspace escape: <target>"; we match loosely so a
1027
- // wording tweak still classifies it. Used to fall the read back onto the exec
1028
- // path (which follows the link and isn't path-validated) instead of 404-ing.
1029
- export function isWorkspaceEscapeError(error: unknown): boolean {
1411
+ // wording tweak still classifies it. The caller must preserve the rejection;
1412
+ // it must never fall back to a path that follows the escaping link.
1413
+ function isWorkspaceEscapeError(error: unknown): boolean {
1030
1414
  const msg = error instanceof Error ? error.message : String(error ?? "");
1031
1415
  const lower = msg.toLowerCase();
1032
1416
  return (
1033
1417
  lower.includes("workspace escape") ||
1418
+ lower.includes("escapes the workspace") ||
1419
+ lower.includes("outside workspace") ||
1034
1420
  (lower.includes("remote validation") && lower.includes("escape"))
1035
1421
  );
1036
1422
  }
1037
1423
 
1038
- // Detect the Modal "the exec-session you're writing to no longer exists" banner.
1039
- // writeStdin reports a vanished session as a non-throwing string of the shape
1040
- // `write_stdin failed: session not found: <N>` (it does NOT raise). We treat that
1041
- // as a lost PTY (the box rolled over / was re-created since the open) so the
1042
- // caller surfaces a clean reconnect instead of writing the raw failure into
1043
- // xterm. Matched loosely (`session not found`) with the id when present so a
1044
- // future wording tweak still classifies it; the command's own output cannot spoof
1045
- // it because the SDK emits this as the whole writeStdin return, not user output.
1046
- export function isExecSessionLostBanner(out: string, execSessionId: number): boolean {
1047
- if (!out) return false;
1048
- const lower = out.toLowerCase();
1049
- if (!lower.includes("session not found")) return false;
1050
- // When the id is present require it to match ours; when absent, the generic
1051
- // "session not found" still classifies (it is never legitimate stdout here).
1424
+ /** Classify only provider errors that carry an explicit path-miss fact. Generic
1425
+ * 404s and "not found" text are unsafe here because they can describe the box or
1426
+ * session disappearing rather than the requested file. */
1427
+ function isDefinitePathNotFoundError(error: unknown): boolean {
1428
+ if (!error || typeof error !== "object") return false;
1429
+ const candidate = error as { code?: unknown; errno?: unknown; osNotFound?: unknown };
1052
1430
  return (
1053
- lower.includes(`session not found: ${execSessionId}`) || !/session not found:\s*\d+/.test(lower)
1431
+ candidate.osNotFound === true ||
1432
+ candidate.code === "ENOENT" ||
1433
+ candidate.errno === "ENOENT" ||
1434
+ candidate.errno === -2
1054
1435
  );
1055
1436
  }
1056
1437
 
1057
- // Recover the numeric exec-session id the SDK embeds in a formatExecResponse
1058
- // banner for a STILL-RUNNING process (`Process running with session ID <N>`).
1059
- // A finished command emits `Process exited with code <N>` instead (no session
1060
- // id) — that yields null. Only the banner region (before the `Output:` marker)
1061
- // is scanned so a session-id-looking line in the command's own output can't
1062
- // spoof it. This is what makes the interactive PTY work on backends whose only
1063
- // exec surface is execCommand (Modal): without it ptyOpen reports execSessionId
1064
- // = null and every pty/write 409s ("interactive terminal unsupported").
1065
- export function parseExecBannerSessionId(raw: string): number | null {
1066
- const outputIdx = raw.indexOf("\nOutput:\n");
1067
- const banner = outputIdx >= 0 ? raw.slice(0, outputIdx) : raw.startsWith("Output:\n") ? "" : raw;
1068
- const match = banner.match(/Process running with session ID (\d+)/);
1069
- if (!match) return null;
1070
- const n = Number.parseInt(match[1]!, 10);
1071
- return Number.isFinite(n) ? n : null;
1438
+ // Detect the Modal "the exec-session you're writing to no longer exists" banner.
1439
+ // writeStdin reports a vanished session as a non-throwing string of the shape
1440
+ // `write_stdin failed: session not found: <N>` (it does NOT raise). This is a
1441
+ // hard cancellation-fence fact, so classify only the complete known banner (or
1442
+ // its bare provider fact) carrying the exact tracked numeric id. ID-less,
1443
+ // malformed, mismatched, or embellished/ambiguous text must remain fail-closed.
1444
+ export function isExecSessionLostBanner(out: string, execSessionId: number): boolean {
1445
+ if (!out || !Number.isSafeInteger(execSessionId) || execSessionId < 0) return false;
1446
+ const match = out.match(/^(?:write_stdin failed: )?session not found: (\d+)$/);
1447
+ // JavaScript's `$` also matches immediately before one final line terminator.
1448
+ // Requiring the regex match to consume the entire string keeps even that
1449
+ // otherwise-special case fail-closed.
1450
+ if (!match || match[0] !== out) return false;
1451
+ // String equality is intentional: parseInt would normalize malformed facts
1452
+ // such as `01` and can round an out-of-range integer into another identity.
1453
+ return match[1] === String(execSessionId);
1072
1454
  }
1073
1455
 
1074
1456
  function sniffBinary(bytes: Buffer): boolean {
@@ -1077,19 +1459,37 @@ function sniffBinary(bytes: Buffer): boolean {
1077
1459
  return false;
1078
1460
  }
1079
1461
 
1462
+ function addedLines(bytes: Buffer): GitDiffHunk["lines"] {
1463
+ if (bytes.length === 0) return [];
1464
+ const lines = bytes.toString("utf8").split("\n");
1465
+ if (lines.at(-1) === "") lines.pop();
1466
+ return lines.map((text, index) => ({
1467
+ type: "add",
1468
+ oldNo: null,
1469
+ newNo: index + 1,
1470
+ text,
1471
+ }));
1472
+ }
1473
+
1080
1474
  function normalizeRelPath(p: string): string {
1081
1475
  const trimmed = (p ?? "").replace(/^\/+/, "").replace(/\/+$/, "");
1082
1476
  return trimmed;
1083
1477
  }
1084
1478
 
1479
+ function assertSafeRelPathOrRoot(p: string): string {
1480
+ const norm = normalizeRelPath(p);
1481
+ if (p.startsWith("/")) throw new ChannelAValidationError(`absolute paths are not allowed: ${p}`);
1482
+ if (norm.split("/").some((seg) => seg === "..")) {
1483
+ throw new ChannelAValidationError(`path traversal is not allowed: ${p}`);
1484
+ }
1485
+ return norm;
1486
+ }
1487
+
1085
1488
  // Reject path traversal / absolute paths (case 4); the box normalizes against
1086
1489
  // the workspace root, so a leading slash or `..` is a 400.
1087
1490
  export function assertSafeRelPath(p: string): string {
1088
- const norm = normalizeRelPath(p);
1491
+ const norm = assertSafeRelPathOrRoot(p);
1089
1492
  if (norm === "") throw new ChannelAValidationError("path is required");
1090
- if (p.startsWith("/")) throw new ChannelAValidationError(`absolute paths are not allowed: ${p}`);
1091
- if (norm.split("/").some((seg) => seg === ".."))
1092
- throw new ChannelAValidationError(`path traversal is not allowed: ${p}`);
1093
1493
  return norm;
1094
1494
  }
1095
1495
 
@@ -1097,6 +1497,19 @@ function shellQuote(s: string): string {
1097
1497
  return `'${s.replace(/'/g, `'\\''`)}'`;
1098
1498
  }
1099
1499
 
1500
+ /** Run control-plane-generated Bash without user/provider startup files. The
1501
+ * marker protocol is private control data; profile output must not corrupt it. */
1502
+ function internalBashCommand(script: string): string {
1503
+ return `env -u BASH_ENV bash --noprofile --norc -c ${shellQuote(script)}`;
1504
+ }
1505
+
1506
+ function assertSafePruneDirectoryName(name: string): string {
1507
+ if (name.length > 255 || !/^[A-Za-z0-9._@+-]+$/.test(name) || name === "." || name === "..") {
1508
+ throw new ChannelAValidationError(`invalid directory prune name: ${JSON.stringify(name)}`);
1509
+ }
1510
+ return name;
1511
+ }
1512
+
1100
1513
  function basename(p: string): string {
1101
1514
  const parts = p.split("/").filter(Boolean);
1102
1515
  return parts.length ? parts[parts.length - 1]! : "";