machine-bridge-mcp 1.1.5 → 1.2.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/CODE_OF_CONDUCT.md +24 -0
  3. package/CONTRIBUTING.md +3 -1
  4. package/GOVERNANCE.md +50 -0
  5. package/README.md +26 -0
  6. package/SUPPORT.md +31 -0
  7. package/browser-extension/browser-operations.js +10 -4
  8. package/browser-extension/devtools-input.js +2 -2
  9. package/browser-extension/manifest.json +2 -2
  10. package/browser-extension/page-automation.js +1 -1
  11. package/docs/ARCHITECTURE.md +14 -17
  12. package/docs/AUDIT.md +28 -0
  13. package/docs/ENGINEERING.md +8 -2
  14. package/docs/PROJECT_STANDARDS.md +4 -2
  15. package/docs/TESTING.md +9 -3
  16. package/docs/UPGRADING.md +32 -0
  17. package/package.json +12 -4
  18. package/scripts/coverage-check.mjs +27 -10
  19. package/src/local/account-access.mjs +7 -5
  20. package/src/local/agent-context.mjs +7 -148
  21. package/src/local/agent-contract.mjs +206 -0
  22. package/src/local/browser-bridge.mjs +30 -281
  23. package/src/local/browser-extension-protocol.mjs +19 -4
  24. package/src/local/browser-operation-service.mjs +325 -0
  25. package/src/local/call-registry.mjs +44 -1
  26. package/src/local/capability-ranking.mjs +13 -0
  27. package/src/local/cli-local-admin.mjs +74 -38
  28. package/src/local/cli-options.mjs +18 -18
  29. package/src/local/cli-policy.mjs +1 -1
  30. package/src/local/cli-service.mjs +132 -0
  31. package/src/local/cli.mjs +27 -109
  32. package/src/local/daemon-process.mjs +1 -1
  33. package/src/local/full-access-test.mjs +1 -1
  34. package/src/local/job-runner.mjs +9 -3
  35. package/src/local/managed-job-plan.mjs +3 -3
  36. package/src/local/monotonic-deadline.mjs +7 -0
  37. package/src/local/numbers.mjs +8 -0
  38. package/src/local/policy.mjs +97 -16
  39. package/src/local/project-metadata.mjs +7 -1
  40. package/src/local/project-package.mjs +1 -1
  41. package/src/local/records.mjs +6 -0
  42. package/src/local/resource-operations.mjs +1 -1
  43. package/src/local/runtime-capabilities.mjs +66 -0
  44. package/src/local/runtime-diagnostics.mjs +91 -0
  45. package/src/local/runtime-reporting.mjs +113 -0
  46. package/src/local/runtime.mjs +54 -191
  47. package/src/local/service-convergence.mjs +1 -1
  48. package/src/local/service.mjs +1 -1
  49. package/src/local/state.mjs +1 -1
  50. package/src/local/windows-service.mjs +1 -1
  51. package/src/local/worker-deployment.mjs +15 -10
  52. package/src/local/worker-health.mjs +8 -4
  53. package/src/worker/access.ts +8 -7
  54. package/src/worker/account-admin.ts +2 -2
  55. package/src/worker/authority.ts +3 -3
  56. package/src/worker/http.ts +2 -2
  57. package/src/worker/index.ts +29 -337
  58. package/src/worker/oauth-controller.ts +343 -0
  59. package/src/worker/oauth-state.ts +1 -1
  60. package/src/worker/oauth-tokens.ts +2 -2
  61. package/src/worker/tool-catalog.ts +1 -1
  62. package/tsconfig.json +2 -1
  63. package/tsconfig.local.json +27 -0
@@ -0,0 +1,343 @@
1
+ import { DEFAULT_ACCOUNT_ROLE, normalizeAccountRole, type AccountRole } from "./access.ts";
2
+ import { accountAdminAuthorized, handleAccountAdminOperation } from "./account-admin.ts";
3
+ import { exchangeOAuthToken } from "./oauth-tokens.ts";
4
+ import {
5
+ AUTH_BLOCK_SECONDS, accountByName, authorizationIdentity, emptyOAuthStore,
6
+ isCurrentOAuthStore, pruneAuthFailures, pruneClientRecordByExpiry, pruneRecordByExpiry, randomToken,
7
+ recordAuthorizationFailure, safeEqual, sha256Hex, validateAuthorizationRequest, verifyAccountPassword,
8
+ type OAuthClient, type OAuthStore, type ValidatedAuthorization,
9
+ } from "./oauth-state.ts";
10
+ import {
11
+ HttpError, authorizationRedirectLocation, escapeHtml, html, json, normalizeDisplayText,
12
+ normalizeRedirectUri, oauthRedirect, parseRequestBody, searchParamsEntries, searchParamsObject,
13
+ } from "./http.ts";
14
+
15
+ const OAUTH_BODY_LIMIT_BYTES = 64 * 1024;
16
+ const OAUTH_UNUSED_CLIENT_TTL_SECONDS = 60 * 60;
17
+ const MAX_OAUTH_CLIENTS = 50;
18
+ const MAX_OAUTH_CLIENTS_PER_IDENTITY = 5;
19
+ const OAUTH_CLIENT_IDLE_TTL_SECONDS = 60 * 60 * 24 * 90;
20
+ const MAX_CODES_PER_CLIENT = 10;
21
+ const MAX_OAUTH_CODES = 200;
22
+ const MAX_AUTH_FAILURE_IDENTITIES = 200;
23
+ const AUTHORIZATION_FIELDS = new Set(["response_type", "client_id", "redirect_uri", "code_challenge", "code_challenge_method", "scope", "resource", "state"]);
24
+
25
+ export interface OAuthControllerEnv {
26
+ ACCOUNT_ADMIN_SECRET: string;
27
+ DAEMON_SHARED_SECRET: string;
28
+ OAUTH_TOKEN_VERSION: string;
29
+ }
30
+
31
+ export interface AuthorizedToken {
32
+ tokenKey: string;
33
+ accountId: string;
34
+ accountVersion: number;
35
+ role: AccountRole;
36
+ }
37
+
38
+ export class OAuthController {
39
+ private readonly ctx: DurableObjectState;
40
+ private readonly env: OAuthControllerEnv;
41
+ private readonly serverName: string;
42
+ private oauthQueue: Promise<void> = Promise.resolve();
43
+
44
+ constructor(ctx: DurableObjectState, env: OAuthControllerEnv, serverName: string) {
45
+ this.ctx = ctx;
46
+ this.env = env;
47
+ this.serverName = serverName;
48
+ }
49
+
50
+ private async oauthStore(): Promise<OAuthStore> {
51
+ const raw = await this.ctx.storage.get<unknown>("oauth");
52
+ if (raw !== undefined && !isCurrentOAuthStore(raw)) {
53
+ throw new HttpError(503, "oauth_state_schema_mismatch", "OAuth state does not match the current schema");
54
+ }
55
+ const store = isCurrentOAuthStore(raw) ? raw : emptyOAuthStore();
56
+ let changed = false;
57
+ const now = Math.floor(Date.now() / 1000);
58
+
59
+ for (const account of Object.values(store.accounts)) {
60
+ if (normalizeAccountRole(account.role)) continue;
61
+ account.role = DEFAULT_ACCOUNT_ROLE;
62
+ account.active = false;
63
+ account.version = Number.isInteger(account.version) && account.version > 0 ? account.version + 1 : 1;
64
+ account.updated_at = now;
65
+ changed = true;
66
+ }
67
+
68
+ for (const [code, value] of Object.entries(store.codes)) {
69
+ const account = store.accounts[value.account_id];
70
+ if (value.expires_at <= now || !account || !account.active || account.version !== value.account_version || account.role !== value.role) {
71
+ delete store.codes[code];
72
+ changed = true;
73
+ }
74
+ }
75
+ for (const [token, value] of Object.entries(store.tokens)) {
76
+ const account = store.accounts[value.account_id];
77
+ if (value.expires_at <= now || !account || !account.active || account.version !== value.account_version || account.role !== value.role) {
78
+ delete store.tokens[token];
79
+ changed = true;
80
+ }
81
+ }
82
+ for (const [identity, value] of Object.entries(store.auth_failures)) {
83
+ if (!identity.startsWith("hmac-sha256:") || value.last_attempt + AUTH_BLOCK_SECONDS <= now) {
84
+ delete store.auth_failures[identity];
85
+ changed = true;
86
+ }
87
+ }
88
+ const activeClientIds = new Set([
89
+ ...Object.values(store.codes).map((value) => value.client_id),
90
+ ...Object.values(store.tokens).map((value) => value.client_id),
91
+ ]);
92
+ for (const [clientId, client] of Object.entries(store.clients)) {
93
+ if (client.registration_identity && !client.registration_identity.startsWith("hmac-sha256:")) {
94
+ delete client.registration_identity;
95
+ changed = true;
96
+ }
97
+ const ttl = client.has_been_authorized === false ? OAUTH_UNUSED_CLIENT_TTL_SECONDS : OAUTH_CLIENT_IDLE_TTL_SECONDS;
98
+ if (!activeClientIds.has(clientId) && client.last_used_at + ttl <= now) {
99
+ delete store.clients[clientId];
100
+ changed = true;
101
+ }
102
+ }
103
+ if (changed) await this.ctx.storage.put("oauth", store);
104
+ return store;
105
+ }
106
+
107
+ async handleAccountAdmin(request: Request, operation: "accounts" | "rotate-password"): Promise<Response> {
108
+ if (!(await accountAdminAuthorized(request, this.env.ACCOUNT_ADMIN_SECRET ?? ""))) return json({ error: "unauthorized" }, 401);
109
+ return this.withOAuthLock(async () => {
110
+ const store = await this.oauthStore();
111
+ return handleAccountAdminOperation({
112
+ request, operation, store, now: Math.floor(Date.now() / 1000),
113
+ save: () => this.ctx.storage.put("oauth", store),
114
+ });
115
+ });
116
+ }
117
+
118
+ async registerClient(request: Request): Promise<Response> {
119
+ const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
120
+ const redirectUris = body.redirect_uris;
121
+ if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
122
+ return json({ error: "invalid_client_metadata", error_description: "redirect_uris must be a non-empty array" }, 400);
123
+ }
124
+ if (redirectUris.length > 5) {
125
+ return json({ error: "invalid_client_metadata", error_description: "redirect_uris must contain at most 5 entries" }, 400);
126
+ }
127
+ const suppliedRedirectUris = redirectUris.map((item) => String(item));
128
+ if (suppliedRedirectUris.some((item) => item.length > 1024)) {
129
+ return json({ error: "invalid_client_metadata", error_description: "redirect_uri is too long" }, 400);
130
+ }
131
+ const canonicalRedirectUris = suppliedRedirectUris.map(normalizeRedirectUri);
132
+ if (canonicalRedirectUris.some((item) => item === null)) {
133
+ return json({ error: "invalid_client_metadata", error_description: "redirect_uris must be canonicalizable https or local http URLs without credentials or fragments" }, 400);
134
+ }
135
+ const normalized = [...new Set(canonicalRedirectUris as string[])];
136
+
137
+ return this.withOAuthLock(async () => {
138
+ const store = await this.oauthStore();
139
+ const registrationIdentity = await authorizationIdentity(request, this.identityKey());
140
+ const pendingIdentityClientCount = Object.values(store.clients).filter((client) => (
141
+ client.registration_identity === registrationIdentity && client.has_been_authorized === false
142
+ )).length;
143
+ if (pendingIdentityClientCount >= MAX_OAUTH_CLIENTS_PER_IDENTITY) {
144
+ return json({ error: "too_many_requests", error_description: "pending client registration limit reached for this source" }, 429);
145
+ }
146
+ if (Object.keys(store.clients).length >= MAX_OAUTH_CLIENTS) {
147
+ return json({ error: "temporarily_unavailable", error_description: "client registry is full; remove stale state or retry after inactive clients expire" }, 503);
148
+ }
149
+ const now = Math.floor(Date.now() / 1000);
150
+ const client: OAuthClient = {
151
+ client_id: randomToken("mcp_client"),
152
+ client_name: normalizeDisplayText(stringOrUndefined(body.client_name) ?? "MCP Client", 128),
153
+ redirect_uris: normalized,
154
+ created_at: now,
155
+ last_used_at: now,
156
+ has_been_authorized: false,
157
+ registration_identity: registrationIdentity,
158
+ };
159
+ store.clients[client.client_id] = client;
160
+ await this.ctx.storage.put("oauth", store);
161
+ return json({
162
+ client_id: client.client_id,
163
+ client_name: client.client_name,
164
+ redirect_uris: client.redirect_uris,
165
+ grant_types: ["authorization_code", "refresh_token"],
166
+ response_types: ["code"],
167
+ token_endpoint_auth_method: "none",
168
+ client_id_issued_at: client.created_at,
169
+ });
170
+ });
171
+ }
172
+
173
+ async authorizeGet(request: Request, base: string): Promise<Response> {
174
+ const body = searchParamsObject(new URL(request.url).searchParams);
175
+ return this.withOAuthLock(async () => {
176
+ const store = await this.oauthStore();
177
+ const validation = validateAuthorizationRequest(body, base, this.serverName, store);
178
+ if ("error" in validation) {
179
+ return this.authorizePage(request, base, validation.error, body, validation.status, undefined, false);
180
+ }
181
+ return this.authorizePage(request, base, "", body, 200, validation.value, true);
182
+ });
183
+ }
184
+
185
+ private authorizePage(
186
+ request: Request,
187
+ base: string,
188
+ error = "",
189
+ submitted?: Record<string, unknown>,
190
+ status = 200,
191
+ authorization?: ValidatedAuthorization,
192
+ allowSubmit = true,
193
+ ): Response {
194
+ const url = new URL(request.url);
195
+ const sourceEntries = submitted ? Object.entries(submitted) : searchParamsEntries(url.searchParams);
196
+ const hidden = sourceEntries
197
+ .filter(([key]) => AUTHORIZATION_FIELDS.has(key))
198
+ .map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(String(value))}">`)
199
+ .join("\n");
200
+ const resource = normalizeDisplayText(
201
+ authorization?.requestedResource ?? String(submitted?.resource ?? url.searchParams.get("resource") ?? `${base}/mcp`),
202
+ 1024,
203
+ `${base}/mcp`,
204
+ );
205
+ const clientBlock = authorization
206
+ ? `<p><strong>Client:</strong> ${escapeHtml(authorization.client.client_name)}</p>
207
+ <p><strong>Redirect URI:</strong> <code>${escapeHtml(authorization.redirectUri)}</code></p>`
208
+ : "";
209
+ const errorBlock = error ? `<p role="alert" aria-live="assertive" style="color:#b91c1c; font-weight:600">${escapeHtml(error)}</p>` : "";
210
+ const accountName = normalizeDisplayText(String(submitted?.account_name ?? ""), 64, "");
211
+ const form = allowSubmit
212
+ ? `<form method="post" action="/oauth/authorize">
213
+ ${hidden}
214
+ <label>Account name<br><input name="account_name" value="${escapeHtml(accountName)}" autocomplete="username" autofocus required style="width: 100%; box-sizing: border-box; padding: 8px;"></label>
215
+ <p><label>Account password<br><input name="account_password" type="password" autocomplete="current-password" required style="width: 100%; box-sizing: border-box; padding: 8px;"></label></p>
216
+ <p><button type="submit">Authorize</button></p>
217
+ </form>`
218
+ : "<p>Authorization cannot continue. Return to the MCP client and start the connection again.</p>";
219
+ const redirectOrigin = authorization ? new URL(authorization.redirectUri).origin : "";
220
+ return html(`<!doctype html>
221
+ <html>
222
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Authorize ${this.serverName}</title></head>
223
+ <body style="font-family: system-ui, sans-serif; max-width: 640px; margin: 48px auto; line-height: 1.5; padding: 0 16px;">
224
+ <h1>Authorize ${this.serverName}</h1>
225
+ <p>Only continue if you initiated this MCP connection and recognize the client and redirect URI below.</p>
226
+ ${clientBlock}
227
+ <p><strong>Resource:</strong> <code>${escapeHtml(resource)}</code></p>
228
+ ${errorBlock}
229
+ ${form}
230
+ </body>
231
+ </html>`, status, redirectOrigin);
232
+ }
233
+
234
+ async authorizeSubmit(request: Request, base: string): Promise<Response> {
235
+ const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
236
+ return this.withOAuthLock(async () => {
237
+ const store = await this.oauthStore();
238
+ const validation = validateAuthorizationRequest(body, base, this.serverName, store);
239
+ if ("error" in validation) {
240
+ return this.authorizePage(request, base, validation.error, body, validation.status, undefined, false);
241
+ }
242
+ const { client, clientId, redirectUri, codeChallenge, requestedResource, scope, state } = validation.value;
243
+ const now = Math.floor(Date.now() / 1000);
244
+ const identity = await authorizationIdentity(request, this.identityKey());
245
+ const failure = store.auth_failures[identity];
246
+ if (failure?.blocked_until > now) {
247
+ return this.authorizePage(request, base, "Too many failed attempts. Try again later.", body, 429, validation.value);
248
+ }
249
+
250
+ const account = accountByName(store, body.account_name);
251
+ const credentialsValid = Boolean(account?.active && await verifyAccountPassword(account, body.account_password));
252
+ if (!account || !credentialsValid) {
253
+ recordAuthorizationFailure(store, identity, now);
254
+ pruneAuthFailures(store, MAX_AUTH_FAILURE_IDENTITIES);
255
+ await this.ctx.storage.put("oauth", store);
256
+ const status = store.auth_failures[identity]?.blocked_until > now ? 429 : 401;
257
+ return this.authorizePage(request, base, "Invalid account credentials.", body, status, validation.value);
258
+ }
259
+ delete store.auth_failures[identity];
260
+ client.last_used_at = now;
261
+ client.has_been_authorized = true;
262
+
263
+ const code = randomToken("mcp_code");
264
+ const redirectLocation = authorizationRedirectLocation(redirectUri, code, state);
265
+ store.codes[code] = {
266
+ client_id: clientId,
267
+ account_id: account.account_id,
268
+ account_version: account.version,
269
+ role: account.role,
270
+ redirect_uri: redirectUri,
271
+ code_challenge: codeChallenge,
272
+ scope,
273
+ resource: requestedResource,
274
+ expires_at: now + 300,
275
+ };
276
+ pruneClientRecordByExpiry(store.codes, clientId, MAX_CODES_PER_CLIENT);
277
+ pruneRecordByExpiry(store.codes, MAX_OAUTH_CODES);
278
+ await this.ctx.storage.put("oauth", store);
279
+
280
+ return oauthRedirect(redirectLocation);
281
+ });
282
+ }
283
+
284
+ exchangeToken(request: Request, base: string): Promise<Response> {
285
+ return exchangeOAuthToken(request, base, {
286
+ storage: this.ctx.storage,
287
+ tokenVersion: this.env.OAUTH_TOKEN_VERSION ?? "",
288
+ serverName: this.serverName,
289
+ loadOAuthStore: () => this.oauthStore(),
290
+ withLock: (callback) => this.withOAuthLock(callback),
291
+ });
292
+ }
293
+
294
+ async verifyAccessToken(token: string, base: string): Promise<AuthorizedToken | null> {
295
+ return this.withOAuthLock(async () => {
296
+ if (!token) return null;
297
+ const store = await this.oauthStore();
298
+ const key = `sha256:${await sha256Hex(token)}`;
299
+ const record = store.tokens[key];
300
+ if (!record) return null;
301
+ if (record.expires_at <= Math.floor(Date.now() / 1000)) {
302
+ delete store.tokens[key];
303
+ await this.ctx.storage.put("oauth", store);
304
+ return null;
305
+ }
306
+ const currentVersion = this.env.OAUTH_TOKEN_VERSION ?? "";
307
+ if (!record.version || !currentVersion || !(await safeEqual(record.version, currentVersion))) return null;
308
+ if (record.resource !== `${base}/mcp`) return null;
309
+ const account = store.accounts[record.account_id];
310
+ if (!account || !account.active || account.version !== record.account_version || account.role !== record.role) {
311
+ delete store.tokens[key];
312
+ await this.ctx.storage.put("oauth", store);
313
+ return null;
314
+ }
315
+ return {
316
+ tokenKey: key, accountId: account.account_id,
317
+ accountVersion: account.version, role: account.role,
318
+ };
319
+ });
320
+ }
321
+
322
+ private async withOAuthLock<T>(callback: () => Promise<T>): Promise<T> {
323
+ const previous = this.oauthQueue;
324
+ let release = () => {};
325
+ this.oauthQueue = new Promise<void>((resolve) => { release = resolve; });
326
+ await previous;
327
+ try {
328
+ return await callback();
329
+ } finally {
330
+ release();
331
+ }
332
+ }
333
+
334
+ identityKey(): string {
335
+ const key = this.env.OAUTH_TOKEN_VERSION || this.env.DAEMON_SHARED_SECRET || this.env.ACCOUNT_ADMIN_SECRET;
336
+ if (!key) throw new HttpError(503, "server_not_configured", "OAuth identity key is not configured");
337
+ return key;
338
+ }
339
+ }
340
+
341
+ function stringOrUndefined(value: unknown): string | undefined {
342
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
343
+ }
@@ -1,4 +1,4 @@
1
- import { normalizeAccountRole, type AccountRole } from "./access";
1
+ import { normalizeAccountRole, type AccountRole } from "./access.ts";
2
2
 
3
3
  const OAUTH_STORE_SCHEMA_VERSION = 1;
4
4
  const OAUTH_REFRESH_STORE_SCHEMA_VERSION = 1;
@@ -2,8 +2,8 @@ import {
2
2
  emptyOAuthRefreshStore, isCurrentOAuthRefreshStore, normalizeOAuthScope, pkceS256,
3
3
  pruneClientRecordByExpiry, pruneRecordByExpiry, randomToken, safeEqual, sha256Hex,
4
4
  type OAuthCode, type OAuthRefreshStore, type OAuthRefreshToken, type OAuthStore,
5
- } from "./oauth-state";
6
- import { HttpError, json, parseRequestBody } from "./http";
5
+ } from "./oauth-state.ts";
6
+ import { HttpError, json, parseRequestBody } from "./http.ts";
7
7
 
8
8
  const ACCESS_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
9
9
  const REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 90;
@@ -1,4 +1,4 @@
1
- import toolCatalog from "../shared/tool-catalog.json";
1
+ import toolCatalog from "../shared/tool-catalog.json" with { type: "json" };
2
2
 
3
3
  export type WorkerToolDefinition = Record<string, unknown> & { name: string; availability?: string };
4
4
 
package/tsconfig.json CHANGED
@@ -13,7 +13,8 @@
13
13
  "noUnusedParameters": true,
14
14
  "skipLibCheck": true,
15
15
  "forceConsistentCasingInFileNames": true,
16
- "resolveJsonModule": true
16
+ "resolveJsonModule": true,
17
+ "allowImportingTsExtensions": true
17
18
  },
18
19
  "include": [
19
20
  "src/worker/**/*.ts",
@@ -0,0 +1,27 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "allowJs": true,
7
+ "checkJs": false,
8
+ "noEmit": true,
9
+ "strict": true,
10
+ "types": [
11
+ "node"
12
+ ],
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true
15
+ },
16
+ "include": [
17
+ "src/local/numbers.mjs",
18
+ "src/local/records.mjs",
19
+ "src/local/monotonic-deadline.mjs",
20
+ "src/local/project-metadata.mjs",
21
+ "src/local/capability-ranking.mjs",
22
+ "src/local/agent-contract.mjs",
23
+ "src/local/browser-extension-protocol.mjs",
24
+ "src/local/call-registry.mjs",
25
+ "src/local/policy.mjs"
26
+ ]
27
+ }