machine-bridge-mcp 0.18.1 → 1.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 +29 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +2 -0
- package/SECURITY.md +8 -6
- package/browser-extension/manifest.json +2 -2
- package/browser-extension/page-automation.js +0 -1
- package/docs/AGENT_CONTEXT.md +2 -1
- package/docs/ARCHITECTURE.md +3 -3
- package/docs/AUDIT.md +23 -0
- package/docs/ENGINEERING.md +3 -1
- package/docs/GETTING_STARTED.md +3 -0
- package/docs/LOCAL_AUTOMATION.md +1 -1
- package/docs/OPERATIONS.md +21 -1
- package/docs/PROJECT_STANDARDS.md +1 -1
- package/docs/TESTING.md +6 -2
- package/package.json +6 -3
- package/scripts/privacy-check.mjs +13 -13
- package/scripts/sarif-security-gate.mjs +146 -0
- package/src/local/account-access.mjs +0 -1
- package/src/local/atomic-fs.mjs +1 -1
- package/src/local/browser-bridge.mjs +2 -2
- package/src/local/browser-extension-protocol.mjs +1 -1
- package/src/local/browser-pairing-store.mjs +3 -4
- package/src/local/cli.mjs +21 -55
- package/src/local/default-instructions.mjs +4 -3
- package/src/local/errors.mjs +1 -5
- package/src/local/exclusive-file.mjs +9 -3
- package/src/local/full-access-test.mjs +5 -5
- package/src/local/job-runner.mjs +1 -1
- package/src/local/managed-jobs.mjs +27 -51
- package/src/local/relay-connection.mjs +7 -0
- package/src/local/runtime.mjs +20 -6
- package/src/local/secure-file.mjs +89 -10
- package/src/local/service.mjs +25 -35
- package/src/local/shell.mjs +26 -7
- package/src/local/ssh-key.mjs +71 -45
- package/src/local/state.mjs +46 -56
- package/src/local/worker-secret-file.mjs +130 -0
- package/src/local/workspace-file-service.mjs +10 -2
- package/src/shared/server-metadata.json +2 -3
- package/src/worker/http.ts +7 -8
- package/src/worker/index.ts +31 -41
- package/src/worker/mcp-session.ts +72 -0
- package/src/worker/oauth-state.ts +1 -1
- package/src/worker/pending-calls.ts +36 -5
- package/src/worker/tool-timeout.ts +18 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { lstatSync, readdirSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { createExclusiveFileSync } from "./exclusive-file.mjs";
|
|
5
|
+
import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-identity.mjs";
|
|
6
|
+
import { chmodRegularFileSync, ensureOwnerOnlyDirectorySync } from "./secure-file.mjs";
|
|
7
|
+
|
|
8
|
+
const SECRET_FILE_PATTERN = /^worker-secrets-(\d+)-(\d+)(?:-p(\d+))?(?:-([a-f0-9]+))?\.json$/;
|
|
9
|
+
|
|
10
|
+
export async function withWorkerSecretsFile(state, callback, options = {}) {
|
|
11
|
+
const dir = state.paths.profileDir;
|
|
12
|
+
ensureOwnerOnlyDirectorySync(dir, options.directoryOptions);
|
|
13
|
+
cleanupStaleWorkerSecretFiles(dir, options);
|
|
14
|
+
const createdAt = integerTimestamp(options.now?.() ?? Date.now());
|
|
15
|
+
const processStartedAt = integerTimestamp(options.processStartedAtMs ?? currentProcessStartTimeMs());
|
|
16
|
+
const random = (options.randomBytes || randomBytes)(6).toString("hex");
|
|
17
|
+
const tempPath = resolve(dir, `worker-secrets-${process.pid}-${createdAt}-p${processStartedAt}-${random}.json`);
|
|
18
|
+
const payload = {
|
|
19
|
+
ACCOUNT_ADMIN_SECRET: state.worker.accountAdminSecret,
|
|
20
|
+
DAEMON_SHARED_SECRET: state.worker.daemonSecret,
|
|
21
|
+
OAUTH_TOKEN_VERSION: state.worker.oauthTokenVersion,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
let created = false;
|
|
25
|
+
let createdIdentity = null;
|
|
26
|
+
let result;
|
|
27
|
+
let primaryError;
|
|
28
|
+
try {
|
|
29
|
+
createExclusiveFileSync(tempPath, JSON.stringify(payload), { mode: 0o600 });
|
|
30
|
+
created = true;
|
|
31
|
+
createdIdentity = fileIdentity((options.lstatSync || lstatSync)(tempPath));
|
|
32
|
+
(options.chmodFile || chmodRegularFileSync)(tempPath, 0o600, "temporary Worker secrets file");
|
|
33
|
+
result = await callback(tempPath);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
primaryError = error;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let cleanupError;
|
|
39
|
+
if (created) {
|
|
40
|
+
try {
|
|
41
|
+
removeFile(tempPath, options.removeFile || unlinkSync, "could not remove temporary Worker secrets file", createdIdentity, options.lstatSync || lstatSync);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
cleanupError = error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (primaryError && cleanupError) {
|
|
48
|
+
const message = primaryError instanceof Error && primaryError.message
|
|
49
|
+
? primaryError.message
|
|
50
|
+
: "Worker deployment failed";
|
|
51
|
+
throw new AggregateError(
|
|
52
|
+
[primaryError, cleanupError],
|
|
53
|
+
`${message}; temporary Worker secrets cleanup also failed`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (primaryError) throw primaryError;
|
|
57
|
+
if (cleanupError) throw cleanupError;
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function cleanupStaleWorkerSecretFiles(dir, options = {}) {
|
|
62
|
+
const inspect = options.inspectProcess || inspectProcessInstance;
|
|
63
|
+
const remove = options.removeFile || unlinkSync;
|
|
64
|
+
const readDirectory = options.readDirectory || readdirSync;
|
|
65
|
+
const inspectPath = options.lstatSync || lstatSync;
|
|
66
|
+
for (const entry of readDirectory(dir, { withFileTypes: true })) {
|
|
67
|
+
if (!entry.isFile()) continue;
|
|
68
|
+
const match = SECRET_FILE_PATTERN.exec(entry.name);
|
|
69
|
+
if (!match) continue;
|
|
70
|
+
const file = resolve(dir, entry.name);
|
|
71
|
+
let snapshot;
|
|
72
|
+
try {
|
|
73
|
+
const info = inspectPath(file);
|
|
74
|
+
if (info.isSymbolicLink() || !info.isFile()) continue;
|
|
75
|
+
snapshot = fileIdentity(info);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error?.code === "ENOENT") continue;
|
|
78
|
+
throw new Error(`could not inspect temporary Worker secrets file: ${entry.name}`, { cause: error });
|
|
79
|
+
}
|
|
80
|
+
const pid = Number(match[1]);
|
|
81
|
+
const createdAt = Number(match[2]);
|
|
82
|
+
const processStartedAt = match[3] ? Number(match[3]) : null;
|
|
83
|
+
let identity;
|
|
84
|
+
try {
|
|
85
|
+
identity = inspect({
|
|
86
|
+
pid,
|
|
87
|
+
startedAt: new Date(createdAt).toISOString(),
|
|
88
|
+
processStartedAt: processStartedAt ? new Date(processStartedAt).toISOString() : undefined,
|
|
89
|
+
});
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw new Error(`could not inspect temporary Worker secrets owner: ${entry.name}`, { cause: error });
|
|
92
|
+
}
|
|
93
|
+
if (!identity?.reclaimable) continue;
|
|
94
|
+
removeFile(file, remove, `could not remove stale temporary Worker secrets file: ${entry.name}`, snapshot, inspectPath);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function removeFile(file, remove, message = "could not remove temporary Worker secrets file", expectedIdentity = null, inspectPath = lstatSync) {
|
|
99
|
+
try {
|
|
100
|
+
if (expectedIdentity) {
|
|
101
|
+
const current = inspectPath(file);
|
|
102
|
+
if (current.isSymbolicLink() || !current.isFile() || !sameFileIdentity(expectedIdentity, fileIdentity(current))) {
|
|
103
|
+
throw new Error("temporary Worker secrets file identity changed before cleanup");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
remove(file);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error?.code === "ENOENT") return;
|
|
109
|
+
throw new Error(message, { cause: error });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function fileIdentity(info) {
|
|
114
|
+
return {
|
|
115
|
+
dev: Number(info.dev),
|
|
116
|
+
ino: Number(info.ino),
|
|
117
|
+
size: Number(info.size),
|
|
118
|
+
mtimeMs: Number(info.mtimeMs),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function sameFileIdentity(left, right) {
|
|
123
|
+
return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function integerTimestamp(value) {
|
|
127
|
+
const timestamp = Math.floor(Number(value));
|
|
128
|
+
if (!Number.isSafeInteger(timestamp) || timestamp <= 0) throw new Error("temporary Worker secrets timestamp is invalid");
|
|
129
|
+
return timestamp;
|
|
130
|
+
}
|
|
@@ -331,12 +331,20 @@ async function readUtf8File(filePath) {
|
|
|
331
331
|
return decodeUtf8(buffer);
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
async function applyExactMode(filePath, mode) {
|
|
335
|
+
try {
|
|
336
|
+
await chmod(filePath, mode);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (process.platform !== "win32") throw error;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
334
342
|
async function atomicWriteText(full, content, existing = null, options = {}) {
|
|
335
343
|
await mkdir(dirname(full), { recursive: true });
|
|
336
344
|
const temp = join(dirname(full), `.${basename(full)}.mbm-${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
|
|
337
345
|
try {
|
|
338
346
|
await writeFile(temp, content, { encoding: "utf8", flag: "wx", mode: existing ? existing.mode & 0o777 : 0o600 });
|
|
339
|
-
if (existing) await
|
|
347
|
+
if (existing) await applyExactMode(temp, existing.mode & 0o777);
|
|
340
348
|
if (options.expectedHash) {
|
|
341
349
|
const current = await readUtf8File(full).catch(() => null);
|
|
342
350
|
if (current === null || sha256(current) !== options.expectedHash) throw new Error("file changed before atomic commit");
|
|
@@ -377,7 +385,7 @@ async function commitPatchTransaction(operations) {
|
|
|
377
385
|
await mkdir(dirname(operation.target), { recursive: true });
|
|
378
386
|
const temp = join(dirname(operation.target), `.${basename(operation.target)}.mbm-patch-${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
|
|
379
387
|
await writeFile(temp, operation.content, { encoding: "utf8", flag: "wx", mode: operation.mode });
|
|
380
|
-
await
|
|
388
|
+
await applyExactMode(temp, operation.mode);
|
|
381
389
|
staged.push({ operation, temp });
|
|
382
390
|
}
|
|
383
391
|
|
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
"name": "machine-bridge-mcp",
|
|
3
3
|
"protocolVersion": "2025-11-25",
|
|
4
4
|
"supportedProtocolVersions": [
|
|
5
|
-
"2025-11-25"
|
|
6
|
-
"2025-06-18",
|
|
7
|
-
"2025-03-26"
|
|
5
|
+
"2025-11-25"
|
|
8
6
|
],
|
|
9
7
|
"instructions": [
|
|
10
8
|
"You are connected to a local workspace through machine-bridge-mcp.",
|
|
11
9
|
"At the start of each substantive local task, call resolve_task_capabilities with the current user request and target path. Apply the returned session/project instructions, follow the automatically selected relevant skill when present, and prefer registered local commands for repeatable workflows.",
|
|
10
|
+
"Before any GitHub read or mutation, apply the effective project control-plane instructions. When a project requires local git, gh, and gh api through Machine Bridge, never use a hosted GitHub connector or ChatGPT GitHub plugin; if the local control plane is unavailable, stop rather than substitute another GitHub tool.",
|
|
12
11
|
"The MCP initialize response automatically appends conservative built-in working agreements, bounded automatic project facts, the configured global model_instructions_file, and the current root instruction chain when the local daemon is reachable; session_bootstrap exposes the same material explicitly.",
|
|
13
12
|
"For existing-browser work, use the paired Machine Bridge browser extension. It operates the user current Chromium profile and tabs, can inspect DOM/source, fill complex forms, and inject sensitive text from registered local resources without returning those values.",
|
|
14
13
|
"For local application work, use structured application discovery and Accessibility actions. Arbitrary caller-supplied AppleScript or JavaScript is intentionally not accepted.",
|
package/src/worker/http.ts
CHANGED
|
@@ -43,15 +43,14 @@ export function authorizationRedirectLocation(redirectUri: string, code: string,
|
|
|
43
43
|
return location;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
export function json(value: unknown, status = 200): Response {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
"cache-control": "no-store",
|
|
52
|
-
"x-content-type-options": "nosniff",
|
|
53
|
-
},
|
|
46
|
+
export function json(value: unknown, status = 200, extraHeaders: HeadersInit = {}): Response {
|
|
47
|
+
const headers = new Headers({
|
|
48
|
+
"content-type": "application/json; charset=utf-8",
|
|
49
|
+
"cache-control": "no-store",
|
|
50
|
+
"x-content-type-options": "nosniff",
|
|
54
51
|
});
|
|
52
|
+
for (const [name, headerValue] of new Headers(extraHeaders)) headers.set(name, headerValue);
|
|
53
|
+
return new Response(JSON.stringify(value), { status, headers });
|
|
55
54
|
}
|
|
56
55
|
|
|
57
56
|
export function html(value: string, status = 200): Response {
|
package/src/worker/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { DurableObject } from "cloudflare:workers";
|
|
2
2
|
import serverMetadata from "../shared/server-metadata.json";
|
|
3
|
-
import { PendingCallRegistry } from "./pending-calls";
|
|
3
|
+
import { PendingCallRegistrationError, PendingCallRegistry } from "./pending-calls";
|
|
4
|
+
import { mcpClientRequestKey, resolveMcpSession } from "./mcp-session";
|
|
5
|
+
import { daemonToolTimeoutMs } from "./tool-timeout";
|
|
4
6
|
import { WorkerObservability } from "./observability";
|
|
5
7
|
import { daemonToolError, publicWorkerToolError, WorkerToolError } from "./errors";
|
|
6
8
|
import { sanitizeDaemonPolicy, sanitizeDaemonTools, type DaemonPolicy } from "./policy";
|
|
@@ -20,7 +22,7 @@ import {
|
|
|
20
22
|
} from "./http";
|
|
21
23
|
|
|
22
24
|
const SERVER_NAME = String(serverMetadata.name);
|
|
23
|
-
const SERVER_VERSION = "0.
|
|
25
|
+
const SERVER_VERSION = "1.0.0";
|
|
24
26
|
const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
|
|
25
27
|
const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
|
|
26
28
|
const JSONRPC_VERSION = "2.0";
|
|
@@ -289,12 +291,15 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
289
291
|
if (!isJsonRpcRequest(body)) return json(rpcError(null, -32600, "Invalid JSON-RPC request"), 400);
|
|
290
292
|
const protocolError = validateProtocolVersionHeader(request, body);
|
|
291
293
|
if (protocolError) return json(protocolError, 400);
|
|
292
|
-
|
|
294
|
+
|
|
295
|
+
const session = await resolveMcpSession(request, body.method, this.identityKey(), authorized.tokenKey);
|
|
296
|
+
if (session.kind === "invalid") return json(rpcError(body.id, -32001, "MCP session not found"), 404);
|
|
297
|
+
const response = await this.dispatchJsonRpc(body, base, authorized, session.kind === "active" ? session.sessionId : "");
|
|
293
298
|
if (response === null) return new Response(null, { status: 202 });
|
|
294
|
-
return json(response);
|
|
299
|
+
return session.kind === "initialize" ? json(response, 200, { "mcp-session-id": session.sessionId }) : json(response);
|
|
295
300
|
}
|
|
296
301
|
|
|
297
|
-
private async dispatchJsonRpc(request: JsonRpcRequest, base: string, authorized: AuthorizedToken): Promise<Record<string, unknown> | null> {
|
|
302
|
+
private async dispatchJsonRpc(request: JsonRpcRequest, base: string, authorized: AuthorizedToken, sessionId: string): Promise<Record<string, unknown> | null> {
|
|
298
303
|
if (request.method === "initialize") {
|
|
299
304
|
const requested = asObject(request.params).protocolVersion;
|
|
300
305
|
const protocolVersion = typeof requested === "string" && MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(requested as typeof MCP_SUPPORTED_PROTOCOL_VERSIONS[number])
|
|
@@ -318,7 +323,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
318
323
|
}
|
|
319
324
|
if (request.method === "notifications/initialized") return null;
|
|
320
325
|
if (request.method === "notifications/cancelled") {
|
|
321
|
-
this.cancelClientRequest(
|
|
326
|
+
this.cancelClientRequest(mcpClientRequestKey(authorized.tokenKey, sessionId, asObject(request.params).requestId));
|
|
322
327
|
return null;
|
|
323
328
|
}
|
|
324
329
|
if (request.method === "logging/setLevel") return rpcResult(request.id, {});
|
|
@@ -330,7 +335,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
330
335
|
const name = requiredString(params, "name");
|
|
331
336
|
const args = asObject(params.arguments);
|
|
332
337
|
try {
|
|
333
|
-
const result = await this.callTool(name, args, base, authorized,
|
|
338
|
+
const result = await this.callTool(name, args, base, authorized, mcpClientRequestKey(authorized.tokenKey, sessionId, request.id));
|
|
334
339
|
return rpcResult(request.id, textToolResult(result));
|
|
335
340
|
} catch (error) {
|
|
336
341
|
return rpcResult(request.id, textToolResult({ error: publicWorkerToolError(error) }, true));
|
|
@@ -378,16 +383,25 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
378
383
|
if (!socket) throw new WorkerToolError("unavailable", "local daemon is not connected; keep the CLI start command running", true);
|
|
379
384
|
const id = randomToken("call");
|
|
380
385
|
const timeoutMs = daemonToolTimeoutMs(name, args);
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
386
|
+
let result: Promise<unknown>;
|
|
387
|
+
try {
|
|
388
|
+
result = this.pending.register({
|
|
389
|
+
id,
|
|
390
|
+
socket,
|
|
391
|
+
clientRequestKey: requestKey,
|
|
392
|
+
tool: name,
|
|
393
|
+
timeoutMs,
|
|
394
|
+
onTimeout: (record) => {
|
|
395
|
+
sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
396
|
+
return new WorkerToolError("timeout", `daemon tool timed out: ${name}`, true);
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
} catch (error) {
|
|
400
|
+
if (error instanceof PendingCallRegistrationError) {
|
|
401
|
+
throw new WorkerToolError(error.code, error.message, error.retryable);
|
|
402
|
+
}
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
391
405
|
this.observability.callStarted(name);
|
|
392
406
|
try {
|
|
393
407
|
socket.send(JSON.stringify({
|
|
@@ -1004,25 +1018,6 @@ function stringOrUndefined(value: unknown): string | undefined {
|
|
|
1004
1018
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
1005
1019
|
}
|
|
1006
1020
|
|
|
1007
|
-
function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
|
|
1008
|
-
const number = typeof value === "number" && Number.isFinite(value) ? value : Number.parseInt(String(value ?? ""), 10);
|
|
1009
|
-
const safe = Number.isFinite(number) ? number : fallback;
|
|
1010
|
-
return Math.min(Math.max(Math.floor(safe), min), max);
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
function daemonToolTimeoutMs(name: string, args: Record<string, unknown>): number {
|
|
1014
|
-
if (name === "session_bootstrap") return 10_000;
|
|
1015
|
-
const configurable = new Set([
|
|
1016
|
-
"exec_command", "run_process", "run_local_command", "open_local_application",
|
|
1017
|
-
"inspect_local_application", "operate_local_application", "browser_list_tabs",
|
|
1018
|
-
"browser_manage_tabs", "browser_wait", "browser_get_source", "browser_inspect_page", "browser_action", "browser_fill_form",
|
|
1019
|
-
"browser_screenshot", "browser_upload_files",
|
|
1020
|
-
]);
|
|
1021
|
-
if (!configurable.has(name)) return 60_000;
|
|
1022
|
-
const seconds = clampNumber(args.timeout_seconds, name === "browser_fill_form" ? 60 : 120, 1, 600);
|
|
1023
|
-
return Math.min((seconds + 5) * 1000, 610_000);
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
1021
|
function sessionInstructionText(value: unknown): string {
|
|
1027
1022
|
const object = asObject(value);
|
|
1028
1023
|
const instructions = typeof object.instructions === "string" ? object.instructions : "";
|
|
@@ -1042,8 +1037,3 @@ function validateProtocolVersionHeader(request: Request, body: JsonRpcRequest):
|
|
|
1042
1037
|
supported: [...MCP_SUPPORTED_PROTOCOL_VERSIONS],
|
|
1043
1038
|
});
|
|
1044
1039
|
}
|
|
1045
|
-
|
|
1046
|
-
function clientRequestKey(authorized: AuthorizedToken, requestId: unknown): string | undefined {
|
|
1047
|
-
if (requestId === null || (typeof requestId !== "string" && typeof requestId !== "number")) return undefined;
|
|
1048
|
-
return `${authorized.tokenKey}:${typeof requestId}:${String(requestId)}`;
|
|
1049
|
-
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const SESSION_PATTERN = /^mcp_([A-Za-z0-9_-]{32})_([A-Za-z0-9_-]{43})$/;
|
|
2
|
+
|
|
3
|
+
export async function createMcpSessionId(identityKey: string, tokenKey: string): Promise<string> {
|
|
4
|
+
const nonceBytes = new Uint8Array(24);
|
|
5
|
+
crypto.getRandomValues(nonceBytes);
|
|
6
|
+
const nonce = base64Url(nonceBytes);
|
|
7
|
+
const signature = await sessionSignature(identityKey, tokenKey, nonce);
|
|
8
|
+
return `mcp_${nonce}_${signature}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function validateMcpSessionId(sessionId: string, identityKey: string, tokenKey: string): Promise<boolean> {
|
|
12
|
+
const match = String(sessionId || "").match(SESSION_PATTERN);
|
|
13
|
+
if (!match) return false;
|
|
14
|
+
const expected = await sessionSignature(identityKey, tokenKey, match[1]);
|
|
15
|
+
return constantTimeTextEqual(match[2], expected);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function sessionSignature(identityKey: string, tokenKey: string, nonce: string): Promise<string> {
|
|
19
|
+
const key = await crypto.subtle.importKey(
|
|
20
|
+
"raw",
|
|
21
|
+
new TextEncoder().encode(identityKey),
|
|
22
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
23
|
+
false,
|
|
24
|
+
["sign"],
|
|
25
|
+
);
|
|
26
|
+
const signature = await crypto.subtle.sign(
|
|
27
|
+
"HMAC",
|
|
28
|
+
key,
|
|
29
|
+
new TextEncoder().encode(`machine-bridge-mcp-session-v1\0${tokenKey}\0${nonce}`),
|
|
30
|
+
);
|
|
31
|
+
return base64Url(new Uint8Array(signature));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function constantTimeTextEqual(left: string, right: string): boolean {
|
|
35
|
+
const leftBytes = new TextEncoder().encode(left);
|
|
36
|
+
const rightBytes = new TextEncoder().encode(right);
|
|
37
|
+
let difference = leftBytes.length ^ rightBytes.length;
|
|
38
|
+
for (let index = 0; index < Math.max(leftBytes.length, rightBytes.length); index += 1) {
|
|
39
|
+
difference |= (leftBytes[index] ?? 0) ^ (rightBytes[index] ?? 0);
|
|
40
|
+
}
|
|
41
|
+
return difference === 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function base64Url(bytes: Uint8Array): string {
|
|
45
|
+
return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type McpSessionResolution =
|
|
49
|
+
| { kind: "initialize"; sessionId: string }
|
|
50
|
+
| { kind: "active"; sessionId: string }
|
|
51
|
+
| { kind: "invalid"; sessionId: string };
|
|
52
|
+
|
|
53
|
+
export async function resolveMcpSession(
|
|
54
|
+
request: Request,
|
|
55
|
+
method: string,
|
|
56
|
+
identityKey: string,
|
|
57
|
+
tokenKey: string,
|
|
58
|
+
): Promise<McpSessionResolution> {
|
|
59
|
+
if (method === "initialize") {
|
|
60
|
+
return { kind: "initialize", sessionId: await createMcpSessionId(identityKey, tokenKey) };
|
|
61
|
+
}
|
|
62
|
+
const sessionId = request.headers.get("mcp-session-id")?.trim() || "";
|
|
63
|
+
if (sessionId && !(await validateMcpSessionId(sessionId, identityKey, tokenKey))) {
|
|
64
|
+
return { kind: "invalid", sessionId };
|
|
65
|
+
}
|
|
66
|
+
return { kind: "active", sessionId };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function mcpClientRequestKey(tokenKey: string, sessionId: string, requestId: unknown): string | undefined {
|
|
70
|
+
if (!sessionId || requestId === null || (typeof requestId !== "string" && typeof requestId !== "number")) return undefined;
|
|
71
|
+
return `${tokenKey}:${sessionId}:${typeof requestId}:${String(requestId)}`;
|
|
72
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { normalizeAccountRole, type AccountRole } from "./access";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const OAUTH_STORE_SCHEMA_VERSION = 1;
|
|
4
4
|
const PASSWORD_TOKEN_PATTERN = /^[a-z][a-z0-9_]{2,31}_[A-Za-z0-9_-]{43}$/;
|
|
5
5
|
|
|
6
6
|
export interface AccountRecord {
|
|
@@ -1,7 +1,21 @@
|
|
|
1
|
+
export class PendingCallRegistrationError extends Error {
|
|
2
|
+
readonly code: "conflict" | "limit_exceeded";
|
|
3
|
+
readonly retryable: boolean;
|
|
4
|
+
|
|
5
|
+
constructor(code: "conflict" | "limit_exceeded", message: string, retryable = false) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "PendingCallRegistrationError";
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.retryable = retryable;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
1
13
|
export interface PendingCallRecord {
|
|
2
14
|
id: string;
|
|
3
15
|
socket: WebSocket;
|
|
4
16
|
clientRequestKey?: string;
|
|
17
|
+
tool: string;
|
|
18
|
+
startedAt: number;
|
|
5
19
|
timeout: ReturnType<typeof setTimeout>;
|
|
6
20
|
resolve: (value: unknown) => void;
|
|
7
21
|
reject: (error: Error) => void;
|
|
@@ -11,6 +25,7 @@ interface RegisterPendingCall {
|
|
|
11
25
|
id: string;
|
|
12
26
|
socket: WebSocket;
|
|
13
27
|
clientRequestKey?: string;
|
|
28
|
+
tool: string;
|
|
14
29
|
timeoutMs: number;
|
|
15
30
|
onTimeout: (record: PendingCallRecord) => Error;
|
|
16
31
|
}
|
|
@@ -33,11 +48,12 @@ export class PendingCallRegistry {
|
|
|
33
48
|
}
|
|
34
49
|
|
|
35
50
|
register(input: RegisterPendingCall): Promise<unknown> {
|
|
36
|
-
if (this.byId.size >= this.maximum) throw new
|
|
37
|
-
if (this.byId.has(input.id)) throw new
|
|
51
|
+
if (this.byId.size >= this.maximum) throw new PendingCallRegistrationError("limit_exceeded", "too many concurrent daemon tool calls", true);
|
|
52
|
+
if (this.byId.has(input.id)) throw new PendingCallRegistrationError("conflict", "duplicate internal daemon call id");
|
|
38
53
|
if (input.clientRequestKey && this.byRequestKey.has(input.clientRequestKey)) {
|
|
39
|
-
throw new
|
|
54
|
+
throw new PendingCallRegistrationError("conflict", "duplicate in-flight JSON-RPC request id within this MCP session");
|
|
40
55
|
}
|
|
56
|
+
const startedAt = Date.now();
|
|
41
57
|
return new Promise((resolve, reject) => {
|
|
42
58
|
const timeout = setTimeout(() => {
|
|
43
59
|
const record = this.take(input.id);
|
|
@@ -51,6 +67,8 @@ export class PendingCallRegistry {
|
|
|
51
67
|
id: input.id,
|
|
52
68
|
socket: input.socket,
|
|
53
69
|
clientRequestKey: input.clientRequestKey,
|
|
70
|
+
tool: String(input.tool || "unknown"),
|
|
71
|
+
startedAt,
|
|
54
72
|
timeout,
|
|
55
73
|
resolve,
|
|
56
74
|
reject,
|
|
@@ -92,8 +110,21 @@ export class PendingCallRegistry {
|
|
|
92
110
|
return ids.length;
|
|
93
111
|
}
|
|
94
112
|
|
|
95
|
-
snapshot(): { active: number; request_keys: number; maximum: number } {
|
|
96
|
-
|
|
113
|
+
snapshot(): { active: number; request_keys: number; maximum: number; oldest_ms: number; by_tool: Record<string, number> } {
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
const byTool: Record<string, number> = {};
|
|
116
|
+
let oldestMs = 0;
|
|
117
|
+
for (const record of this.byId.values()) {
|
|
118
|
+
byTool[record.tool] = (byTool[record.tool] || 0) + 1;
|
|
119
|
+
oldestMs = Math.max(oldestMs, now - record.startedAt);
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
active: this.byId.size,
|
|
123
|
+
request_keys: this.byRequestKey.size,
|
|
124
|
+
maximum: this.maximum,
|
|
125
|
+
oldest_ms: oldestMs,
|
|
126
|
+
by_tool: byTool,
|
|
127
|
+
};
|
|
97
128
|
}
|
|
98
129
|
|
|
99
130
|
private take(id: string): PendingCallRecord | undefined {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function daemonToolTimeoutMs(name: string, args: Record<string, unknown>): number {
|
|
2
|
+
if (name === "session_bootstrap") return 10_000;
|
|
3
|
+
const configurable = new Set([
|
|
4
|
+
"exec_command", "run_process", "run_local_command", "open_local_application",
|
|
5
|
+
"inspect_local_application", "operate_local_application", "browser_list_tabs",
|
|
6
|
+
"browser_manage_tabs", "browser_wait", "browser_get_source", "browser_inspect_page",
|
|
7
|
+
"browser_action", "browser_fill_form", "browser_screenshot", "browser_upload_files",
|
|
8
|
+
]);
|
|
9
|
+
if (!configurable.has(name)) return 60_000;
|
|
10
|
+
const seconds = clampNumber(args.timeout_seconds, name === "browser_fill_form" ? 60 : 120, 1, 600);
|
|
11
|
+
return Math.min((seconds + 5) * 1000, 610_000);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
|
|
15
|
+
const number = typeof value === "number" && Number.isFinite(value) ? value : Number.parseInt(String(value ?? ""), 10);
|
|
16
|
+
const safe = Number.isFinite(number) ? number : fallback;
|
|
17
|
+
return Math.min(Math.max(Math.floor(safe), min), max);
|
|
18
|
+
}
|