@sema-agent/core 2.3.0 → 2.4.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/dist/agents/send-message-tool.d.ts +4 -0
- package/dist/agents/send-message-tool.js +37 -24
- package/dist/agents/subagent.js +275 -127
- package/dist/brain/errors.d.ts +1 -0
- package/dist/brain/errors.js +14 -0
- package/dist/brain/stream-engine.js +3 -3
- package/dist/core/context-edit.js +2 -1
- package/dist/core/runner/prepare-task.js +20 -1
- package/dist/core/runner/tool-output-projection.js +2 -1
- package/dist/core/store-contracts/checkpoint-store-contract.d.ts +37 -0
- package/dist/core/store-contracts/checkpoint-store-contract.js +195 -0
- package/dist/core/store-contracts/contract-harness.d.ts +6 -0
- package/dist/core/store-contracts/contract-harness.js +16 -0
- package/dist/core/store-contracts/contract-kit-version.d.ts +1 -0
- package/dist/core/store-contracts/contract-kit-version.js +2 -0
- package/dist/core/store-contracts/file-snapshot-store-contract.d.ts +3 -0
- package/dist/core/store-contracts/file-snapshot-store-contract.js +126 -0
- package/dist/core/store-contracts/mailbox-store-contract.d.ts +6 -0
- package/dist/core/store-contracts/mailbox-store-contract.js +193 -0
- package/dist/core/store-contracts/session-repo-contract.d.ts +3 -0
- package/dist/core/store-contracts/session-repo-contract.js +36 -0
- package/dist/core/store-contracts/tool-result-store-contract.d.ts +3 -0
- package/dist/core/store-contracts/tool-result-store-contract.js +35 -0
- package/dist/core/task-notification.d.ts +2 -0
- package/dist/core/task-registry-agent.d.ts +4 -0
- package/dist/core/task-registry-agent.js +13 -0
- package/dist/core/task-registry-monitor.js +6 -6
- package/dist/core/task-registry-shared.d.ts +8 -2
- package/dist/core/task-registry-shared.js +1 -1
- package/dist/core/task-registry.d.ts +6 -0
- package/dist/core/task-registry.js +48 -4
- package/dist/core/tool-result-store.d.ts +3 -2
- package/dist/core/tool-result-store.js +12 -4
- package/dist/core/trace.d.ts +7 -0
- package/dist/engine/lsp/node-lsp-manager.d.ts +2 -0
- package/dist/engine/lsp/node-lsp-manager.js +16 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/orchestration/builtin-workflows.d.ts +1 -1
- package/dist/orchestration/builtin-workflows.js +11 -2
- package/dist/orchestration/workflow-governance.d.ts +6 -1
- package/dist/orchestration/workflow-governance.js +24 -4
- package/dist/orchestration/workflow-primitives.js +7 -1
- package/dist/orchestration/workflow.d.ts +1 -0
- package/dist/orchestration/workflow.js +31 -2
- package/dist/tools/fs/fs-bash.d.ts +7 -1
- package/dist/tools/fs/fs-bash.js +51 -20
- package/dist/tools/fs/fs-read.js +22 -11
- package/dist/tools/fs/fs-search-tools.js +3 -3
- package/dist/tools/fs/fs-shared.d.ts +20 -7
- package/dist/tools/fs/fs-shared.js +17 -3
- package/dist/tools/fs/fs-write.js +4 -4
- package/dist/tools/fs/index.d.ts +2 -0
- package/dist/tools/fs/index.js +7 -1
- package/dist/tools/fs/repo-map.js +2 -2
- package/dist/tools/fs/safety.d.ts +10 -0
- package/dist/tools/fs/safety.js +15 -1
- package/dist/tools/monitor.js +18 -4
- package/dist/tools/web.js +6 -2
- package/dist/tools/worktree.js +46 -25
- package/package.json +1 -1
package/dist/tools/fs/fs-bash.js
CHANGED
|
@@ -5,9 +5,9 @@ import { TASK_OUTPUT_TOOL_NAME, TASK_STOP_TOOL_NAME, TASK_OUTPUT_ALIASES, TASK_S
|
|
|
5
5
|
import { hasBackgroundShell } from "../../core/background-shell.js";
|
|
6
6
|
import { delimitUntrusted } from "../../core/untrusted-text.js";
|
|
7
7
|
import { MCP_IMAGE_MAX_BASE64 } from "../../core/mcp.js";
|
|
8
|
-
import { imageMagicMatches } from "./safety.js";
|
|
8
|
+
import { imageMagicMatches, withinAnyRoot } from "./safety.js";
|
|
9
9
|
import { ghRateLimitHint } from "./gh-rate-limit.js";
|
|
10
|
-
import {
|
|
10
|
+
import { resolveBashTimeoutCaps, bashTimeoutCapsSec, bashMaxOutputChars, clipShellOutput, writeShellOverflowFile, shellRecoveryHint, CWD_SENTINEL, } from "./fs-shared.js";
|
|
11
11
|
import { BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyCompoundReadonly } from "./bash-readonly-classifier.js";
|
|
12
12
|
export function bashReversibilityProbe(allow) {
|
|
13
13
|
const allowSet = new Set(allow ?? BASH_READONLY_DEFAULT_ALLOW);
|
|
@@ -156,9 +156,15 @@ export function canAutoBackground(command) {
|
|
|
156
156
|
return false;
|
|
157
157
|
return true;
|
|
158
158
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
159
|
+
function msTimeoutToRequestedSec(timeoutMs) {
|
|
160
|
+
if (timeoutMs === undefined || !Number.isFinite(timeoutMs))
|
|
161
|
+
return undefined;
|
|
162
|
+
return Math.max(1, Math.round(timeoutMs / 1000));
|
|
163
|
+
}
|
|
164
|
+
async function runShell(env, cwd, toolName, command, timeoutSec, caps, signal, cwdRef, detach, execClamp, toolCallId, readOnly, containmentRoots) {
|
|
165
|
+
const requestedSec = Math.max(1, Math.floor(timeoutSec ?? caps.defaultSec));
|
|
166
|
+
let timeout = Math.min(caps.maxSec, requestedSec);
|
|
167
|
+
const cappedByMaxTimeout = requestedSec > caps.maxSec;
|
|
162
168
|
let clampedByDeadline = false;
|
|
163
169
|
let deadlineExhausted = false;
|
|
164
170
|
if (execClamp) {
|
|
@@ -235,13 +241,16 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
|
|
|
235
241
|
? deadlineExhausted
|
|
236
242
|
? `Command was cut off after ${timeout}s — NOT its requested ${requestedSec}s timeout: the task's wall-clock deadline has already been reached, so the command only got a ${timeout}s grace window. The process was killed; output produced before the cutoff is shown below. Do NOT retry this foreground command — there is no time left. Use run_in_background (exempt from this clamp) if the work must continue, or write out your results now.`
|
|
237
243
|
: `Command was cut off after ${timeout}s — NOT its requested ${requestedSec}s timeout: the task is near its wall-clock deadline, so the foreground time budget was clamped to the ${timeout}s remaining. The process was killed; output produced before the cutoff is shown below. Do NOT retry this foreground command — the next attempt gets even less time. Use run_in_background (exempt from this clamp) if the work must continue, or write out your results now.`
|
|
238
|
-
:
|
|
244
|
+
: cappedByMaxTimeout
|
|
245
|
+
? `Command timed out after ${timeout}s — requested ${requestedSec}s, capped at ${caps.maxSec}s (engine ceiling: requests above ${caps.maxSec}s are reduced to it). The process was killed; output produced before the cutoff is shown below. Re-running with a larger timeout gets the same ${caps.maxSec}s ceiling — use run_in_background for work that needs longer, a narrower command, or resume from the partial progress below.`
|
|
246
|
+
: `Command timed out after ${timeout}s (timeout limit: ${timeout}s). The process was killed; output produced before the cutoff is shown below. Re-running the same command will likely time out again — consider run_in_background for long commands, a narrower command, or resuming from the partial progress below.`
|
|
239
247
|
: res.error.code === "aborted"
|
|
240
248
|
? `Command was interrupted (aborted) before completion. Output produced before the interrupt is shown below.`
|
|
241
249
|
: `Command started but its completion could not be observed — a host-side output-stream callback failed mid-run and the process was terminated (${res.error.message}). Output captured before the cut is shown below; its effects up to that point may have landed. Verify before assuming it needs a full re-run.`;
|
|
242
250
|
const captured = (pStdout ? `--- partial stdout ---\n${pStdout}` : "") +
|
|
243
251
|
(pStderr ? `${pStdout ? "\n" : ""}--- partial stderr ---\n${pStderr}` : "");
|
|
244
|
-
const
|
|
252
|
+
const zeroOutput = captured.length === 0;
|
|
253
|
+
const body = !zeroOutput
|
|
245
254
|
? `\n${delimitUntrusted("partial command output", captured)}`
|
|
246
255
|
: `\n(no output was produced before the cutoff)`;
|
|
247
256
|
const overflowNote = cutOverflowFile !== undefined ? shellRecoveryHint(cutOverflowFile, readOnly) : "";
|
|
@@ -257,12 +266,15 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
|
|
|
257
266
|
? {
|
|
258
267
|
timedOut: true,
|
|
259
268
|
timeoutSec: timeout,
|
|
260
|
-
...(clampedByDeadline ? { requestedTimeoutSec: requestedSec
|
|
269
|
+
...(clampedByDeadline || cappedByMaxTimeout ? { requestedTimeoutSec: requestedSec } : {}),
|
|
270
|
+
...(clampedByDeadline ? { clampedByDeadline: true } : {}),
|
|
271
|
+
...(cappedByMaxTimeout ? { cappedToMaxTimeoutSec: caps.maxSec } : {}),
|
|
261
272
|
}
|
|
262
273
|
: res.error.code === "aborted"
|
|
263
274
|
? { aborted: true }
|
|
264
275
|
: { callbackError: true }),
|
|
265
276
|
},
|
|
277
|
+
...(zeroOutput ? { isError: true } : {}),
|
|
266
278
|
};
|
|
267
279
|
}
|
|
268
280
|
return {
|
|
@@ -291,6 +303,13 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
|
|
|
291
303
|
cwdRef.current = captured;
|
|
292
304
|
}
|
|
293
305
|
}
|
|
306
|
+
let cwdOutsideNote = "";
|
|
307
|
+
if (cwdRef && containmentRoots !== undefined && containmentRoots.length > 0 && !withinAnyRoot(containmentRoots, cwdRef.current)) {
|
|
308
|
+
const cwdCanon = await env.canonicalPath(cwdRef.current, signal);
|
|
309
|
+
if (!(cwdCanon.ok && withinAnyRoot(containmentRoots, cwdCanon.value))) {
|
|
310
|
+
cwdOutsideNote = `NOTE: working directory is now outside the task root(s) (${cwdRef.current}); relative paths in the structured file tools (Read/Edit/Write/Grep/Glob) will resolve there and may be refused. Pass absolute in-root paths, or \`cd\` back inside.`;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
294
313
|
const clippedStdout = clipShellOutput(stdout);
|
|
295
314
|
const clippedStderr = clipShellOutput(stderr);
|
|
296
315
|
const stdoutImage = dataUriImageFromStdout(stdout);
|
|
@@ -300,7 +319,7 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
|
|
|
300
319
|
return {
|
|
301
320
|
content: [
|
|
302
321
|
{ type: "image", data: stdoutImage.data, mimeType: stdoutImage.mime },
|
|
303
|
-
{ type: "text", text: `exit code: ${exitCode}\n[Image data detected in stdout and shown above]${stderr.trim() !== "" ? `\n--- stderr ---\n${clippedStderr}` : ""}${imgOverflowNote}` },
|
|
322
|
+
{ type: "text", text: `exit code: ${exitCode}\n[Image data detected in stdout and shown above]${stderr.trim() !== "" ? `\n--- stderr ---\n${clippedStderr}` : ""}${imgOverflowNote}${cwdOutsideNote ? `\n${cwdOutsideNote}` : ""}` },
|
|
304
323
|
],
|
|
305
324
|
details: { type: "bash", stdout: "[image data]", stderr: clippedStderr, exitCode, isImage: true, ...(imgOverflowFile !== undefined ? { output_file: imgOverflowFile } : {}) },
|
|
306
325
|
};
|
|
@@ -313,6 +332,8 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
|
|
|
313
332
|
parts.push(`--- stderr ---\n${clippedStderr}`);
|
|
314
333
|
if (overflowNote)
|
|
315
334
|
parts.push(overflowNote.trim());
|
|
335
|
+
if (cwdOutsideNote)
|
|
336
|
+
parts.push(cwdOutsideNote);
|
|
316
337
|
return {
|
|
317
338
|
content: parts.join("\n"),
|
|
318
339
|
details: {
|
|
@@ -325,7 +346,7 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
|
|
|
325
346
|
},
|
|
326
347
|
};
|
|
327
348
|
}
|
|
328
|
-
function bashDescription(coAuthor, bgNotifies = false, bgRetained = false) {
|
|
349
|
+
function bashDescription(coAuthor, caps, bgNotifies = false, bgRetained = false) {
|
|
329
350
|
const coAuthorLines = coAuthor === false
|
|
330
351
|
? "- Follow the deployment's commit-message conventions."
|
|
331
352
|
: `- End git commit messages with:\nCo-Authored-By: ${coAuthor}`;
|
|
@@ -334,7 +355,7 @@ function bashDescription(coAuthor, bgNotifies = false, bgRetained = false) {
|
|
|
334
355
|
- Working directory persists between calls, but prefer absolute paths — a \`cd\` carries over and moves the base for every later command. Shell state (env vars, functions) does not persist; the shell is re-initialized each call, starting at the configured root.
|
|
335
356
|
- IMPORTANT: Avoid using this tool to run \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task. Instead, use the appropriate dedicated tool as this will provide a much better experience for the user.
|
|
336
357
|
- File search by name: use the Glob tool (NOT \`find\` or \`ls\`); content search: use the Grep tool (NOT \`grep\`/\`rg\` in the shell).
|
|
337
|
-
- \`timeout\` is in milliseconds: default ${
|
|
358
|
+
- \`timeout\` is in milliseconds: default ${caps.defaultMs}, max ${caps.maxMs}.
|
|
338
359
|
- \`run_in_background\` runs the command detached: it keeps running across turns and ${bgNotifies ? "re-invokes you when it exits" : "you read its output later with TaskOutput(task_id)"}. No \`&\` needed.${bgRetained ? "" : " Background processes do NOT survive the session — they are reaped when the task ends. A deliverable that must stay alive afterwards (a server, a daemon) needs a self-detaching FOREGROUND start instead: run `nohup cmd >log 2>&1 &` as a normal foreground command (portable; setsid does not exist on macOS), or use a service manager."}
|
|
339
360
|
|
|
340
361
|
# Git
|
|
@@ -348,15 +369,17 @@ ${coAuthorLines}
|
|
|
348
369
|
- Treat evidence of a deployment restriction — "Operation not permitted", "Read-only file system", a denied path, or a network failure — as a boundary, not a bug: adjust within the allowed scope or report the limit; don't reach for a destructive or privilege-escalating workaround.`;
|
|
349
370
|
}
|
|
350
371
|
export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = { current: rootCanonical }, taskOpts = {}) {
|
|
372
|
+
const timeoutCaps = resolveBashTimeoutCaps(taskOpts);
|
|
373
|
+
const timeoutCapsSecView = bashTimeoutCapsSec(timeoutCaps);
|
|
351
374
|
const bgNotifies = taskOpts.taskRegistry !== undefined && taskOpts.onTaskNotification !== undefined;
|
|
352
375
|
const bgRetained = hasBackgroundShell(env) && env.backgroundCapabilities.retainBackgroundProcesses === true;
|
|
353
376
|
return defineTool({
|
|
354
377
|
name: "Bash",
|
|
355
378
|
contract: { contractId: "core.bash@1", implementationRevision: "1" },
|
|
356
|
-
description: bashDescription(coAuthor, bgNotifies, bgRetained),
|
|
379
|
+
description: bashDescription(coAuthor, timeoutCaps, bgNotifies, bgRetained),
|
|
357
380
|
parameters: Type.Object({
|
|
358
381
|
command: Type.String({ description: "The command to execute" }),
|
|
359
|
-
timeout: Type.Optional(Type.Number({ description: `Optional timeout in milliseconds (max ${
|
|
382
|
+
timeout: Type.Optional(Type.Number({ description: `Optional timeout in milliseconds (max ${timeoutCaps.maxMs})` })),
|
|
360
383
|
description: Type.Optional(Type.String({
|
|
361
384
|
description: 'Clear, concise description of what this command does in active voice. Never use words like "complex" or "risk" in the description - just describe what it does.\n' +
|
|
362
385
|
"\n" +
|
|
@@ -406,7 +429,12 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
|
|
|
406
429
|
const appliedTimeoutSec = typeof bgCaps.defaultBgTimeoutSec === "number" && typeof bgCaps.maxBgTimeoutSec === "number"
|
|
407
430
|
? Math.min(requestedTimeoutSec ?? bgCaps.defaultBgTimeoutSec, bgCaps.maxBgTimeoutSec)
|
|
408
431
|
: undefined;
|
|
409
|
-
const
|
|
432
|
+
const bgCappedByMax = appliedTimeoutSec !== undefined && requestedTimeoutSec !== undefined && typeof bgCaps.maxBgTimeoutSec === "number" && requestedTimeoutSec > bgCaps.maxBgTimeoutSec;
|
|
433
|
+
const budgetNote = appliedTimeoutSec !== undefined
|
|
434
|
+
? bgCappedByMax
|
|
435
|
+
? ` Time budget: requested ${requestedTimeoutSec}s, capped at ${bgCaps.maxBgTimeoutSec}s (env ceiling: requests above ${bgCaps.maxBgTimeoutSec}s are reduced to it) — auto-terminates if still running after ${appliedTimeoutSec}s.`
|
|
436
|
+
: ` Time budget: auto-terminates if still running after ${appliedTimeoutSec}s (hard cap ${bgCaps.maxBgTimeoutSec}s).`
|
|
437
|
+
: "";
|
|
410
438
|
const lifetimeNote = bgCaps.retainBackgroundProcesses === true
|
|
411
439
|
? ""
|
|
412
440
|
: ` NOTE: background processes do NOT survive the session (reaped at task end). If this is a deliverable service that must stay alive afterwards, host it with a FOREGROUND command instead: \`nohup cmd >log 2>&1 &\` (self-detaching — survives the session).`;
|
|
@@ -462,7 +490,7 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
|
|
|
462
490
|
? ` Output file: ${outputFile} (full output is appended there — Read it any time).`
|
|
463
491
|
: ` Use TaskOutput("${taskId}") to check interim output.`;
|
|
464
492
|
if (taskOpts.oneShot === true && onNotify !== undefined) {
|
|
465
|
-
return (`Command running in background; task_id=${taskId}.${interimNote} This is a ONE-SHOT submission — there is no later turn for a background notification to land in, so do NOT end your turn expecting one. Actively wait instead: TaskOutput("${taskId}",
|
|
493
|
+
return (`Command running in background; task_id=${taskId}.${interimNote} This is a ONE-SHOT submission — there is no later turn for a background notification to land in, so do NOT end your turn expecting one. Actively wait instead: TaskOutput({ task_id: "${taskId}", block: true }). If it is still running after the wait, wait again (bounded) rather than ending the turn, or write out your best available answer now if you are near your own time budget. TaskStop("${taskId}") to stop.` +
|
|
466
494
|
budgetNote +
|
|
467
495
|
lifetimeNote);
|
|
468
496
|
}
|
|
@@ -560,7 +588,8 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
|
|
|
560
588
|
: `Use TaskOutput("${taskId}") to read its output. `) +
|
|
561
589
|
`TaskStop("${taskId}") to stop it. ` +
|
|
562
590
|
(taskOpts.oneShot === true && onNotify !== undefined
|
|
563
|
-
?
|
|
591
|
+
?
|
|
592
|
+
`This is a ONE-SHOT submission — there is no later turn for a background notification to land in, so do NOT end your turn expecting one. Actively wait instead: TaskOutput({ task_id: "${taskId}", block: true }).`
|
|
564
593
|
: onNotify !== undefined
|
|
565
594
|
? `You will be notified when it completes — do not poll.`
|
|
566
595
|
: `Poll TaskOutput until its status is no longer "running".`) +
|
|
@@ -574,7 +603,7 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
|
|
|
574
603
|
: undefined;
|
|
575
604
|
let res;
|
|
576
605
|
try {
|
|
577
|
-
res = await runShell(env, rootCanonical, "Bash", command,
|
|
606
|
+
res = await runShell(env, rootCanonical, "Bash", command, msTimeoutToRequestedSec(timeout), timeoutCapsSecView, ctx.signal, cwdRef, detachChain, taskOpts.execClamp, ctx.toolCallId, false, [rootCanonical, ...(taskOpts.additionalRoots ?? [])]);
|
|
578
607
|
}
|
|
579
608
|
finally {
|
|
580
609
|
taskOpts.detachHub?.gc(ctx.toolCallId);
|
|
@@ -591,7 +620,9 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
|
|
|
591
620
|
},
|
|
592
621
|
});
|
|
593
622
|
}
|
|
594
|
-
export function createBashReadonlyTool(env, rootCanonical, allow, execClamp) {
|
|
623
|
+
export function createBashReadonlyTool(env, rootCanonical, allow, execClamp, timeoutOpts) {
|
|
624
|
+
const timeoutCaps = resolveBashTimeoutCaps(timeoutOpts);
|
|
625
|
+
const timeoutCapsSecView = bashTimeoutCapsSec(timeoutCaps);
|
|
595
626
|
const sample = [...allow].slice(0, 6).join(", ");
|
|
596
627
|
return defineTool({
|
|
597
628
|
name: "Bash",
|
|
@@ -601,7 +632,7 @@ export function createBashReadonlyTool(env, rootCanonical, allow, execClamp) {
|
|
|
601
632
|
"allowlisted commands run. Still subject to the deployment's approval policy.",
|
|
602
633
|
parameters: Type.Object({
|
|
603
634
|
command: Type.String({ description: "A single allowlisted read-only command (no shell operators)." }),
|
|
604
|
-
timeout: Type.Optional(Type.Number({ description: `Timeout in milliseconds (default ${
|
|
635
|
+
timeout: Type.Optional(Type.Number({ description: `Timeout in milliseconds (default ${timeoutCaps.defaultMs}, max ${timeoutCaps.maxMs}).` })),
|
|
605
636
|
}),
|
|
606
637
|
effect: "read",
|
|
607
638
|
execute: async (args, ctx) => {
|
|
@@ -609,7 +640,7 @@ export function createBashReadonlyTool(env, rootCanonical, allow, execClamp) {
|
|
|
609
640
|
const reason = coarseReadonlyCheck(command, allow);
|
|
610
641
|
if (reason)
|
|
611
642
|
return errorResult(`Error (Bash): ${reason}`);
|
|
612
|
-
return runShell(env, rootCanonical, "Bash", command,
|
|
643
|
+
return runShell(env, rootCanonical, "Bash", command, msTimeoutToRequestedSec(timeout), timeoutCapsSecView, ctx.signal, undefined, undefined, execClamp, ctx.toolCallId, true);
|
|
613
644
|
},
|
|
614
645
|
});
|
|
615
646
|
}
|
package/dist/tools/fs/fs-read.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import { defineTool, errorResult } from "../../core/tools.js";
|
|
3
|
-
import { sha256, resolveKey, violationText, hasBinaryExtension, isBinaryContent, fileArgPath, imageMimeForRead, imageMagicMatches, } from "./safety.js";
|
|
3
|
+
import { sha256, resolveKey, violationText, violationDetails, hasBinaryExtension, isBinaryContent, fileArgPath, imageMimeForRead, imageMagicMatches, } from "./safety.js";
|
|
4
4
|
import { decodeTextBytes } from "./encoding.js";
|
|
5
5
|
import { isNotebookPath, parseNotebookCells, renderNotebookCells, stripNotebookImageData, NOTEBOOK_IMAGE_BASE64_BUDGET } from "./notebook.js";
|
|
6
6
|
import { MCP_IMAGE_MAX_BASE64, IMAGE_TARGET_RAW_SIZE } from "../../core/mcp.js";
|
|
@@ -38,8 +38,8 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
|
|
|
38
38
|
"- Do NOT re-read a file you just edited to verify — Edit/Write would have errored if the change failed, and the harness tracks file state for you.",
|
|
39
39
|
parameters: Type.Object({
|
|
40
40
|
...FILE_PATH_PARAMS,
|
|
41
|
-
offset: Type.Optional(Type.
|
|
42
|
-
limit: Type.Optional(Type.
|
|
41
|
+
offset: Type.Optional(Type.Integer({ minimum: 0, description: "The line number to start reading from. Only provide if the file is too large to read at once (1-based; default 1)." })),
|
|
42
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, description: "The number of lines to read. Only provide if the file is too large to read at once. (Default: the whole file, bounded by the output-token cap.)" })),
|
|
43
43
|
pages: Type.Optional(Type.String({ description: `Page range for PDF files (e.g., "1-5", "3", "10-20"). Only applicable to PDF files. Maximum ${PDF_MAX_PAGES_PER_READ} pages per request.` })),
|
|
44
44
|
}),
|
|
45
45
|
effect: "read",
|
|
@@ -48,9 +48,13 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
|
|
|
48
48
|
const path = fileArgPath(args);
|
|
49
49
|
if (path === undefined)
|
|
50
50
|
return errorResult(`Error (Read): file_path is required.`);
|
|
51
|
+
const fileUnchangedResult = (text, reason, startLine, endLine, totalLines) => ({
|
|
52
|
+
content: text,
|
|
53
|
+
details: { type: "file_unchanged", reason, file: { filePath: path, startLine, endLine, totalLines } },
|
|
54
|
+
});
|
|
51
55
|
const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots, bgOutputReadExemption === undefined ? undefined : (key) => bgOutputReadExemption(key, ctx));
|
|
52
56
|
if (!r.ok)
|
|
53
|
-
return errorResult(violationText("Read", r.violation));
|
|
57
|
+
return errorResult(violationText("Read", r.violation), violationDetails(r.violation));
|
|
54
58
|
const isNb = isNotebookPath(r.key);
|
|
55
59
|
const imageMime = imageMimeForRead(r.key);
|
|
56
60
|
if (imageMime !== undefined) {
|
|
@@ -116,20 +120,23 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
|
|
|
116
120
|
if (r.key.toLowerCase().endsWith(".pdf")) {
|
|
117
121
|
return pdfResultToToolReturn(await readPdfFile(env, path, r.key, pages, ctx.signal, imageDownsampler, cwdRef?.current ?? rootCanonical, undefined, pdfCapabilities));
|
|
118
122
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
123
|
+
const binaryExt = hasBinaryExtension(r.key);
|
|
124
|
+
const binaryExtRefusal = () => errorResult(`Error (Read): "${path}" appears to be a binary file (by extension); this tool reads UTF-8 text only.`);
|
|
122
125
|
const info = await env.fileInfo(r.key, ctx.signal);
|
|
123
126
|
if (info.ok) {
|
|
124
127
|
if (info.value.kind === "directory") {
|
|
125
128
|
return errorResult(`Error (Read): "${path}" is a directory, not a file; use glob/grep or list it with the shell.`);
|
|
126
129
|
}
|
|
127
130
|
if (info.value.size > SLICED_READ_MAX_BYTES) {
|
|
131
|
+
if (binaryExt)
|
|
132
|
+
return binaryExtRefusal();
|
|
128
133
|
return errorResult(`Error (Read): "${path}" is too large to read with this tool even as an offset/limit slice ` +
|
|
129
134
|
`(${info.value.size} bytes > ${SLICED_READ_MAX_BYTES}-byte cap — the reader loads the whole file into memory before slicing). ` +
|
|
130
135
|
`Stream a portion with bash instead, e.g. \`sed -n '1,200p' <file>\` for a line range or \`head -c 65536 <file>\` for the leading bytes, or use grep to search it.`);
|
|
131
136
|
}
|
|
132
137
|
if (!isNb && info.value.size > MAX_READ_BYTES && limit === undefined) {
|
|
138
|
+
if (binaryExt)
|
|
139
|
+
return binaryExtRefusal();
|
|
133
140
|
return errorResult(`Error (Read): "${path}" is too large to read in full (${info.value.size} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead.`);
|
|
134
141
|
}
|
|
135
142
|
}
|
|
@@ -143,11 +150,15 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
|
|
|
143
150
|
return errorResult(`Error (Read): cannot read "${path}": ${readBin.error.message}`);
|
|
144
151
|
const readSize = readBin.value.byteLength;
|
|
145
152
|
if (readSize > SLICED_READ_MAX_BYTES) {
|
|
153
|
+
if (binaryExt)
|
|
154
|
+
return binaryExtRefusal();
|
|
146
155
|
return errorResult(`Error (Read): "${path}" is too large to read with this tool even as an offset/limit slice ` +
|
|
147
156
|
`(${readSize} bytes > ${SLICED_READ_MAX_BYTES}-byte cap — the reader loads the whole file into memory before slicing). ` +
|
|
148
157
|
`Stream a portion with bash instead, e.g. \`sed -n '1,200p' <file>\` for a line range or \`head -c 65536 <file>\` for the leading bytes, or use grep to search it.`);
|
|
149
158
|
}
|
|
150
159
|
if (!isNb && readSize > MAX_READ_BYTES && limit === undefined) {
|
|
160
|
+
if (binaryExt)
|
|
161
|
+
return binaryExtRefusal();
|
|
151
162
|
return errorResult(`Error (Read): "${path}" is too large to read in full (${readSize} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead.`);
|
|
152
163
|
}
|
|
153
164
|
if (pdfMagicMatches(readBin.value)) {
|
|
@@ -194,10 +205,10 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
|
|
|
194
205
|
}
|
|
195
206
|
const prevNb = state.get(r.key);
|
|
196
207
|
if (prevNb?.seededFromContext && !prevNb.isPartialView && prevNb.hash === hash) {
|
|
197
|
-
return seededFileUnchangedReminder(r.key);
|
|
208
|
+
return fileUnchangedResult(seededFileUnchangedReminder(r.key), "already-in-context", 1, total, total);
|
|
198
209
|
}
|
|
199
210
|
if (prevNb && !prevNb.isPartialView && prevNb.hash === hash && prevNb.view && prevNb.view.start === 1 && prevNb.view.end === total) {
|
|
200
|
-
return `[${path}: unchanged since you last read it (lines 1-${total} of ${total}); content omitted to save context]
|
|
211
|
+
return fileUnchangedResult(`[${path}: unchanged since you last read it (lines 1-${total} of ${total}); content omitted to save context]`, "unchanged-since-last-read", 1, total, total);
|
|
201
212
|
}
|
|
202
213
|
state.set(r.key, { hash, totalLines: countLines(content), truncated: false, view: { start: 1, end: total }, lastReadAt: Date.now() });
|
|
203
214
|
const bodyBlocks = rendered.blocks.length > 0 ? rendered.blocks : [{ type: "text", text: "[notebook has 0 cells]" }];
|
|
@@ -251,10 +262,10 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
|
|
|
251
262
|
const truncated = start > 1 || end < total || pageMarker !== undefined;
|
|
252
263
|
const prev = state.get(r.key);
|
|
253
264
|
if (total > 0 && prev?.seededFromContext && !prev.isPartialView && start === 1 && effLimit === undefined && prev.hash === hash) {
|
|
254
|
-
return seededFileUnchangedReminder(r.key);
|
|
265
|
+
return fileUnchangedResult(seededFileUnchangedReminder(r.key), "already-in-context", 1, total, total);
|
|
255
266
|
}
|
|
256
267
|
if (total > 0 && prev && !prev.isPartialView && prev.hash === hash && prev.view && prev.view.start === start && prev.view.end === end) {
|
|
257
|
-
return `[${path}: unchanged since you last read it (lines ${start}-${end} of ${total}); content omitted to save context]
|
|
268
|
+
return fileUnchangedResult(`[${path}: unchanged since you last read it (lines ${start}-${end} of ${total}); content omitted to save context]`, "unchanged-since-last-read", start, end, total);
|
|
258
269
|
}
|
|
259
270
|
state.set(r.key, {
|
|
260
271
|
hash,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import { defineTool, errorResult } from "../../core/tools.js";
|
|
3
|
-
import { resolveKey, violationText } from "./safety.js";
|
|
3
|
+
import { resolveKey, violationText, violationDetails } from "./safety.js";
|
|
4
4
|
import { runGrepDetailed, runGlobDetailed, splitAbsoluteGlobPattern, invalidGlobTokens } from "./search.js";
|
|
5
5
|
export function createGrepTool(env, rootCanonical, additionalRoots) {
|
|
6
6
|
return defineTool({
|
|
@@ -63,7 +63,7 @@ export function createGrepTool(env, rootCanonical, additionalRoots) {
|
|
|
63
63
|
if (a.path !== undefined) {
|
|
64
64
|
const r = await resolveKey(env, rootCanonical, a.path, ctx.signal, rootCanonical, additionalRoots);
|
|
65
65
|
if (!r.ok)
|
|
66
|
-
return errorResult(violationText("Grep", r.violation));
|
|
66
|
+
return errorResult(violationText("Grep", r.violation), violationDetails(r.violation));
|
|
67
67
|
scoped = r.key;
|
|
68
68
|
}
|
|
69
69
|
const grepRun = await runGrepDetailed(env, rootCanonical, {
|
|
@@ -182,7 +182,7 @@ export function createGlobTool(env, rootCanonical, additionalRoots) {
|
|
|
182
182
|
if (path !== undefined) {
|
|
183
183
|
const r = await resolveKey(env, rootCanonical, path, ctx.signal, rootCanonical, additionalRoots);
|
|
184
184
|
if (!r.ok)
|
|
185
|
-
return errorResult(violationText("Glob", r.violation));
|
|
185
|
+
return errorResult(violationText("Glob", r.violation), violationDetails(r.violation));
|
|
186
186
|
scoped = r.key;
|
|
187
187
|
}
|
|
188
188
|
const r2 = await runGlobDetailed(env, rootCanonical, pattern, { path: scoped, max: max_results }, ctx.signal);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import type { ExecutionEnv } from "../../internal/harness-types.js";
|
|
3
|
-
import { type ReadFileState } from "./safety.js";
|
|
3
|
+
import { type FsViolation, type ReadFileState } from "./safety.js";
|
|
4
4
|
import { type DecodedTextFile } from "./encoding.js";
|
|
5
5
|
import { type ImageDownsampler } from "../../core/mcp.js";
|
|
6
6
|
export declare const MAX_READ_BYTES: number;
|
|
@@ -15,11 +15,7 @@ export declare function decodeEditBytes(bytes: Uint8Array, path: string): {
|
|
|
15
15
|
message: string;
|
|
16
16
|
};
|
|
17
17
|
export declare function persistedTextOf(encoded: string | Uint8Array): string;
|
|
18
|
-
export declare function notReadRefusalText(env: ExecutionEnv, toolName: string, key: string, v:
|
|
19
|
-
code: string;
|
|
20
|
-
message: string;
|
|
21
|
-
partialView?: boolean;
|
|
22
|
-
}, signal?: AbortSignal, fallbackHint?: string): Promise<string>;
|
|
18
|
+
export declare function notReadRefusalText(env: ExecutionEnv, toolName: string, key: string, v: Pick<FsViolation, "code" | "message" | "partialView">, signal?: AbortSignal, fallbackHint?: string): Promise<string>;
|
|
23
19
|
export declare const MAX_IMAGE_READ_BYTES: number;
|
|
24
20
|
export declare const MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES: number;
|
|
25
21
|
export type ReadImageDownsamplerOption = ImageDownsampler | false | undefined;
|
|
@@ -30,6 +26,20 @@ export declare const BASH_DEFAULT_TIMEOUT_SEC = 120;
|
|
|
30
26
|
export declare const BASH_MAX_TIMEOUT_SEC = 600;
|
|
31
27
|
export declare const BASH_DEFAULT_TIMEOUT_MS: number;
|
|
32
28
|
export declare const BASH_MAX_TIMEOUT_MS: number;
|
|
29
|
+
export declare function resolveBashTimeoutCaps(opts?: {
|
|
30
|
+
bashDefaultTimeoutMs?: number;
|
|
31
|
+
bashMaxTimeoutMs?: number;
|
|
32
|
+
}): {
|
|
33
|
+
defaultMs: number;
|
|
34
|
+
maxMs: number;
|
|
35
|
+
};
|
|
36
|
+
export declare function bashTimeoutCapsSec(caps: {
|
|
37
|
+
defaultMs: number;
|
|
38
|
+
maxMs: number;
|
|
39
|
+
}): {
|
|
40
|
+
defaultSec: number;
|
|
41
|
+
maxSec: number;
|
|
42
|
+
};
|
|
33
43
|
export declare function bashMaxOutputChars(): number;
|
|
34
44
|
export declare const FILE_PATH_PARAMS: {
|
|
35
45
|
file_path: Type.TOptional<Type.TString>;
|
|
@@ -40,7 +50,10 @@ export declare function writeShellOverflowFile(env: ExecutionEnv, stdout: string
|
|
|
40
50
|
export declare function shellRecoveryHint(path: string, readOnly: boolean | undefined): string;
|
|
41
51
|
export declare const FILE_STATE_TRAILER = " (file state is current in your context \u2014 no need to Read it back)";
|
|
42
52
|
export declare const CWD_SENTINEL = "__cc_cwd_9f2c1b__";
|
|
43
|
-
export declare function msTimeoutToSec(timeoutMs: number | undefined
|
|
53
|
+
export declare function msTimeoutToSec(timeoutMs: number | undefined, caps?: {
|
|
54
|
+
defaultMs: number;
|
|
55
|
+
maxMs: number;
|
|
56
|
+
}): number;
|
|
44
57
|
export declare function ipynbRedirect(toolName: string, path: string): string | undefined;
|
|
45
58
|
export declare function countLines(s: string): number;
|
|
46
59
|
export declare function seededFileUnchangedReminder(filePath: string): string;
|
|
@@ -51,6 +51,19 @@ export const BASH_DEFAULT_TIMEOUT_SEC = 120;
|
|
|
51
51
|
export const BASH_MAX_TIMEOUT_SEC = 600;
|
|
52
52
|
export const BASH_DEFAULT_TIMEOUT_MS = BASH_DEFAULT_TIMEOUT_SEC * 1000;
|
|
53
53
|
export const BASH_MAX_TIMEOUT_MS = BASH_MAX_TIMEOUT_SEC * 1000;
|
|
54
|
+
function validTimeoutMs(n) {
|
|
55
|
+
return n !== undefined && Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
|
|
56
|
+
}
|
|
57
|
+
export function resolveBashTimeoutCaps(opts) {
|
|
58
|
+
const defaultMs = validTimeoutMs(opts?.bashDefaultTimeoutMs) ??
|
|
59
|
+
validTimeoutMs(Number(process.env.BASH_DEFAULT_TIMEOUT_MS)) ??
|
|
60
|
+
BASH_DEFAULT_TIMEOUT_MS;
|
|
61
|
+
const maxMs = Math.max(validTimeoutMs(opts?.bashMaxTimeoutMs) ?? validTimeoutMs(Number(process.env.BASH_MAX_TIMEOUT_MS)) ?? BASH_MAX_TIMEOUT_MS, defaultMs);
|
|
62
|
+
return { defaultMs, maxMs };
|
|
63
|
+
}
|
|
64
|
+
export function bashTimeoutCapsSec(caps) {
|
|
65
|
+
return { defaultSec: Math.max(1, Math.round(caps.defaultMs / 1000)), maxSec: Math.max(1, Math.round(caps.maxMs / 1000)) };
|
|
66
|
+
}
|
|
54
67
|
const BASH_DEFAULT_MAX_OUTPUT_CHARS = 30_000;
|
|
55
68
|
const BASH_MAX_OUTPUT_CHARS_CEILING = 150_000;
|
|
56
69
|
export function bashMaxOutputChars() {
|
|
@@ -84,10 +97,11 @@ export function shellRecoveryHint(path, readOnly) {
|
|
|
84
97
|
}
|
|
85
98
|
export const FILE_STATE_TRAILER = " (file state is current in your context — no need to Read it back)";
|
|
86
99
|
export const CWD_SENTINEL = "__cc_cwd_9f2c1b__";
|
|
87
|
-
export function msTimeoutToSec(timeoutMs) {
|
|
100
|
+
export function msTimeoutToSec(timeoutMs, caps = resolveBashTimeoutCaps()) {
|
|
101
|
+
const { defaultSec, maxSec } = bashTimeoutCapsSec(caps);
|
|
88
102
|
if (timeoutMs === undefined || !Number.isFinite(timeoutMs))
|
|
89
|
-
return
|
|
90
|
-
return Math.min(
|
|
103
|
+
return defaultSec;
|
|
104
|
+
return Math.min(maxSec, Math.max(1, Math.round(timeoutMs / 1000)));
|
|
91
105
|
}
|
|
92
106
|
export function ipynbRedirect(toolName, path) {
|
|
93
107
|
if (path.toLowerCase().endsWith(".ipynb")) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { defineTool, errorResult } from "../../core/tools.js";
|
|
4
|
-
import { sha256, resolveKey, violationText, requireRead, checkStale, checkEditMatch, checkNoChange, fileArgPath, resolveQuoteMatch, adaptNewStringQuotes, resolveEscapeMatch, adaptNewStringEscapes, escapeMatchWasAttempted, ESCAPE_MATCH_MISS_NOTE, deletionOldString, countOccurrences, WRITE_ENCODING_DEADLOCK_ESCAPE_HINT, } from "./safety.js";
|
|
4
|
+
import { sha256, resolveKey, violationText, violationDetails, requireRead, checkStale, checkEditMatch, checkNoChange, fileArgPath, resolveQuoteMatch, adaptNewStringQuotes, resolveEscapeMatch, adaptNewStringEscapes, escapeMatchWasAttempted, ESCAPE_MATCH_MISS_NOTE, deletionOldString, countOccurrences, WRITE_ENCODING_DEADLOCK_ESCAPE_HINT, } from "./safety.js";
|
|
5
5
|
import { decodeTextBytes, encodeTextForFile, normalizeEditText, normalizeFileText } from "./encoding.js";
|
|
6
6
|
import { MAX_EDIT_BYTES, formatByteSize, decodeEditBytes, persistedTextOf, notReadRefusalText, enoentMessage, FILE_STATE_TRAILER, FILE_PATH_PARAMS, ipynbRedirect, countLines, } from "./fs-shared.js";
|
|
7
7
|
async function gateToolWrite(hook, tool, path, key, content) {
|
|
@@ -56,7 +56,7 @@ export function createEditFileTool(env, state, rootCanonical, cwdRef, additional
|
|
|
56
56
|
return errorResult(`Error (Edit): file_path is required.`);
|
|
57
57
|
const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots);
|
|
58
58
|
if (!r.ok)
|
|
59
|
-
return errorResult(violationText("Edit", r.violation));
|
|
59
|
+
return errorResult(violationText("Edit", r.violation), violationDetails(r.violation));
|
|
60
60
|
if (!batch && a.old_string === a.new_string) {
|
|
61
61
|
return errorResult(violationText("Edit", { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." }));
|
|
62
62
|
}
|
|
@@ -241,7 +241,7 @@ export function createWriteFileTool(env, state, rootCanonical, cwdRef, additiona
|
|
|
241
241
|
return errorResult(ipynb);
|
|
242
242
|
const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots);
|
|
243
243
|
if (!r.ok)
|
|
244
|
-
return errorResult(violationText("Write", r.violation));
|
|
244
|
+
return errorResult(violationText("Write", r.violation), violationDetails(r.violation));
|
|
245
245
|
const exists = await env.exists(r.key, ctx.signal);
|
|
246
246
|
if (!exists.ok)
|
|
247
247
|
return errorResult(`Error (Write): cannot stat "${path}": ${exists.error.message}`);
|
|
@@ -331,7 +331,7 @@ export function createNotebookEditTool(env, state, rootCanonical, cwdRef, additi
|
|
|
331
331
|
return errorResult(`Error (NotebookEdit): cell_id is required for replace/delete.`);
|
|
332
332
|
const r = await resolveKey(env, rootCanonical, notebook_path, ctx.signal, cwdRef?.current, additionalRoots);
|
|
333
333
|
if (!r.ok)
|
|
334
|
-
return errorResult(violationText("NotebookEdit", r.violation));
|
|
334
|
+
return errorResult(violationText("NotebookEdit", r.violation), violationDetails(r.violation));
|
|
335
335
|
const notRead = requireRead(state, r.key);
|
|
336
336
|
if (notRead)
|
|
337
337
|
return errorResult(await notReadRefusalText(env, "NotebookEdit", r.key, notRead, ctx.signal));
|
package/dist/tools/fs/index.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface HandsToolkitOptions {
|
|
|
29
29
|
}) => void;
|
|
30
30
|
detachHub?: import("../../core/tool-detach.js").ToolDetachHub;
|
|
31
31
|
execClamp?: ExecClampOption;
|
|
32
|
+
bashDefaultTimeoutMs?: number;
|
|
33
|
+
bashMaxTimeoutMs?: number;
|
|
32
34
|
oneShot?: boolean;
|
|
33
35
|
autoBackgroundOnTimeout?: boolean;
|
|
34
36
|
readImageDownsampler?: ReadImageDownsamplerOption;
|
package/dist/tools/fs/index.js
CHANGED
|
@@ -35,7 +35,10 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
|
|
|
35
35
|
tools.push(createGrepTool(env, rootCanonical, additionalRoots), createGlobTool(env, rootCanonical, additionalRoots), createRepoMapTool(env, rootCanonical, additionalRoots));
|
|
36
36
|
if (includeShell) {
|
|
37
37
|
tools.push(readOnly
|
|
38
|
-
? createBashReadonlyTool(env, rootCanonical, new Set(bashReadonlyAllow ?? BASH_READONLY_DEFAULT_ALLOW), opts.execClamp
|
|
38
|
+
? createBashReadonlyTool(env, rootCanonical, new Set(bashReadonlyAllow ?? BASH_READONLY_DEFAULT_ALLOW), opts.execClamp, {
|
|
39
|
+
...(opts.bashDefaultTimeoutMs !== undefined ? { bashDefaultTimeoutMs: opts.bashDefaultTimeoutMs } : {}),
|
|
40
|
+
...(opts.bashMaxTimeoutMs !== undefined ? { bashMaxTimeoutMs: opts.bashMaxTimeoutMs } : {}),
|
|
41
|
+
})
|
|
39
42
|
: createBashTool(env, rootCanonical, commitCoAuthor, cwdRef, {
|
|
40
43
|
taskRegistry: opts.taskRegistry,
|
|
41
44
|
taskOwner: opts.taskOwner,
|
|
@@ -46,6 +49,9 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
|
|
|
46
49
|
execClamp: opts.execClamp,
|
|
47
50
|
...(opts.autoBackgroundOnTimeout !== undefined ? { autoBackgroundOnTimeout: opts.autoBackgroundOnTimeout } : {}),
|
|
48
51
|
...(opts.oneShot !== undefined ? { oneShot: opts.oneShot } : {}),
|
|
52
|
+
...(additionalRoots !== undefined ? { additionalRoots } : {}),
|
|
53
|
+
...(opts.bashDefaultTimeoutMs !== undefined ? { bashDefaultTimeoutMs: opts.bashDefaultTimeoutMs } : {}),
|
|
54
|
+
...(opts.bashMaxTimeoutMs !== undefined ? { bashMaxTimeoutMs: opts.bashMaxTimeoutMs } : {}),
|
|
49
55
|
}));
|
|
50
56
|
if (!readOnly && mountBackgroundTaskTools && hasBackgroundShell(env)) {
|
|
51
57
|
const sessionAxis = opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import { defineTool, errorResult } from "../../core/tools.js";
|
|
3
|
-
import { resolveKey, violationText } from "./safety.js";
|
|
3
|
+
import { resolveKey, violationText, violationDetails } from "./safety.js";
|
|
4
4
|
import { buildIgnore, walk, walkIsPartial } from "./search.js";
|
|
5
5
|
const DEFAULT_MAX_CHARS = 16_000;
|
|
6
6
|
const DEFAULT_MAX_FILES = 400;
|
|
@@ -101,7 +101,7 @@ export function createRepoMapTool(env, rootCanonical, additionalRoots) {
|
|
|
101
101
|
if (a.path !== undefined) {
|
|
102
102
|
const r = await resolveKey(env, rootCanonical, a.path, signal, rootCanonical, additionalRoots);
|
|
103
103
|
if (!r.ok)
|
|
104
|
-
return errorResult(violationText("RepoMap", r.violation));
|
|
104
|
+
return errorResult(violationText("RepoMap", r.violation), violationDetails(r.violation));
|
|
105
105
|
start = r.key;
|
|
106
106
|
}
|
|
107
107
|
const maxChars = Math.max(1, Math.floor(a.max_chars ?? DEFAULT_MAX_CHARS));
|
|
@@ -18,6 +18,8 @@ export declare function sha256(content: string): string;
|
|
|
18
18
|
export interface FsViolation {
|
|
19
19
|
code: "path_not_in_root" | "not_read" | "stale" | "ambiguous_edit" | "invalid";
|
|
20
20
|
partialView?: true;
|
|
21
|
+
target?: string;
|
|
22
|
+
roots?: readonly string[];
|
|
21
23
|
message: string;
|
|
22
24
|
}
|
|
23
25
|
export declare function isBlockedDevicePath(key: string): boolean;
|
|
@@ -43,6 +45,14 @@ export declare function canonicalizeTarget(env: ExecutionEnv, path: string, sign
|
|
|
43
45
|
unresolvedSymlink?: true;
|
|
44
46
|
}>;
|
|
45
47
|
export declare function violationText(toolName: string, v: FsViolation): string;
|
|
48
|
+
export declare function violationDetails(v: FsViolation): {
|
|
49
|
+
type: "path_not_in_root";
|
|
50
|
+
code: "path_not_in_root";
|
|
51
|
+
target: string;
|
|
52
|
+
roots: readonly string[];
|
|
53
|
+
} | undefined;
|
|
54
|
+
export declare function withinAnyRoot(rootsCanonical: readonly string[], p: string): boolean;
|
|
55
|
+
export declare const PATH_NOT_IN_ROOT_ESCAPE_HINT = "(This boundary applies to the structured file tools. If you genuinely need content outside the allowed root(s) and the deployment permits it, use the Bash tool \u2014 it is not confined by this fence, though every call remains subject to the deployment's approval policy. Or ask for the directory to be added to additionalDirectories.)";
|
|
46
56
|
export declare function requireRead(state: ReadFileState, key: string): FsViolation | undefined;
|
|
47
57
|
export declare const OVERSIZE_READ_ESCAPE_HINT = "(This file is over the Read tool's whole-file byte cap, so a default Read is refused \u2014 read it in slices with explicit offset/limit to satisfy the read-first rule, or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
|
|
48
58
|
export declare const PARTIAL_VIEW_READ_ESCAPE_HINT = "(Your last Read of this file returned only a PARTIAL view \u2014 the output token cap paginated it, so a default Read will keep returning the same page. Re-read it with explicit offset/limit (start from the page marker's next-page hint) until you have seen the part you are about to change; an explicit slice that fits satisfies the read-first rule. Or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
|
package/dist/tools/fs/safety.js
CHANGED
|
@@ -175,7 +175,12 @@ export async function resolveKey(env, rootCanonical, path, signal, baseCwd, addi
|
|
|
175
175
|
const roots = [rootCanonical, ...(additionalRootsCanonical ?? [])];
|
|
176
176
|
return {
|
|
177
177
|
ok: false,
|
|
178
|
-
violation: {
|
|
178
|
+
violation: {
|
|
179
|
+
code: "path_not_in_root",
|
|
180
|
+
message: `path "${path}" ${key !== path ? `(canonical target: "${key}") ` : ""}resolves outside the allowed root${roots.length > 1 ? "s" : ""} (${roots.join(", ")}); access denied. ${PATH_NOT_IN_ROOT_ESCAPE_HINT}`,
|
|
181
|
+
target: key,
|
|
182
|
+
roots,
|
|
183
|
+
},
|
|
179
184
|
};
|
|
180
185
|
}
|
|
181
186
|
return { ok: true, key };
|
|
@@ -241,6 +246,15 @@ async function canonicalizeNewPath(env, abs, signal) {
|
|
|
241
246
|
export function violationText(toolName, v) {
|
|
242
247
|
return `Error (${toolName}): ${v.message}`;
|
|
243
248
|
}
|
|
249
|
+
export function violationDetails(v) {
|
|
250
|
+
return v.code === "path_not_in_root" && v.target !== undefined
|
|
251
|
+
? { type: "path_not_in_root", code: "path_not_in_root", target: v.target, roots: v.roots ?? [] }
|
|
252
|
+
: undefined;
|
|
253
|
+
}
|
|
254
|
+
export function withinAnyRoot(rootsCanonical, p) {
|
|
255
|
+
return rootsCanonical.some((r) => within(r, p));
|
|
256
|
+
}
|
|
257
|
+
export const PATH_NOT_IN_ROOT_ESCAPE_HINT = "(This boundary applies to the structured file tools. If you genuinely need content outside the allowed root(s) and the deployment permits it, use the Bash tool — it is not confined by this fence, though every call remains subject to the deployment's approval policy. Or ask for the directory to be added to additionalDirectories.)";
|
|
244
258
|
export function requireRead(state, key) {
|
|
245
259
|
const entry = state.get(key);
|
|
246
260
|
if (entry === undefined || entry.isPartialView) {
|
package/dist/tools/monitor.js
CHANGED
|
@@ -91,17 +91,31 @@ export function createMonitorTool(env, opts) {
|
|
|
91
91
|
return errorResult(`Error (Monitor): the watch process was started but could not be registered (${e instanceof Error ? e.message : String(e)}); it has been terminated.`);
|
|
92
92
|
}
|
|
93
93
|
const bgTimeout = env.backgroundCapabilities.defaultBgTimeoutSec;
|
|
94
|
+
const wasClamped = timeout_ms !== undefined && timeout_ms !== timeoutMs;
|
|
95
|
+
const clampNote = !isPersistent && wasClamped ? ` (clamped to ${timeoutMs}ms from your requested ${timeout_ms}ms)` : "";
|
|
96
|
+
const timeoutIgnoredNote = isPersistent && timeout_ms !== undefined ? ` (the timeout_ms you passed is ignored because persistent is true)` : "";
|
|
94
97
|
const lifetime = isPersistent
|
|
95
98
|
? typeof bgTimeout === "number"
|
|
96
|
-
? `It is persistent: no watch timeout of its own, but the execution environment kills background processes after ${bgTimeout}s; it runs until then, until you stop it, or until the session ends.`
|
|
97
|
-
:
|
|
98
|
-
: `It will be killed after ${timeoutMs}ms if still running.`;
|
|
99
|
-
|
|
99
|
+
? `It is persistent: no watch timeout of its own${timeoutIgnoredNote}, but the execution environment kills background processes after ${bgTimeout}s; it runs until then, until you stop it, or until the session ends.`
|
|
100
|
+
: `It is persistent: no watch timeout of its own${timeoutIgnoredNote}, but the execution environment's background time budget still applies; it runs until then, until you stop it, or until the session ends.`
|
|
101
|
+
: `It will be killed after ${timeoutMs}ms${clampNote} if still running.`;
|
|
102
|
+
const content = onNotify !== undefined
|
|
100
103
|
? `Monitoring in background; task_id=${taskId}. Each stdout line becomes a notification event (lines within ` +
|
|
101
104
|
`~200ms are batched); you'll get a final notification with the exit code when it ends — do not poll. ` +
|
|
102
105
|
`${lifetime} TaskOutput("${taskId}") reads the full spool (stdout+stderr); TaskStop("${taskId}") stops the watch.`
|
|
103
106
|
: `Monitoring in background; task_id=${taskId}. No notification channel is wired in this mount — read output with ` +
|
|
104
107
|
`TaskOutput("${taskId}") (re-readable spool, stdout+stderr) and stop with TaskStop("${taskId}"). ${lifetime}`;
|
|
108
|
+
return {
|
|
109
|
+
content,
|
|
110
|
+
details: {
|
|
111
|
+
type: "monitor-start",
|
|
112
|
+
taskId,
|
|
113
|
+
persistent: isPersistent,
|
|
114
|
+
...(isPersistent
|
|
115
|
+
? { timeoutIgnored: timeout_ms !== undefined }
|
|
116
|
+
: { timeoutMs, ...(timeout_ms !== undefined ? { requestedTimeoutMs: timeout_ms } : {}), wasClamped }),
|
|
117
|
+
},
|
|
118
|
+
};
|
|
105
119
|
},
|
|
106
120
|
});
|
|
107
121
|
}
|