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