@mono-agent/agent-runtime 0.19.1 → 0.20.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/MIGRATION.md +1 -1
- package/README.md +101 -4
- package/package.json +1 -1
- package/src/agent/sandbox-seam.js +16 -2
- package/src/agent/tools/bash.js +26 -4
- package/src/agent/tools/edit.js +72 -5
- package/src/agent/tools/exec.js +22 -4
- package/src/agent/tools/glob.js +65 -9
- package/src/agent/tools/grep.js +66 -11
- package/src/agent/tools/node-repl.js +5 -2
- package/src/agent/tools/pi-bridge.js +263 -48
- package/src/agent/tools/read.js +50 -10
- package/src/agent/tools/shared/path-resolver.js +67 -2
- package/src/agent/tools/shared/process-jobs.js +188 -0
- package/src/agent/tools/shared/process-runner.js +541 -30
- package/src/agent/tools/shared/protected-filesystem.js +150 -0
- package/src/agent/tools/web-search.js +63 -8
- package/src/agent/tools/write.js +52 -6
- package/src/ai/providers/acp.js +4 -0
- package/src/ai/providers/claude-cli.js +35 -2
- package/src/ai/providers/claude-sdk.js +12 -0
- package/src/ai/providers/codex-app.js +15 -2
- package/src/ai/providers/pi-native/stream-subscriber.js +29 -2
- package/src/ai/providers/pi-native/turn-runner.js +3 -0
- package/src/ai/providers/pi-native.js +7 -1
- package/src/ai/runtime/capabilities.js +2 -0
- package/src/ai/runtime/router.js +78 -6
- package/src/ai/streaming/codex-events.js +15 -0
- package/src/ai/streaming/opencode-events.js +5 -0
- package/src/ai/tool-lifecycle.js +347 -0
- package/src/ai/types.js +58 -0
- package/src/runtime.js +35 -21
- package/types/agent/sandbox-seam.d.ts +19 -6
- package/types/agent/tools/bash.d.ts +11 -26
- package/types/agent/tools/edit.d.ts +3 -2
- package/types/agent/tools/exec.d.ts +13 -26
- package/types/agent/tools/glob.d.ts +3 -2
- package/types/agent/tools/grep.d.ts +3 -2
- package/types/agent/tools/pi-bridge.d.ts +11 -4
- package/types/agent/tools/read.d.ts +3 -2
- package/types/agent/tools/shared/path-resolver.d.ts +8 -0
- package/types/agent/tools/shared/process-jobs.d.ts +64 -0
- package/types/agent/tools/shared/process-runner.d.ts +45 -3
- package/types/agent/tools/shared/protected-filesystem.d.ts +51 -0
- package/types/agent/tools/write.d.ts +3 -2
- package/types/ai/providers/pi-native/stream-subscriber.d.ts +2 -0
- package/types/ai/runtime/capabilities.d.ts +3 -0
- package/types/ai/streaming/codex-events.d.ts +1 -0
- package/types/ai/streaming/opencode-events.d.ts +1 -0
- package/types/ai/tool-lifecycle.d.ts +43 -0
- package/types/ai/types.d.ts +118 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Protected-root filesystem operations must cross the native sandbox boundary.
|
|
2
|
+
// A host-side path check cannot close a parent-symlink swap between authorization
|
|
3
|
+
// and open(2); SRT evaluates the path at the actual child-process syscall.
|
|
4
|
+
|
|
5
|
+
// @ts-check
|
|
6
|
+
|
|
7
|
+
import { basename, isAbsolute, relative, resolve, sep } from "node:path";
|
|
8
|
+
import { readToolRuntime } from "./runtime-context.js";
|
|
9
|
+
import { runPreparedProcess } from "./process-runner.js";
|
|
10
|
+
import { resolveSandboxPolicy } from "./tool-context.js";
|
|
11
|
+
|
|
12
|
+
const PROTECTED_OPERATION_TIMEOUT_MS = 15_000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {{command: string, args?: string[], cwd?: string, env?: Record<string, string|undefined>}} command
|
|
16
|
+
* @param {{sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, input?: string|Buffer, maxBufferBytes?: number}} [options]
|
|
17
|
+
* @returns {Promise<any|null>}
|
|
18
|
+
*/
|
|
19
|
+
export async function runProtectedFilesystemCommand(command, {
|
|
20
|
+
sandboxPolicy,
|
|
21
|
+
sandboxEngine,
|
|
22
|
+
ctx,
|
|
23
|
+
input,
|
|
24
|
+
maxBufferBytes,
|
|
25
|
+
} = {}) {
|
|
26
|
+
const resolvedCtx = ctx ?? readToolRuntime();
|
|
27
|
+
const policy = resolveSandboxPolicy(resolvedCtx, sandboxPolicy);
|
|
28
|
+
if (!hasProtectedRoots(policy)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const sandbox = resolvedCtx.sandbox;
|
|
32
|
+
let prepared;
|
|
33
|
+
try {
|
|
34
|
+
prepared = await sandbox.prepareCommand({
|
|
35
|
+
policy,
|
|
36
|
+
engine: sandboxEngine ?? resolvedCtx.sandboxEngine ?? undefined,
|
|
37
|
+
command,
|
|
38
|
+
});
|
|
39
|
+
return await runPreparedProcess(prepared, {
|
|
40
|
+
timeoutMs: PROTECTED_OPERATION_TIMEOUT_MS,
|
|
41
|
+
...(input === undefined ? {} : { input }),
|
|
42
|
+
...(maxBufferBytes === undefined ? {} : { maxBufferBytes }),
|
|
43
|
+
});
|
|
44
|
+
} finally {
|
|
45
|
+
await prepared?.cleanup?.();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @param {any} policy */
|
|
50
|
+
function hasProtectedRoots(policy) {
|
|
51
|
+
return policy !== undefined
|
|
52
|
+
&& Array.isArray(policy.protectedRoots)
|
|
53
|
+
&& policy.protectedRoots.length > 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build a metadata-free operation plan rooted at a configured policy path.
|
|
58
|
+
* Search tools keep the model-controlled target as an argument; file helpers
|
|
59
|
+
* use the stable cwd with their absolute target. In both cases the host avoids
|
|
60
|
+
* target metadata and target-derived cwd resolution before SRT enforces policy.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} target
|
|
63
|
+
* @param {{sandboxPolicy?: any, ctx?: any}} [options]
|
|
64
|
+
* @returns {{cwd: string, searchTarget: string}|null}
|
|
65
|
+
*/
|
|
66
|
+
export function protectedFilesystemTargetPlan(target, { sandboxPolicy, ctx } = {}) {
|
|
67
|
+
const resolvedCtx = ctx ?? readToolRuntime();
|
|
68
|
+
const policy = resolveSandboxPolicy(resolvedCtx, sandboxPolicy);
|
|
69
|
+
if (!hasProtectedRoots(policy)) return null;
|
|
70
|
+
const resolvedTarget = resolve(target);
|
|
71
|
+
const roots = [
|
|
72
|
+
policy.root,
|
|
73
|
+
...(Array.isArray(policy.readableRoots) ? policy.readableRoots : []),
|
|
74
|
+
resolvedCtx.workspace,
|
|
75
|
+
resolvedCtx.repoRoot,
|
|
76
|
+
process.cwd(),
|
|
77
|
+
];
|
|
78
|
+
const cwd = roots
|
|
79
|
+
.filter((root) => typeof root === "string" && root.length > 0)
|
|
80
|
+
.map((root) => resolve(root))
|
|
81
|
+
.find((root) => lexicallyContains(root, resolvedTarget))
|
|
82
|
+
?? resolve(policy.root || resolvedCtx.workspace || resolvedCtx.repoRoot || process.cwd());
|
|
83
|
+
const rel = relative(cwd, resolvedTarget);
|
|
84
|
+
const searchTarget = rel === ""
|
|
85
|
+
? "."
|
|
86
|
+
: (!rel.startsWith("..") && !isAbsolute(rel) ? rel : resolvedTarget);
|
|
87
|
+
return { cwd, searchTarget };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** @param {string} searchTarget */
|
|
91
|
+
export function protectedDirectorySearchTarget(searchTarget) {
|
|
92
|
+
return searchTarget.endsWith(sep) ? searchTarget : `${searchTarget}${sep}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Scope a target-relative user glob to ripgrep's stable host cwd.
|
|
97
|
+
* @param {string} pattern
|
|
98
|
+
* @param {string} searchTarget
|
|
99
|
+
*/
|
|
100
|
+
export function scopeProtectedSearchGlob(pattern, searchTarget) {
|
|
101
|
+
const normalizedTarget = normalizeSearchPath(searchTarget);
|
|
102
|
+
if (normalizedTarget === ".") return pattern;
|
|
103
|
+
const negated = pattern.startsWith("!");
|
|
104
|
+
const body = (negated ? pattern.slice(1) : pattern).replace(/^\.\//u, "").replace(/^\/+/u, "");
|
|
105
|
+
const prefix = normalizedTarget
|
|
106
|
+
.split("/")
|
|
107
|
+
.map((part) => part.replace(/[!\\*?\[\]{}]/gu, "\\$&"))
|
|
108
|
+
.join("/");
|
|
109
|
+
return `${negated ? "!" : ""}${prefix}/${body}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Restore the historical target-relative search output after ripgrep runs from
|
|
114
|
+
* the stable policy root.
|
|
115
|
+
* @param {string} line
|
|
116
|
+
* @param {string} searchTarget
|
|
117
|
+
*/
|
|
118
|
+
export function normalizeProtectedSearchLine(line, searchTarget) {
|
|
119
|
+
const normalizedLine = normalizeSearchPath(line).replace(/^\.\//u, "");
|
|
120
|
+
const normalizedTarget = normalizeSearchPath(searchTarget).replace(/^\.\//u, "");
|
|
121
|
+
if (normalizedTarget === ".") return normalizedLine;
|
|
122
|
+
if (normalizedLine === normalizedTarget) return basename(normalizedTarget);
|
|
123
|
+
if (normalizedLine.startsWith(`${normalizedTarget}/`)) {
|
|
124
|
+
return normalizedLine.slice(normalizedTarget.length + 1);
|
|
125
|
+
}
|
|
126
|
+
if (normalizedLine.startsWith(`${normalizedTarget}:`)) {
|
|
127
|
+
return `${basename(normalizedTarget)}${normalizedLine.slice(normalizedTarget.length)}`;
|
|
128
|
+
}
|
|
129
|
+
return normalizedLine;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** @param {any} result */
|
|
133
|
+
export function protectedCommandSucceeded(result) {
|
|
134
|
+
return result !== null
|
|
135
|
+
&& result.code === 0
|
|
136
|
+
&& result.signal === null
|
|
137
|
+
&& result.spawnError === null
|
|
138
|
+
&& result.timedOut === false
|
|
139
|
+
&& result.bufferExceeded === false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normalizeSearchPath(path) {
|
|
143
|
+
const normalized = sep === "\\" ? String(path).replaceAll("\\", "/") : String(path);
|
|
144
|
+
return normalized.replace(/\/+$/u, "") || ".";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function lexicallyContains(root, target) {
|
|
148
|
+
const rel = relative(resolve(root), resolve(target));
|
|
149
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
150
|
+
}
|
|
@@ -35,7 +35,16 @@ const KEYLESS_DEFAULT_THROTTLE = {
|
|
|
35
35
|
// behind it has to actually work.
|
|
36
36
|
const KEYLESS_BACKENDS = ["duckduckgo", "startpage"];
|
|
37
37
|
// Markers that identify an interstitial/bot-gate body served with a 2xx status.
|
|
38
|
-
|
|
38
|
+
// The last two are Anubis, the proof-of-work gate Startpage now fronts its
|
|
39
|
+
// results with. It says none of the classic things — no captcha, no anomaly,
|
|
40
|
+
// just "Verifying your request..." — so without these it read as a clean 200
|
|
41
|
+
// that happened to parse to nothing, which is exactly the lie this guard exists
|
|
42
|
+
// to prevent.
|
|
43
|
+
const CHALLENGE_BODY_RE =
|
|
44
|
+
/anomaly|unusual traffic|captcha|are you a robot|challenge-(?:platform|form)|anubis[_-]?challenge|verifying your request/iu;
|
|
45
|
+
// Reasons SearXNG reports for an engine that is being throttled or gated rather
|
|
46
|
+
// than merely erroring, e.g. "CAPTCHA", "too many requests", "Suspended: CAPTCHA".
|
|
47
|
+
const SEARXNG_THROTTLE_REASON_RE = /captcha|too many requests|rate.?limit|suspend|blocked|denied/iu;
|
|
39
48
|
// Statuses these engines use to say "you are sending too much", all of which
|
|
40
49
|
// must put the backend into cooldown rather than be retried next search.
|
|
41
50
|
const RATE_LIMIT_STATUSES = new Set([202, 403, 429]);
|
|
@@ -228,17 +237,22 @@ async function searchOneQuery(query, options) {
|
|
|
228
237
|
// too. Reporting only the last one is what made a DuckDuckGo ban surface as
|
|
229
238
|
// "startpage request failed: fetch failed" and sent diagnosis the wrong way.
|
|
230
239
|
const failures = [];
|
|
240
|
+
// A genuinely empty 200 is a real answer, not a transport failure — but it is
|
|
241
|
+
// only worth returning once every backend has had its turn. Scoped to the whole
|
|
242
|
+
// chain, not just the keyless loop: an empty SearXNG answer used to short-
|
|
243
|
+
// circuit `auto` outright, so the fallbacks below could never rescue a query.
|
|
244
|
+
let emptySuccess = null;
|
|
231
245
|
if (options.signal?.aborted) return abortedSearch(config.backend, failures);
|
|
232
246
|
if (config.backend === "searxng" || (config.backend === "auto" && config.endpoint)) {
|
|
233
247
|
const result = await searchSearxng(query, options);
|
|
234
|
-
|
|
235
|
-
|
|
248
|
+
// Strict mode has nothing to fall through to, so its answer stands as-is.
|
|
249
|
+
if (config.backend === "searxng") return { ...result, failures };
|
|
250
|
+
if (result.ok && result.results.length > 0) return { ...result, failures };
|
|
251
|
+
if (result.ok) emptySuccess = result;
|
|
252
|
+
else failures.push(result);
|
|
236
253
|
if (options.signal?.aborted) return abortedSearch(result.backend, failures);
|
|
237
254
|
}
|
|
238
255
|
if (config.backend === "keyless" || config.backend === "auto") {
|
|
239
|
-
// A genuinely empty 200 is a real answer, not a transport failure — but it
|
|
240
|
-
// is only worth returning once every backend has had its turn.
|
|
241
|
-
let emptySuccess = null;
|
|
242
256
|
for (const backend of KEYLESS_BACKENDS) {
|
|
243
257
|
if (options.signal?.aborted) return abortedSearch(backend, failures);
|
|
244
258
|
if (backendInCooldown(backend)) {
|
|
@@ -254,14 +268,14 @@ async function searchOneQuery(query, options) {
|
|
|
254
268
|
const result = await KEYLESS_RUNNERS[backend](query, options);
|
|
255
269
|
if (result.ok) {
|
|
256
270
|
if (result.results.length > 0) return { ...result, failures };
|
|
257
|
-
emptySuccess
|
|
271
|
+
emptySuccess ??= result;
|
|
258
272
|
continue;
|
|
259
273
|
}
|
|
260
274
|
// The cooldown is already open — rateLimited() sets it at detection.
|
|
261
275
|
failures.push(result);
|
|
262
276
|
}
|
|
263
|
-
if (emptySuccess) return { ...emptySuccess, failures };
|
|
264
277
|
}
|
|
278
|
+
if (emptySuccess) return { ...emptySuccess, failures };
|
|
265
279
|
return {
|
|
266
280
|
...(failures[failures.length - 1] || {
|
|
267
281
|
ok: false,
|
|
@@ -385,6 +399,33 @@ async function searchSearxng(query, options) {
|
|
|
385
399
|
const results = Array.isArray(data?.results)
|
|
386
400
|
? data.results.flatMap((entry) => normalizedResult(entry, "searxng"))
|
|
387
401
|
: [];
|
|
402
|
+
// An instance whose engines are all captcha'd or suspended still answers
|
|
403
|
+
// `200 {"results": []}`, and `unresponsive_engines` is the only thing that
|
|
404
|
+
// tells that apart from a query nothing matched. Reading `results` alone is
|
|
405
|
+
// what let a completely dead instance report "No results." on every query
|
|
406
|
+
// for weeks. Naming each engine and its reason is what makes the next one
|
|
407
|
+
// diagnosable from the tool output instead of from the container logs.
|
|
408
|
+
//
|
|
409
|
+
// Counted on the RAW array, not the normalized one: results that all fail
|
|
410
|
+
// canonicalization are an unusable answer from working engines, which is a
|
|
411
|
+
// different fault and must not be blamed on the engines that did fail.
|
|
412
|
+
const unresponsive = normalizeUnresponsiveEngines(data?.unresponsive_engines);
|
|
413
|
+
if (!Array.isArray(data?.results) || (data.results.length === 0 && unresponsive.length > 0)) {
|
|
414
|
+
if (unresponsive.length === 0) {
|
|
415
|
+
return { ok: false, backend: "searxng", message: "SearXNG returned no results array.", retryable: false };
|
|
416
|
+
}
|
|
417
|
+
// Deliberately not "every engine failed": SearXNG lists only the engines
|
|
418
|
+
// that failed, so a working engine that simply matched nothing is
|
|
419
|
+
// indistinguishable here from one that was never queried.
|
|
420
|
+
const detail = unresponsive.map((entry) => `${entry.name}: ${entry.reason}`).join("; ");
|
|
421
|
+
return {
|
|
422
|
+
ok: false,
|
|
423
|
+
backend: "searxng",
|
|
424
|
+
message: `SearXNG returned no results and ${unresponsive.length === 1 ? "1 engine" : `${unresponsive.length} engines`} failed (${detail})`,
|
|
425
|
+
retryable: true,
|
|
426
|
+
rateLimited: unresponsive.some((entry) => SEARXNG_THROTTLE_REASON_RE.test(entry.reason)),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
388
429
|
return { ok: true, backend: "searxng", results };
|
|
389
430
|
} catch (error) {
|
|
390
431
|
return fetchFailure("searxng", error);
|
|
@@ -573,6 +614,20 @@ export function parseStartpageResults(html) {
|
|
|
573
614
|
return results;
|
|
574
615
|
}
|
|
575
616
|
|
|
617
|
+
/**
|
|
618
|
+
* SearXNG reports each failed engine as a `[name, reason]` pair. Older builds
|
|
619
|
+
* and some forks send objects instead, so both shapes are accepted.
|
|
620
|
+
*/
|
|
621
|
+
function normalizeUnresponsiveEngines(value) {
|
|
622
|
+
if (!Array.isArray(value)) return [];
|
|
623
|
+
return value.flatMap((entry) => {
|
|
624
|
+
const [name, reason] = Array.isArray(entry) ? entry : [entry?.name, entry?.error ?? entry?.reason];
|
|
625
|
+
const normalizedName = collapseWhitespace(name);
|
|
626
|
+
if (!normalizedName) return [];
|
|
627
|
+
return [{ name: normalizedName, reason: collapseWhitespace(reason) || "unknown error" }];
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
576
631
|
function normalizedResult(entry, backend) {
|
|
577
632
|
if (!entry || typeof entry !== "object") return [];
|
|
578
633
|
const url = canonicalizeSearchUrl(entry.url);
|
package/src/agent/tools/write.js
CHANGED
|
@@ -1,18 +1,64 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { MAX_WRITE_BYTES } from "./shared/constants.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
isWritablePathAllowed,
|
|
6
|
+
isWritablePathLexicallyAllowed,
|
|
7
|
+
resolveToolPath,
|
|
8
|
+
} from "./shared/path-resolver.js";
|
|
9
|
+
import {
|
|
10
|
+
protectedCommandSucceeded,
|
|
11
|
+
protectedFilesystemTargetPlan,
|
|
12
|
+
runProtectedFilesystemCommand,
|
|
13
|
+
} from "./shared/protected-filesystem.js";
|
|
14
|
+
|
|
15
|
+
const PROTECTED_WRITE_SOURCE = String.raw`
|
|
16
|
+
"use strict";
|
|
17
|
+
const { mkdirSync, writeFileSync } = require("node:fs");
|
|
18
|
+
const { dirname } = require("node:path");
|
|
19
|
+
const chunks = [];
|
|
20
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
21
|
+
process.stdin.on("end", () => {
|
|
22
|
+
const target = process.argv[1];
|
|
23
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
24
|
+
writeFileSync(target, Buffer.concat(chunks));
|
|
25
|
+
});
|
|
26
|
+
`;
|
|
5
27
|
|
|
6
28
|
/**
|
|
7
29
|
* @param {{file_path: string, content?: string, workdir?: string}} params
|
|
8
|
-
* @param {{sandboxPolicy?: any, ctx?: any}} [options]
|
|
30
|
+
* @param {{sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
|
|
9
31
|
*/
|
|
10
|
-
export async function writeToolImpl({ file_path, content, workdir }, { sandboxPolicy, ctx } = {}) {
|
|
32
|
+
export async function writeToolImpl({ file_path, content, workdir }, { sandboxPolicy, sandboxEngine, ctx } = {}) {
|
|
11
33
|
const target = resolveToolPath(file_path, workdir, ctx);
|
|
12
|
-
|
|
34
|
+
const pathOptions = { sandboxPolicy, ctx };
|
|
35
|
+
const protectedTarget = protectedFilesystemTargetPlan(target, { sandboxPolicy, ctx });
|
|
36
|
+
const protectedExecution = protectedTarget !== null;
|
|
37
|
+
if (protectedExecution) {
|
|
38
|
+
if (!isWritablePathLexicallyAllowed(target, workdir, pathOptions)) {
|
|
39
|
+
return "Error: Protected filesystem write was denied.";
|
|
40
|
+
}
|
|
41
|
+
} else if (!isWritablePathAllowed(target, workdir, pathOptions)) {
|
|
42
|
+
return `Error: Path not allowed: ${file_path}`;
|
|
43
|
+
}
|
|
13
44
|
const bytes = Buffer.byteLength(content || "", "utf8");
|
|
14
45
|
if (bytes > MAX_WRITE_BYTES) return `Error: Content too large (${bytes} bytes)`;
|
|
15
|
-
|
|
16
|
-
|
|
46
|
+
try {
|
|
47
|
+
if (protectedExecution) {
|
|
48
|
+
const protectedResult = await runProtectedFilesystemCommand({
|
|
49
|
+
command: process.execPath,
|
|
50
|
+
args: ["--input-type=commonjs", "--eval", PROTECTED_WRITE_SOURCE, target],
|
|
51
|
+
cwd: protectedTarget.cwd,
|
|
52
|
+
}, { sandboxPolicy, sandboxEngine, ctx, input: content || "" });
|
|
53
|
+
if (!protectedCommandSucceeded(protectedResult)) {
|
|
54
|
+
return "Error: Protected filesystem write was denied.";
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
58
|
+
writeFileSync(target, content || "", "utf8");
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
return "Error: Protected filesystem write was denied.";
|
|
62
|
+
}
|
|
17
63
|
return `Successfully wrote ${bytes} bytes to ${target}`;
|
|
18
64
|
}
|
package/src/ai/providers/acp.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
ownAcpSessionUpdateKind,
|
|
18
18
|
sanitizeAcpHostValueWithStatus,
|
|
19
19
|
} from "./acp-privacy.js";
|
|
20
|
+
import { toolLifecycleMetadata } from "../tool-lifecycle.js";
|
|
20
21
|
|
|
21
22
|
/** @param {any} callback @param {any} event */
|
|
22
23
|
function emit(callback, event) {
|
|
@@ -189,6 +190,9 @@ function normalizeUpdate(update, state) {
|
|
|
189
190
|
tool_use_id: publicValue(ownValue(protocolBody, "toolCallId")),
|
|
190
191
|
content: typeof output === "string" ? output : JSON.stringify(jsonSafe(output)),
|
|
191
192
|
is_error: ownValue(protocolBody, "status") === "failed",
|
|
193
|
+
tool_lifecycle: toolLifecycleMetadata(ownValue(protocolBody, "status") === "failed"
|
|
194
|
+
? { state: "error", failure_kind: "runtime_error", detail_code: "acp_tool_failed" }
|
|
195
|
+
: { state: "success" }),
|
|
192
196
|
}],
|
|
193
197
|
},
|
|
194
198
|
});
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from "./claude-sandbox.js";
|
|
27
27
|
import { resolveSandboxPolicy } from "../../agent/tools/shared/tool-context.js";
|
|
28
28
|
import { createClaudeSubagentActivityNormalizer } from "./claude-subagent-activity.js";
|
|
29
|
+
import { toolLifecycleMetadata } from "../tool-lifecycle.js";
|
|
29
30
|
|
|
30
31
|
const CODEX_CLI_SANDBOX_POLICY_UNSUPPORTED =
|
|
31
32
|
"Direct Codex CLI cannot enforce mono-agent's native srt sandbox scopes. Remove the mono-agent sandbox policy or use a Pi runtime for exact readableRoots, writableRoots, denyWrite, and network rules.";
|
|
@@ -260,7 +261,8 @@ export function normalizeCliEvent(raw, context = {}) {
|
|
|
260
261
|
if (raw.type === "assistant") {
|
|
261
262
|
return thinkingBuffer ? thinkingBuffer.rehydrate(raw) : raw;
|
|
262
263
|
}
|
|
263
|
-
if (raw.type === "user"
|
|
264
|
+
if (raw.type === "user") return annotateClaudeCliToolResults(raw);
|
|
265
|
+
if (raw.type === "result" || raw.type === "error") return raw;
|
|
264
266
|
if (raw.type === "message" && raw.message) return { type: "assistant", message: raw.message };
|
|
265
267
|
if (raw.type === "item.completed" && raw.item?.type === "agent_message" && typeof raw.item.text === "string") {
|
|
266
268
|
return { type: "assistant", message: { content: [{ type: "text", text: raw.item.text }] } };
|
|
@@ -276,11 +278,42 @@ export function normalizeCliEvent(raw, context = {}) {
|
|
|
276
278
|
return { type: "assistant", message: { content: [{ type: "tool_use", id: raw.id, name: raw.name, input: raw.input || raw.arguments }] } };
|
|
277
279
|
}
|
|
278
280
|
if (raw.type === "tool_result") {
|
|
279
|
-
|
|
281
|
+
const isError = raw.is_error === true;
|
|
282
|
+
return {
|
|
283
|
+
type: "user",
|
|
284
|
+
message: {
|
|
285
|
+
content: [{
|
|
286
|
+
type: "tool_result",
|
|
287
|
+
tool_use_id: raw.id || raw.tool_use_id,
|
|
288
|
+
content: raw.output || raw.result || "",
|
|
289
|
+
is_error: isError,
|
|
290
|
+
tool_lifecycle: toolLifecycleMetadata(isError
|
|
291
|
+
? { state: "error", failure_kind: "runtime_error", detail_code: "claude_cli_tool_error" }
|
|
292
|
+
: { state: "success" }),
|
|
293
|
+
}],
|
|
294
|
+
},
|
|
295
|
+
};
|
|
280
296
|
}
|
|
281
297
|
return { type: "cli_event", raw };
|
|
282
298
|
}
|
|
283
299
|
|
|
300
|
+
/** Preserve Claude CLI's only trustworthy terminal distinction: tool_result is_error. */
|
|
301
|
+
function annotateClaudeCliToolResults(raw) {
|
|
302
|
+
if (!raw?.message || !Array.isArray(raw.message.content)) return raw;
|
|
303
|
+
let changed = false;
|
|
304
|
+
const content = raw.message.content.map((block) => {
|
|
305
|
+
if (!block || block.type !== "tool_result") return block;
|
|
306
|
+
changed = true;
|
|
307
|
+
return {
|
|
308
|
+
...block,
|
|
309
|
+
tool_lifecycle: toolLifecycleMetadata(block.is_error === true
|
|
310
|
+
? { state: "error", failure_kind: "runtime_error", detail_code: "claude_cli_tool_error" }
|
|
311
|
+
: { state: "success" }),
|
|
312
|
+
};
|
|
313
|
+
});
|
|
314
|
+
return changed ? { ...raw, message: { ...raw.message, content } } : raw;
|
|
315
|
+
}
|
|
316
|
+
|
|
284
317
|
function textFromEvent(raw) {
|
|
285
318
|
if (typeof raw?.text === "string") return raw.text;
|
|
286
319
|
if (typeof raw?.item?.text === "string") return raw.item.text;
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
claudeSandboxPolicyProblem,
|
|
20
20
|
} from "./claude-sandbox.js";
|
|
21
21
|
import { createClaudeSubagentActivityNormalizer } from "./claude-subagent-activity.js";
|
|
22
|
+
import { toolLifecycleMetadata } from "../tool-lifecycle.js";
|
|
22
23
|
|
|
23
24
|
const CLAUDE_EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
24
25
|
const CLAUDE_SETTING_SOURCES = new Set(["user", "project", "local"]);
|
|
@@ -383,6 +384,16 @@ function toolResultText(block) {
|
|
|
383
384
|
try { return JSON.stringify(content); } catch { return String(content); }
|
|
384
385
|
}
|
|
385
386
|
|
|
387
|
+
function annotateClaudeToolLifecycles(event) {
|
|
388
|
+
if (event?.type !== "user" || !Array.isArray(event.message?.content)) return;
|
|
389
|
+
for (const block of event.message.content) {
|
|
390
|
+
if (!block || block.type !== "tool_result") continue;
|
|
391
|
+
block.tool_lifecycle = toolLifecycleMetadata(block.is_error === true
|
|
392
|
+
? { state: "error", failure_kind: "runtime_error", detail_code: "claude_sdk_tool_error" }
|
|
393
|
+
: { state: "success" });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
386
397
|
function structuredOutputRejectionFromEvent(event) {
|
|
387
398
|
if (event?.type !== "user" || !Array.isArray(event.message?.content)) return null;
|
|
388
399
|
for (const block of event.message.content) {
|
|
@@ -666,6 +677,7 @@ export async function generateClaudeResponse(systemPrompt, options) {
|
|
|
666
677
|
|
|
667
678
|
function emitEvent(event) {
|
|
668
679
|
if (!event) return;
|
|
680
|
+
annotateClaudeToolLifecycles(event);
|
|
669
681
|
capturedEvents.push(event);
|
|
670
682
|
onEvent(event);
|
|
671
683
|
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
isAllowAllToolPolicy,
|
|
16
16
|
TOOL_POLICY_ALLOW_ALL_ONLY,
|
|
17
17
|
} from "../runtime/tool-policy.js";
|
|
18
|
+
import { toolLifecycleMetadata } from "../tool-lifecycle.js";
|
|
18
19
|
|
|
19
20
|
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
20
21
|
const DEFAULT_THREAD_START_ATTEMPTS = 2;
|
|
@@ -535,6 +536,9 @@ function delay(ms, signal) {
|
|
|
535
536
|
});
|
|
536
537
|
}
|
|
537
538
|
|
|
539
|
+
// `thread/start.sandbox` selects only the thread-level filesystem class; it
|
|
540
|
+
// does not own network. `sandboxPolicyForRun()` supplies authoritative per-turn
|
|
541
|
+
// networkAccess, per the app-server 0.147.0 / gpt-5.6-sol retained-thread matrix.
|
|
538
542
|
function sandboxForRun(options) {
|
|
539
543
|
if (options.codexNoToolsProbe === true) return "read-only";
|
|
540
544
|
if (options.permissionMode === "bypassPermissions") return "danger-full-access";
|
|
@@ -553,11 +557,14 @@ function approvalPolicyForRun(options) {
|
|
|
553
557
|
function sandboxPolicyForRun(options) {
|
|
554
558
|
if (options.codexNoToolsProbe === true) return { type: "readOnly", networkAccess: false };
|
|
555
559
|
if (options.permissionMode === "bypassPermissions") return { type: "dangerFullAccess" };
|
|
556
|
-
|
|
560
|
+
// App-server 0.147.0 / gpt-5.6-sol retained-thread matrix proved per-turn
|
|
561
|
+
// networkAccess authoritative in both directions.
|
|
562
|
+
const networkAccess = options.codexSandboxNetworkAccess === true;
|
|
563
|
+
if (options.permissionMode === "plan") return { type: "readOnly", networkAccess };
|
|
557
564
|
return {
|
|
558
565
|
type: "workspaceWrite",
|
|
559
566
|
writableRoots: [options.cwd || process.cwd()],
|
|
560
|
-
networkAccess
|
|
567
|
+
networkAccess,
|
|
561
568
|
excludeTmpdirEnvVar: false,
|
|
562
569
|
excludeSlashTmp: false,
|
|
563
570
|
};
|
|
@@ -1064,6 +1071,9 @@ function mapThreadItem(method, item) {
|
|
|
1064
1071
|
...(item.error ? { error: item.error } : {}),
|
|
1065
1072
|
},
|
|
1066
1073
|
is_error: item.status === "failed" || Boolean(item.error),
|
|
1074
|
+
tool_lifecycle: toolLifecycleMetadata(item.status === "failed" || Boolean(item.error)
|
|
1075
|
+
? { state: "error", failure_kind: "runtime_error", detail_code: "codex_collab_failed" }
|
|
1076
|
+
: { state: "success" }),
|
|
1067
1077
|
}],
|
|
1068
1078
|
},
|
|
1069
1079
|
};
|
|
@@ -1090,6 +1100,9 @@ function mapThreadItem(method, item) {
|
|
|
1090
1100
|
tool_use_id: item.id,
|
|
1091
1101
|
content: item.contentItems || item.result || item.error || "",
|
|
1092
1102
|
is_error: item.status === "failed" || item.success === false || Boolean(item.error),
|
|
1103
|
+
tool_lifecycle: toolLifecycleMetadata(item.status === "failed" || item.success === false || Boolean(item.error)
|
|
1104
|
+
? { state: "error", failure_kind: "runtime_error", detail_code: "codex_dynamic_tool_failed" }
|
|
1105
|
+
: { state: "success" }),
|
|
1093
1106
|
}],
|
|
1094
1107
|
},
|
|
1095
1108
|
};
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
streamContentKey,
|
|
15
15
|
} from "../pi-events.js";
|
|
16
16
|
import { contextUsageFromAssistantMessage } from "./result-builder.js";
|
|
17
|
+
import { classifyPiToolResult, toolLifecycleMetadata } from "../../tool-lifecycle.js";
|
|
17
18
|
|
|
18
19
|
function toolResultFileChange(result) {
|
|
19
20
|
const fileChange = result?.details?.file_change;
|
|
@@ -66,6 +67,7 @@ function toolResultOutcome(result) {
|
|
|
66
67
|
* @property {Set<unknown>} textDeltaIndexes
|
|
67
68
|
* @property {Set<unknown>} thinkingDeltaIndexes
|
|
68
69
|
* @property {Map<string, number>} toolStartTimes
|
|
70
|
+
* @property {Map<string, any>} toolApprovals
|
|
69
71
|
* @property {number} turnCount
|
|
70
72
|
* @property {number} toolResultsSeen
|
|
71
73
|
* @property {string|null} lastToolName
|
|
@@ -128,7 +130,14 @@ export function createStreamSubscriber(runState, { onEvent, options, toolLimits,
|
|
|
128
130
|
const input = eventToolArgs(event.toolName, event.args, { cwd: options.cwd, toolLimits });
|
|
129
131
|
onEvent({
|
|
130
132
|
type: "assistant",
|
|
131
|
-
message: {
|
|
133
|
+
message: {
|
|
134
|
+
content: [{
|
|
135
|
+
type: "tool_use",
|
|
136
|
+
id: event.toolCallId,
|
|
137
|
+
name: event.toolName,
|
|
138
|
+
input,
|
|
139
|
+
}],
|
|
140
|
+
},
|
|
132
141
|
});
|
|
133
142
|
} else if (event.type === "tool_execution_update") {
|
|
134
143
|
const input = eventToolArgs(event.toolName, event.args, { cwd: options.cwd, toolLimits });
|
|
@@ -143,6 +152,12 @@ export function createStreamSubscriber(runState, { onEvent, options, toolLimits,
|
|
|
143
152
|
const resultContent = toolResultContent(event.result);
|
|
144
153
|
const fileChange = toolResultFileChange(event.result);
|
|
145
154
|
const outcome = toolResultOutcome(event.result);
|
|
155
|
+
const classification = classifyPiToolResult({
|
|
156
|
+
result: event.result,
|
|
157
|
+
isError: !!event.isError,
|
|
158
|
+
aborted: options.abortSignal?.aborted === true,
|
|
159
|
+
approval: runState.toolApprovals.get(event.toolCallId),
|
|
160
|
+
});
|
|
146
161
|
if (!event.isError) runState.toolResultsSeen += 1;
|
|
147
162
|
const startedAt = runState.toolStartTimes.get(event.toolCallId);
|
|
148
163
|
if (startedAt !== undefined) {
|
|
@@ -154,8 +169,15 @@ export function createStreamSubscriber(runState, { onEvent, options, toolLimits,
|
|
|
154
169
|
execution_ms: Date.now() - startedAt,
|
|
155
170
|
is_error: !!event.isError,
|
|
156
171
|
...(outcome || {}),
|
|
172
|
+
tool_lifecycle: toolLifecycleMetadata({
|
|
173
|
+
state: classification.state,
|
|
174
|
+
failure_kind: classification.failureKind,
|
|
175
|
+
detail_code: classification.detailCode,
|
|
176
|
+
}),
|
|
157
177
|
});
|
|
158
178
|
}
|
|
179
|
+
runState.toolApprovals.delete(event.toolCallId);
|
|
180
|
+
const rawResult = compactToolRawResult(jsonSerializable(event.result, resultContent), resultContent);
|
|
159
181
|
onEvent({
|
|
160
182
|
type: "user",
|
|
161
183
|
message: {
|
|
@@ -163,9 +185,14 @@ export function createStreamSubscriber(runState, { onEvent, options, toolLimits,
|
|
|
163
185
|
type: "tool_result",
|
|
164
186
|
tool_use_id: event.toolCallId,
|
|
165
187
|
content: resultContent,
|
|
166
|
-
raw_result:
|
|
188
|
+
raw_result: rawResult,
|
|
167
189
|
...(fileChange === null ? {} : { file_change: fileChange }),
|
|
168
190
|
is_error: !!event.isError,
|
|
191
|
+
tool_lifecycle: toolLifecycleMetadata({
|
|
192
|
+
state: classification.state,
|
|
193
|
+
failure_kind: classification.failureKind,
|
|
194
|
+
detail_code: classification.detailCode,
|
|
195
|
+
}),
|
|
169
196
|
}],
|
|
170
197
|
},
|
|
171
198
|
});
|
|
@@ -120,6 +120,7 @@ export async function buildTurnTools(runState, {
|
|
|
120
120
|
approvalModel: runtime.model?.id || runtime.model?.name || resolved.model,
|
|
121
121
|
nodeReplController,
|
|
122
122
|
webController,
|
|
123
|
+
processJobsController: options.processJobs,
|
|
123
124
|
toolExecutionMode,
|
|
124
125
|
subagents: options.subagents,
|
|
125
126
|
// The child inherits the parent's route and workspace unless its profile
|
|
@@ -166,6 +167,8 @@ export async function buildTurnTools(runState, {
|
|
|
166
167
|
sandboxPolicy: options.sandboxPolicy,
|
|
167
168
|
sandboxEngine,
|
|
168
169
|
ctx: runCtx,
|
|
170
|
+
mcpApps: options.mcpApps,
|
|
171
|
+
runId: runCtx?.runId,
|
|
169
172
|
}));
|
|
170
173
|
// Surface MCP init/list failures BOTH to the live event stream and to runtimeWarnings, so a
|
|
171
174
|
// failed server (e.g. an stdio adapter-send child that closed on startup) lands in the run
|
|
@@ -276,6 +276,7 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
|
|
|
276
276
|
textDeltaIndexes: new Set(),
|
|
277
277
|
thinkingDeltaIndexes: new Set(),
|
|
278
278
|
toolStartTimes: new Map(),
|
|
279
|
+
toolApprovals: new Map(),
|
|
279
280
|
turnCount: 0,
|
|
280
281
|
toolResultsSeen: 0,
|
|
281
282
|
lastToolName: null,
|
|
@@ -345,7 +346,12 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
|
|
|
345
346
|
let structuredOutputFinalizationRetryFailed = false;
|
|
346
347
|
const piTransport = resolvePiTransport(options.piTransport);
|
|
347
348
|
|
|
348
|
-
const onEvent = (event) =>
|
|
349
|
+
const onEvent = (event) => {
|
|
350
|
+
if (event?.type === "tool_approval_denied" && typeof event.toolUseId === "string") {
|
|
351
|
+
runState.toolApprovals.set(event.toolUseId, event);
|
|
352
|
+
}
|
|
353
|
+
emitCaptured(events, options.onEvent, event);
|
|
354
|
+
};
|
|
349
355
|
const approvalRiskTiers = {
|
|
350
356
|
...(options.toolRiskTiers || {}),
|
|
351
357
|
...(
|
|
@@ -9,6 +9,7 @@ export const COMMON_CAPABILITIES = {
|
|
|
9
9
|
supports_session_resume: false,
|
|
10
10
|
native_runtime_config: null,
|
|
11
11
|
supports_mcp: true,
|
|
12
|
+
supports_mcp_apps: false,
|
|
12
13
|
supports_skills: true,
|
|
13
14
|
supports_builtin_tools: true,
|
|
14
15
|
supports_live_input: true,
|
|
@@ -36,6 +37,7 @@ export const RUNTIME_CAPABILITIES = {
|
|
|
36
37
|
// tool, so advertise no support rather than letting callers expect it.
|
|
37
38
|
supports_native_subagents: false,
|
|
38
39
|
supports_request_tool_environment: true,
|
|
40
|
+
supports_mcp_apps: true,
|
|
39
41
|
},
|
|
40
42
|
codex: {
|
|
41
43
|
runtime: "cli",
|