@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.
- package/dist/{agent-RJQLKFIW.js → agent-ME6HT4DH.js} +2 -2
- package/dist/assets/codemode-client.json +1 -1
- package/dist/browser-action-json-schema.d.ts +4 -0
- package/dist/{chunk-J5AR5XAJ.js → chunk-FAFZCSN7.js} +695 -210
- package/dist/chunk-FAFZCSN7.js.map +1 -0
- package/dist/{chunk-ZG2YH25H.js → chunk-JAYHA6BF.js} +2 -2
- package/dist/{chunk-UNICCRW5.js → chunk-LZIXCZUV.js} +6 -3
- package/dist/{chunk-UNICCRW5.js.map → chunk-LZIXCZUV.js.map} +1 -1
- package/dist/{chunk-J6J3VYFW.js → chunk-P4V7JGQ2.js} +1037 -105
- package/dist/chunk-P4V7JGQ2.js.map +1 -0
- package/dist/{chunk-SPT3PLMX.js → chunk-PRHMSM6C.js} +2 -2
- package/dist/codex-tool-search.d.ts +12 -0
- package/dist/context-compaction.d.ts +4 -2
- package/dist/index.d.ts +27 -8
- package/dist/index.js +23 -7
- package/dist/interaction-tools.d.ts +1 -1
- package/dist/{lazy-manifest-OEYEHNVY.js → lazy-manifest-GCGUFIWP.js} +3 -3
- package/dist/{localSources-TWHTEKGO.js → localSources-FDH65AFI.js} +4 -4
- package/dist/metrics.d.ts +17 -0
- package/dist/model-provider-routing.d.ts +14 -0
- package/dist/operational-instructions.d.ts +1 -1
- package/dist/provider-quota.d.ts +76 -0
- package/dist/sandbox/browser-control-client.d.ts +28 -1
- package/dist/sandbox/channel-a.d.ts +43 -0
- package/dist/sandbox/index.d.ts +3 -3
- package/dist/sandbox/index.js +10 -6
- package/dist/sandbox/providers/modal-command-router-wire.d.ts +8 -0
- package/dist/sandbox/providers/modal.d.ts +5 -6
- package/dist/workspace-tool-gateway.js +5 -5
- package/package.json +11 -11
- package/src/browser-action-json-schema.ts +10 -0
- package/src/bundled_artifact_skills/opengeni-documents/SKILL.md +4 -1
- package/src/bundled_artifact_skills/opengeni-documents/references/api.md +20 -0
- package/src/bundled_default_skills/opengeni-client/SKILL.md +4 -1
- package/src/bundled_default_skills/opengeni-client/references/discovery-and-autonomy.md +31 -0
- package/src/bundled_default_skills/opengeni-client/references/runtime-profile-and-verification.md +32 -0
- package/src/codex-tool-search.ts +54 -6
- package/src/context-compaction.ts +19 -3
- package/src/index.ts +285 -56
- package/src/interaction-tools.ts +656 -48
- package/src/lazy-tool-transport.ts +4 -0
- package/src/metrics.ts +26 -0
- package/src/model-context-inspector.ts +1 -0
- package/src/model-provider-client.ts +37 -16
- package/src/model-provider-routing.ts +32 -6
- package/src/operational-instructions.ts +1 -1
- package/src/provider-quota.ts +412 -0
- package/src/sandbox/browser-control-client.ts +241 -26
- package/src/sandbox/channel-a.ts +370 -0
- package/src/sandbox/index.ts +6 -1
- package/src/sandbox/providers/modal-command-control.ts +7 -1
- package/src/sandbox/providers/modal-command-router-wire.ts +47 -6
- package/src/sandbox/providers/modal.ts +16 -24
- package/src/sandbox/routing/routing-session.ts +29 -6
- package/dist/chunk-J5AR5XAJ.js.map +0 -1
- package/dist/chunk-J6J3VYFW.js.map +0 -1
- /package/dist/{agent-RJQLKFIW.js.map → agent-ME6HT4DH.js.map} +0 -0
- /package/dist/{chunk-ZG2YH25H.js.map → chunk-JAYHA6BF.js.map} +0 -0
- /package/dist/{chunk-SPT3PLMX.js.map → chunk-PRHMSM6C.js.map} +0 -0
- /package/dist/{lazy-manifest-OEYEHNVY.js.map → lazy-manifest-GCGUFIWP.js.map} +0 -0
- /package/dist/{localSources-TWHTEKGO.js.map → localSources-FDH65AFI.js.map} +0 -0
package/src/sandbox/channel-a.ts
CHANGED
|
@@ -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.
|
package/src/sandbox/index.ts
CHANGED
|
@@ -175,7 +175,7 @@ export {
|
|
|
175
175
|
ensureModalRegistryImage,
|
|
176
176
|
deleteModalCheckpointSnapshot,
|
|
177
177
|
inspectModalSandboxLifecycle,
|
|
178
|
-
|
|
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 (
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
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
|
|
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
|
}
|