machine-bridge-mcp 0.16.2 → 0.17.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/CHANGELOG.md +40 -0
- package/CONTRIBUTING.md +14 -0
- package/README.md +11 -1
- package/SECURITY.md +9 -1
- package/browser-extension/manifest.json +2 -2
- package/browser-extension/service-worker.js +52 -29
- package/docs/ARCHITECTURE.md +4 -2
- package/docs/CLIENTS.md +2 -0
- package/docs/ENGINEERING.md +3 -1
- package/docs/GETTING_STARTED.md +400 -0
- package/docs/MULTI_ACCOUNT.md +406 -0
- 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 +13 -4
- 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 +2 -7
- 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/index.ts +33 -17
- package/tsconfig.json +1 -1
- package/src/worker/worker-configuration.d.ts +0 -14716
|
@@ -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;
|
|
@@ -850,9 +851,3 @@ function boundedString(value, maxBytes, label) {
|
|
|
850
851
|
if (Buffer.byteLength(value) > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes`);
|
|
851
852
|
return value;
|
|
852
853
|
}
|
|
853
|
-
|
|
854
|
-
function clampInt(value, fallback, min, max) {
|
|
855
|
-
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
856
|
-
const number = Number.isFinite(parsed) ? parsed : fallback;
|
|
857
|
-
return Math.min(Math.max(number, min), max);
|
|
858
|
-
}
|
|
@@ -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";
|
package/src/worker/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from "./http";
|
|
18
18
|
|
|
19
19
|
const SERVER_NAME = String(serverMetadata.name);
|
|
20
|
-
const SERVER_VERSION = "0.
|
|
20
|
+
const SERVER_VERSION = "0.17.1";
|
|
21
21
|
const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
|
|
22
22
|
const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
|
|
23
23
|
const JSONRPC_VERSION = "2.0";
|
|
@@ -162,14 +162,14 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
162
162
|
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
|
163
163
|
const size = typeof message === "string" ? new TextEncoder().encode(message).byteLength : message.byteLength;
|
|
164
164
|
if (size > MAX_DAEMON_MESSAGE_BYTES) {
|
|
165
|
-
|
|
165
|
+
closeWebSocketQuietly(ws, 1009, "message too large");
|
|
166
166
|
return;
|
|
167
167
|
}
|
|
168
168
|
let text: string;
|
|
169
169
|
try {
|
|
170
170
|
text = typeof message === "string" ? message : new TextDecoder("utf-8", { fatal: true }).decode(message);
|
|
171
171
|
} catch {
|
|
172
|
-
|
|
172
|
+
closeWebSocketQuietly(ws, 1007, "invalid UTF-8");
|
|
173
173
|
return;
|
|
174
174
|
}
|
|
175
175
|
let parsed: unknown;
|
|
@@ -187,12 +187,12 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
187
187
|
|
|
188
188
|
const socketAttachment = this.socketAttachment(ws);
|
|
189
189
|
if (!socketAttachment) {
|
|
190
|
-
|
|
190
|
+
closeWebSocketQuietly(ws, 1008, "missing daemon attachment");
|
|
191
191
|
return;
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
if (socketAttachment.role === "expired") {
|
|
195
|
-
|
|
195
|
+
closeWebSocketQuietly(ws, 1008, "expired daemon candidate");
|
|
196
196
|
return;
|
|
197
197
|
}
|
|
198
198
|
|
|
@@ -204,7 +204,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
204
204
|
const previousDaemons = this.daemonSockets().filter((socket) => socket !== ws);
|
|
205
205
|
if (socketAttachment.role === "candidate") {
|
|
206
206
|
if (!isFreshDaemonCandidate(socketAttachment.connectedAt)) {
|
|
207
|
-
|
|
207
|
+
closeWebSocketQuietly(ws, 1008, "stale daemon candidate");
|
|
208
208
|
await this.scheduleCandidateAlarm();
|
|
209
209
|
return;
|
|
210
210
|
}
|
|
@@ -225,17 +225,17 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
225
225
|
role: "expired",
|
|
226
226
|
connectedAt: socketAttachment.connectedAt,
|
|
227
227
|
} satisfies DaemonAttachment);
|
|
228
|
-
|
|
228
|
+
closeWebSocketQuietly(ws, 1011, "daemon hello acknowledgement failed");
|
|
229
229
|
return;
|
|
230
230
|
}
|
|
231
231
|
for (const socket of previousDaemons) {
|
|
232
|
-
|
|
232
|
+
closeWebSocketQuietly(socket, 1012, "replaced by authenticated daemon");
|
|
233
233
|
}
|
|
234
234
|
return;
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
if (socketAttachment.role !== "daemon") {
|
|
238
|
-
|
|
238
|
+
closeWebSocketQuietly(ws, 1008, "daemon hello required");
|
|
239
239
|
return;
|
|
240
240
|
}
|
|
241
241
|
|
|
@@ -387,7 +387,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
387
387
|
clientRequestKey: requestKey,
|
|
388
388
|
timeoutMs,
|
|
389
389
|
onTimeout: (record) => {
|
|
390
|
-
|
|
390
|
+
sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
391
391
|
return new WorkerToolError("timeout", `daemon tool timed out: ${name}`, true);
|
|
392
392
|
},
|
|
393
393
|
});
|
|
@@ -410,7 +410,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
410
410
|
private cancelClientRequest(requestKey?: string): void {
|
|
411
411
|
if (!requestKey) return;
|
|
412
412
|
this.pending.cancelRequest(requestKey, (record) => {
|
|
413
|
-
|
|
413
|
+
sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
414
414
|
return new WorkerToolError("cancelled", "tool call cancelled by client");
|
|
415
415
|
});
|
|
416
416
|
}
|
|
@@ -422,7 +422,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
422
422
|
if (!expected || !(await safeEqual(supplied, expected))) return new Response("Unauthorized daemon", { status: 401 });
|
|
423
423
|
|
|
424
424
|
for (const socket of this.nonDaemonSockets()) {
|
|
425
|
-
|
|
425
|
+
closeWebSocketQuietly(socket, 1012, "replaced by newer daemon candidate");
|
|
426
426
|
}
|
|
427
427
|
|
|
428
428
|
const pair = new WebSocketPair();
|
|
@@ -508,8 +508,8 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
508
508
|
role: "expired",
|
|
509
509
|
connectedAt: attachment?.connectedAt ?? new Date(0).toISOString(),
|
|
510
510
|
} satisfies DaemonAttachment);
|
|
511
|
-
|
|
512
|
-
|
|
511
|
+
sendWebSocketQuietly(socket, { type: "error", error: "daemon_hello_timeout" });
|
|
512
|
+
closeWebSocketQuietly(socket, 1008, "daemon hello timeout");
|
|
513
513
|
continue;
|
|
514
514
|
}
|
|
515
515
|
nextDeadline = Math.min(nextDeadline, deadline);
|
|
@@ -524,7 +524,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
524
524
|
const attachment = this.socketAttachment(socket);
|
|
525
525
|
const connectedAt = Date.parse(attachment?.connectedAt ?? "");
|
|
526
526
|
if (!Number.isFinite(connectedAt)) {
|
|
527
|
-
|
|
527
|
+
closeWebSocketQuietly(socket, 1008, "invalid daemon candidate timestamp");
|
|
528
528
|
continue;
|
|
529
529
|
}
|
|
530
530
|
nextDeadline = Math.min(nextDeadline, connectedAt + DAEMON_HELLO_TIMEOUT_MS);
|
|
@@ -885,8 +885,24 @@ function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
|
885
885
|
}
|
|
886
886
|
|
|
887
887
|
function rejectDaemonMessage(ws: WebSocket, error: string, closeCode: number, closeReason: string): void {
|
|
888
|
-
|
|
889
|
-
|
|
888
|
+
sendWebSocketQuietly(ws, { type: "error", error });
|
|
889
|
+
closeWebSocketQuietly(ws, closeCode, closeReason);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function sendWebSocketQuietly(ws: WebSocket, value: unknown): void {
|
|
893
|
+
try {
|
|
894
|
+
ws.send(typeof value === "string" ? value : JSON.stringify(value));
|
|
895
|
+
} catch {
|
|
896
|
+
// Best-effort protocol cleanup must not replace the primary timeout or rejection.
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function closeWebSocketQuietly(ws: WebSocket, code?: number, reason?: string): void {
|
|
901
|
+
try {
|
|
902
|
+
ws.close(code, reason);
|
|
903
|
+
} catch {
|
|
904
|
+
// The socket may already be closed or detached; no recovery remains at this boundary.
|
|
905
|
+
}
|
|
890
906
|
}
|
|
891
907
|
|
|
892
908
|
function isJsonRpcRequest(value: unknown): value is JsonRpcRequest {
|