machine-bridge-mcp 0.16.1 → 0.17.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 +34 -0
- package/CONTRIBUTING.md +14 -0
- package/README.md +1 -1
- package/browser-extension/manifest.json +2 -2
- package/browser-extension/service-worker.js +52 -29
- package/docs/ENGINEERING.md +3 -1
- package/docs/PROJECT_STANDARDS.md +143 -0
- package/docs/RELEASING.md +12 -0
- package/docs/TESTING.md +7 -5
- package/docs/TOOL_REFERENCE.md +2836 -0
- package/package.json +14 -5
- package/scripts/commit-message-check.mjs +63 -0
- package/scripts/coverage-check.mjs +9 -1
- package/scripts/generate-policy-reference.mjs +1 -4
- package/scripts/generate-tool-reference.mjs +87 -0
- package/scripts/generate-worker-types.mjs +19 -0
- package/scripts/markdown.mjs +9 -0
- package/src/local/agent-context.mjs +8 -15
- package/src/local/cli.mjs +1 -107
- package/src/local/daemon-process.mjs +16 -4
- package/src/local/default-instructions.mjs +2 -45
- package/src/local/git-service.mjs +4 -9
- package/src/local/managed-job-plan.mjs +2 -7
- package/src/local/managed-jobs.mjs +13 -11
- package/src/local/numbers.mjs +9 -0
- package/src/local/process-execution.mjs +4 -9
- package/src/local/process-sessions.mjs +5 -10
- package/src/local/project-metadata.mjs +50 -0
- package/src/local/project-package.mjs +2 -45
- package/src/local/records.mjs +5 -0
- package/src/local/runtime.mjs +5 -12
- package/src/local/state-inventory.mjs +139 -0
- package/src/local/workspace-file-service.mjs +6 -12
- package/src/worker/errors.ts +41 -0
- package/src/worker/http.ts +252 -0
- package/src/worker/index.ts +33 -17
- package/src/worker/oauth-state.ts +170 -0
- package/src/worker/observability.ts +111 -0
- package/src/worker/pending-calls.ts +109 -0
- package/src/worker/policy.ts +79 -0
- package/tsconfig.json +1 -1
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { lstatSync, realpathSync, statSync } from "node:fs";
|
|
3
3
|
import { basename, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { readBoundedRegularFileWithInfoSync } from "./secure-file.mjs";
|
|
5
|
+
import { clampInteger } from "./numbers.mjs";
|
|
5
6
|
|
|
6
7
|
const RESOURCE_NAME = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
7
8
|
const RESOURCE_TOKEN = /\{\{resource:([a-z][a-z0-9._-]{0,63})\}\}/g;
|
|
@@ -112,7 +113,7 @@ function validateSteps(value, label, context, allowEmpty = false) {
|
|
|
112
113
|
env_resources: envResources,
|
|
113
114
|
stdin,
|
|
114
115
|
stdin_resource: stdinResource,
|
|
115
|
-
timeout_seconds:
|
|
116
|
+
timeout_seconds: clampInteger(input.timeout_seconds, 600, 1, 3600),
|
|
116
117
|
allow_failure: input.allow_failure === true,
|
|
117
118
|
capture_output: input.capture_output === "discard" ? "discard" : "redacted",
|
|
118
119
|
};
|
|
@@ -227,9 +228,3 @@ function boundedString(value, maxBytes, label) {
|
|
|
227
228
|
if (Buffer.byteLength(value) > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes`);
|
|
228
229
|
return value;
|
|
229
230
|
}
|
|
230
|
-
|
|
231
|
-
function clampInt(value, fallback, min, max) {
|
|
232
|
-
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
233
|
-
const number = Number.isFinite(parsed) ? parsed : fallback;
|
|
234
|
-
return Math.min(Math.max(number, min), max);
|
|
235
|
-
}
|
|
@@ -11,6 +11,7 @@ import { createToolAuthorizer } from "./policy.mjs";
|
|
|
11
11
|
import { BridgeError } from "./errors.mjs";
|
|
12
12
|
import { inspectResourceFile, normalizeResourceRegistry, publicResourceRegistry, validatePlan, validateResourceName } from "./managed-job-plan.mjs";
|
|
13
13
|
export { inspectResourceFile, publicResourceRegistry, validateResourceName } from "./managed-job-plan.mjs";
|
|
14
|
+
import { clampInteger } from "./numbers.mjs";
|
|
14
15
|
|
|
15
16
|
const JOB_ID = /^job_[A-Za-z0-9_-]{24,}$/;
|
|
16
17
|
const MAX_JOBS = 50;
|
|
@@ -218,7 +219,7 @@ export class ManagedJobManager {
|
|
|
218
219
|
this.authorizeTool("list_jobs");
|
|
219
220
|
this.assertMaintenanceAvailable();
|
|
220
221
|
this.prune();
|
|
221
|
-
const limit =
|
|
222
|
+
const limit = clampInteger(args.limit, 20, 1, MAX_JOBS);
|
|
222
223
|
const jobs = [];
|
|
223
224
|
for (const entry of safeReadDir(this.jobRoot)) {
|
|
224
225
|
if (!entry.isDirectory() || !JOB_ID.test(entry.name)) continue;
|
|
@@ -697,10 +698,17 @@ function runnerProcessIsCurrent(status, dir, { ownerOnly = false } = {}) {
|
|
|
697
698
|
|
|
698
699
|
function scrubFinishedPlan(dir, status) {
|
|
699
700
|
if (PLAN_RETAINING_STATES.has(status.status)) return;
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
701
|
+
const safeRm = (path) => {
|
|
702
|
+
try {
|
|
703
|
+
rmSync(path, { force: true });
|
|
704
|
+
} catch (error) {
|
|
705
|
+
if (!["ENOENT", "EPERM", "EACCES"].includes(error.code)) throw error;
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
safeRm(join(dir, "plan.json"));
|
|
709
|
+
safeRm(join(dir, "runner.pid"));
|
|
710
|
+
safeRm(join(dir, "recovery.lock"));
|
|
711
|
+
safeRm(join(dir, "transition.lock"));
|
|
704
712
|
}
|
|
705
713
|
|
|
706
714
|
function reviewablePlan(plan) {
|
|
@@ -843,9 +851,3 @@ function boundedString(value, maxBytes, label) {
|
|
|
843
851
|
if (Buffer.byteLength(value) > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes`);
|
|
844
852
|
return value;
|
|
845
853
|
}
|
|
846
|
-
|
|
847
|
-
function clampInt(value, fallback, min, max) {
|
|
848
|
-
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
849
|
-
const number = Number.isFinite(parsed) ? parsed : fallback;
|
|
850
|
-
return Math.min(Math.max(number, min), max);
|
|
851
|
-
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function clampInteger(value, fallback, minimum, maximum) {
|
|
2
|
+
const parsed = typeof value === "number"
|
|
3
|
+
? value
|
|
4
|
+
: typeof value === "string" && value.trim()
|
|
5
|
+
? Number(value)
|
|
6
|
+
: Number.NaN;
|
|
7
|
+
const number = Number.isInteger(parsed) ? parsed : fallback;
|
|
8
|
+
return Math.min(Math.max(number, minimum), maximum);
|
|
9
|
+
}
|
|
@@ -3,6 +3,7 @@ import { stat } from "node:fs/promises";
|
|
|
3
3
|
import { executionEnv, workspaceShellCommand } from "./shell.mjs";
|
|
4
4
|
import { MAX_COMMAND_BYTES, terminateProcessTreeWithEscalation, validateArgv } from "./process-sessions.mjs";
|
|
5
5
|
import { BridgeError } from "./errors.mjs";
|
|
6
|
+
import { clampInteger } from "./numbers.mjs";
|
|
6
7
|
|
|
7
8
|
const MAX_STDIN_BYTES = 1024 * 1024;
|
|
8
9
|
const DEFAULT_OUTPUT_BYTES = 512 * 1024;
|
|
@@ -25,7 +26,7 @@ export class ProcessExecutionService {
|
|
|
25
26
|
const argv = validateArgv(args.argv);
|
|
26
27
|
const cwd = await this.resolveExistingPath(args.cwd || ".");
|
|
27
28
|
if (!(await stat(cwd)).isDirectory()) throw new BridgeError("invalid_request", "cwd is not a directory");
|
|
28
|
-
return this.run(argv[0], argv.slice(1),
|
|
29
|
+
return this.run(argv[0], argv.slice(1), clampInteger(args.timeout_seconds, 120, 1, 600) * 1000, false, DEFAULT_OUTPUT_BYTES, context, cwd);
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
async runRegistered(args, context = {}) {
|
|
@@ -36,7 +37,7 @@ export class ProcessExecutionService {
|
|
|
36
37
|
if (!(await stat(cwd)).isDirectory()) throw new BridgeError("invalid_request", "registered command cwd is not a directory");
|
|
37
38
|
const requested = args.timeout_seconds === undefined
|
|
38
39
|
? command.timeoutSeconds
|
|
39
|
-
:
|
|
40
|
+
: clampInteger(args.timeout_seconds, command.timeoutSeconds, 1, 600);
|
|
40
41
|
const timeoutSeconds = Math.min(requested, command.timeoutSeconds);
|
|
41
42
|
const result = await this.run(argv[0], argv.slice(1), timeoutSeconds * 1000, false, DEFAULT_OUTPUT_BYTES, context, cwd);
|
|
42
43
|
return { name: command.name, cwd: this.displayPath(cwd), timeout_seconds: timeoutSeconds, ...result };
|
|
@@ -53,7 +54,7 @@ export class ProcessExecutionService {
|
|
|
53
54
|
if (command.includes("\0")) throw new BridgeError("invalid_request", "command contains a NUL byte");
|
|
54
55
|
if (Buffer.byteLength(command) > MAX_COMMAND_BYTES) throw new BridgeError("limit_exceeded", `command exceeds maximum size (${MAX_COMMAND_BYTES} bytes)`);
|
|
55
56
|
const shell = workspaceShellCommand(command);
|
|
56
|
-
return this.run(shell.cmd, shell.args,
|
|
57
|
+
return this.run(shell.cmd, shell.args, clampInteger(timeoutSeconds, 120, 1, 600) * 1000, false, DEFAULT_OUTPUT_BYTES, context);
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
terminateAll(signal = "SIGTERM", escalate = false) {
|
|
@@ -145,9 +146,3 @@ function appendLimited(current, chunk, maximum) {
|
|
|
145
146
|
function finalizeOutput(value, truncated) {
|
|
146
147
|
return truncated > 0 ? `${value}\n\n[truncated ${truncated} bytes]` : value;
|
|
147
148
|
}
|
|
148
|
-
|
|
149
|
-
function clampInt(value, fallback, minimum, maximum) {
|
|
150
|
-
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
151
|
-
const number = Number.isFinite(parsed) ? parsed : fallback;
|
|
152
|
-
return Math.min(Math.max(number, minimum), maximum);
|
|
153
|
-
}
|
|
@@ -3,6 +3,7 @@ import { randomBytes } from "node:crypto";
|
|
|
3
3
|
import { basename } from "node:path";
|
|
4
4
|
import { executionEnv } from "./shell.mjs";
|
|
5
5
|
import { createToolAuthorizer } from "./policy.mjs";
|
|
6
|
+
import { clampInteger } from "./numbers.mjs";
|
|
6
7
|
|
|
7
8
|
export const MAX_COMMAND_BYTES = 64 * 1024;
|
|
8
9
|
const MAX_ARGV_ITEMS = 256;
|
|
@@ -112,10 +113,10 @@ export class ProcessSessionManager {
|
|
|
112
113
|
async read(args, context = {}) {
|
|
113
114
|
this.authorizeTool("read_process");
|
|
114
115
|
const session = this.get(args.session_id);
|
|
115
|
-
const stdoutOffset =
|
|
116
|
-
const stderrOffset =
|
|
117
|
-
const maxBytes =
|
|
118
|
-
const waitMs =
|
|
116
|
+
const stdoutOffset = clampInteger(args.stdout_offset, 0, 0, Number.MAX_SAFE_INTEGER);
|
|
117
|
+
const stderrOffset = clampInteger(args.stderr_offset, 0, 0, Number.MAX_SAFE_INTEGER);
|
|
118
|
+
const maxBytes = clampInteger(args.max_bytes, 64 * 1024, 1, 256 * 1024);
|
|
119
|
+
const waitMs = clampInteger(args.wait_ms, 0, 0, 30_000);
|
|
119
120
|
this.throwIfCancelled(context);
|
|
120
121
|
const waitForExit = args.wait_for_exit === true;
|
|
121
122
|
if (waitMs > 0 && session.closedAt === null) {
|
|
@@ -341,9 +342,3 @@ function boundedErrorMessage(error) {
|
|
|
341
342
|
const message = error instanceof Error ? error.message : String(error);
|
|
342
343
|
return message.replace(/[\r\n]+/g, " ").slice(0, 4096) || "process failed";
|
|
343
344
|
}
|
|
344
|
-
|
|
345
|
-
function clampInt(value, fallback, min, max) {
|
|
346
|
-
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
347
|
-
const number = Number.isFinite(parsed) ? parsed : fallback;
|
|
348
|
-
return Math.min(Math.max(number, min), max);
|
|
349
|
-
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { constants as fsConstants } from "node:fs";
|
|
2
|
+
import { lstat, open } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
const SKIPPABLE_METADATA_CODES = new Set(["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP", "EBUSY"]);
|
|
5
|
+
|
|
6
|
+
export function safeSingleLine(value, maxLength) {
|
|
7
|
+
if (typeof value !== "string") return "";
|
|
8
|
+
return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim().slice(0, maxLength);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function skippableMetadataError(error) {
|
|
12
|
+
return SKIPPABLE_METADATA_CODES.has(error?.code);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function isRegularNonSymlink(filePath) {
|
|
16
|
+
const info = await lstat(filePath).catch((error) => skippableMetadataError(error) ? null : Promise.reject(error));
|
|
17
|
+
return Boolean(info && !info.isSymbolicLink() && info.isFile());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function readOptionalRegularUtf8(filePath, maxBytes) {
|
|
21
|
+
const handle = await open(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0))
|
|
22
|
+
.catch((error) => skippableMetadataError(error) ? null : Promise.reject(error));
|
|
23
|
+
if (!handle) return null;
|
|
24
|
+
try {
|
|
25
|
+
let pathInfo;
|
|
26
|
+
let current;
|
|
27
|
+
try {
|
|
28
|
+
[pathInfo, current] = await Promise.all([lstat(filePath), handle.stat()]);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (skippableMetadataError(error)) return null;
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
if (pathInfo.isSymbolicLink() || !pathInfo.isFile() || !current.isFile()
|
|
34
|
+
|| pathInfo.dev !== current.dev || pathInfo.ino !== current.ino || current.size > maxBytes) return null;
|
|
35
|
+
const buffer = Buffer.alloc(current.size);
|
|
36
|
+
let offset = 0;
|
|
37
|
+
while (offset < buffer.length) {
|
|
38
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
|
|
39
|
+
if (!bytesRead) break;
|
|
40
|
+
offset += bytesRead;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset));
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
} finally {
|
|
48
|
+
await handle.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { constants as fsConstants } from "node:fs";
|
|
2
|
-
import { lstat, open } from "node:fs/promises";
|
|
3
1
|
import { join } from "node:path";
|
|
2
|
+
import { isRegularNonSymlink, readOptionalRegularUtf8, safeSingleLine } from "./project-metadata.mjs";
|
|
3
|
+
import { isPlainRecord } from "./records.mjs";
|
|
4
4
|
|
|
5
5
|
const MAX_METADATA_FILE_BYTES = 1024 * 1024;
|
|
6
6
|
const MAX_PACKAGE_COMMANDS = 64;
|
|
@@ -189,46 +189,3 @@ function safePackageManager(value) {
|
|
|
189
189
|
const normalized = value.trim();
|
|
190
190
|
return /^(?:npm|pnpm|yarn|bun)@[A-Za-z0-9][A-Za-z0-9.+_-]{0,90}$/.test(normalized) ? normalized : "";
|
|
191
191
|
}
|
|
192
|
-
|
|
193
|
-
async function isRegularNonSymlink(filePath) {
|
|
194
|
-
const info = await lstat(filePath).catch((error) => skippableMetadataError(error) ? null : Promise.reject(error));
|
|
195
|
-
return Boolean(info && !info.isSymbolicLink() && info.isFile());
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
async function readOptionalRegularUtf8(filePath, maxBytes) {
|
|
199
|
-
const info = await lstat(filePath).catch((error) => skippableMetadataError(error) ? null : Promise.reject(error));
|
|
200
|
-
if (!info || info.isSymbolicLink() || !info.isFile() || info.size > maxBytes) return null;
|
|
201
|
-
const handle = await open(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0)).catch((error) => skippableMetadataError(error) ? null : Promise.reject(error));
|
|
202
|
-
if (!handle) return null;
|
|
203
|
-
try {
|
|
204
|
-
const current = await handle.stat();
|
|
205
|
-
if (!current.isFile() || current.size > maxBytes) return null;
|
|
206
|
-
const buffer = Buffer.alloc(current.size);
|
|
207
|
-
let offset = 0;
|
|
208
|
-
while (offset < buffer.length) {
|
|
209
|
-
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
|
|
210
|
-
if (!bytesRead) break;
|
|
211
|
-
offset += bytesRead;
|
|
212
|
-
}
|
|
213
|
-
try {
|
|
214
|
-
return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset));
|
|
215
|
-
} catch {
|
|
216
|
-
return null;
|
|
217
|
-
}
|
|
218
|
-
} finally {
|
|
219
|
-
await handle.close();
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function skippableMetadataError(error) {
|
|
224
|
-
return ["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP", "EBUSY"].includes(error?.code);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function safeSingleLine(value, maxLength) {
|
|
228
|
-
if (typeof value !== "string") return "";
|
|
229
|
-
return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim().slice(0, maxLength);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function isPlainRecord(value) {
|
|
233
|
-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
234
|
-
}
|
package/src/local/runtime.mjs
CHANGED
|
@@ -26,6 +26,8 @@ import { AppAutomationManager } from "./app-automation.mjs";
|
|
|
26
26
|
import { BrowserBridgeManager } from "./browser-bridge.mjs";
|
|
27
27
|
import { CapabilityObserver } from "./capability-observer.mjs";
|
|
28
28
|
import { readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
29
|
+
import { clampInteger } from "./numbers.mjs";
|
|
30
|
+
import { isPlainRecord } from "./records.mjs";
|
|
29
31
|
|
|
30
32
|
const MAX_WS_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
31
33
|
const MAX_CONCURRENT_TOOL_CALLS = 16;
|
|
@@ -58,7 +60,7 @@ const RUNTIME_TOOL_HANDLERS = Object.freeze({
|
|
|
58
60
|
browser_upload_files: (runtime, args, context) => runtime.browserBridgeManager.uploadFiles(args, context),
|
|
59
61
|
list_roots: (runtime) => runtime.listRoots(),
|
|
60
62
|
list_dir: (runtime, args, context) => runtime.listDir(args.path || ".", context),
|
|
61
|
-
list_files: (runtime, args, context) => runtime.listFiles(args.path || ".",
|
|
63
|
+
list_files: (runtime, args, context) => runtime.listFiles(args.path || ".", clampInteger(args.max_files, 1000, 1, 10000), context),
|
|
62
64
|
read_file: (runtime, args, context) => runtime.readFile(args, context),
|
|
63
65
|
view_image: (runtime, args, context) => runtime.viewImage(args, context),
|
|
64
66
|
write_file: (runtime, args, context) => runtime.writeFile(args, context),
|
|
@@ -82,7 +84,7 @@ const RUNTIME_TOOL_HANDLERS = Object.freeze({
|
|
|
82
84
|
read_process: (runtime, args, context) => runtime.processSessionManager.read(args, context),
|
|
83
85
|
write_process: (runtime, args, context) => runtime.processSessionManager.write(args, context),
|
|
84
86
|
kill_process: (runtime, args, context) => runtime.processSessionManager.kill(args, context),
|
|
85
|
-
exec_command: (runtime, args, context) => runtime.execCommand(args.command,
|
|
87
|
+
exec_command: (runtime, args, context) => runtime.execCommand(args.command, clampInteger(args.timeout_seconds, 120, 1, 600), context),
|
|
86
88
|
});
|
|
87
89
|
|
|
88
90
|
export function runtimeToolHandlerNames() {
|
|
@@ -809,13 +811,10 @@ function normalizeRelayToolCall(message) {
|
|
|
809
811
|
id,
|
|
810
812
|
tool,
|
|
811
813
|
arguments: argumentsValue,
|
|
812
|
-
timeoutMs:
|
|
814
|
+
timeoutMs: clampInteger(message.timeout_ms, 60_000, 1000, 610_000),
|
|
813
815
|
};
|
|
814
816
|
}
|
|
815
817
|
|
|
816
|
-
function isPlainRecord(value) {
|
|
817
|
-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
818
|
-
}
|
|
819
818
|
|
|
820
819
|
|
|
821
820
|
function collectToolPathCandidates(error, toolArgs, workspace) {
|
|
@@ -857,9 +856,3 @@ function replacePathPrefix(message, pathValue, replacement) {
|
|
|
857
856
|
function shortCallId(value) {
|
|
858
857
|
return String(value || "").slice(0, 20);
|
|
859
858
|
}
|
|
860
|
-
|
|
861
|
-
function clampInt(value, fallback, minimum, maximum) {
|
|
862
|
-
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
863
|
-
const number = Number.isFinite(parsed) ? parsed : fallback;
|
|
864
|
-
return Math.min(Math.max(number, minimum), maximum);
|
|
865
|
-
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { existsSync, readdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { activeManagedJobs } from "./managed-jobs.mjs";
|
|
4
|
+
import { inspectProcessInstance } from "./process-identity.mjs";
|
|
5
|
+
import { readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
6
|
+
import { expandHome, readDaemonLockOwner, resolveWorkspace } from "./state.mjs";
|
|
7
|
+
|
|
8
|
+
const PROFILE_NAME = /^[a-f0-9]{24}$/;
|
|
9
|
+
const WORKER_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
10
|
+
const MAX_STATE_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
export function knownWorkerNames(stateRoot) {
|
|
13
|
+
const profiles = profilesDirectory(stateRoot);
|
|
14
|
+
if (!existsSync(profiles)) return [];
|
|
15
|
+
const names = new Set();
|
|
16
|
+
for (const entry of profileDirectories(profiles)) {
|
|
17
|
+
const profileDir = resolve(profiles, entry.name);
|
|
18
|
+
const stateFile = resolve(profileDir, "state.json");
|
|
19
|
+
if (!existsSync(stateFile)) {
|
|
20
|
+
const evidence = readdirSync(profileDir).some((name) => /^state\.json\.corrupt-/.test(name) || name === "daemon.lock");
|
|
21
|
+
if (evidence) throw unreadableWorkerState(entry.name);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
let state;
|
|
25
|
+
try {
|
|
26
|
+
state = readStateJson(stateFile);
|
|
27
|
+
} catch {
|
|
28
|
+
throw unreadableWorkerState(entry.name);
|
|
29
|
+
}
|
|
30
|
+
if (!state || typeof state !== "object" || Array.isArray(state)) throw unreadableWorkerState(entry.name);
|
|
31
|
+
const name = String(state?.worker?.name || "");
|
|
32
|
+
if (name && !WORKER_NAME.test(name)) {
|
|
33
|
+
throw new Error(`profile ${entry.name} contains an invalid Worker name; local state was kept for inspection`);
|
|
34
|
+
}
|
|
35
|
+
if (name) names.add(name);
|
|
36
|
+
}
|
|
37
|
+
return [...names];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function knownProfileStates(stateRoot) {
|
|
41
|
+
const canonicalStateRoot = canonicalRoot(stateRoot);
|
|
42
|
+
const profiles = resolve(canonicalStateRoot, "profiles");
|
|
43
|
+
if (!existsSync(profiles)) return [];
|
|
44
|
+
const states = [];
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
for (const entry of profileDirectories(profiles)) {
|
|
47
|
+
const profileDir = resolve(profiles, entry.name);
|
|
48
|
+
const statePath = resolve(profileDir, "state.json");
|
|
49
|
+
const candidates = [];
|
|
50
|
+
if (existsSync(statePath)) {
|
|
51
|
+
try {
|
|
52
|
+
const value = readStateJson(statePath);
|
|
53
|
+
if (typeof value?.workspace?.path === "string") candidates.push(value.workspace.path);
|
|
54
|
+
} catch {
|
|
55
|
+
// A daemon lock may still provide a verified workspace for corrupt state.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const daemonLock = resolve(profileDir, "daemon.lock");
|
|
59
|
+
const daemonOwner = readDaemonLockOwner(daemonLock);
|
|
60
|
+
if (existsSync(daemonLock) && !daemonOwner) {
|
|
61
|
+
throw new Error(`cannot inspect daemon lock for profile ${entry.name}; service definitions and state were kept`);
|
|
62
|
+
}
|
|
63
|
+
if (typeof daemonOwner?.workspace === "string") candidates.push(daemonOwner.workspace);
|
|
64
|
+
for (const candidate of candidates) {
|
|
65
|
+
try {
|
|
66
|
+
const workspace = resolveWorkspace(candidate);
|
|
67
|
+
if (seen.has(workspace)) break;
|
|
68
|
+
states.push({
|
|
69
|
+
schemaVersion: 5,
|
|
70
|
+
workspace: { path: workspace, hash: entry.name },
|
|
71
|
+
paths: { stateRoot: canonicalStateRoot, profileDir, statePath },
|
|
72
|
+
});
|
|
73
|
+
seen.add(workspace);
|
|
74
|
+
break;
|
|
75
|
+
} catch {
|
|
76
|
+
// Ignore an invalid historical candidate and try the remaining evidence.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return states;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function activeStateJobs(stateRoot) {
|
|
84
|
+
const profiles = profilesDirectory(stateRoot);
|
|
85
|
+
if (!existsSync(profiles)) return [];
|
|
86
|
+
const active = [];
|
|
87
|
+
for (const profile of profileDirectories(profiles)) {
|
|
88
|
+
for (const job of activeManagedJobs(resolve(profiles, profile.name, "jobs"))) {
|
|
89
|
+
active.push({ profile: profile.name, ...job });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return active;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function activeStateLocks(stateRoot) {
|
|
96
|
+
const profiles = profilesDirectory(stateRoot);
|
|
97
|
+
if (!existsSync(profiles)) return [];
|
|
98
|
+
const active = [];
|
|
99
|
+
for (const profile of profileDirectories(profiles)) {
|
|
100
|
+
for (const [kind, name] of [["daemon", "daemon.lock"], ["startup", "startup.lock"]]) {
|
|
101
|
+
const lockPath = resolve(profiles, profile.name, name);
|
|
102
|
+
if (!existsSync(lockPath)) continue;
|
|
103
|
+
const owner = readDaemonLockOwner(lockPath);
|
|
104
|
+
if (!owner) {
|
|
105
|
+
active.push({ kind, pid: null, path: lockPath, reason: "invalid_or_unreadable_lock" });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const maxAgeMs = kind === "startup" ? 2 * 60 * 60 * 1000 : Number.POSITIVE_INFINITY;
|
|
109
|
+
const identity = inspectProcessInstance(owner, { maxAgeMs });
|
|
110
|
+
if (identity.current || (identity.alive && !identity.reclaimable)) {
|
|
111
|
+
active.push({ kind, pid: owner.pid, path: lockPath, reason: identity.reason });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return active;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function profilesDirectory(stateRoot) {
|
|
119
|
+
return resolve(canonicalRoot(stateRoot), "profiles");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function canonicalRoot(stateRoot) {
|
|
123
|
+
const expanded = resolve(expandHome(stateRoot));
|
|
124
|
+
if (!existsSync(expanded)) return expanded;
|
|
125
|
+
return realpathSync.native ? realpathSync.native(expanded) : realpathSync(expanded);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function profileDirectories(profiles) {
|
|
129
|
+
return readdirSync(profiles, { withFileTypes: true })
|
|
130
|
+
.filter((entry) => entry.isDirectory() && PROFILE_NAME.test(entry.name));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function readStateJson(path) {
|
|
134
|
+
return JSON.parse(readBoundedRegularFileSync(path, MAX_STATE_BYTES).toString("utf8"));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function unreadableWorkerState(profile) {
|
|
138
|
+
return new Error(`cannot determine deployed Worker from profile ${profile}; local state was kept for inspection`);
|
|
139
|
+
}
|
|
@@ -3,6 +3,7 @@ import { constants as fsConstants } from "node:fs";
|
|
|
3
3
|
import { chmod, link, lstat, mkdir, open, opendir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import path, { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import { applyUpdateHunks, parsePatchEnvelope } from "./patch.mjs";
|
|
6
|
+
import { clampInteger } from "./numbers.mjs";
|
|
6
7
|
|
|
7
8
|
export const MAX_WRITE_BYTES = 5 * 1024 * 1024;
|
|
8
9
|
const MAX_DIRECTORY_ENTRIES = 10_000;
|
|
@@ -89,11 +90,11 @@ export class WorkspaceFileService {
|
|
|
89
90
|
const { buffer, info } = await readBoundedFile(full, MAX_WRITE_BYTES, "readable text file");
|
|
90
91
|
const content = decodeUtf8(buffer);
|
|
91
92
|
this.throwIfCancelled(context);
|
|
92
|
-
const maxBytes =
|
|
93
|
-
const startLine = args.start_line === undefined ? 1 :
|
|
93
|
+
const maxBytes = clampInteger(args.max_bytes, 1024 * 1024, 1, MAX_WRITE_BYTES);
|
|
94
|
+
const startLine = args.start_line === undefined ? 1 : clampInteger(args.start_line, 1, 1, Number.MAX_SAFE_INTEGER);
|
|
94
95
|
const rawLines = content.split(/\r?\n/);
|
|
95
96
|
const totalLines = content.endsWith("\n") ? Math.max(1, rawLines.length - 1) : rawLines.length;
|
|
96
|
-
const endLine = args.end_line === undefined ? totalLines :
|
|
97
|
+
const endLine = args.end_line === undefined ? totalLines : clampInteger(args.end_line, totalLines, 1, Number.MAX_SAFE_INTEGER);
|
|
97
98
|
if (endLine < startLine) throw new Error("end_line must be greater than or equal to start_line");
|
|
98
99
|
if (startLine > totalLines) throw new Error(`start_line exceeds total lines (${startLine} > ${totalLines})`);
|
|
99
100
|
const selectedEnd = Math.min(endLine, totalLines);
|
|
@@ -237,8 +238,8 @@ export class WorkspaceFileService {
|
|
|
237
238
|
const query = String(args.query || "");
|
|
238
239
|
if (!query) throw new Error("query is required");
|
|
239
240
|
const root = await this.resolveExistingPath(args.path || ".");
|
|
240
|
-
const max =
|
|
241
|
-
const maxFiles =
|
|
241
|
+
const max = clampInteger(args.max_matches, 100, 1, 1000);
|
|
242
|
+
const maxFiles = clampInteger(args.max_files, 10000, 1, 100000);
|
|
242
243
|
let visitedFiles = 0;
|
|
243
244
|
const matches = [];
|
|
244
245
|
const rootInfo = await stat(root);
|
|
@@ -435,13 +436,6 @@ export function sha256(value) {
|
|
|
435
436
|
return createHash("sha256").update(String(value)).digest("hex");
|
|
436
437
|
}
|
|
437
438
|
|
|
438
|
-
function clampInt(value, fallback, min, max) {
|
|
439
|
-
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
440
|
-
const number = Number.isFinite(parsed) ? parsed : fallback;
|
|
441
|
-
return Math.min(Math.max(number, min), max);
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
|
|
445
439
|
function detectImageMime(buffer) {
|
|
446
440
|
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
|
|
447
441
|
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const WORKER_ERROR_CODES = new Set([
|
|
2
|
+
"cancelled", "timeout", "authentication_failed", "authorization_denied", "policy_denied",
|
|
3
|
+
"invalid_request", "not_found", "conflict", "limit_exceeded", "permission_denied",
|
|
4
|
+
"path_boundary", "network_error", "protocol_error", "unavailable", "integrity_error",
|
|
5
|
+
"execution_failed", "internal_error",
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
export class WorkerToolError extends Error {
|
|
9
|
+
readonly code: string;
|
|
10
|
+
readonly retryable: boolean;
|
|
11
|
+
|
|
12
|
+
constructor(code: string, message: string, retryable = false) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "WorkerToolError";
|
|
15
|
+
this.code = normalizeCode(code);
|
|
16
|
+
this.retryable = retryable;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function daemonToolError(value: unknown): WorkerToolError {
|
|
21
|
+
const input = asObject(value);
|
|
22
|
+
const code = normalizeCode(input.code);
|
|
23
|
+
const message = typeof input.message === "string" && input.message
|
|
24
|
+
? input.message.slice(0, 2000)
|
|
25
|
+
: "daemon tool failed";
|
|
26
|
+
return new WorkerToolError(code, message, input.retryable === true);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function publicWorkerToolError(error: unknown): { code: string; message: string; retryable: boolean } {
|
|
30
|
+
if (error instanceof WorkerToolError) return { code: error.code, message: error.message, retryable: error.retryable };
|
|
31
|
+
return { code: "execution_failed", message: "tool execution failed", retryable: false };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeCode(value: unknown): string {
|
|
35
|
+
const code = String(value || "execution_failed").toLowerCase().replace(/[^a-z0-9_-]/g, "_").slice(0, 64);
|
|
36
|
+
return WORKER_ERROR_CODES.has(code) ? code : "execution_failed";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function asObject(value: unknown): Record<string, unknown> {
|
|
40
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
41
|
+
}
|