@opengeni/runtime 4.0.1 → 4.0.3-canary.36199476632001

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/dist/{agent-RJQLKFIW.js → agent-ME6HT4DH.js} +2 -2
  2. package/dist/assets/codemode-client.json +1 -1
  3. package/dist/browser-action-json-schema.d.ts +4 -0
  4. package/dist/{chunk-J5AR5XAJ.js → chunk-FAFZCSN7.js} +695 -210
  5. package/dist/chunk-FAFZCSN7.js.map +1 -0
  6. package/dist/{chunk-ZG2YH25H.js → chunk-JAYHA6BF.js} +2 -2
  7. package/dist/{chunk-UNICCRW5.js → chunk-LZIXCZUV.js} +6 -3
  8. package/dist/{chunk-UNICCRW5.js.map → chunk-LZIXCZUV.js.map} +1 -1
  9. package/dist/{chunk-J6J3VYFW.js → chunk-P4V7JGQ2.js} +1037 -105
  10. package/dist/chunk-P4V7JGQ2.js.map +1 -0
  11. package/dist/{chunk-SPT3PLMX.js → chunk-PRHMSM6C.js} +2 -2
  12. package/dist/codex-tool-search.d.ts +12 -0
  13. package/dist/context-compaction.d.ts +4 -2
  14. package/dist/index.d.ts +27 -8
  15. package/dist/index.js +23 -7
  16. package/dist/interaction-tools.d.ts +1 -1
  17. package/dist/{lazy-manifest-OEYEHNVY.js → lazy-manifest-GCGUFIWP.js} +3 -3
  18. package/dist/{localSources-TWHTEKGO.js → localSources-FDH65AFI.js} +4 -4
  19. package/dist/metrics.d.ts +17 -0
  20. package/dist/model-provider-routing.d.ts +14 -0
  21. package/dist/operational-instructions.d.ts +1 -1
  22. package/dist/provider-quota.d.ts +76 -0
  23. package/dist/sandbox/browser-control-client.d.ts +28 -1
  24. package/dist/sandbox/channel-a.d.ts +43 -0
  25. package/dist/sandbox/index.d.ts +3 -3
  26. package/dist/sandbox/index.js +10 -6
  27. package/dist/sandbox/providers/modal-command-router-wire.d.ts +8 -0
  28. package/dist/sandbox/providers/modal.d.ts +5 -6
  29. package/dist/workspace-tool-gateway.js +5 -5
  30. package/package.json +11 -11
  31. package/src/browser-action-json-schema.ts +10 -0
  32. package/src/bundled_artifact_skills/opengeni-documents/SKILL.md +4 -1
  33. package/src/bundled_artifact_skills/opengeni-documents/references/api.md +20 -0
  34. package/src/bundled_default_skills/opengeni-client/SKILL.md +4 -1
  35. package/src/bundled_default_skills/opengeni-client/references/discovery-and-autonomy.md +31 -0
  36. package/src/bundled_default_skills/opengeni-client/references/runtime-profile-and-verification.md +32 -0
  37. package/src/codex-tool-search.ts +54 -6
  38. package/src/context-compaction.ts +19 -3
  39. package/src/index.ts +285 -56
  40. package/src/interaction-tools.ts +656 -48
  41. package/src/lazy-tool-transport.ts +4 -0
  42. package/src/metrics.ts +26 -0
  43. package/src/model-context-inspector.ts +1 -0
  44. package/src/model-provider-client.ts +37 -16
  45. package/src/model-provider-routing.ts +32 -6
  46. package/src/operational-instructions.ts +1 -1
  47. package/src/provider-quota.ts +412 -0
  48. package/src/sandbox/browser-control-client.ts +241 -26
  49. package/src/sandbox/channel-a.ts +370 -0
  50. package/src/sandbox/index.ts +6 -1
  51. package/src/sandbox/providers/modal-command-control.ts +7 -1
  52. package/src/sandbox/providers/modal-command-router-wire.ts +47 -6
  53. package/src/sandbox/providers/modal.ts +16 -24
  54. package/src/sandbox/routing/routing-session.ts +29 -6
  55. package/dist/chunk-J5AR5XAJ.js.map +0 -1
  56. package/dist/chunk-J6J3VYFW.js.map +0 -1
  57. /package/dist/{agent-RJQLKFIW.js.map → agent-ME6HT4DH.js.map} +0 -0
  58. /package/dist/{chunk-ZG2YH25H.js.map → chunk-JAYHA6BF.js.map} +0 -0
  59. /package/dist/{chunk-SPT3PLMX.js.map → chunk-PRHMSM6C.js.map} +0 -0
  60. /package/dist/{lazy-manifest-OEYEHNVY.js.map → lazy-manifest-GCGUFIWP.js.map} +0 -0
  61. /package/dist/{localSources-TWHTEKGO.js.map → localSources-FDH65AFI.js.map} +0 -0
@@ -20,6 +20,7 @@
20
20
  // apply-patch-only path which cannot do binary, C4), falling back to
21
21
  // `createEditor` for text when `exec` is absent.
22
22
 
23
+ import { constants as zlibConstants, createGunzip } from "node:zlib";
23
24
  import type {
24
25
  FileSystemRouteIdentity,
25
26
  FsChangedPayload,
@@ -331,6 +332,140 @@ export type RepositoryDiscoveryResult = {
331
332
  };
332
333
 
333
334
  const REPOSITORY_DISCOVERY_TRUNCATED_SENTINEL = "__OPENGENI_REPOSITORY_DISCOVERY_TRUNCATED__";
335
+
336
+ /** Raw ripgrep stdout kept on the box before encoding. */
337
+ export const CODE_SEARCH_RG_MAX_BYTES = 64 * 1024 * 1024;
338
+ // Providers retain about 1 MiB per output stream. Like Git capture, compressed
339
+ // ripgrep output travels as 512 KiB chunks (~683 KiB base64) with room to spare.
340
+ const CODE_SEARCH_RG_CHUNK_BYTES = 512 * 1024;
341
+ const CODE_SEARCH_RG_FRAME_CHARS = 768 * 1024;
342
+ /** Compressed bytes fetched per call at most; the rest is reported as a cut. */
343
+ const CODE_SEARCH_RG_MAX_TRANSFER_BYTES = 8 * 1024 * 1024;
344
+ const CODE_SEARCH_RG_TOKEN = /^opengeni-code-search\.[A-Za-z0-9]+$/;
345
+ const CODE_SEARCH_RG_BEGIN = "__OPENGENI_CODE_SEARCH_RG_BEGIN__";
346
+ const CODE_SEARCH_RG_END = "__OPENGENI_CODE_SEARCH_RG_END__";
347
+ // Search: status:timedOut:compressedSize:token:rawSize:stored. A box without
348
+ // ripgrep sends only `127:0`, and a chunk fetch sends `0:0:0:-` or `0:0:0:missing`.
349
+ const CODE_SEARCH_RG_TRAILER = new RegExp(
350
+ `${CODE_SEARCH_RG_END}(\\d+):([01])(?::(\\d+):([A-Za-z0-9._-]+)(?::(\\d+):([01]))?)?__`,
351
+ );
352
+ const CODE_SEARCH_RG_STORE_FAILED =
353
+ "code search could not store its output on this machine; its temporary directory may be full";
354
+ const CODE_SEARCH_KINDS_BEGIN = "__OPENGENI_CODE_SEARCH_KINDS_BEGIN__";
355
+ const CODE_SEARCH_KINDS_END = "__OPENGENI_CODE_SEARCH_KINDS_END__";
356
+
357
+ export type CodeSearchRipgrepOutcome = {
358
+ /** False when ripgrep is not installed on the box. */
359
+ available: boolean;
360
+ stdout: string;
361
+ /** rg exit status (0 matches, 1 none, 2 error); null after a timeout. */
362
+ exitCode: number | null;
363
+ /** Output hit the byte cap and was cut at a line boundary. */
364
+ truncated: boolean;
365
+ timedOut: boolean;
366
+ };
367
+
368
+ const CODE_SEARCH_RG_FLAGS: ReadonlySet<string> = new Set([
369
+ "--files",
370
+ "--null",
371
+ "--line-number",
372
+ "--with-filename",
373
+ "--no-heading",
374
+ "-i",
375
+ "-w",
376
+ "--no-require-git",
377
+ "--hidden",
378
+ ]);
379
+ const CODE_SEARCH_RG_VALUE_FLAGS: ReadonlyMap<string, (value: string) => boolean> = new Map([
380
+ ["--color", (value: string) => value === "never"],
381
+ ["-m", (value: string) => /^[1-9]\d{0,5}$/.test(value)],
382
+ ["--max-columns", (value: string) => /^[1-9]\d{0,6}$/.test(value)],
383
+ ["--max-filesize", (value: string) => /^[1-9]\d{0,9}$/.test(value)],
384
+ ["-g", (value: string) => value.length > 0 && value.length <= 512],
385
+ ["-e", (value: string) => value.length > 0 && value.length <= 16_384],
386
+ ]);
387
+
388
+ /**
389
+ * Accept only the ripgrep arguments `code_search` needs. Flags that run a
390
+ * program (`--pre`, `-z`) or read other files, and paths that leave the
391
+ * workspace, are rejected before a command is built. `--no-config` stops a
392
+ * `RIPGREP_CONFIG_PATH` on the box from adding flags.
393
+ */
394
+ export function validateCodeSearchRipgrepArgs(
395
+ args: readonly string[],
396
+ workspaceRoot = "",
397
+ ): string[] {
398
+ const out: string[] = [];
399
+ let index = 0;
400
+ for (; index < args.length; index++) {
401
+ const arg = args[index]!;
402
+ if (arg.includes(NUL)) throw new ChannelAValidationError("ripgrep argument contains NUL");
403
+ if (arg === "--") break;
404
+ if (CODE_SEARCH_RG_FLAGS.has(arg)) {
405
+ out.push(arg);
406
+ continue;
407
+ }
408
+ const valid = CODE_SEARCH_RG_VALUE_FLAGS.get(arg);
409
+ const value = args[index + 1];
410
+ if (!valid || value === undefined || value.includes(NUL) || !valid(value)) {
411
+ throw new ChannelAValidationError(`ripgrep argument is not allowed: ${arg}`);
412
+ }
413
+ out.push(arg, value);
414
+ index++;
415
+ }
416
+ const paths = args.slice(index + 1);
417
+ if (index >= args.length || paths.length === 0) {
418
+ throw new ChannelAValidationError("ripgrep arguments must end with -- and paths");
419
+ }
420
+ out.push("--");
421
+ for (const path of paths) {
422
+ if (path.includes(NUL) || path.startsWith("-") || path.startsWith("/")) {
423
+ throw new ChannelAValidationError(`ripgrep path is not allowed: ${path}`);
424
+ }
425
+ out.push(assertSafeRelPathOrRoot(path, workspaceRoot) || ".");
426
+ }
427
+ return ["--no-config", ...out];
428
+ }
429
+
430
+ /** The base64 body between the begin marker and the trailer at `trailerIndex`. */
431
+ function decodeCodeSearchChunk(stdout: string, trailerIndex: number): Buffer {
432
+ const begin = stdout.indexOf(CODE_SEARCH_RG_BEGIN);
433
+ if (begin < 0 || begin > trailerIndex) {
434
+ throw new ChannelAUnavailableError("code search output frame is missing");
435
+ }
436
+ const encoded = stdout
437
+ .slice(begin + CODE_SEARCH_RG_BEGIN.length, trailerIndex)
438
+ .replace(/\s+/g, "");
439
+ // A frame longer than one chunk can only come from a broken or hostile box.
440
+ if (encoded.length > Math.ceil(CODE_SEARCH_RG_CHUNK_BYTES / 3) * 4) {
441
+ throw new ChannelAUnavailableError("code search output frame is malformed");
442
+ }
443
+ return Buffer.from(encoded, "base64");
444
+ }
445
+
446
+ /**
447
+ * Inflate at most `limit` bytes of box-controlled gzip. A sync flush decodes a
448
+ * cut stream as a prefix, and reading stops once `limit` is reached, so a
449
+ * small hostile frame cannot inflate to hundreds of megabytes in the worker.
450
+ */
451
+ async function gunzipCodeSearchPrefix(input: Buffer, limit: number): Promise<Buffer> {
452
+ const gunzip = createGunzip({ finishFlush: zlibConstants.Z_SYNC_FLUSH, chunkSize: 64 * 1024 });
453
+ gunzip.end(input);
454
+ const chunks: Buffer[] = [];
455
+ let size = 0;
456
+ try {
457
+ for await (const chunk of gunzip as AsyncIterable<Buffer>) {
458
+ chunks.push(chunk);
459
+ size += chunk.length;
460
+ // Leaving the loop destroys the stream before it inflates further.
461
+ if (size >= limit) break;
462
+ }
463
+ } catch {
464
+ throw new ChannelAUnavailableError("code search output could not be decoded");
465
+ }
466
+ return Buffer.concat(chunks, size).subarray(0, limit);
467
+ }
468
+
334
469
  const REPOSITORY_DISCOVERY_STATUS_PREFIX = "__OPENGENI_REPOSITORY_DISCOVERY_STATUS__:";
335
470
 
336
471
  const NUL = String.fromCharCode(0); // \0 NUL — find/porcelain/numstat -z separator
@@ -2278,6 +2413,241 @@ export class SandboxChannelAService {
2278
2413
  return (await this.detectReposDetailed()).repos;
2279
2414
  }
2280
2415
 
2416
+ // ═════════════════════════ Code search (worker-only) ═══════════════════════
2417
+
2418
+ /**
2419
+ * Run ripgrep read-only in the workspace root for the Jev `code_search` tool.
2420
+ * Arguments are checked against a closed allowlist (no `--pre`, `-z` or other
2421
+ * flags that run programs), so the command is provably read-only and skips
2422
+ * durable mutation admission. stdout is capped at `maxBytes`, gzip+base64
2423
+ * framed so providers that drop newlines or retain limited output cannot
2424
+ * corrupt it, and bounded by a wall-clock watchdog (not GNU `timeout`, which
2425
+ * stock macOS lacks). Resolves `available: false` when `rg` is not installed,
2426
+ * and throws when the box could not store the output whole (a full
2427
+ * temporary directory) rather than returning a prefix as complete.
2428
+ */
2429
+ async codeSearchRipgrep(
2430
+ args: readonly string[],
2431
+ options: { timeoutMs: number; maxBytes: number; maxTransferBytes?: number },
2432
+ ): Promise<CodeSearchRipgrepOutcome> {
2433
+ const argv = validateCodeSearchRipgrepArgs(args, this.workspaceRoot);
2434
+ const maxBytes = Math.max(1, Math.min(CODE_SEARCH_RG_MAX_BYTES, Math.floor(options.maxBytes)));
2435
+ const maxTransferBytes = Math.max(
2436
+ CODE_SEARCH_RG_CHUNK_BYTES,
2437
+ Math.min(
2438
+ CODE_SEARCH_RG_MAX_TRANSFER_BYTES,
2439
+ Math.floor(options.maxTransferBytes ?? CODE_SEARCH_RG_MAX_TRANSFER_BYTES),
2440
+ ),
2441
+ );
2442
+ const seconds = Math.max(1, Math.min(120, Math.ceil(options.timeoutMs / 1_000)));
2443
+ const script = [
2444
+ // Remove output a previous call could not fetch (worker gone mid-transfer).
2445
+ // The trailing slash makes find enter a symlinked /tmp, as on macOS.
2446
+ `find "\${TMPDIR:-/tmp}/" -maxdepth 1 -name 'opengeni-code-search.*' -mmin +15 -exec rm -f {} + 2>/dev/null`,
2447
+ 'gz_file=$(mktemp "${TMPDIR:-/tmp}/opengeni-code-search.XXXXXX") || exit 70',
2448
+ 'status_file="${gz_file}.status"',
2449
+ 'timeout_file="${gz_file}.timed-out"',
2450
+ "keep_gz=",
2451
+ "search_pid=",
2452
+ "watchdog_pid=",
2453
+ 'cleanup() { if [ -n "$search_pid" ]; then kill -TERM -- "-$search_pid" 2>/dev/null || kill "$search_pid" 2>/dev/null || true; fi; if [ -n "$watchdog_pid" ]; then kill "$watchdog_pid" 2>/dev/null || true; fi; rm -f "$status_file" "$timeout_file"; [ -n "$keep_gz" ] || rm -f "$gz_file"; }',
2454
+ "abort() { trap - EXIT; keep_gz=; cleanup; exit 143; }",
2455
+ "trap cleanup EXIT",
2456
+ "trap abort HUP INT TERM",
2457
+ `if ! command -v rg >/dev/null 2>&1; then printf '${CODE_SEARCH_RG_END}127:0__'; exit 0; fi`,
2458
+ // Job control gives the pipeline its own process group, so the watchdog
2459
+ // and cleanup stop ripgrep itself rather than only the subshell. Output is
2460
+ // compressed once, straight into the kept file: providers retain only
2461
+ // about 1 MiB per output stream, and later chunks are fetched by name.
2462
+ // That TERM stops only ripgrep and head. The subshell and gzip ignore it
2463
+ // (bash 3.2 runs a trap before the pipeline ends), so a stopped search
2464
+ // still stores a whole member and records every stage's status.
2465
+ "set -m",
2466
+ `( trap '' TERM; ( trap - TERM; exec rg ${argv.map(shellQuote).join(" ")} ) 2>/dev/null | ( trap - TERM; exec head -c ${maxBytes + 1} ) | gzip -c > "$gz_file"; printf '%s %s %s' "\${PIPESTATUS[0]}" "\${PIPESTATUS[1]}" "\${PIPESTATUS[2]}" > "$status_file" ) </dev/null >/dev/null 2>&1 &`,
2467
+ "search_pid=$!",
2468
+ `(sleeper_pid=; stop_watchdog() { if [ -n "$sleeper_pid" ]; then kill "$sleeper_pid" 2>/dev/null || true; wait "$sleeper_pid" 2>/dev/null || true; fi; exit 0; }; trap stop_watchdog HUP INT TERM; sleep ${seconds} & sleeper_pid=$!; wait "$sleeper_pid"; sleeper_pid=; if kill -TERM -- "-$search_pid" 2>/dev/null; then : > "$timeout_file"; fi) </dev/null >/dev/null 2>&1 &`,
2469
+ "watchdog_pid=$!",
2470
+ 'wait "$search_pid" 2>/dev/null',
2471
+ "search_pid=",
2472
+ 'kill "$watchdog_pid" 2>/dev/null || true',
2473
+ 'wait "$watchdog_pid" 2>/dev/null || true',
2474
+ "watchdog_pid=",
2475
+ "set +m",
2476
+ "rg_status= head_status= gzip_status=",
2477
+ 'if [ -f "$status_file" ]; then read -r rg_status head_status gzip_status < "$status_file"; fi',
2478
+ '[ -n "$rg_status" ] || rg_status=125',
2479
+ "timed_out=0",
2480
+ 'if [ -f "$timeout_file" ]; then timed_out=1; fi',
2481
+ // A full temporary directory cuts the member without a word, so decode it
2482
+ // end to end: that yields the raw size and fails on a cut or corrupt member.
2483
+ "stored=0",
2484
+ 'if raw_size=$(set -o pipefail; gzip -dc < "$gz_file" 2>/dev/null | wc -c | tr -d \' \') && [ "$head_status" = 0 ] && [ "$gzip_status" = 0 ]; then stored=1; fi',
2485
+ '[ -n "$raw_size" ] || raw_size=0',
2486
+ "gz_size=$(wc -c < \"$gz_file\" | tr -d ' ')",
2487
+ '[ -n "$gz_size" ] || gz_size=0',
2488
+ "token=-",
2489
+ // The worker fails an unstored search that did not time out, so only
2490
+ // keep output it will fetch.
2491
+ `if [ "$gz_size" -gt ${CODE_SEARCH_RG_CHUNK_BYTES} ] && { [ "$stored" = 1 ] || [ "$timed_out" = 1 ]; }; then keep_gz=1; token=$(basename "$gz_file"); fi`,
2492
+ `printf '${CODE_SEARCH_RG_BEGIN}'`,
2493
+ `head -c ${CODE_SEARCH_RG_CHUNK_BYTES} "$gz_file" | base64 | tr -d '\\r\\n'`,
2494
+ `printf '${CODE_SEARCH_RG_END}%s:%s:%s:%s:%s:%s__' "$rg_status" "$timed_out" "$gz_size" "$token" "$raw_size" "$stored"`,
2495
+ ].join("\n");
2496
+ const { stdout } = await this.runReadOnly({
2497
+ cmd: internalBashCommand(script),
2498
+ workdir: this.providerWorkspaceRoot(),
2499
+ yieldTimeMs: (seconds + 15) * 1_000,
2500
+ maxOutputTokens: CODE_SEARCH_RG_FRAME_CHARS,
2501
+ });
2502
+ const trailer = CODE_SEARCH_RG_TRAILER.exec(stdout);
2503
+ if (!trailer) {
2504
+ throw new ChannelAUnavailableError("code search did not complete in this workspace");
2505
+ }
2506
+ const exitCode = Number.parseInt(trailer[1]!, 10);
2507
+ if (exitCode === 127) {
2508
+ return { available: false, stdout: "", exitCode: null, truncated: false, timedOut: false };
2509
+ }
2510
+ if (trailer[3] === undefined || trailer[5] === undefined) {
2511
+ throw new ChannelAUnavailableError("code search output trailer is malformed");
2512
+ }
2513
+ const timedOut = trailer[2] === "1";
2514
+ // A stopped search keeps the whole lines it stored. Otherwise a lost
2515
+ // status or a failed or corrupt store must not pass as complete output.
2516
+ if (!timedOut && exitCode === 125) {
2517
+ throw new ChannelAUnavailableError(
2518
+ "code search did not record its status on this machine; its temporary directory may be full",
2519
+ );
2520
+ }
2521
+ if (!timedOut && trailer[6] !== "1") {
2522
+ throw new ChannelAUnavailableError(CODE_SEARCH_RG_STORE_FAILED);
2523
+ }
2524
+ const first = decodeCodeSearchChunk(stdout, trailer.index);
2525
+ const reportedSize = Number.parseInt(trailer[3], 10);
2526
+ const rawSize = Number.parseInt(trailer[5], 10);
2527
+ const token = trailer[4] === "-" ? null : trailer[4]!;
2528
+ const parts = [first];
2529
+ let fetched = first.length;
2530
+ if (token !== null && reportedSize > fetched) {
2531
+ if (!CODE_SEARCH_RG_TOKEN.test(token)) {
2532
+ throw new ChannelAUnavailableError("code search output frame is malformed");
2533
+ }
2534
+ // A box that lies about its size never gets more than the transfer cap
2535
+ // fetched from it, nor more calls than that cap needs.
2536
+ const target = Math.min(reportedSize, maxTransferBytes);
2537
+ const maxCalls = Math.ceil(maxTransferBytes / CODE_SEARCH_RG_CHUNK_BYTES);
2538
+ for (let call = 0; call < maxCalls && fetched < target; call++) {
2539
+ const length = Math.min(CODE_SEARCH_RG_CHUNK_BYTES, target - fetched);
2540
+ const chunk = await this.codeSearchRipgrepChunk(
2541
+ token,
2542
+ fetched,
2543
+ length,
2544
+ fetched + length >= target,
2545
+ );
2546
+ if (chunk === null) break;
2547
+ parts.push(chunk);
2548
+ fetched += chunk.length;
2549
+ // A real box returns every byte asked for; a short chunk ends the
2550
+ // transfer and is reported as a cut.
2551
+ if (chunk.length < length) break;
2552
+ }
2553
+ }
2554
+ const transferCut = fetched < reportedSize;
2555
+ // The box already cut stdout at maxBytes + 1, so more decoded bytes than
2556
+ // that come from a broken or hostile box and are dropped as a cut.
2557
+ const compressed = Buffer.concat(parts);
2558
+ const raw = compressed.length
2559
+ ? await gunzipCodeSearchPrefix(compressed, maxBytes + 1)
2560
+ : Buffer.alloc(0);
2561
+ const rawCut = raw.length > maxBytes;
2562
+ // With nothing cut, the output must be exactly what the box verified.
2563
+ const sizeMismatch = !rawCut && !transferCut && !timedOut && raw.length !== rawSize;
2564
+ const truncated = rawCut || transferCut || sizeMismatch;
2565
+ let text = (rawCut ? raw.subarray(0, maxBytes) : raw).toString("utf8");
2566
+ if (truncated || timedOut) {
2567
+ // Drop the last partial line so every returned record is whole.
2568
+ const lastNewline = text.lastIndexOf("\n");
2569
+ text = lastNewline >= 0 ? text.slice(0, lastNewline + 1) : "";
2570
+ }
2571
+ return {
2572
+ available: true,
2573
+ stdout: text,
2574
+ exitCode: timedOut ? null : exitCode,
2575
+ truncated,
2576
+ timedOut,
2577
+ };
2578
+ }
2579
+
2580
+ /**
2581
+ * Fetch one chunk of compressed ripgrep output kept on the box by a previous
2582
+ * codeSearchRipgrep call. The last fetch deletes the file. Null when the
2583
+ * file is gone.
2584
+ */
2585
+ private async codeSearchRipgrepChunk(
2586
+ token: string,
2587
+ offset: number,
2588
+ length: number,
2589
+ last: boolean,
2590
+ ): Promise<Buffer | null> {
2591
+ const script = [
2592
+ 'f="${TMPDIR:-/tmp}/$1"',
2593
+ `if [ ! -f "$f" ]; then printf '${CODE_SEARCH_RG_END}0:0:0:missing__'; exit 0; fi`,
2594
+ `printf '${CODE_SEARCH_RG_BEGIN}'`,
2595
+ 'tail -c +"$2" "$f" | head -c "$3" | base64 | tr -d \'\\r\\n\'',
2596
+ `printf '${CODE_SEARCH_RG_END}0:0:0:-__'`,
2597
+ 'if [ "$4" = 1 ]; then rm -f "$f"; fi',
2598
+ ].join("\n");
2599
+ const { stdout } = await this.runReadOnly({
2600
+ cmd: `${internalBashCommand(script)} opengeni-code-search ${shellQuote(token)} ${offset + 1} ${length} ${last ? 1 : 0}`,
2601
+ workdir: this.providerWorkspaceRoot(),
2602
+ yieldTimeMs: 30_000,
2603
+ maxOutputTokens: CODE_SEARCH_RG_FRAME_CHARS,
2604
+ });
2605
+ const trailer = CODE_SEARCH_RG_TRAILER.exec(stdout);
2606
+ if (!trailer) {
2607
+ throw new ChannelAUnavailableError("code search did not complete in this workspace");
2608
+ }
2609
+ if (trailer[4] === "missing") return null;
2610
+ const chunk = decodeCodeSearchChunk(stdout, trailer.index);
2611
+ if (chunk.length > length) {
2612
+ throw new ChannelAUnavailableError("code search output frame is malformed");
2613
+ }
2614
+ return chunk;
2615
+ }
2616
+
2617
+ /** Classify workspace-relative paths for `code_search` path filters. */
2618
+ async codeSearchPathKinds(
2619
+ paths: readonly string[],
2620
+ ): Promise<Record<string, "file" | "directory" | "missing">> {
2621
+ const checked = paths.map((path) => {
2622
+ if (path.startsWith("-")) throw new ChannelAValidationError(`invalid path: ${path}`);
2623
+ return assertSafeRelPathOrRoot(path, this.workspaceRoot) || ".";
2624
+ });
2625
+ if (checked.length === 0) return {};
2626
+ const script = [
2627
+ `printf '${CODE_SEARCH_KINDS_BEGIN}'`,
2628
+ 'for p in "$@"; do if [ -d "$p" ]; then printf d; elif [ -e "$p" ]; then printf f; else printf m; fi; done',
2629
+ `printf '${CODE_SEARCH_KINDS_END}'`,
2630
+ ].join("\n");
2631
+ const { stdout } = await this.runReadOnly({
2632
+ cmd: `${internalBashCommand(script)} opengeni-code-search ${checked.map(shellQuote).join(" ")}`,
2633
+ workdir: this.providerWorkspaceRoot(),
2634
+ yieldTimeMs: 20_000,
2635
+ maxOutputTokens: 4_096,
2636
+ });
2637
+ const match = new RegExp(`${CODE_SEARCH_KINDS_BEGIN}([dfm]*)${CODE_SEARCH_KINDS_END}`).exec(
2638
+ stdout,
2639
+ );
2640
+ if (!match || match[1]!.length !== checked.length) {
2641
+ throw new ChannelAUnavailableError("code search path check did not complete");
2642
+ }
2643
+ const kinds: Record<string, "file" | "directory" | "missing"> = {};
2644
+ paths.forEach((path, index) => {
2645
+ const code = match[1]![index];
2646
+ kinds[path] = code === "d" ? "directory" : code === "f" ? "file" : "missing";
2647
+ });
2648
+ return kinds;
2649
+ }
2650
+
2281
2651
  // ════════════════════════ Terminal exec + PTY (A2) ════════════════════════
2282
2652
 
2283
2653
  /** Run a bounded command to physical completion and return buffered output.
@@ -175,7 +175,7 @@ export {
175
175
  ensureModalRegistryImage,
176
176
  deleteModalCheckpointSnapshot,
177
177
  inspectModalSandboxLifecycle,
178
- isModalTaskExecStartDnsResolutionError,
178
+ isModalTaskExecStartPreDispatchUnavailableError,
179
179
  modalSessionMatchesCheckpointProviderBinding,
180
180
  modalSandboxAttributionEnvironment,
181
181
  modalSandboxAttributionTags,
@@ -303,6 +303,8 @@ export {
303
303
  provisionBrowserControlClient,
304
304
  validateComputerControlFrameEvidence,
305
305
  type BrowserControlPlacementSession,
306
+ type BrowserControlScreenshotOptions,
307
+ type BrowserControlFrame,
306
308
  type BrowserStateDownloadGrant,
307
309
  type BrowserStateUploadGrant,
308
310
  type BrowserViewGrant,
@@ -429,6 +431,9 @@ export {
429
431
  parseNumstatZ,
430
432
  parseUnifiedPatch,
431
433
  REPOSITORY_DISCOVERY_LIMIT,
434
+ CODE_SEARCH_RG_MAX_BYTES,
435
+ validateCodeSearchRipgrepArgs,
436
+ type CodeSearchRipgrepOutcome,
432
437
  type ChannelASession,
433
438
  type ChannelAExecArgs,
434
439
  type ChannelAExecResult,
@@ -20,6 +20,7 @@ import {
20
20
  } from "./modal-legacy-command-control";
21
21
  import {
22
22
  ModalCommandRouterWire,
23
+ ModalCommandStartPreDispatchUnavailableError,
23
24
  ModalCommandStartRejectedError,
24
25
  } from "./modal-command-router-wire";
25
26
 
@@ -236,7 +237,12 @@ export class ModalCommandControl {
236
237
  // A client-chosen router id remains the only possible invocation. The
237
238
  // supervisor is idle, so an ambiguous launch never ran user code. Retain
238
239
  // the descriptor and reconcile that id; do not replay the start.
239
- if (!supervision || error instanceof ModalCommandStartRejectedError) throw error;
240
+ if (
241
+ !supervision ||
242
+ error instanceof ModalCommandStartRejectedError ||
243
+ error instanceof ModalCommandStartPreDispatchUnavailableError
244
+ )
245
+ throw error;
240
246
  }
241
247
  return command;
242
248
  }
@@ -47,6 +47,35 @@ export class ModalCommandStartRejectedError extends ProviderCommandStartRejected
47
47
  }
48
48
  }
49
49
 
50
+ /** Only a local channel-readiness failure BEFORE TaskExecStart is issued can
51
+ * prove that start was never dispatched. RPC status/details, even if they look
52
+ * like a DNS resolver error, can originate from an accepting server. */
53
+ export class ModalCommandStartPreDispatchUnavailableError extends Error {
54
+ private constructor(cause: Error) {
55
+ super("Modal command router was not ready before Start dispatch", { cause });
56
+ this.name = "ModalCommandStartPreDispatchUnavailableError";
57
+ }
58
+
59
+ static async ensureReady(client: Client, signal?: AbortSignal): Promise<void> {
60
+ signal?.throwIfAborted();
61
+ await new Promise<void>((resolve, reject) => {
62
+ let settled = false;
63
+ const finish = (error?: Error) => {
64
+ if (settled) return;
65
+ settled = true;
66
+ signal?.removeEventListener("abort", abort);
67
+ if (signal?.aborted) reject(signal.reason);
68
+ else if (error) reject(new ModalCommandStartPreDispatchUnavailableError(error));
69
+ else resolve();
70
+ };
71
+ const abort = () => finish();
72
+ signal?.addEventListener("abort", abort, { once: true });
73
+ client.waitForReady(Date.now() + 5_000, finish);
74
+ if (signal?.aborted) abort();
75
+ });
76
+ }
77
+ }
78
+
50
79
  export type ModalRouterIdentity = { taskId: string; execId: string };
51
80
  export type ModalRouterAccess = { url: string; jwt: string };
52
81
  export type ModalRouterStart = ModalRouterIdentity & {
@@ -76,16 +105,21 @@ export class ModalCommandRouterWire {
76
105
  private readonly client: Client;
77
106
  private readonly metadata: Metadata;
78
107
  private closed = false;
79
-
80
108
  constructor(access: ModalRouterAccess, trustedRoots?: Buffer) {
81
109
  const url = new URL(access.url);
82
110
  if (url.protocol !== "https:" || url.username || url.password || !access.jwt)
83
111
  throw new Error("Modal command router requires authenticated TLS");
84
- this.client = new Client(url.host, credentials.createSsl(trustedRoots), {
85
- "grpc.max_receive_message_length": maxWireBytes,
86
- "grpc.max_send_message_length": maxWireBytes,
87
- "grpc.enable_retries": 0,
88
- });
112
+ // URL.host elides HTTPS's default :443; use the actual normalized gRPC
113
+ // target for both the resolver and the authenticated TLS transport.
114
+ this.client = new Client(
115
+ `${url.hostname}:${url.port || "443"}`,
116
+ credentials.createSsl(trustedRoots),
117
+ {
118
+ "grpc.max_receive_message_length": maxWireBytes,
119
+ "grpc.max_send_message_length": maxWireBytes,
120
+ "grpc.enable_retries": 0,
121
+ },
122
+ );
89
123
  this.metadata = new Metadata();
90
124
  this.metadata.set("authorization", `Bearer ${access.jwt}`);
91
125
  }
@@ -127,6 +161,13 @@ export class ModalCommandRouterWire {
127
161
  }
128
162
 
129
163
  async start(request: ModalRouterStart, signal?: AbortSignal): Promise<void> {
164
+ if (this.closed) throw new Error("Modal command router is closed");
165
+ try {
166
+ await ModalCommandStartPreDispatchUnavailableError.ensureReady(this.client, signal);
167
+ } catch (error) {
168
+ if (this.closed) throw new Error("Modal command router is closed", { cause: error });
169
+ throw error;
170
+ }
130
171
  try {
131
172
  await this.unary(
132
173
  "TaskExecStart",
@@ -15,6 +15,8 @@ import { CAPABILITY_DESCRIPTORS } from "../capabilities";
15
15
  import { SandboxChannelAService, type ChannelASession } from "../channel-a";
16
16
  import { installModalCommandSession } from "./modal-command-session";
17
17
  import { ModalCommandControl } from "./modal-command-control";
18
+ import { ModalCommandStartPreDispatchUnavailableError } from "./modal-command-router-wire";
19
+ import { isRoutingMutationOutcomeUnknownError } from "../routing/routing-session";
18
20
  import type { ModalClient } from "modal";
19
21
  import { ModalProcessObservationUnavailableError, SandboxConfigError } from "../errors";
20
22
  export { ModalProcessObservationUnavailableError } from "../errors";
@@ -207,9 +209,6 @@ const MODAL_EXEC_STDIN_WRITE_PATH =
207
209
  "/modal.task_command_router.TaskCommandRouter/TaskExecStdinWrite";
208
210
  const MODAL_EXEC_ALREADY_COMPLETED_DETAILS =
209
211
  /^Exec has already completed; stdin is no longer accepting writes(?: \(Error code: [A-Z0-9]+\))?$/;
210
- const MODAL_TASK_EXEC_START_PATH = "/modal.task_command_router.TaskCommandRouter/TaskExecStart";
211
- const MODAL_TASK_EXEC_START_DNS_RESOLUTION_DETAILS =
212
- /^Name resolution failed for target dns:task-[a-z0-9]+\.w\.modal\.host:443$/;
213
212
  const MODAL_TASK_EXEC_START_ERROR_MAX_DEPTH = 8;
214
213
  const MODAL_TASK_EXEC_START_ERROR_MAX_NODES = 64;
215
214
  const MODAL_TASK_EXEC_START_ERROR_MAX_AGGREGATE_ERRORS = 32;
@@ -234,24 +233,13 @@ function hasContradictoryModalHttpStatus(record: Record<string, unknown>): boole
234
233
  return values.some((value) => modalHttpStatus(value) !== null);
235
234
  }
236
235
 
237
- function isModalTaskExecStartDnsResolutionLeaf(record: Record<string, unknown>): boolean {
238
- return (
239
- record.name === "ClientError" &&
240
- record.path === MODAL_TASK_EXEC_START_PATH &&
241
- (record.code === 14 || record.code === "UNAVAILABLE") &&
242
- typeof record.details === "string" &&
243
- MODAL_TASK_EXEC_START_DNS_RESOLUTION_DETAILS.test(record.details)
244
- );
245
- }
246
-
247
236
  /**
248
- * Modal exhausted its own retries before failing to resolve the exact command
249
- * router DNS name, so TaskExecStart never connected and replay is safe. Keep
250
- * this fail-closed across Agents SDK wrappers: every reachable structural leaf
251
- * must be that exact ClientError with no HTTP status metadata, and any incomplete
252
- * or mixed graph is rejected.
237
+ * Only the native client's pre-dispatch readiness gate proves Start was not
238
+ * sent. Server-controlled gRPC/SDK status and DNS-looking details prove nothing.
239
+ * Any retained or outcome-unknown routing boundary vetoes recovery even when
240
+ * it contains a genuine pre-dispatch error among its causes.
253
241
  */
254
- export function isModalTaskExecStartDnsResolutionError(error: unknown): boolean {
242
+ export function isModalTaskExecStartPreDispatchUnavailableError(error: unknown): boolean {
255
243
  const pending: Array<{ depth: number; value: unknown }> = [{ depth: 0, value: error }];
256
244
  const seen = new WeakSet<object>();
257
245
  let inspected = 0;
@@ -270,8 +258,16 @@ export function isModalTaskExecStartDnsResolutionError(error: unknown): boolean
270
258
  let nested: unknown[];
271
259
  try {
272
260
  const record = current.value as Record<string, unknown>;
261
+ if (isRoutingMutationOutcomeUnknownError(current.value)) return false;
273
262
  if (hasContradictoryModalHttpStatus(record)) return false;
274
263
 
264
+ // This instance is created only by the client's readiness gate before
265
+ // Start dispatch; an RPC's own status or details never enters this path.
266
+ if (current.value instanceof ModalCommandStartPreDispatchUnavailableError) {
267
+ matchingLeaves += 1;
268
+ continue;
269
+ }
270
+
275
271
  nested = [];
276
272
  for (const key of ["cause", "error"] as const) {
277
273
  const value = record[key];
@@ -290,11 +286,7 @@ export function isModalTaskExecStartDnsResolutionError(error: unknown): boolean
290
286
  nested.push(...errors);
291
287
  }
292
288
 
293
- if (nested.length === 0) {
294
- if (!isModalTaskExecStartDnsResolutionLeaf(record)) return false;
295
- matchingLeaves += 1;
296
- continue;
297
- }
289
+ if (nested.length === 0) return false;
298
290
  } catch {
299
291
  return false;
300
292
  }