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