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