machine-bridge-mcp 1.2.10 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.md +11 -6
  4. package/browser-extension/manifest.json +2 -2
  5. package/docs/ARCHITECTURE.md +23 -11
  6. package/docs/AUDIT.md +37 -3
  7. package/docs/CLIENTS.md +2 -2
  8. package/docs/ENGINEERING.md +2 -2
  9. package/docs/GETTING_STARTED.md +1 -1
  10. package/docs/LOCAL_AUTHORIZATION.md +111 -0
  11. package/docs/LOGGING.md +7 -5
  12. package/docs/MULTI_ACCOUNT.md +5 -5
  13. package/docs/OPERATIONS.md +33 -7
  14. package/docs/OVERVIEW.md +12 -8
  15. package/docs/PROJECT_STANDARDS.md +1 -1
  16. package/docs/RELEASING.md +4 -4
  17. package/docs/TESTING.md +9 -2
  18. package/docs/THREAT_MODEL.md +7 -6
  19. package/docs/TOOL_REFERENCE.md +4 -4
  20. package/docs/UPGRADING.md +10 -6
  21. package/package.json +8 -2
  22. package/scripts/check-plan.mjs +5 -0
  23. package/scripts/check-runner.mjs +75 -0
  24. package/scripts/coverage-check.mjs +1 -1
  25. package/scripts/github-backlog.mjs +116 -0
  26. package/scripts/github-push.mjs +4 -0
  27. package/scripts/local-release-acceptance.mjs +2 -2
  28. package/scripts/release-acceptance.mjs +36 -17
  29. package/scripts/run-checks.mjs +11 -21
  30. package/src/local/account-admin.mjs +28 -3
  31. package/src/local/bounded-output.mjs +20 -3
  32. package/src/local/cli-approval.mjs +117 -0
  33. package/src/local/cli-options.mjs +8 -2
  34. package/src/local/cli.mjs +13 -2
  35. package/src/local/device-identity.mjs +108 -0
  36. package/src/local/errors.mjs +5 -1
  37. package/src/local/execution-limits.mjs +6 -1
  38. package/src/local/operation-authorization.mjs +366 -0
  39. package/src/local/operation-risk.mjs +221 -0
  40. package/src/local/operation-state-lock.mjs +92 -0
  41. package/src/local/process-execution.mjs +94 -14
  42. package/src/local/process-output-stream.mjs +82 -0
  43. package/src/local/process-result-projection.mjs +25 -0
  44. package/src/local/process-sessions.mjs +37 -51
  45. package/src/local/relay-connection.mjs +12 -5
  46. package/src/local/runtime-relay.mjs +13 -5
  47. package/src/local/runtime.mjs +14 -7
  48. package/src/local/secure-file.mjs +24 -4
  49. package/src/local/state.mjs +9 -3
  50. package/src/local/tool-executor.mjs +4 -2
  51. package/src/local/tools.mjs +4 -9
  52. package/src/local/worker-deployment.mjs +4 -3
  53. package/src/local/worker-secret-file.mjs +2 -1
  54. package/src/shared/admin-auth.d.mts +10 -0
  55. package/src/shared/admin-auth.mjs +36 -0
  56. package/src/shared/daemon-auth.d.mts +20 -0
  57. package/src/shared/daemon-auth.mjs +55 -0
  58. package/src/shared/result-projection.d.mts +6 -0
  59. package/src/shared/result-projection.json +4 -0
  60. package/src/shared/result-projection.mjs +50 -0
  61. package/src/shared/server-metadata.json +1 -0
  62. package/src/shared/tool-catalog.json +4 -4
  63. package/src/worker/account-admin.ts +73 -4
  64. package/src/worker/daemon-auth.ts +205 -0
  65. package/src/worker/daemon-sockets.ts +15 -2
  66. package/src/worker/errors.ts +18 -4
  67. package/src/worker/index.ts +59 -10
  68. package/src/worker/mcp-jsonrpc.ts +4 -2
  69. package/src/worker/nonce-store.ts +79 -0
  70. package/src/worker/oauth-controller.ts +11 -6
  71. package/src/worker/oauth-refresh-families.ts +172 -0
  72. package/src/worker/oauth-state.ts +48 -3
  73. package/src/worker/oauth-tokens.ts +49 -40
@@ -0,0 +1,6 @@
1
+ export const MCP_TEXT_PROJECTION_KEY: "$mcpText";
2
+ export function mirroredResultText(value: unknown): string;
3
+ export function projectMcpResult(value: unknown): {
4
+ text: string;
5
+ structuredContent: Record<string, unknown> | null;
6
+ };
@@ -0,0 +1,4 @@
1
+ {
2
+ "maxMirroredJsonBytes": 16384,
3
+ "maximumSummaryKeys": 8
4
+ }
@@ -0,0 +1,50 @@
1
+ // @ts-check
2
+
3
+ import resultProjection from "./result-projection.json" with { type: "json" };
4
+
5
+ export const MCP_TEXT_PROJECTION_KEY = "$mcpText";
6
+
7
+ /**
8
+ * Keep MCP text content useful for human-only clients without duplicating a
9
+ * large object that is already present in structuredContent.
10
+ * @param {unknown} value
11
+ */
12
+ export function mirroredResultText(value) {
13
+ if (typeof value === "string") return value;
14
+ const serialized = JSON.stringify(value, null, 2);
15
+ if (typeof serialized !== "string") return String(value ?? "");
16
+ const bytes = new TextEncoder().encode(serialized).byteLength;
17
+ if (bytes <= Number(resultProjection.maxMirroredJsonBytes)) return serialized;
18
+ const object = asObject(value);
19
+ const keys = Object.keys(object)
20
+ .slice(0, Number(resultProjection.maximumSummaryKeys))
21
+ .map((key) => key.replace(/[\r\n\t]+/g, " ").slice(0, 80));
22
+ const fields = keys.length ? ` Fields: ${keys.join(", ")}.` : "";
23
+ return `Structured result is ${bytes} bytes and is available in structuredContent.${fields}`;
24
+ }
25
+
26
+ /**
27
+ * Extract an optional domain-provided text projection while keeping the
28
+ * authoritative structured object single-copy and marker-free.
29
+ * @param {unknown} value
30
+ */
31
+ export function projectMcpResult(value) {
32
+ const object = asObject(value);
33
+ if (Object.prototype.hasOwnProperty.call(object, MCP_TEXT_PROJECTION_KEY)
34
+ && typeof object[MCP_TEXT_PROJECTION_KEY] === "string") {
35
+ const structuredContent = { ...object };
36
+ const text = String(structuredContent[MCP_TEXT_PROJECTION_KEY]).slice(0, 4096);
37
+ delete structuredContent[MCP_TEXT_PROJECTION_KEY];
38
+ return { text, structuredContent };
39
+ }
40
+ return {
41
+ text: mirroredResultText(value),
42
+ structuredContent: value && typeof value === "object" && !Array.isArray(value) ? value : null,
43
+ };
44
+ }
45
+
46
+ /** @param {unknown} value */
47
+ function asObject(value) {
48
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
49
+ return value;
50
+ }
@@ -24,6 +24,7 @@
24
24
  "Managed-job resource redaction is defense in depth, not a guarantee against transformed or partial secret output. Use capture_output=discard for steps that may echo credentials.",
25
25
  "Do not use shell encoding, renaming, or alternate tools to bypass host or platform safety policy.",
26
26
  "run_process avoids shell parsing but is not an OS sandbox. exec_command is exposed only in shell mode and has the local user's authority.",
27
+ "For commands that may produce substantial output, prefer start_process/read_process or use the output_session_id returned by a truncated run_process, run_local_command, or exec_command result. Read output in bounded pages instead of repeatedly requesting a larger one-shot response.",
27
28
  "Per-tool success, failure, cancellation, and timing events are debug-only; default logs contain lifecycle and infrastructure events, not a tool-call transcript.",
28
29
  "Inspect before editing, prefer read_file line ranges, edit_file, or apply_patch over whole-file replacement, and report commands that were run."
29
30
  ]
@@ -1230,7 +1230,7 @@
1230
1230
  {
1231
1231
  "name": "run_local_command",
1232
1232
  "title": "Run registered local command",
1233
- "description": "Run an effective manifest or automatic package-script command through its fixed argv, cwd, timeout ceiling, and extra-argument policy.",
1233
+ "description": "Run an effective manifest or automatic package-script command through its fixed argv, cwd, timeout ceiling, and extra-argument policy. Large stdout/stderr is previewed inline and retained temporarily for paged read_process continuation.",
1234
1234
  "availability": "direct-exec",
1235
1235
  "annotations": {
1236
1236
  "readOnlyHint": false,
@@ -1665,7 +1665,7 @@
1665
1665
  {
1666
1666
  "name": "run_process",
1667
1667
  "title": "Run process directly",
1668
- "description": "Execute an argv array without a command shell. This avoids shell parsing but does not sandbox the executable or code it launches.",
1668
+ "description": "Execute an argv array without a command shell. This avoids shell parsing but does not sandbox the executable or code it launches. Large stdout/stderr is previewed inline and retained temporarily for paged read_process continuation.",
1669
1669
  "availability": "direct-exec",
1670
1670
  "annotations": {
1671
1671
  "readOnlyHint": false,
@@ -1737,7 +1737,7 @@
1737
1737
  {
1738
1738
  "name": "read_process",
1739
1739
  "title": "Read process session",
1740
- "description": "Read bounded stdout and stderr deltas from a server-managed process session, optionally waiting briefly for new output.",
1740
+ "description": "Read bounded stdout and stderr deltas from a running process session or a recently completed one-shot command continuation, optionally waiting briefly for new output.",
1741
1741
  "availability": "direct-exec",
1742
1742
  "annotations": {
1743
1743
  "readOnlyHint": true,
@@ -2382,7 +2382,7 @@
2382
2382
  {
2383
2383
  "name": "exec_command",
2384
2384
  "title": "Execute shell command",
2385
- "description": "Execute a shell command with workspace cwd. This is not a sandbox and has the operating-system authority of the local user.",
2385
+ "description": "Execute a shell command with workspace cwd. This is not a sandbox and has the operating-system authority of the local user. Large stdout/stderr is previewed inline and retained temporarily for paged read_process continuation.",
2386
2386
  "availability": "shell-exec",
2387
2387
  "annotations": {
2388
2388
  "readOnlyHint": false,
@@ -1,4 +1,6 @@
1
+ import { ADMIN_AUTH_SCHEME, ADMIN_AUTH_TTL_SECONDS, adminAuthTranscript } from "../shared/admin-auth.mjs";
1
2
  import { json, methodNotAllowed, parseRequestBody } from "./http.ts";
3
+ import { consumeBoundedNonce } from "./nonce-store.ts";
2
4
  import {
3
5
  accountByName, createAccount, publicAccount, replaceAccountPassword, revokeAccountCredentials,
4
6
  safeEqual, updateAccount, type AccountRecord, type OAuthStore,
@@ -7,10 +9,67 @@ import {
7
9
  const BODY_LIMIT_BYTES = 64 * 1024;
8
10
  const MAX_ACCOUNTS = 64;
9
11
 
10
- export async function accountAdminAuthorized(request: Request, expected: string): Promise<boolean> {
11
- const header = request.headers.get("authorization") ?? "";
12
- const supplied = /^Bearer\s+(.+)$/i.exec(header)?.[1] ?? "";
13
- return Boolean(expected && await safeEqual(supplied, expected));
12
+ export interface AccountAdminAuthorization {
13
+ nonce: string;
14
+ expiresAt: number;
15
+ }
16
+
17
+ export async function accountAdminAuthorized(
18
+ request: Request,
19
+ expected: string,
20
+ now = Math.floor(Date.now() / 1000),
21
+ ): Promise<AccountAdminAuthorization | null> {
22
+ if (!expected) return null;
23
+ const scheme = request.headers.get("X-Bridge-Admin-Scheme") || "";
24
+ const issuedAt = Number(request.headers.get("X-Bridge-Admin-Time"));
25
+ const nonce = request.headers.get("X-Bridge-Admin-Nonce") || "";
26
+ const bodyHash = request.headers.get("X-Bridge-Admin-Body-SHA256") || "";
27
+ const suppliedSignature = request.headers.get("X-Bridge-Admin-Signature") || "";
28
+ if (scheme !== ADMIN_AUTH_SCHEME) return null;
29
+ if (!Number.isSafeInteger(issuedAt) || Math.abs(now - issuedAt) > ADMIN_AUTH_TTL_SECONDS) return null;
30
+ if (!/^[A-Za-z0-9_-]{32,128}$/.test(nonce) || !/^[a-f0-9]{64}$/.test(bodyHash) || !/^[A-Za-z0-9_-]{43}$/.test(suppliedSignature)) return null;
31
+ let transcript: string;
32
+ try {
33
+ transcript = adminAuthTranscript({
34
+ origin: new URL(request.url).origin,
35
+ method: request.method.toUpperCase(),
36
+ pathname: new URL(request.url).pathname,
37
+ bodyHash,
38
+ issuedAt,
39
+ nonce,
40
+ });
41
+ } catch {
42
+ return null;
43
+ }
44
+ const key = await crypto.subtle.importKey(
45
+ "raw",
46
+ new TextEncoder().encode(expected),
47
+ { name: "HMAC", hash: "SHA-256" },
48
+ false,
49
+ ["sign"],
50
+ );
51
+ const expectedSignature = base64Url(new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(transcript))));
52
+ if (!(await safeEqual(suppliedSignature, expectedSignature))) return null;
53
+ const body = new Uint8Array(await request.clone().arrayBuffer());
54
+ if (body.byteLength > BODY_LIMIT_BYTES) return null;
55
+ const actualBodyHash = hex(new Uint8Array(await crypto.subtle.digest("SHA-256", body)));
56
+ if (!(await safeEqual(bodyHash, actualBodyHash))) return null;
57
+ return { nonce, expiresAt: Math.max(now, issuedAt) + ADMIN_AUTH_TTL_SECONDS };
58
+ }
59
+
60
+ export async function consumeAccountAdminNonce(
61
+ storage: DurableObjectStorage,
62
+ authorization: AccountAdminAuthorization,
63
+ now = Math.floor(Date.now() / 1000),
64
+ ): Promise<boolean> {
65
+ return consumeBoundedNonce(storage, {
66
+ key: "account-admin-nonces",
67
+ nonce: authorization.nonce,
68
+ expiresAt: authorization.expiresAt,
69
+ now,
70
+ noncePattern: /^[A-Za-z0-9_-]{32,128}$/,
71
+ maximum: 256,
72
+ });
14
73
  }
15
74
 
16
75
  export async function handleAccountAdminOperation(options: {
@@ -101,3 +160,13 @@ async function rotatePassword(request: Request, store: OAuthStore, save: () => P
101
160
  function activeOwnerCount(store: OAuthStore): number {
102
161
  return Object.values(store.accounts).filter((account) => account.active && account.role === "owner").length;
103
162
  }
163
+
164
+ function base64Url(bytes: Uint8Array): string {
165
+ let binary = "";
166
+ for (const byte of bytes) binary += String.fromCharCode(byte);
167
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
168
+ }
169
+
170
+ function hex(bytes: Uint8Array): string {
171
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
172
+ }
@@ -0,0 +1,205 @@
1
+ import { DAEMON_AUTH_CHALLENGE_TTL_SECONDS, DAEMON_AUTH_SCHEME, DAEMON_PREFLIGHT_SCHEME, DAEMON_PREFLIGHT_TTL_SECONDS, daemonAuthTranscript, daemonPreflightTranscript } from "../shared/daemon-auth.mjs";
2
+ import { safeEqual } from "./oauth-state.ts";
3
+ import { consumeBoundedNonce } from "./nonce-store.ts";
4
+
5
+ export interface DaemonChallenge {
6
+ scheme: typeof DAEMON_AUTH_SCHEME;
7
+ challenge: string;
8
+ issuedAt: number;
9
+ expiresAt: number;
10
+ workerOrigin: string;
11
+ }
12
+
13
+ export function createDaemonChallenge(workerOrigin: string, now = Math.floor(Date.now() / 1000)): DaemonChallenge {
14
+ const bytes = new Uint8Array(32);
15
+ crypto.getRandomValues(bytes);
16
+ return {
17
+ scheme: DAEMON_AUTH_SCHEME,
18
+ challenge: `daemon_challenge_${base64Url(bytes)}`,
19
+ issuedAt: now,
20
+ expiresAt: now + DAEMON_AUTH_CHALLENGE_TTL_SECONDS,
21
+ workerOrigin: normalizeWorkerOrigin(workerOrigin),
22
+ };
23
+ }
24
+
25
+
26
+ export function sanitizeDaemonChallengeAttachment(value: Record<string, unknown>): Partial<{
27
+ authChallenge: string;
28
+ authIssuedAt: number;
29
+ authExpiresAt: number;
30
+ workerOrigin: string;
31
+ }> {
32
+ const authChallenge = typeof value.authChallenge === "string" && /^daemon_challenge_[A-Za-z0-9_-]{40,96}$/.test(value.authChallenge)
33
+ ? value.authChallenge
34
+ : undefined;
35
+ const authIssuedAt = positiveSafeInteger(value.authIssuedAt);
36
+ const authExpiresAt = positiveSafeInteger(value.authExpiresAt);
37
+ let workerOrigin: string | undefined;
38
+ try { workerOrigin = normalizeWorkerOrigin(String(value.workerOrigin || "")); } catch {}
39
+ return { authChallenge, authIssuedAt, authExpiresAt, workerOrigin };
40
+ }
41
+
42
+ export interface DaemonPreflightAuthorization {
43
+ nonce: string;
44
+ expiresAt: number;
45
+ }
46
+
47
+ export async function verifyDaemonPreflight(input: {
48
+ publicKeyJson: string;
49
+ headers: Headers;
50
+ workerOrigin: string;
51
+ server: string;
52
+ version: string;
53
+ now?: number;
54
+ }): Promise<DaemonPreflightAuthorization | null> {
55
+ const now = Number.isSafeInteger(input.now) ? Number(input.now) : Math.floor(Date.now() / 1000);
56
+ const scheme = input.headers.get("X-Bridge-Device-Scheme") || "";
57
+ const keyId = input.headers.get("X-Bridge-Device-Key") || "";
58
+ const nonce = input.headers.get("X-Bridge-Device-Nonce") || "";
59
+ const issuedAt = Number(input.headers.get("X-Bridge-Device-Time"));
60
+ const signature = decodeBase64Url(input.headers.get("X-Bridge-Device-Signature") || "", 64);
61
+ if (scheme !== DAEMON_PREFLIGHT_SCHEME || !signature) return null;
62
+ if (!Number.isSafeInteger(issuedAt) || Math.abs(now - issuedAt) > DAEMON_PREFLIGHT_TTL_SECONDS) return null;
63
+ if (!/^[A-Za-z0-9_-]{24,128}$/.test(nonce)) return null;
64
+ let publicJwk: JsonWebKey;
65
+ try { publicJwk = parsePublicJwk(input.publicKeyJson); } catch { return null; }
66
+ if (!(await safeEqual(keyId, await publicKeyId(publicJwk)))) return null;
67
+ let transcript: string;
68
+ try {
69
+ transcript = daemonPreflightTranscript({
70
+ workerOrigin: input.workerOrigin,
71
+ server: input.server,
72
+ version: input.version,
73
+ nonce,
74
+ issuedAt,
75
+ });
76
+ } catch {
77
+ return null;
78
+ }
79
+ try {
80
+ const key = await crypto.subtle.importKey("jwk", publicJwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
81
+ const valid = await crypto.subtle.verify(
82
+ { name: "ECDSA", hash: "SHA-256" },
83
+ key,
84
+ signature,
85
+ new TextEncoder().encode(transcript),
86
+ );
87
+ return valid ? { nonce, expiresAt: issuedAt + DAEMON_PREFLIGHT_TTL_SECONDS } : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+
94
+ export async function consumeDaemonPreflightNonce(
95
+ storage: DurableObjectStorage,
96
+ authorization: DaemonPreflightAuthorization,
97
+ now = Math.floor(Date.now() / 1000),
98
+ ): Promise<boolean> {
99
+ return consumeBoundedNonce(storage, {
100
+ key: "daemon-preflight-nonces",
101
+ nonce: authorization.nonce,
102
+ expiresAt: authorization.expiresAt,
103
+ now,
104
+ noncePattern: /^[A-Za-z0-9_-]{24,128}$/,
105
+ maximum: 128,
106
+ });
107
+ }
108
+
109
+ export async function verifyDaemonAuthentication(input: {
110
+ publicKeyJson: string;
111
+ authentication: unknown;
112
+ challenge: DaemonChallenge;
113
+ server: string;
114
+ version: string;
115
+ instanceId: string;
116
+ now?: number;
117
+ }): Promise<boolean> {
118
+ const now = Number.isSafeInteger(input.now) ? Number(input.now) : Math.floor(Date.now() / 1000);
119
+ if (now < input.challenge.issuedAt - 5 || now > input.challenge.expiresAt) return false;
120
+ if (!input.authentication || typeof input.authentication !== "object" || Array.isArray(input.authentication)) return false;
121
+ const auth = input.authentication as Record<string, unknown>;
122
+ if (auth.scheme !== DAEMON_AUTH_SCHEME) return false;
123
+ if (!(await safeEqual(String(auth.challenge || ""), input.challenge.challenge))) return false;
124
+ if (Number(auth.issued_at) !== input.challenge.issuedAt) return false;
125
+ const signature = decodeBase64Url(String(auth.signature || ""), 64);
126
+ if (!signature) return false;
127
+ let publicJwk: JsonWebKey;
128
+ try { publicJwk = parsePublicJwk(input.publicKeyJson); } catch { return false; }
129
+ const expectedKeyId = await publicKeyId(publicJwk);
130
+ if (!(await safeEqual(String(auth.key_id || ""), expectedKeyId))) return false;
131
+ let transcript: string;
132
+ try {
133
+ transcript = daemonAuthTranscript({
134
+ challenge: input.challenge.challenge,
135
+ workerOrigin: input.challenge.workerOrigin,
136
+ server: input.server,
137
+ version: input.version,
138
+ instanceId: input.instanceId,
139
+ issuedAt: input.challenge.issuedAt,
140
+ });
141
+ } catch {
142
+ return false;
143
+ }
144
+ try {
145
+ const key = await crypto.subtle.importKey("jwk", publicJwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
146
+ return crypto.subtle.verify(
147
+ { name: "ECDSA", hash: "SHA-256" },
148
+ key,
149
+ signature,
150
+ new TextEncoder().encode(transcript),
151
+ );
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ function parsePublicJwk(value: string): JsonWebKey {
158
+ const parsed = JSON.parse(value) as JsonWebKey;
159
+ if (!parsed || parsed.kty !== "EC" || parsed.crv !== "P-256") throw new Error("invalid device public key");
160
+ if (typeof parsed.x !== "string" || typeof parsed.y !== "string" || parsed.d !== undefined) throw new Error("invalid device public key");
161
+ return { kty: "EC", crv: "P-256", x: parsed.x, y: parsed.y, ext: true, key_ops: ["verify"] };
162
+ }
163
+
164
+ async function publicKeyId(jwk: JsonWebKey): Promise<string> {
165
+ const canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
166
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
167
+ return `device_${base64Url(new Uint8Array(digest)).slice(0, 32)}`;
168
+ }
169
+
170
+ function decodeBase64Url(value: string, expectedBytes: number): Uint8Array<ArrayBuffer> | null {
171
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) return null;
172
+ const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
173
+ const padding = "=".repeat((4 - (normalized.length % 4)) % 4);
174
+ try {
175
+ const binary = atob(normalized + padding);
176
+ if (binary.length !== expectedBytes) return null;
177
+ const bytes = new Uint8Array(expectedBytes);
178
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
179
+ return bytes;
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ function base64Url(bytes: Uint8Array): string {
186
+ let binary = "";
187
+ for (const byte of bytes) binary += String.fromCharCode(byte);
188
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
189
+ }
190
+
191
+
192
+ function positiveSafeInteger(value: unknown): number | undefined {
193
+ const number = Number(value);
194
+ return Number.isSafeInteger(number) && number > 0 ? number : undefined;
195
+ }
196
+
197
+ function normalizeWorkerOrigin(value: string): string {
198
+ const url = new URL(value);
199
+ const secure = url.protocol === "https:";
200
+ const loopback = url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
201
+ if ((!secure && !loopback) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
202
+ throw new Error("invalid Worker origin");
203
+ }
204
+ return url.origin;
205
+ }
@@ -1,6 +1,7 @@
1
1
  import { isLiveDaemonAttachment, withDaemonLastSeenAt, type DaemonRole } from "./daemon-liveness.ts";
2
2
  import { sanitizeMetadataText } from "./http.ts";
3
3
  import { sanitizeDaemonPolicy, sanitizeDaemonTools, type DaemonPolicy } from "./policy.ts";
4
+ import { sanitizeDaemonChallengeAttachment, type DaemonChallenge } from "./daemon-auth.ts";
4
5
 
5
6
  export interface DaemonAttachment {
6
7
  role: DaemonRole;
@@ -10,6 +11,10 @@ export interface DaemonAttachment {
10
11
  instanceId?: string;
11
12
  policy?: DaemonPolicy;
12
13
  tools?: string[];
14
+ authChallenge?: string;
15
+ authIssuedAt?: number;
16
+ authExpiresAt?: number;
17
+ workerOrigin?: string;
13
18
  }
14
19
 
15
20
  interface WebSocketContext {
@@ -33,6 +38,7 @@ export class DaemonSocketRegistry {
33
38
  instanceId: sanitizeDaemonInstanceId(candidate.instanceId),
34
39
  policy,
35
40
  tools: sanitizeDaemonTools(candidate.tools, policy),
41
+ ...sanitizeDaemonChallengeAttachment(candidate as Record<string, unknown>),
36
42
  };
37
43
  }
38
44
 
@@ -52,8 +58,15 @@ export class DaemonSocketRegistry {
52
58
  return this.context.getWebSockets().filter((socket) => this.attachment(socket)?.role !== "daemon" && socket.readyState === WebSocket.OPEN);
53
59
  }
54
60
 
55
- beginCandidate(socket: WebSocket, connectedAt = new Date().toISOString()): void {
56
- socket.serializeAttachment({ role: "candidate", connectedAt } satisfies DaemonAttachment);
61
+ beginCandidate(socket: WebSocket, challenge: DaemonChallenge, connectedAt = new Date().toISOString()): void {
62
+ socket.serializeAttachment({
63
+ role: "candidate",
64
+ connectedAt,
65
+ authChallenge: challenge.challenge,
66
+ authIssuedAt: challenge.issuedAt,
67
+ authExpiresAt: challenge.expiresAt,
68
+ workerOrigin: challenge.workerOrigin,
69
+ } satisfies DaemonAttachment);
57
70
  }
58
71
 
59
72
  beginProbe(socket: WebSocket, values: { connectedAt: string; probeId: string; instanceId: string; policy: DaemonPolicy; tools: string[] }): void {
@@ -8,12 +8,14 @@ const WORKER_ERROR_CODES = new Set([
8
8
  export class WorkerToolError extends Error {
9
9
  readonly code: string;
10
10
  readonly retryable: boolean;
11
+ readonly details?: Record<string, unknown>;
11
12
 
12
- constructor(code: string, message: string, retryable = false) {
13
+ constructor(code: string, message: string, retryable = false, details?: Record<string, unknown>) {
13
14
  super(message);
14
15
  this.name = "WorkerToolError";
15
16
  this.code = normalizeCode(code);
16
17
  this.retryable = retryable;
18
+ this.details = details;
17
19
  }
18
20
  }
19
21
 
@@ -23,11 +25,19 @@ export function daemonToolError(value: unknown): WorkerToolError {
23
25
  const message = typeof input.message === "string" && input.message
24
26
  ? input.message.slice(0, 2000)
25
27
  : "daemon tool failed";
26
- return new WorkerToolError(code, message, input.retryable === true);
28
+ return new WorkerToolError(
29
+ code, message, input.retryable === true,
30
+ isRecord(input.details) ? input.details : undefined,
31
+ );
27
32
  }
28
33
 
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 };
34
+ export function publicWorkerToolError(error: unknown): { code: string; message: string; retryable: boolean; details?: Record<string, unknown> } {
35
+ if (error instanceof WorkerToolError) {
36
+ return {
37
+ code: error.code, message: error.message, retryable: error.retryable,
38
+ ...(error.details ? { details: error.details } : {}),
39
+ };
40
+ }
31
41
  return { code: "execution_failed", message: "tool execution failed", retryable: false };
32
42
  }
33
43
 
@@ -36,6 +46,10 @@ function normalizeCode(value: unknown): string {
36
46
  return WORKER_ERROR_CODES.has(code) ? code : "execution_failed";
37
47
  }
38
48
 
49
+ function isRecord(value: unknown): value is Record<string, unknown> {
50
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
51
+ }
52
+
39
53
  function asObject(value: unknown): Record<string, unknown> {
40
54
  return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
41
55
  }
@@ -11,6 +11,7 @@ import {
11
11
  isFreshDaemonCandidate,
12
12
  } from "./daemon-liveness.ts";
13
13
  import { DaemonSocketRegistry } from "./daemon-sockets.ts";
14
+ import { consumeDaemonPreflightNonce, createDaemonChallenge, verifyDaemonAuthentication, verifyDaemonPreflight } from "./daemon-auth.ts";
14
15
  import { mcpClientRequestKey, resolveMcpSession } from "./mcp-session.ts";
15
16
  import { daemonToolTimeoutMs } from "./tool-timeout.ts";
16
17
  import { WorkerObservability } from "./observability.ts";
@@ -20,7 +21,7 @@ import { accountRoleAllowsTool, accountRoleToolNames, type AccountRole } from ".
20
21
  import { OAuthController, type AuthorizedToken, type OAuthControllerEnv } from "./oauth-controller.ts";
21
22
  import { accountAuthoritySnapshot, decorateProjectOverview, describeDaemonCeiling } from "./authority.ts";
22
23
  import { serverInfoTool, workspaceTools } from "./tool-catalog.ts";
23
- import { OFFLINE_ACCESS_SCOPE, randomToken, safeEqual } from "./oauth-state.ts";
24
+ import { OFFLINE_ACCESS_SCOPE, randomToken } from "./oauth-state.ts";
24
25
  import {
25
26
  HttpError, applyCors, baseUrl, bearerToken, corsPreflight, json, methodNotAllowed,
26
27
  parseJsonRequest, workerErrorClass,
@@ -34,7 +35,7 @@ import {
34
35
  } from "./websocket-protocol.ts";
35
36
 
36
37
  const SERVER_NAME = String(serverMetadata.name);
37
- const SERVER_VERSION = "1.2.10";
38
+ const SERVER_VERSION = "2.0.0";
38
39
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
39
40
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
40
41
  const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
@@ -46,7 +47,7 @@ const DAEMON_RECONNECT_GRACE_MS = 30_000;
46
47
  interface BridgeEnv extends OAuthControllerEnv {
47
48
  BRIDGE: DurableObjectNamespace<BridgeRoom>;
48
49
  ACCOUNT_ADMIN_SECRET: string;
49
- DAEMON_SHARED_SECRET: string;
50
+ DAEMON_DEVICE_PUBLIC_KEY: string;
50
51
  OAUTH_TOKEN_VERSION: string;
51
52
  MBM_WORKER_MAX_BODY_BYTES?: string;
52
53
  MBM_ALLOWED_ORIGINS?: string;
@@ -179,12 +180,34 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
179
180
  await this.scheduleSocketAlarms();
180
181
  return;
181
182
  }
182
- const daemonPolicy = sanitizeDaemonPolicy(body.policy);
183
183
  const instanceId = daemonInstanceId(body.instance_id);
184
184
  if (!instanceId) {
185
185
  rejectDaemonMessage(ws, "invalid_daemon_instance", 1002, "daemon instance id required");
186
186
  return;
187
187
  }
188
+ if (!socketAttachment.authChallenge || !socketAttachment.authIssuedAt || !socketAttachment.authExpiresAt || !socketAttachment.workerOrigin) {
189
+ rejectDaemonMessage(ws, "missing_daemon_challenge", 1008, "daemon challenge missing");
190
+ return;
191
+ }
192
+ const authenticated = await verifyDaemonAuthentication({
193
+ publicKeyJson: this.env.DAEMON_DEVICE_PUBLIC_KEY ?? "",
194
+ authentication: body.authentication,
195
+ challenge: {
196
+ scheme: "device-signature-v1",
197
+ challenge: socketAttachment.authChallenge,
198
+ issuedAt: socketAttachment.authIssuedAt,
199
+ expiresAt: socketAttachment.authExpiresAt,
200
+ workerOrigin: socketAttachment.workerOrigin,
201
+ },
202
+ server: SERVER_NAME,
203
+ version: SERVER_VERSION,
204
+ instanceId,
205
+ });
206
+ if (!authenticated) {
207
+ rejectDaemonMessage(ws, "daemon_authentication_failed", 1008, "daemon authentication failed");
208
+ return;
209
+ }
210
+ const daemonPolicy = sanitizeDaemonPolicy(body.policy);
188
211
  const authenticatedAt = new Date().toISOString();
189
212
  const probeId = randomToken("probe");
190
213
  this.daemonRegistry.beginProbe(ws, {
@@ -482,7 +505,12 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
482
505
  try {
483
506
  socket.send(JSON.stringify({
484
507
  type: "tool_call", id, tool: name, arguments: args, timeout_ms: timeoutMs,
485
- authorization: { account_id: authorized.accountId, account_version: authorized.accountVersion, role: authorized.role },
508
+ authorization: {
509
+ account_id: authorized.accountId,
510
+ account_version: authorized.accountVersion,
511
+ client_id: authorized.clientId,
512
+ role: authorized.role,
513
+ },
486
514
  }));
487
515
  } catch {
488
516
  this.pending.reject(id, new WorkerToolError("network_error", "failed to send daemon tool call", true), socket);
@@ -508,9 +536,19 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
508
536
 
509
537
  private async acceptDaemonWebSocket(request: Request): Promise<Response> {
510
538
  if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") return new Response("Expected Upgrade: websocket", { status: 426 });
511
- const expected = this.env.DAEMON_SHARED_SECRET ?? "";
512
- const supplied = request.headers.get("X-Bridge-Token") ?? "";
513
- if (!expected || !(await safeEqual(supplied, expected))) return new Response("Unauthorized daemon", { status: 401 });
539
+ if (!this.env.DAEMON_DEVICE_PUBLIC_KEY) return new Response("Daemon device identity is not configured", { status: 503 });
540
+ const workerOrigin = new URL(request.url).origin;
541
+ const preflight = await verifyDaemonPreflight({
542
+ publicKeyJson: this.env.DAEMON_DEVICE_PUBLIC_KEY,
543
+ headers: request.headers,
544
+ workerOrigin,
545
+ server: SERVER_NAME,
546
+ version: SERVER_VERSION,
547
+ });
548
+ if (!preflight || !(await consumeDaemonPreflightNonce(this.ctx.storage, preflight))) {
549
+ return new Response("Unauthorized daemon device", { status: 401 });
550
+ }
551
+ const challenge = createDaemonChallenge(workerOrigin);
514
552
 
515
553
  for (const socket of this.daemonRegistry.nonReadySockets()) {
516
554
  closeWebSocketQuietly(socket, 1012, "replaced by newer daemon candidate");
@@ -520,9 +558,20 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
520
558
  const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
521
559
  this.ctx.acceptWebSocket(server);
522
560
  this.observability.socketCandidate();
523
- this.daemonRegistry.beginCandidate(server);
561
+ this.daemonRegistry.beginCandidate(server, challenge);
524
562
  await this.scheduleSocketAlarms();
525
- server.send(JSON.stringify({ type: "welcome", server: SERVER_NAME, version: SERVER_VERSION }));
563
+ server.send(JSON.stringify({
564
+ type: "welcome",
565
+ server: SERVER_NAME,
566
+ version: SERVER_VERSION,
567
+ worker_origin: workerOrigin,
568
+ authentication: {
569
+ scheme: challenge.scheme,
570
+ challenge: challenge.challenge,
571
+ issued_at: challenge.issuedAt,
572
+ expires_at: challenge.expiresAt,
573
+ },
574
+ }));
526
575
  return new Response(null, { status: 101, webSocket: client });
527
576
  }
528
577
 
@@ -1,3 +1,4 @@
1
+ import { projectMcpResult } from "../shared/result-projection.mjs";
1
2
  const JSONRPC_VERSION = "2.0";
2
3
  const MAX_SESSION_INSTRUCTION_BYTES = 3 * 1024 * 1024;
3
4
 
@@ -48,11 +49,12 @@ export function textToolResult(value: unknown, isError = false): Record<string,
48
49
  return result;
49
50
  }
50
51
  }
52
+ const projection = projectMcpResult(value);
51
53
  const result: Record<string, unknown> = {
52
- content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }],
54
+ content: [{ type: "text", text: projection.text }],
53
55
  isError,
54
56
  };
55
- if (value && typeof value === "object" && !Array.isArray(value)) result.structuredContent = value;
57
+ if (projection.structuredContent) result.structuredContent = projection.structuredContent;
56
58
  return result;
57
59
  }
58
60