machine-bridge-mcp 1.2.10 → 2.0.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/CHANGELOG.md +23 -1
- package/CONTRIBUTING.md +1 -1
- package/README.md +11 -6
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +23 -11
- package/docs/AUDIT.md +37 -3
- package/docs/CLIENTS.md +2 -2
- package/docs/ENGINEERING.md +2 -2
- package/docs/GETTING_STARTED.md +1 -1
- package/docs/LOCAL_AUTHORIZATION.md +111 -0
- package/docs/LOGGING.md +7 -5
- package/docs/MULTI_ACCOUNT.md +5 -5
- package/docs/OPERATIONS.md +33 -7
- package/docs/OVERVIEW.md +12 -8
- package/docs/PROJECT_STANDARDS.md +1 -1
- package/docs/RELEASING.md +4 -4
- package/docs/TESTING.md +9 -2
- package/docs/THREAT_MODEL.md +7 -6
- package/docs/TOOL_REFERENCE.md +4 -4
- package/docs/UPGRADING.md +10 -6
- package/package.json +8 -2
- package/scripts/check-plan.mjs +5 -0
- package/scripts/check-runner.mjs +75 -0
- package/scripts/coverage-check.mjs +1 -1
- package/scripts/github-backlog.mjs +116 -0
- package/scripts/github-push.mjs +4 -0
- package/scripts/local-release-acceptance.mjs +2 -2
- package/scripts/release-acceptance.mjs +36 -17
- package/scripts/run-checks.mjs +11 -21
- package/src/local/account-admin.mjs +28 -3
- package/src/local/bounded-output.mjs +20 -3
- package/src/local/cli-approval.mjs +117 -0
- package/src/local/cli-options.mjs +8 -2
- package/src/local/cli.mjs +13 -2
- package/src/local/device-identity.mjs +108 -0
- package/src/local/errors.mjs +5 -1
- package/src/local/execution-limits.mjs +6 -1
- package/src/local/operation-authorization.mjs +366 -0
- package/src/local/operation-risk.mjs +221 -0
- package/src/local/operation-state-lock.mjs +92 -0
- package/src/local/process-execution.mjs +94 -14
- package/src/local/process-output-stream.mjs +82 -0
- package/src/local/process-result-projection.mjs +25 -0
- package/src/local/process-sessions.mjs +37 -51
- package/src/local/relay-connection.mjs +12 -5
- package/src/local/runtime-relay.mjs +13 -5
- package/src/local/runtime.mjs +14 -7
- package/src/local/secure-file.mjs +24 -4
- package/src/local/state.mjs +9 -3
- package/src/local/tool-executor.mjs +4 -2
- package/src/local/tools.mjs +4 -9
- package/src/local/worker-deployment.mjs +4 -3
- package/src/local/worker-secret-file.mjs +2 -1
- package/src/shared/admin-auth.d.mts +10 -0
- package/src/shared/admin-auth.mjs +36 -0
- package/src/shared/daemon-auth.d.mts +20 -0
- package/src/shared/daemon-auth.mjs +55 -0
- package/src/shared/result-projection.d.mts +6 -0
- package/src/shared/result-projection.json +4 -0
- package/src/shared/result-projection.mjs +50 -0
- package/src/shared/server-metadata.json +1 -0
- package/src/shared/tool-catalog.json +4 -4
- package/src/worker/account-admin.ts +73 -4
- package/src/worker/daemon-auth.ts +205 -0
- package/src/worker/daemon-sockets.ts +15 -2
- package/src/worker/errors.ts +18 -4
- package/src/worker/index.ts +59 -10
- package/src/worker/mcp-jsonrpc.ts +4 -2
- package/src/worker/nonce-store.ts +79 -0
- package/src/worker/oauth-controller.ts +11 -6
- package/src/worker/oauth-refresh-families.ts +172 -0
- package/src/worker/oauth-state.ts +48 -3
- package/src/worker/oauth-tokens.ts +49 -40
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const OPERATION_APPROVAL_SCOPES = Object.freeze([
|
|
5
|
+
"shell",
|
|
6
|
+
"external-write",
|
|
7
|
+
"sensitive-write",
|
|
8
|
+
"external-read",
|
|
9
|
+
"sensitive-read",
|
|
10
|
+
"browser-session",
|
|
11
|
+
"data-export",
|
|
12
|
+
"persistent-job",
|
|
13
|
+
"application-control",
|
|
14
|
+
"credential-operation",
|
|
15
|
+
"full",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const SHELL_TOOLS = new Set(["exec_command", "run_process", "start_process", "run_local_command", "read_process", "write_process", "kill_process"]);
|
|
19
|
+
const PERSISTENT_TOOLS = new Set(["stage_job", "start_job", "list_jobs", "read_job", "cancel_job"]);
|
|
20
|
+
const APPLICATION_CONTROL_TOOLS = new Set(["open_local_application", "inspect_local_application", "operate_local_application"]);
|
|
21
|
+
const CREDENTIAL_TOOLS = new Set(["generate_ssh_key_resource"]);
|
|
22
|
+
const BROWSER_PROFILE_TOOLS = new Set([
|
|
23
|
+
"pair_browser_extension", "browser_list_tabs", "browser_manage_tabs", "browser_get_source",
|
|
24
|
+
"browser_inspect_page", "browser_wait", "browser_action", "browser_fill_form", "browser_screenshot",
|
|
25
|
+
]);
|
|
26
|
+
const AUTOMATIC_TOOLS = new Set([
|
|
27
|
+
"server_info", "project_overview", "list_local_applications", "browser_status", "list_roots",
|
|
28
|
+
"diagnose_runtime", "list_local_resources",
|
|
29
|
+
]);
|
|
30
|
+
const FILE_WRITE_TOOLS = new Set(["write_file", "edit_file"]);
|
|
31
|
+
const FILE_READ_TOOLS = new Set([
|
|
32
|
+
"session_bootstrap", "resolve_task_capabilities", "agent_context", "list_local_skills", "load_local_skill", "list_local_commands",
|
|
33
|
+
"list_dir", "list_files", "read_file", "view_image", "search_text",
|
|
34
|
+
"git_status", "git_diff", "git_log", "git_show",
|
|
35
|
+
]);
|
|
36
|
+
const SENSITIVE_SEGMENTS = new Set([
|
|
37
|
+
".ssh", ".aws", ".azure", ".gnupg", ".kube", ".docker", ".npmrc", ".pypirc",
|
|
38
|
+
"keychains", "cookies", "login data", "credentials", "secrets",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
export function reviewedOperationToolNames() {
|
|
42
|
+
return new Set([
|
|
43
|
+
...AUTOMATIC_TOOLS,
|
|
44
|
+
...SHELL_TOOLS,
|
|
45
|
+
...PERSISTENT_TOOLS,
|
|
46
|
+
...APPLICATION_CONTROL_TOOLS,
|
|
47
|
+
...CREDENTIAL_TOOLS,
|
|
48
|
+
...BROWSER_PROFILE_TOOLS,
|
|
49
|
+
...FILE_WRITE_TOOLS,
|
|
50
|
+
...FILE_READ_TOOLS,
|
|
51
|
+
"browser_upload_files",
|
|
52
|
+
"apply_patch",
|
|
53
|
+
]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function classifyOperation(tool, args = {}, options = {}) {
|
|
57
|
+
const name = String(tool || "");
|
|
58
|
+
if (SHELL_TOOLS.has(name)) return requirement("shell", "remote shell or process control", name, args);
|
|
59
|
+
if (PERSISTENT_TOOLS.has(name)) return requirement("persistent-job", "persistent managed job", name, args);
|
|
60
|
+
if (name === "operate_local_application" && args.value_resource) {
|
|
61
|
+
return requirement(["application-control", "data-export"], "desktop application control using protected local data", name, {
|
|
62
|
+
application: args.application,
|
|
63
|
+
action: args.action,
|
|
64
|
+
value_resource: args.value_resource,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (APPLICATION_CONTROL_TOOLS.has(name)) return requirement("application-control", "desktop application control", name, args);
|
|
68
|
+
if (CREDENTIAL_TOOLS.has(name)) return requirement("credential-operation", "credential or key operation", name, args);
|
|
69
|
+
if (name === "browser_upload_files") {
|
|
70
|
+
return requirement(["browser-session", "data-export"], "existing browser profile file upload", name, args);
|
|
71
|
+
}
|
|
72
|
+
if (name === "browser_action" && args.value_resource) {
|
|
73
|
+
return requirement(["browser-session", "data-export"], "existing browser profile input from protected local data", name, {
|
|
74
|
+
action: args.action,
|
|
75
|
+
tab_id: args.tab_id,
|
|
76
|
+
value_resource: args.value_resource,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (name === "browser_fill_form" && Array.isArray(args.fields) && args.fields.some((field) => field?.value_resource || field?.sensitive === true)) {
|
|
80
|
+
return requirement(["browser-session", "data-export"], "existing browser profile form containing protected local data", name, {
|
|
81
|
+
tab_id: args.tab_id,
|
|
82
|
+
resource_fields: args.fields.filter((field) => field?.value_resource || field?.sensitive === true).length,
|
|
83
|
+
submit: args.submit === true,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (BROWSER_PROFILE_TOOLS.has(name)) {
|
|
87
|
+
return requirement("browser-session", "access to the existing browser profile", name, {
|
|
88
|
+
action: args.action,
|
|
89
|
+
tab_id: args.tab_id,
|
|
90
|
+
frame_id: args.frame_id,
|
|
91
|
+
submit: args.submit === true,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (FILE_WRITE_TOOLS.has(name)) {
|
|
95
|
+
const target = await canonicalWritePath(args.path, options);
|
|
96
|
+
const scopes = pathWriteScopes(options.workspace, target);
|
|
97
|
+
if (scopes.length) return requirement(scopes, pathWriteCategory(scopes), name, { path: target });
|
|
98
|
+
}
|
|
99
|
+
if (name === "apply_patch") {
|
|
100
|
+
const paths = patchPaths(args.patch);
|
|
101
|
+
const targets = [];
|
|
102
|
+
for (const candidate of paths) targets.push(await canonicalWritePath(candidate, options));
|
|
103
|
+
const scopes = normalizeScopes(targets.flatMap((target) => pathWriteScopes(options.workspace, target)));
|
|
104
|
+
if (scopes.length) return requirement(scopes, pathWriteCategory(scopes, "patch"), name, { paths: targets });
|
|
105
|
+
}
|
|
106
|
+
if (FILE_READ_TOOLS.has(name) && args.path !== undefined) {
|
|
107
|
+
const target = await canonicalExistingPath(args.path, options);
|
|
108
|
+
const scopes = [];
|
|
109
|
+
if (!isWithin(options.workspace, target)) scopes.push("external-read");
|
|
110
|
+
if (isSensitivePath(target)) scopes.push("sensitive-read");
|
|
111
|
+
if (scopes.length) return requirement(scopes, pathReadCategory(scopes), name, { path: target });
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function requirement(scopes, category, tool, target) {
|
|
117
|
+
const normalized = normalizeScopes(Array.isArray(scopes) ? scopes : [scopes]);
|
|
118
|
+
if (!normalized.length) throw new Error("operation approval requirement is missing a scope");
|
|
119
|
+
return {
|
|
120
|
+
scope: normalized[0],
|
|
121
|
+
scopes: normalized,
|
|
122
|
+
category,
|
|
123
|
+
targetHash: createHash("sha256").update(JSON.stringify({ tool, target: redactTarget(target) })).digest("hex"),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeScopes(scopes) {
|
|
128
|
+
const requested = new Set(scopes.map((scope) => String(scope || "")));
|
|
129
|
+
return OPERATION_APPROVAL_SCOPES.filter((scope) => requested.has(scope));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function pathWriteScopes(workspace, target) {
|
|
133
|
+
const scopes = [];
|
|
134
|
+
if (!isWithin(workspace, target)) scopes.push("external-write");
|
|
135
|
+
if (isSensitiveWritePath(target)) scopes.push("sensitive-write");
|
|
136
|
+
return scopes;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function pathWriteCategory(scopes, subject = "write") {
|
|
140
|
+
const external = scopes.includes("external-write");
|
|
141
|
+
const sensitive = scopes.includes("sensitive-write");
|
|
142
|
+
if (external && sensitive) return `${subject} outside the selected workspace to a credential or persistence-sensitive path`;
|
|
143
|
+
if (sensitive) return `${subject} to a credential or persistence-sensitive path`;
|
|
144
|
+
return `${subject} outside the selected workspace`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function pathReadCategory(scopes) {
|
|
148
|
+
const external = scopes.includes("external-read");
|
|
149
|
+
const sensitive = scopes.includes("sensitive-read");
|
|
150
|
+
if (external && sensitive) return "read outside the selected workspace from a credential-sensitive location";
|
|
151
|
+
if (sensitive) return "read from a credential-sensitive location";
|
|
152
|
+
return "read outside the selected workspace";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function redactTarget(value) {
|
|
156
|
+
if (!value || typeof value !== "object") return String(value || "");
|
|
157
|
+
const out = {};
|
|
158
|
+
for (const [key, item] of Object.entries(value)) {
|
|
159
|
+
if (["content", "command", "stdin", "value", "old_text", "new_text", "patch"].includes(key)) {
|
|
160
|
+
out[key] = `<sha256:${createHash("sha256").update(String(item || "")).digest("hex")}>`;
|
|
161
|
+
} else out[key] = item;
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function canonicalExistingPath(value, options) {
|
|
167
|
+
if (typeof options.resolveExistingPath === "function") return options.resolveExistingPath(value);
|
|
168
|
+
return path.resolve(options.workspace, String(value || "."));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function canonicalWritePath(value, options) {
|
|
172
|
+
if (typeof options.resolveWritePath === "function") return options.resolveWritePath(value);
|
|
173
|
+
return path.resolve(options.workspace, String(value || "."));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isWithin(workspace, target) {
|
|
177
|
+
const relative = path.relative(path.resolve(workspace), path.resolve(target));
|
|
178
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isSensitivePath(target) {
|
|
182
|
+
const lower = path.resolve(target).toLowerCase();
|
|
183
|
+
const segments = lower.split(/[\\/]+/);
|
|
184
|
+
if (segments.some((segment) => SENSITIVE_SEGMENTS.has(segment))) return true;
|
|
185
|
+
const base = path.basename(lower);
|
|
186
|
+
return isSensitiveEnvironmentFile(base) || /(?:token|secret|credential|private[-_]?key)/.test(base);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isSensitiveWritePath(target) {
|
|
190
|
+
const lower = path.resolve(target).toLowerCase();
|
|
191
|
+
const normalized = lower.split(path.sep).join("/");
|
|
192
|
+
const base = path.basename(lower);
|
|
193
|
+
if (isSensitivePath(lower)) return true;
|
|
194
|
+
if ([".zshrc", ".bashrc", ".bash_profile", ".profile", ".gitconfig", ".netrc", ".curlrc", "authorized_keys", "sshd_config", "sudoers"].includes(base)) return true;
|
|
195
|
+
return normalized.includes("/.git/hooks/")
|
|
196
|
+
|| normalized.endsWith("/.git/config")
|
|
197
|
+
|| normalized.endsWith("/.config/git/config")
|
|
198
|
+
|| normalized.includes("/library/launchagents/")
|
|
199
|
+
|| normalized.includes("/library/launchdaemons/")
|
|
200
|
+
|| normalized.includes("/.config/autostart/")
|
|
201
|
+
|| normalized.includes("/.config/systemd/user/")
|
|
202
|
+
|| normalized.includes("/start menu/programs/startup/")
|
|
203
|
+
|| normalized.endsWith("/.config/fish/config.fish");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isSensitiveEnvironmentFile(base) {
|
|
207
|
+
if (base === ".env") return true;
|
|
208
|
+
if (!base.startsWith(".env.")) return false;
|
|
209
|
+
return ![".env.example", ".env.sample", ".env.template", ".env.defaults"].includes(base);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function patchPaths(patch) {
|
|
213
|
+
const paths = [];
|
|
214
|
+
for (const line of String(patch || "").split(/\r?\n/)) {
|
|
215
|
+
const match = /^(?:\*\*\* (?:Add|Update|Delete) File:|\*\*\* Move to:|\+\+\+|---)\s+(.+)$/.exec(line);
|
|
216
|
+
if (!match) continue;
|
|
217
|
+
const value = match[1].replace(/^[ab]\//, "").trim();
|
|
218
|
+
if (value && value !== "/dev/null") paths.push(value);
|
|
219
|
+
}
|
|
220
|
+
return paths;
|
|
221
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createExclusiveFileSync, removeOwnedJsonFileSync } from "./exclusive-file.mjs";
|
|
5
|
+
import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
|
|
6
|
+
import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-identity.mjs";
|
|
7
|
+
import { ensureOwnerOnlyDirectorySync, readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
8
|
+
|
|
9
|
+
const LOCK_PURPOSE = "operation-authorization";
|
|
10
|
+
const LOCK_FILE = "operation-authorization.lock";
|
|
11
|
+
const MAX_LOCK_BYTES = 8 * 1024;
|
|
12
|
+
const DEFAULT_WAIT_MS = 5_000;
|
|
13
|
+
const DEFAULT_POLL_MS = 25;
|
|
14
|
+
|
|
15
|
+
export async function withOperationStateLock(root, callback, options = {}) {
|
|
16
|
+
if (typeof callback !== "function") throw new TypeError("operation-state lock requires a callback");
|
|
17
|
+
const requestedRoot = String(root || "").trim();
|
|
18
|
+
if (!requestedRoot) throw new Error("operation-state lock root is missing");
|
|
19
|
+
const directory = path.resolve(requestedRoot);
|
|
20
|
+
ensureOwnerOnlyDirectorySync(directory);
|
|
21
|
+
const lockPath = path.join(directory, LOCK_FILE);
|
|
22
|
+
const timeoutMs = positiveInteger(options.timeoutMs, DEFAULT_WAIT_MS);
|
|
23
|
+
const pollMs = positiveInteger(options.pollMs, DEFAULT_POLL_MS);
|
|
24
|
+
const deadline = createMonotonicDeadline(timeoutMs);
|
|
25
|
+
const owner = lockOwner();
|
|
26
|
+
|
|
27
|
+
while (true) {
|
|
28
|
+
try {
|
|
29
|
+
createExclusiveFileSync(lockPath, `${JSON.stringify(owner, null, 2)}\n`, { mode: 0o600 });
|
|
30
|
+
break;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error?.code !== "EEXIST") throw error;
|
|
33
|
+
const existing = readLockOwner(lockPath);
|
|
34
|
+
if (!existing) throw new Error("operation authorization lock is malformed; inspect the owner-only state directory");
|
|
35
|
+
if (existing.purpose !== LOCK_PURPOSE) throw new Error("operation authorization lock contains mismatched purpose metadata");
|
|
36
|
+
const identity = inspectProcessInstance(existing, { maxAgeMs: Number.POSITIVE_INFINITY });
|
|
37
|
+
if (identity.reclaimable) {
|
|
38
|
+
if (removeOwnedJsonFileSync(lockPath, { token: existing.token, purpose: LOCK_PURPOSE }, { maxBytes: MAX_LOCK_BYTES })) continue;
|
|
39
|
+
}
|
|
40
|
+
if (deadline.expired()) {
|
|
41
|
+
const pid = Number.isInteger(existing.pid) ? `pid ${existing.pid}` : "unknown process";
|
|
42
|
+
throw new Error(`operation authorization state is busy (${pid}); retry after the current approval operation finishes`);
|
|
43
|
+
}
|
|
44
|
+
await delay(Math.min(pollMs, Math.max(1, deadline.remainingMs())));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let result;
|
|
49
|
+
let callbackError;
|
|
50
|
+
try {
|
|
51
|
+
result = await callback();
|
|
52
|
+
} catch (error) {
|
|
53
|
+
callbackError = error;
|
|
54
|
+
}
|
|
55
|
+
const released = removeOwnedJsonFileSync(lockPath, { token: owner.token, purpose: LOCK_PURPOSE }, { maxBytes: MAX_LOCK_BYTES });
|
|
56
|
+
if (!released) throw new Error("operation authorization lock changed before release; approval state may require inspection");
|
|
57
|
+
if (callbackError) throw callbackError;
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function lockOwner() {
|
|
62
|
+
return {
|
|
63
|
+
pid: process.pid,
|
|
64
|
+
token: randomBytes(16).toString("hex"),
|
|
65
|
+
purpose: LOCK_PURPOSE,
|
|
66
|
+
startedAt: new Date().toISOString(),
|
|
67
|
+
processStartedAt: new Date(currentProcessStartTimeMs()).toISOString(),
|
|
68
|
+
entryScript: String(process.argv[1] || "").slice(0, 4096),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readLockOwner(file) {
|
|
73
|
+
if (!existsSync(file)) return null;
|
|
74
|
+
let parsed;
|
|
75
|
+
try { parsed = JSON.parse(readBoundedRegularFileSync(file, MAX_LOCK_BYTES, "operation authorization lock").toString("utf8")); } catch { return null; }
|
|
76
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
77
|
+
if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) return null;
|
|
78
|
+
if (!/^[a-f0-9]{32}$/.test(String(parsed.token || ""))) return null;
|
|
79
|
+
if (parsed.purpose !== LOCK_PURPOSE) return null;
|
|
80
|
+
if (!Number.isFinite(Date.parse(String(parsed.startedAt || "")))) return null;
|
|
81
|
+
if (!Number.isFinite(Date.parse(String(parsed.processStartedAt || "")))) return null;
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function positiveInteger(value, fallback) {
|
|
86
|
+
const number = Number(value);
|
|
87
|
+
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function delay(milliseconds) {
|
|
91
|
+
return new Promise((resolvePromise) => { setTimeout(resolvePromise, milliseconds); });
|
|
92
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { basename } from "node:path";
|
|
2
3
|
import { stat } from "node:fs/promises";
|
|
3
4
|
import { BoundedOutput } from "./bounded-output.mjs";
|
|
4
5
|
import { executionEnv, workspaceShellCommand } from "./shell.mjs";
|
|
@@ -6,10 +7,21 @@ import { MAX_COMMAND_BYTES, validateArgv } from "./process-contract.mjs";
|
|
|
6
7
|
import { terminateProcessTreeWithEscalation } from "./process-tree.mjs";
|
|
7
8
|
import { BridgeError } from "./errors.mjs";
|
|
8
9
|
import { clampInteger } from "./numbers.mjs";
|
|
10
|
+
import { ProcessOutputStream } from "./process-output-stream.mjs";
|
|
11
|
+
import { processFailureMessage, publicProcessToolResult } from "./process-result-projection.mjs";
|
|
9
12
|
import {
|
|
10
|
-
DEFAULT_PROCESS_OUTPUT_BYTES,
|
|
13
|
+
DEFAULT_PROCESS_OUTPUT_BYTES,
|
|
14
|
+
MAX_PROCESS_SESSION_OUTPUT_BYTES,
|
|
15
|
+
MAX_PROCESS_STDIN_BYTES,
|
|
16
|
+
MAX_PROCESS_TIMEOUT_SECONDS,
|
|
17
|
+
MIN_PROCESS_TIMEOUT_SECONDS,
|
|
18
|
+
PROCESS_SESSION_RETENTION_MS,
|
|
19
|
+
PUBLIC_PROCESS_INLINE_OUTPUT_BYTES,
|
|
11
20
|
} from "./execution-limits.mjs";
|
|
12
21
|
|
|
22
|
+
const PROCESS_OUTPUT_CAPTURE = Symbol("process-output-capture");
|
|
23
|
+
const CONTINUATION_READ_BYTES = 64 * 1024;
|
|
24
|
+
|
|
13
25
|
function spawnDirectProcess(command, args, options) {
|
|
14
26
|
// Keep the production child_process API call structurally separate from the
|
|
15
27
|
// injectable test seam and enforce non-shell execution at the final boundary.
|
|
@@ -23,7 +35,7 @@ function spawnDirectProcess(command, args, options) {
|
|
|
23
35
|
}
|
|
24
36
|
|
|
25
37
|
export class ProcessExecutionService {
|
|
26
|
-
constructor({ workspace, policy, policyGate, runtimeDir, processTracker, resolveExistingPath, resolveLocalCommand, displayPath, throwIfCancelled, spawnProcess = spawnDirectProcess, terminateProcess = terminateProcessTreeWithEscalation }) {
|
|
38
|
+
constructor({ workspace, policy, policyGate, runtimeDir, processTracker, resolveExistingPath, resolveLocalCommand, displayPath, throwIfCancelled, retainCompletedOutput = null, spawnProcess = spawnDirectProcess, terminateProcess = terminateProcessTreeWithEscalation }) {
|
|
27
39
|
this.workspace = workspace;
|
|
28
40
|
this.policy = policy;
|
|
29
41
|
this.policyGate = policyGate;
|
|
@@ -33,6 +45,7 @@ export class ProcessExecutionService {
|
|
|
33
45
|
this.resolveLocalCommand = resolveLocalCommand;
|
|
34
46
|
this.displayPath = displayPath;
|
|
35
47
|
this.throwIfCancelled = throwIfCancelled;
|
|
48
|
+
this.retainCompletedOutput = typeof retainCompletedOutput === "function" ? retainCompletedOutput : null;
|
|
36
49
|
this.spawnProcess = spawnProcess;
|
|
37
50
|
this.terminateProcess = terminateProcess;
|
|
38
51
|
}
|
|
@@ -42,7 +55,12 @@ export class ProcessExecutionService {
|
|
|
42
55
|
const argv = validateArgv(args.argv);
|
|
43
56
|
const cwd = await this.resolveExistingPath(args.cwd || ".");
|
|
44
57
|
if (!(await stat(cwd)).isDirectory()) throw new BridgeError("invalid_request", "cwd is not a directory");
|
|
45
|
-
|
|
58
|
+
const result = await this.runPublic(
|
|
59
|
+
argv[0], argv.slice(1),
|
|
60
|
+
clampInteger(args.timeout_seconds, 120, MIN_PROCESS_TIMEOUT_SECONDS, MAX_PROCESS_TIMEOUT_SECONDS) * 1000,
|
|
61
|
+
context, cwd,
|
|
62
|
+
);
|
|
63
|
+
return publicProcessToolResult(result);
|
|
46
64
|
}
|
|
47
65
|
|
|
48
66
|
async runRegistered(args, context = {}) {
|
|
@@ -55,8 +73,8 @@ export class ProcessExecutionService {
|
|
|
55
73
|
? command.timeoutSeconds
|
|
56
74
|
: clampInteger(args.timeout_seconds, command.timeoutSeconds, MIN_PROCESS_TIMEOUT_SECONDS, MAX_PROCESS_TIMEOUT_SECONDS);
|
|
57
75
|
const timeoutSeconds = Math.min(requested, command.timeoutSeconds);
|
|
58
|
-
const result = await this.
|
|
59
|
-
return { name: command.name, cwd: this.displayPath(cwd), timeout_seconds: timeoutSeconds, ...result };
|
|
76
|
+
const result = await this.runPublic(argv[0], argv.slice(1), timeoutSeconds * 1000, context, cwd);
|
|
77
|
+
return publicProcessToolResult({ name: command.name, cwd: this.displayPath(cwd), timeout_seconds: timeoutSeconds, ...result });
|
|
60
78
|
}
|
|
61
79
|
|
|
62
80
|
async probeShell(context = {}) {
|
|
@@ -70,14 +88,61 @@ export class ProcessExecutionService {
|
|
|
70
88
|
if (command.includes("\0")) throw new BridgeError("invalid_request", "command contains a NUL byte");
|
|
71
89
|
if (Buffer.byteLength(command) > MAX_COMMAND_BYTES) throw new BridgeError("limit_exceeded", `command exceeds maximum size (${MAX_COMMAND_BYTES} bytes)`);
|
|
72
90
|
const shell = workspaceShellCommand(command);
|
|
73
|
-
|
|
91
|
+
const result = await this.runPublic(
|
|
92
|
+
shell.cmd, shell.args,
|
|
93
|
+
clampInteger(timeoutSeconds, 120, MIN_PROCESS_TIMEOUT_SECONDS, MAX_PROCESS_TIMEOUT_SECONDS) * 1000,
|
|
94
|
+
context, this.workspace,
|
|
95
|
+
);
|
|
96
|
+
return publicProcessToolResult(result);
|
|
74
97
|
}
|
|
75
98
|
|
|
76
99
|
terminateAll(signal = "SIGTERM", escalate = false) {
|
|
77
100
|
this.processTracker.terminateAll(signal, escalate);
|
|
78
101
|
}
|
|
79
102
|
|
|
80
|
-
async
|
|
103
|
+
async runPublic(cmd, args, timeoutMs, context, cwd) {
|
|
104
|
+
const startedAt = Date.now();
|
|
105
|
+
const result = await this.run(
|
|
106
|
+
cmd, args, timeoutMs, true, PUBLIC_PROCESS_INLINE_OUTPUT_BYTES,
|
|
107
|
+
context, cwd, null, { retainOutput: true },
|
|
108
|
+
);
|
|
109
|
+
const capture = result[PROCESS_OUTPUT_CAPTURE];
|
|
110
|
+
const needsContinuation = result.stdout_truncated_bytes > 0 || result.stderr_truncated_bytes > 0;
|
|
111
|
+
let continuation = {};
|
|
112
|
+
if (needsContinuation && capture && this.retainCompletedOutput) {
|
|
113
|
+
const session = this.retainCompletedOutput({
|
|
114
|
+
command: basename(cmd), cwd,
|
|
115
|
+
stdout: capture.stdout, stderr: capture.stderr,
|
|
116
|
+
exitCode: result.code, startedAt, closedAt: Date.now(),
|
|
117
|
+
});
|
|
118
|
+
if (session) {
|
|
119
|
+
continuation = {
|
|
120
|
+
output_session_id: session.session_id,
|
|
121
|
+
output_continuation: {
|
|
122
|
+
tool: "read_process",
|
|
123
|
+
session_id: session.session_id,
|
|
124
|
+
stdout_offset: 0,
|
|
125
|
+
stderr_offset: 0,
|
|
126
|
+
max_bytes: CONTINUATION_READ_BYTES,
|
|
127
|
+
retained_bytes_per_stream: MAX_PROCESS_SESSION_OUTPUT_BYTES,
|
|
128
|
+
expires_after_ms: PROCESS_SESSION_RETENTION_MS,
|
|
129
|
+
retention: "best-effort-until-session-expiry-or-capacity-eviction",
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
} else {
|
|
133
|
+
continuation = { output_continuation_unavailable: "process session capacity reached" };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const publicResult = { ...result, ...continuation };
|
|
137
|
+
if (result.code !== 0) {
|
|
138
|
+
throw new BridgeError("execution_failed", processFailureMessage(publicResult), {
|
|
139
|
+
details: { process: publicResult },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return publicResult;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async run(cmd, args, timeoutMs, allowFailure = false, maxOutputBytes = DEFAULT_PROCESS_OUTPUT_BYTES, context = {}, cwd = this.workspace, stdin = null, options = {}) {
|
|
81
146
|
this.throwIfCancelled(context);
|
|
82
147
|
if (stdin !== null && Buffer.byteLength(String(stdin)) > MAX_PROCESS_STDIN_BYTES) {
|
|
83
148
|
throw new BridgeError("limit_exceeded", "process stdin exceeds 1 MiB");
|
|
@@ -86,6 +151,8 @@ export class ProcessExecutionService {
|
|
|
86
151
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
87
152
|
const stdout = new BoundedOutput(maxOutputBytes);
|
|
88
153
|
const stderr = new BoundedOutput(maxOutputBytes);
|
|
154
|
+
const retainedStdout = options.retainOutput ? new ProcessOutputStream(MAX_PROCESS_SESSION_OUTPUT_BYTES) : null;
|
|
155
|
+
const retainedStderr = options.retainOutput ? new ProcessOutputStream(MAX_PROCESS_SESSION_OUTPUT_BYTES) : null;
|
|
89
156
|
let child;
|
|
90
157
|
try {
|
|
91
158
|
child = this.spawnProcess(cmd, args, {
|
|
@@ -147,13 +214,19 @@ export class ProcessExecutionService {
|
|
|
147
214
|
}, timeoutMs);
|
|
148
215
|
timeoutTimer.unref?.();
|
|
149
216
|
|
|
150
|
-
child.stdout?.on?.("data", (chunk) =>
|
|
151
|
-
|
|
217
|
+
child.stdout?.on?.("data", (chunk) => {
|
|
218
|
+
stdout.append(chunk);
|
|
219
|
+
retainedStdout?.append(chunk);
|
|
220
|
+
});
|
|
221
|
+
child.stderr?.on?.("data", (chunk) => {
|
|
222
|
+
stderr.append(chunk);
|
|
223
|
+
retainedStderr?.append(chunk);
|
|
224
|
+
});
|
|
152
225
|
|
|
153
226
|
child.on("error", (error) => {
|
|
154
227
|
cleanupAfterClose();
|
|
155
228
|
settle(() => {
|
|
156
|
-
if (allowFailure) resolvePromise(processResult(127, stdout, error.message || stderr.text()));
|
|
229
|
+
if (allowFailure) resolvePromise(processResult(127, stdout, error.message || stderr.text(), retainedStdout, retainedStderr));
|
|
157
230
|
else rejectPromise(error);
|
|
158
231
|
});
|
|
159
232
|
});
|
|
@@ -165,9 +238,9 @@ export class ProcessExecutionService {
|
|
|
165
238
|
settle(() => rejectPromise(error));
|
|
166
239
|
return;
|
|
167
240
|
}
|
|
168
|
-
const result = processResult(code, stdout, stderr);
|
|
241
|
+
const result = processResult(code, stdout, stderr, retainedStdout, retainedStderr);
|
|
169
242
|
if (code === 0 || allowFailure) settle(() => resolvePromise(result));
|
|
170
|
-
else settle(() => rejectPromise(new BridgeError("execution_failed", result
|
|
243
|
+
else settle(() => rejectPromise(new BridgeError("execution_failed", processFailureMessage(result), { details: { process: result } })));
|
|
171
244
|
});
|
|
172
245
|
|
|
173
246
|
if (signal?.aborted) rejectCancelled();
|
|
@@ -175,15 +248,22 @@ export class ProcessExecutionService {
|
|
|
175
248
|
}
|
|
176
249
|
}
|
|
177
250
|
|
|
178
|
-
function processResult(code, stdout, stderr) {
|
|
251
|
+
function processResult(code, stdout, stderr, retainedStdout = null, retainedStderr = null) {
|
|
179
252
|
const stderrBuffer = stderr instanceof BoundedOutput ? stderr : null;
|
|
180
|
-
|
|
253
|
+
const result = {
|
|
181
254
|
code,
|
|
182
255
|
stdout: stdout instanceof BoundedOutput ? stdout.text() : String(stdout || ""),
|
|
183
256
|
stderr: stderrBuffer ? stderrBuffer.text() : boundedErrorMessage(stderr),
|
|
184
257
|
stdout_truncated_bytes: stdout instanceof BoundedOutput ? stdout.truncatedBytes : 0,
|
|
185
258
|
stderr_truncated_bytes: stderrBuffer ? stderrBuffer.truncatedBytes : 0,
|
|
186
259
|
};
|
|
260
|
+
if (retainedStdout && retainedStderr) {
|
|
261
|
+
Object.defineProperty(result, PROCESS_OUTPUT_CAPTURE, {
|
|
262
|
+
value: { stdout: retainedStdout, stderr: retainedStderr },
|
|
263
|
+
enumerable: false,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
return result;
|
|
187
267
|
}
|
|
188
268
|
|
|
189
269
|
export function boundedErrorMessage(error) {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bounded byte stream shared by interactive process sessions and completed
|
|
5
|
+
* one-shot command continuations. It retains the newest bytes while keeping
|
|
6
|
+
* monotonic offsets so callers can detect data that aged out of the buffer.
|
|
7
|
+
*/
|
|
8
|
+
export class ProcessOutputStream {
|
|
9
|
+
/** @param {number} maximumBytes */
|
|
10
|
+
constructor(maximumBytes) {
|
|
11
|
+
const maximum = Number(maximumBytes);
|
|
12
|
+
if (!Number.isSafeInteger(maximum) || maximum < 1) {
|
|
13
|
+
throw new Error("maximum retained output bytes must be a positive safe integer");
|
|
14
|
+
}
|
|
15
|
+
this.maximumBytes = maximum;
|
|
16
|
+
this.buffer = Buffer.alloc(0);
|
|
17
|
+
this.baseOffset = 0;
|
|
18
|
+
this.totalBytes = 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @param {unknown} chunk */
|
|
22
|
+
append(chunk) {
|
|
23
|
+
const input = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk ?? ""));
|
|
24
|
+
if (!input.length) return;
|
|
25
|
+
this.totalBytes += input.length;
|
|
26
|
+
let combined = this.buffer.length ? Buffer.concat([this.buffer, input]) : Buffer.from(input);
|
|
27
|
+
if (combined.length > this.maximumBytes) {
|
|
28
|
+
const dropped = combined.length - this.maximumBytes;
|
|
29
|
+
combined = combined.subarray(dropped);
|
|
30
|
+
this.baseOffset += dropped;
|
|
31
|
+
}
|
|
32
|
+
this.buffer = combined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @param {unknown} requestedOffset @param {unknown} maximumBytes */
|
|
36
|
+
read(requestedOffset, maximumBytes) {
|
|
37
|
+
const requested = nonNegativeInteger(requestedOffset);
|
|
38
|
+
const maximum = positiveInteger(maximumBytes);
|
|
39
|
+
const clampedOffset = Math.min(requested, this.totalBytes);
|
|
40
|
+
const effectiveOffset = Math.max(clampedOffset, this.baseOffset);
|
|
41
|
+
const start = effectiveOffset - this.baseOffset;
|
|
42
|
+
const slice = this.buffer.subarray(start, Math.min(this.buffer.length, start + maximum));
|
|
43
|
+
const decoded = decodeProcessBytes(slice);
|
|
44
|
+
return {
|
|
45
|
+
...decoded,
|
|
46
|
+
requested_offset: requested,
|
|
47
|
+
start_offset: effectiveOffset,
|
|
48
|
+
next_offset: effectiveOffset + slice.length,
|
|
49
|
+
total_offset: this.totalBytes,
|
|
50
|
+
retained_start_offset: this.baseOffset,
|
|
51
|
+
truncated_before: requested < this.baseOffset,
|
|
52
|
+
truncated_after: effectiveOffset + slice.length < this.totalBytes,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @param {Buffer} bytes */
|
|
58
|
+
function decodeProcessBytes(bytes) {
|
|
59
|
+
try {
|
|
60
|
+
return { data: new TextDecoder("utf-8", { fatal: true }).decode(bytes), encoding: "utf8" };
|
|
61
|
+
} catch {
|
|
62
|
+
return {
|
|
63
|
+
data: new TextDecoder("utf-8").decode(bytes),
|
|
64
|
+
data_base64: bytes.toString("base64"),
|
|
65
|
+
encoding: "base64",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @param {unknown} value */
|
|
71
|
+
function nonNegativeInteger(value) {
|
|
72
|
+
const number = Number(value);
|
|
73
|
+
if (!Number.isSafeInteger(number) || number < 0) return 0;
|
|
74
|
+
return number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @param {unknown} value */
|
|
78
|
+
function positiveInteger(value) {
|
|
79
|
+
const number = Number(value);
|
|
80
|
+
if (!Number.isSafeInteger(number) || number < 1) return 1;
|
|
81
|
+
return number;
|
|
82
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { MCP_TEXT_PROJECTION_KEY } from "../shared/result-projection.mjs";
|
|
4
|
+
|
|
5
|
+
/** @param {Record<string, any>} value */
|
|
6
|
+
export function publicProcessToolResult(value) {
|
|
7
|
+
const truncated = Number(value.stdout_truncated_bytes) + Number(value.stderr_truncated_bytes);
|
|
8
|
+
const continuation = value.output_session_id
|
|
9
|
+
? ` Continue with read_process session ${value.output_session_id}.`
|
|
10
|
+
: truncated > 0 ? " Additional output could not be retained because the process-session limit was reached." : "";
|
|
11
|
+
const summary = `Process exited with code ${value.code}.${truncated > 0 ? ` ${truncated} inline byte(s) omitted.` : ""}${continuation}`;
|
|
12
|
+
return { ...value, [MCP_TEXT_PROJECTION_KEY]: summary };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** @param {Record<string, any>} result */
|
|
16
|
+
export function processFailureMessage(result) {
|
|
17
|
+
const source = String(result.stderr || result.stdout || `process exited ${result.code}`)
|
|
18
|
+
.replace(/[\r\n]+/g, " ").trim().slice(0, 2000);
|
|
19
|
+
const suffix = result.output_session_id
|
|
20
|
+
? `; additional output is available through read_process session ${result.output_session_id}`
|
|
21
|
+
: result.stdout_truncated_bytes > 0 || result.stderr_truncated_bytes > 0
|
|
22
|
+
? "; additional output was truncated"
|
|
23
|
+
: "";
|
|
24
|
+
return `${source || `process exited ${result.code}`}${suffix}`;
|
|
25
|
+
}
|