@mono-agent/agent-runtime 0.19.1 → 0.20.1
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
|
@@ -29,31 +29,76 @@ export function isWritablePathAllowed(path, workdir, options = {}) {
|
|
|
29
29
|
return isPathAllowedFor(path, workdir, "write", options);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// Protected filesystem operations use these metadata-free checks only as a
|
|
33
|
+
// lexical policy preflight. The native sandbox remains responsible for
|
|
34
|
+
// resolving symlinks and enforcing the real path at the operation syscall.
|
|
35
|
+
export function isPathLexicallyAllowed(path, workdir, options = {}) {
|
|
36
|
+
return isPathLexicallyAllowedFor(path, workdir, "read", options);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isWritablePathLexicallyAllowed(path, workdir, options = {}) {
|
|
40
|
+
return isPathLexicallyAllowedFor(path, workdir, "write", options);
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
function isPathAllowedFor(path, workdir, access, options) {
|
|
33
44
|
const ctx = options.ctx;
|
|
34
45
|
const r = resolveToolPath(path, workdir, ctx);
|
|
35
46
|
const policy = resolveSandboxPolicy(ctx ?? readToolRuntime(), options.sandboxPolicy);
|
|
36
47
|
if (policy) {
|
|
37
48
|
const field = access === "write" ? policy.writableRoots : policy.readableRoots;
|
|
38
|
-
return
|
|
49
|
+
return !insideProtectedRoots(Array.isArray(policy.protectedRoots) ? policy.protectedRoots : [], r)
|
|
50
|
+
&& insideSandboxRoots(Array.isArray(field) ? field : [], r)
|
|
39
51
|
&& (access !== "write" || !sandboxDeniesWrite(policy, r, ctx));
|
|
40
52
|
}
|
|
41
53
|
const { workspace, repoRoot } = configured(ctx);
|
|
42
54
|
return insideLegacyRoots([workdir, workspace, repoRoot, process.cwd(), "/tmp"], r);
|
|
43
55
|
}
|
|
44
56
|
|
|
57
|
+
function isPathLexicallyAllowedFor(path, workdir, access, options) {
|
|
58
|
+
const ctx = options.ctx;
|
|
59
|
+
const r = resolveToolPath(path, workdir, ctx);
|
|
60
|
+
const policy = resolveSandboxPolicy(ctx ?? readToolRuntime(), options.sandboxPolicy);
|
|
61
|
+
if (policy) {
|
|
62
|
+
const field = access === "write" ? policy.writableRoots : policy.readableRoots;
|
|
63
|
+
return !insideLexicalRoots(Array.isArray(policy.protectedRoots) ? policy.protectedRoots : [], r)
|
|
64
|
+
&& insideLexicalRoots(Array.isArray(field) ? field : [], r)
|
|
65
|
+
&& (access !== "write" || !sandboxLexicallyDeniesWrite(policy, r, ctx));
|
|
66
|
+
}
|
|
67
|
+
const { workspace, repoRoot } = configured(ctx);
|
|
68
|
+
return insideLegacyRoots([workdir, workspace, repoRoot, process.cwd(), "/tmp"], r);
|
|
69
|
+
}
|
|
70
|
+
|
|
45
71
|
export function isWorkdirAllowed(workdir, options = {}) {
|
|
46
72
|
if (!workdir) return true;
|
|
47
73
|
const ctx = options.ctx;
|
|
48
74
|
const r = resolve(workdir);
|
|
49
75
|
const policy = resolveSandboxPolicy(ctx ?? readToolRuntime(), options.sandboxPolicy);
|
|
50
76
|
if (policy) {
|
|
51
|
-
return
|
|
77
|
+
return !insideProtectedRoots(Array.isArray(policy.protectedRoots) ? policy.protectedRoots : [], r)
|
|
78
|
+
&& insideSandboxRoots(Array.isArray(policy.readableRoots) ? policy.readableRoots : [], r);
|
|
52
79
|
}
|
|
53
80
|
const { workspace, repoRoot } = configured(ctx);
|
|
54
81
|
return insideLegacyRoots([workspace, repoRoot, process.cwd(), "/tmp"], r);
|
|
55
82
|
}
|
|
56
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Protected descendants of one search root, as normalized relative paths.
|
|
86
|
+
* Search tools use these both as ripgrep exclusions and as a defensive output
|
|
87
|
+
* filter; actual reads still cross the native sandbox boundary.
|
|
88
|
+
*/
|
|
89
|
+
export function protectedRelativePaths(directory, options = {}) {
|
|
90
|
+
const ctx = options.ctx;
|
|
91
|
+
const policy = resolveSandboxPolicy(ctx ?? readToolRuntime(), options.sandboxPolicy);
|
|
92
|
+
if (!policy || !Array.isArray(policy.protectedRoots)) return [];
|
|
93
|
+
const root = resolve(directory);
|
|
94
|
+
const out = new Set();
|
|
95
|
+
for (const protectedRoot of normalizeRoots(policy.protectedRoots)) {
|
|
96
|
+
const rel = relative(root, protectedRoot);
|
|
97
|
+
if (rel !== "" && !rel.startsWith("..") && !isAbsolute(rel)) out.add(rel);
|
|
98
|
+
}
|
|
99
|
+
return [...out].sort();
|
|
100
|
+
}
|
|
101
|
+
|
|
57
102
|
// Sandbox roots also enforce realpath containment so a symlink inside an
|
|
58
103
|
// allowed root cannot escape to a target outside the policy.
|
|
59
104
|
function insideSandboxRoots(roots, target) {
|
|
@@ -63,6 +108,16 @@ function insideSandboxRoots(roots, target) {
|
|
|
63
108
|
&& allowedRoots.some((root) => isInsidePath(root, real));
|
|
64
109
|
}
|
|
65
110
|
|
|
111
|
+
// A protected root rejects either spelling: the lexical request and its
|
|
112
|
+
// existing/nearest-existing realpath. This closes symlink aliases in both
|
|
113
|
+
// directions without weakening ordinary readable/writable root checks.
|
|
114
|
+
function insideProtectedRoots(roots, target) {
|
|
115
|
+
const protectedRoots = normalizeRoots(roots);
|
|
116
|
+
const candidates = [...new Set([resolve(target), realTargetPath(target)])];
|
|
117
|
+
return candidates.some((candidate) =>
|
|
118
|
+
protectedRoots.some((root) => isInsidePath(root, candidate)));
|
|
119
|
+
}
|
|
120
|
+
|
|
66
121
|
// Without a sandbox policy, keep the historical literal containment check —
|
|
67
122
|
// symlinks out of the workspace (npm link et al.) stay usable by default.
|
|
68
123
|
function insideLegacyRoots(roots, target) {
|
|
@@ -70,6 +125,11 @@ function insideLegacyRoots(roots, target) {
|
|
|
70
125
|
return allowedRoots.some((root) => isInsidePath(root, target));
|
|
71
126
|
}
|
|
72
127
|
|
|
128
|
+
function insideLexicalRoots(roots, target) {
|
|
129
|
+
const lexicalRoots = [...new Set(roots.filter(Boolean).map((path) => resolve(path)))];
|
|
130
|
+
return lexicalRoots.some((root) => isInsidePath(root, target));
|
|
131
|
+
}
|
|
132
|
+
|
|
73
133
|
function normalizeRoots(paths) {
|
|
74
134
|
const out = new Set();
|
|
75
135
|
for (const path of paths.filter(Boolean)) {
|
|
@@ -109,6 +169,11 @@ function sandboxDeniesWrite(policy, target, ctx) {
|
|
|
109
169
|
patterns.some((pattern) => denyWritePatternMatches(policy, pattern, candidate, ctx)));
|
|
110
170
|
}
|
|
111
171
|
|
|
172
|
+
function sandboxLexicallyDeniesWrite(policy, target, ctx) {
|
|
173
|
+
const patterns = Array.isArray(policy.denyWrite) ? policy.denyWrite : [];
|
|
174
|
+
return patterns.some((pattern) => denyWritePatternMatches(policy, pattern, resolve(target), ctx));
|
|
175
|
+
}
|
|
176
|
+
|
|
112
177
|
function denyWritePatternMatches(policy, pattern, target, ctx) {
|
|
113
178
|
if (!pattern || typeof pattern !== "string") return false;
|
|
114
179
|
const normalizedPattern = normalizeMatchPath(pattern);
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { types as nodeUtilTypes } from "node:util";
|
|
4
|
+
|
|
5
|
+
import { startPreparedProcess } from "./process-runner.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Kernel-local structural controller seam. The typed public interface lives in
|
|
9
|
+
* runtime-adapter; this package deliberately has no workspace dependencies.
|
|
10
|
+
*
|
|
11
|
+
* @typedef {Object} ProcessJobsController
|
|
12
|
+
* @property {(request: {
|
|
13
|
+
* tool: "Exec"|"Bash",
|
|
14
|
+
* prepared: import("../../sandbox-seam.js").PreparedSandboxCommand,
|
|
15
|
+
* summary: string,
|
|
16
|
+
* timeoutMs?: number,
|
|
17
|
+
* maxOutputChars?: number,
|
|
18
|
+
* launch: (options?: {timeoutMs?: number, signal?: AbortSignal, maxBufferBytes?: number, onStdout?: (chunk: Buffer) => void, onStderr?: (chunk: Buffer) => void}) => ReturnType<typeof startPreparedProcess>,
|
|
19
|
+
* }) => Promise<{jobId: string, state: "queued"|"starting"|"running", startedAt: string|null}>} start
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Transfer one prepared command to the injected host controller. From the
|
|
24
|
+
* instant `start()` is invoked, the controller owns cleanup on every path.
|
|
25
|
+
*
|
|
26
|
+
* @param {{
|
|
27
|
+
* controller: ProcessJobsController,
|
|
28
|
+
* tool: "Exec"|"Bash",
|
|
29
|
+
* prepared: import("../../sandbox-seam.js").PreparedSandboxCommand,
|
|
30
|
+
* summary: string,
|
|
31
|
+
* timeoutMs?: number,
|
|
32
|
+
* maxOutputChars?: number,
|
|
33
|
+
* startedAt: number,
|
|
34
|
+
* failed: (text: string, code: string, startedAt: number) => any,
|
|
35
|
+
* }} input
|
|
36
|
+
*/
|
|
37
|
+
export async function handOffProcessJob({
|
|
38
|
+
controller,
|
|
39
|
+
tool,
|
|
40
|
+
prepared,
|
|
41
|
+
summary,
|
|
42
|
+
timeoutMs,
|
|
43
|
+
maxOutputChars,
|
|
44
|
+
startedAt,
|
|
45
|
+
failed,
|
|
46
|
+
}) {
|
|
47
|
+
const ownedPrepared = withCleanupOnce(prepared);
|
|
48
|
+
const boundEnvironment = mergedProcessEnvironment(ownedPrepared.env);
|
|
49
|
+
let launched = false;
|
|
50
|
+
try {
|
|
51
|
+
const result = await controller.start({
|
|
52
|
+
tool,
|
|
53
|
+
prepared: ownedPrepared,
|
|
54
|
+
summary,
|
|
55
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
56
|
+
...(maxOutputChars === undefined ? {} : { maxOutputChars }),
|
|
57
|
+
launch(options = {}) {
|
|
58
|
+
if (launched) throw new Error("Process-job prepared command was already launched.");
|
|
59
|
+
launched = true;
|
|
60
|
+
return startPreparedProcess({ ...ownedPrepared, env: boundEnvironment }, {
|
|
61
|
+
...options,
|
|
62
|
+
waitForProcessGroup: true,
|
|
63
|
+
exactEnvironment: true,
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
if (!validProcessJobStartResult(result)) {
|
|
68
|
+
if (!launched) {
|
|
69
|
+
try {
|
|
70
|
+
await ownedPrepared.cleanup?.();
|
|
71
|
+
} catch {
|
|
72
|
+
return failed(
|
|
73
|
+
`Error: ${PUBLIC_BACKGROUND_START_FAILURES.process_job_cleanup_incomplete}`,
|
|
74
|
+
"process_job_cleanup_incomplete",
|
|
75
|
+
startedAt,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return failed("Error: Process-job controller returned an invalid start result.", "process_job_controller_invalid", startedAt);
|
|
80
|
+
}
|
|
81
|
+
const payload = {
|
|
82
|
+
job_id: result.jobId,
|
|
83
|
+
state: result.state,
|
|
84
|
+
started_at: result.startedAt,
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
text: JSON.stringify(payload),
|
|
88
|
+
outcome: {
|
|
89
|
+
status: "ok",
|
|
90
|
+
code: "background_started",
|
|
91
|
+
retryable: false,
|
|
92
|
+
attempts: 1,
|
|
93
|
+
durationMs: Date.now() - startedAt,
|
|
94
|
+
bytes: 0,
|
|
95
|
+
truncated: false,
|
|
96
|
+
exitCode: null,
|
|
97
|
+
signal: null,
|
|
98
|
+
timedOut: false,
|
|
99
|
+
background: true,
|
|
100
|
+
...payload,
|
|
101
|
+
},
|
|
102
|
+
error: false,
|
|
103
|
+
};
|
|
104
|
+
} catch (error) {
|
|
105
|
+
const failure = publicBackgroundStartFailure(error);
|
|
106
|
+
return failed(
|
|
107
|
+
`Error: ${failure.message}`,
|
|
108
|
+
failure.code,
|
|
109
|
+
startedAt,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const PUBLIC_BACKGROUND_START_FAILURES = Object.freeze({
|
|
115
|
+
background_unsupported: "Background process jobs are unsupported for this tool call.",
|
|
116
|
+
background_unsupported_channel: "Background process jobs are unsupported for this channel.",
|
|
117
|
+
process_job_disabled: "Process jobs are disabled.",
|
|
118
|
+
process_job_controller_unavailable: "The process-job controller is unavailable.",
|
|
119
|
+
process_job_platform_unsupported: "Process jobs are unsupported on this platform.",
|
|
120
|
+
process_job_not_found: "The process job was not found.",
|
|
121
|
+
process_job_conflict: "The process job is no longer in the required state.",
|
|
122
|
+
process_job_capacity: "Process-job capacity is full.",
|
|
123
|
+
process_job_conversation_capacity: "This conversation reached its process-job capacity.",
|
|
124
|
+
process_job_queue_full: "The process-job queue is full.",
|
|
125
|
+
process_job_queue_expired: "The process job expired before launch.",
|
|
126
|
+
process_job_chain_depth_exceeded: "The process-job chain-depth limit was reached.",
|
|
127
|
+
process_job_spawn_failed: "The process job could not be launched.",
|
|
128
|
+
process_job_failed: "The process job failed.",
|
|
129
|
+
process_job_timeout: "The process job exceeded its runtime limit.",
|
|
130
|
+
process_job_cancelled: "The process job was cancelled.",
|
|
131
|
+
process_job_agent_restarted: "The process job was interrupted by an agent restart.",
|
|
132
|
+
process_job_cleanup_incomplete: "Process-job cleanup could not be confirmed.",
|
|
133
|
+
process_job_store_error: "Process-job storage failed.",
|
|
134
|
+
process_job_wake_failed: "Process-job wake delivery failed.",
|
|
135
|
+
process_job_response_too_large: "The process-job response exceeded its size limit.",
|
|
136
|
+
process_job_invalid: "The process-job request is invalid.",
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
function publicBackgroundStartFailure(error) {
|
|
140
|
+
let code = "process_job_controller_unavailable";
|
|
141
|
+
try {
|
|
142
|
+
if (typeof error === "object" && error !== null && !nodeUtilTypes.isProxy(error)) {
|
|
143
|
+
const descriptor = Object.getOwnPropertyDescriptor(error, "code");
|
|
144
|
+
if (descriptor !== undefined
|
|
145
|
+
&& Object.prototype.hasOwnProperty.call(descriptor, "value")
|
|
146
|
+
&& typeof descriptor.value === "string"
|
|
147
|
+
&& Object.prototype.hasOwnProperty.call(PUBLIC_BACKGROUND_START_FAILURES, descriptor.value)) {
|
|
148
|
+
code = descriptor.value;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// Proxies and revoked proxies are hostile input at this boundary.
|
|
153
|
+
}
|
|
154
|
+
return { code, message: PUBLIC_BACKGROUND_START_FAILURES[code] };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function mergedProcessEnvironment(overrides = {}) {
|
|
158
|
+
const environment = { ...process.env };
|
|
159
|
+
for (const [name, value] of Object.entries(overrides)) {
|
|
160
|
+
if (value === undefined) delete environment[name];
|
|
161
|
+
else environment[name] = value;
|
|
162
|
+
}
|
|
163
|
+
return environment;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function withCleanupOnce(prepared) {
|
|
167
|
+
if (typeof prepared.cleanup !== "function") return prepared;
|
|
168
|
+
/** @type {Promise<void>|undefined} */
|
|
169
|
+
let cleanup;
|
|
170
|
+
const original = prepared.cleanup;
|
|
171
|
+
return {
|
|
172
|
+
...prepared,
|
|
173
|
+
cleanup: async () => {
|
|
174
|
+
if (!cleanup) cleanup = Promise.resolve().then(() => original());
|
|
175
|
+
await cleanup;
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function validProcessJobStartResult(value) {
|
|
181
|
+
if (!value || typeof value !== "object") return false;
|
|
182
|
+
if (typeof value.jobId !== "string" || value.jobId.trim().length === 0 || value.jobId.length > 256) return false;
|
|
183
|
+
if (value.state !== "queued" && value.state !== "starting" && value.state !== "running") return false;
|
|
184
|
+
if (value.startedAt === null) return true;
|
|
185
|
+
if (typeof value.startedAt !== "string") return false;
|
|
186
|
+
const timestamp = Date.parse(value.startedAt);
|
|
187
|
+
return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value.startedAt;
|
|
188
|
+
}
|