machine-bridge-mcp 2.0.0 → 3.0.0-beta.9
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 +101 -0
- package/CONTRIBUTING.md +20 -25
- package/README.md +21 -14
- package/SECURITY.md +147 -123
- package/browser-extension/manifest.json +3 -2
- package/browser-extension/service-worker.js +31 -12
- package/docs/ARCHITECTURE.md +27 -13
- package/docs/AUDIT.md +85 -2
- package/docs/CLIENTS.md +2 -2
- package/docs/ENGINEERING.md +9 -3
- package/docs/GETTING_STARTED.md +10 -9
- package/docs/LOCAL_AUTHORIZATION.md +115 -72
- package/docs/LOGGING.md +12 -3
- package/docs/MANAGED_JOBS.md +24 -29
- package/docs/MULTI_ACCOUNT.md +98 -37
- package/docs/OPERATIONS.md +33 -22
- package/docs/OVERVIEW.md +7 -7
- package/docs/PROJECT_STANDARDS.md +9 -5
- package/docs/RELEASING.md +171 -64
- package/docs/TESTING.md +19 -7
- package/docs/THREAT_MODEL.md +138 -67
- package/docs/TOOL_REFERENCE.md +2 -2
- package/docs/UPGRADING.md +102 -18
- package/native/macos/MachineBridgeTrustBroker.swift +233 -0
- package/package.json +36 -5
- package/scripts/candidate-runtime-store.mjs +38 -0
- package/scripts/check-plan.mjs +19 -0
- package/scripts/check-runner.mjs +4 -1
- package/scripts/commit-message-check.mjs +5 -2
- package/scripts/coverage-check.mjs +30 -2
- package/scripts/github-push.mjs +11 -0
- package/scripts/github-release.mjs +38 -7
- package/scripts/install-published-prerelease.mjs +167 -0
- package/scripts/local-release-acceptance.mjs +23 -12
- package/scripts/npm-publication-policy.mjs +16 -0
- package/scripts/prerelease-activation.mjs +79 -0
- package/scripts/privacy-check.mjs +7 -5
- package/scripts/promotion-digest.mjs +121 -0
- package/scripts/publish-npm.mjs +51 -0
- package/scripts/published-release.mjs +80 -0
- package/scripts/release-acceptance.mjs +30 -1
- package/scripts/release-candidate-manifest.mjs +40 -0
- package/scripts/release-channel.mjs +106 -0
- package/scripts/release-impact-check.mjs +12 -22
- package/scripts/release-soak.mjs +237 -0
- package/scripts/start-release-candidate.mjs +113 -7
- package/src/local/account-access.mjs +6 -0
- package/src/local/account-admin.mjs +34 -16
- package/src/local/agent-context.mjs +45 -42
- package/src/local/app-automation.mjs +9 -4
- package/src/local/authority-context.mjs +106 -0
- package/src/local/browser-bridge-http.mjs +4 -1
- package/src/local/browser-bridge.mjs +14 -9
- package/src/local/browser-broker-routes.mjs +4 -0
- package/src/local/browser-broker-server.mjs +5 -4
- package/src/local/browser-extension-identity.mjs +50 -0
- package/src/local/browser-extension-protocol.mjs +9 -4
- package/src/local/browser-operation-service.mjs +7 -0
- package/src/local/browser-pairing-http.mjs +36 -0
- package/src/local/browser-pairing-store.mjs +56 -47
- package/src/local/cli-account-admin.mjs +41 -7
- package/src/local/cli-activate.mjs +90 -0
- package/src/local/cli-approval.mjs +14 -58
- package/src/local/cli-local-admin.mjs +5 -15
- package/src/local/cli-options.mjs +11 -7
- package/src/local/cli-service.mjs +35 -5
- package/src/local/cli.mjs +112 -58
- package/src/local/daemon-process.mjs +6 -0
- package/src/local/delegated-process-sandbox.mjs +147 -0
- package/src/local/device-identity.mjs +157 -48
- package/src/local/device-root-provider.mjs +87 -0
- package/src/local/errors.mjs +25 -3
- package/src/local/git-service.mjs +16 -15
- package/src/local/job-runner.mjs +35 -27
- package/src/local/log.mjs +14 -1
- package/src/local/macos-trust-broker.mjs +320 -0
- package/src/local/managed-job-lock.mjs +18 -0
- package/src/local/managed-job-projection.mjs +9 -1
- package/src/local/managed-job-runner.mjs +13 -1
- package/src/local/managed-job-storage.mjs +2 -1
- package/src/local/managed-job-terminal.mjs +138 -0
- package/src/local/managed-jobs.mjs +88 -32
- package/src/local/operation-authorization.mjs +75 -239
- package/src/local/operation-risk.mjs +28 -0
- package/src/local/operation-state-lock.mjs +7 -86
- package/src/local/owner-state-lock.mjs +104 -0
- package/src/local/policy.mjs +19 -0
- package/src/local/process-execution.mjs +21 -9
- package/src/local/process-sessions.mjs +27 -17
- package/src/local/process-tracker.mjs +30 -6
- package/src/local/process-tree-ownership.mjs +59 -0
- package/src/local/process-tree.mjs +22 -18
- package/src/local/relay-connection.mjs +47 -35
- package/src/local/relay-diagnostics.mjs +65 -0
- package/src/local/remote-configuration.mjs +48 -0
- package/src/local/runtime-activation.mjs +141 -0
- package/src/local/runtime-diagnostics.mjs +21 -0
- package/src/local/runtime-relay.mjs +9 -8
- package/src/local/runtime-reporting.mjs +8 -3
- package/src/local/runtime-tool-handlers.mjs +8 -8
- package/src/local/runtime.mjs +73 -39
- package/src/local/secure-file.mjs +5 -1
- package/src/local/security-audit-log.mjs +204 -0
- package/src/local/service-convergence.mjs +17 -1
- package/src/local/service-ownership.mjs +17 -0
- package/src/local/service-restart-handoff.mjs +50 -0
- package/src/local/service-restart-scheduler.mjs +49 -0
- package/src/local/service-status.mjs +47 -0
- package/src/local/service.mjs +142 -43
- package/src/local/state-inventory.mjs +21 -3
- package/src/local/state.mjs +155 -17
- package/src/local/stdio.mjs +5 -0
- package/src/local/tool-executor.mjs +39 -6
- package/src/local/tool-result-boundary.mjs +51 -0
- package/src/local/tools.mjs +1 -0
- package/src/local/trusted-executable.mjs +53 -0
- package/src/local/trusted-git-executable.mjs +34 -0
- package/src/local/trusted-github-cli.mjs +24 -0
- package/src/local/windows-service.mjs +20 -0
- package/src/local/worker-deployment.mjs +3 -4
- package/src/local/worker-secret-file.mjs +2 -2
- package/src/local/workspace-file-service.mjs +34 -31
- package/src/shared/admin-auth.d.mts +2 -1
- package/src/shared/admin-auth.mjs +3 -3
- package/src/shared/device-session-auth.d.mts +20 -0
- package/src/shared/device-session-auth.mjs +51 -0
- package/src/shared/server-metadata.json +1 -1
- package/src/shared/tool-catalog.json +2 -2
- package/src/worker/account-admin.ts +23 -22
- package/src/worker/authority.ts +9 -2
- package/src/worker/daemon-auth.ts +45 -67
- package/src/worker/daemon-sockets.ts +12 -2
- package/src/worker/device-session-verifier.ts +129 -0
- package/src/worker/dpop.ts +151 -0
- package/src/worker/http.ts +47 -16
- package/src/worker/index.ts +53 -16
- package/src/worker/nonce-store.ts +1 -5
- package/src/worker/oauth-client-admin.ts +45 -0
- package/src/worker/oauth-controller.ts +55 -6
- package/src/worker/oauth-refresh-families.ts +21 -7
- package/src/worker/oauth-state.ts +6 -0
- package/src/worker/oauth-tokens.ts +28 -8
- package/src/worker/websocket-protocol.ts +8 -2
|
@@ -12,10 +12,11 @@ const MAX_WALK_ENTRIES = 200_000;
|
|
|
12
12
|
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
|
13
13
|
|
|
14
14
|
export class WorkspaceFileService {
|
|
15
|
-
constructor({ workspace, policy, policyGate, resolveExistingPath, resolveWritePath, displayPath, throwIfCancelled, withMutationLock }) {
|
|
15
|
+
constructor({ workspace, policy, policyGate, policyForContext = null, resolveExistingPath, resolveWritePath, displayPath, throwIfCancelled, withMutationLock }) {
|
|
16
16
|
this.workspace = workspace;
|
|
17
17
|
this.policy = policy;
|
|
18
18
|
this.policyGate = policyGate;
|
|
19
|
+
this.policyForContext = typeof policyForContext === "function" ? policyForContext : () => this.policy;
|
|
19
20
|
this.resolveExistingPath = resolveExistingPath;
|
|
20
21
|
this.resolveWritePath = resolveWritePath;
|
|
21
22
|
this.displayPath = displayPath;
|
|
@@ -23,18 +24,19 @@ export class WorkspaceFileService {
|
|
|
23
24
|
this.withMutationLock = withMutationLock;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
listRoots() {
|
|
27
|
-
const
|
|
28
|
-
|
|
27
|
+
listRoots(context = {}) {
|
|
28
|
+
const policy = this.policyForContext(context);
|
|
29
|
+
const roots = [{ name: policy.exposeAbsolutePaths ? basename(this.workspace) : "workspace", path: this.displayPath(this.workspace, context), default: true }];
|
|
30
|
+
if (policy.unrestrictedPaths) {
|
|
29
31
|
const home = process.env.HOME || process.env.USERPROFILE;
|
|
30
|
-
if (home && home !== this.workspace) roots.push({ name: "home", path: this.displayPath(resolve(home)), default: false });
|
|
31
|
-
roots.push({ name: "filesystem-root", path: this.displayPath(path.parse(this.workspace).root), default: false });
|
|
32
|
+
if (home && home !== this.workspace) roots.push({ name: "home", path: this.displayPath(resolve(home), context), default: false });
|
|
33
|
+
roots.push({ name: "filesystem-root", path: this.displayPath(path.parse(this.workspace).root, context), default: false });
|
|
32
34
|
}
|
|
33
35
|
return { roots };
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
async listDir(inputPath, context = {}) {
|
|
37
|
-
const full = await this.resolveExistingPath(inputPath);
|
|
39
|
+
const full = await this.resolveExistingPath(inputPath, context);
|
|
38
40
|
const entries = [];
|
|
39
41
|
let resultBytes = 0;
|
|
40
42
|
let truncated = false;
|
|
@@ -44,7 +46,7 @@ export class WorkspaceFileService {
|
|
|
44
46
|
const info = await lstat(entryPath).catch(() => null);
|
|
45
47
|
const item = {
|
|
46
48
|
name: entry.name,
|
|
47
|
-
path: this.displayPath(entryPath),
|
|
49
|
+
path: this.displayPath(entryPath, context),
|
|
48
50
|
type: entry.isDirectory() ? "directory" : entry.isFile() ? "file" : entry.isSymbolicLink() ? "symlink" : "other",
|
|
49
51
|
size: info?.size ?? 0,
|
|
50
52
|
};
|
|
@@ -57,26 +59,26 @@ export class WorkspaceFileService {
|
|
|
57
59
|
resultBytes += itemBytes;
|
|
58
60
|
}
|
|
59
61
|
entries.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
|
|
60
|
-
return { path: this.displayPath(full), entries, truncated };
|
|
62
|
+
return { path: this.displayPath(full, context), entries, truncated };
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
async listFiles(inputPath, maxFiles, context = {}) {
|
|
64
|
-
const root = await this.resolveExistingPath(inputPath);
|
|
66
|
+
const root = await this.resolveExistingPath(inputPath, context);
|
|
65
67
|
const info = await stat(root);
|
|
66
|
-
if (info.isFile()) return { path: this.displayPath(root), files: [this.displayPath(root)], truncated: false };
|
|
68
|
+
if (info.isFile()) return { path: this.displayPath(root, context), files: [this.displayPath(root, context)], truncated: false };
|
|
67
69
|
if (!info.isDirectory()) throw new Error("path is not a file or directory");
|
|
68
70
|
const files = [];
|
|
69
71
|
let resultBytes = 0;
|
|
70
72
|
const walkResult = await this.walk(root, async full => {
|
|
71
73
|
this.throwIfCancelled(context);
|
|
72
|
-
const shown = this.displayPath(full);
|
|
74
|
+
const shown = this.displayPath(full, context);
|
|
73
75
|
const pathBytes = Buffer.byteLength(shown) + 8;
|
|
74
76
|
if (files.length >= maxFiles || resultBytes + pathBytes > MAX_PATH_RESULT_BYTES) return false;
|
|
75
77
|
files.push(shown);
|
|
76
78
|
resultBytes += pathBytes;
|
|
77
79
|
return true;
|
|
78
80
|
}, context);
|
|
79
|
-
return { path: this.displayPath(root), files, truncated: files.length >= maxFiles || resultBytes >= MAX_PATH_RESULT_BYTES || walkResult.truncated };
|
|
81
|
+
return { path: this.displayPath(root, context), files, truncated: files.length >= maxFiles || resultBytes >= MAX_PATH_RESULT_BYTES || walkResult.truncated };
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
async readFile(args, context = {}) {
|
|
@@ -85,7 +87,7 @@ export class WorkspaceFileService {
|
|
|
85
87
|
context = {};
|
|
86
88
|
}
|
|
87
89
|
if (!args.path) throw new Error("path is required");
|
|
88
|
-
const full = await this.resolveExistingPath(args.path);
|
|
90
|
+
const full = await this.resolveExistingPath(args.path, context);
|
|
89
91
|
this.throwIfCancelled(context);
|
|
90
92
|
const { buffer, info } = await readBoundedFile(full, MAX_WRITE_BYTES, "readable text file");
|
|
91
93
|
const content = decodeUtf8(buffer);
|
|
@@ -107,7 +109,7 @@ export class WorkspaceFileService {
|
|
|
107
109
|
const selectedBytes = Buffer.byteLength(selected);
|
|
108
110
|
if (selectedBytes > maxBytes) throw new Error(`selected content exceeds max_bytes (${selectedBytes} > ${maxBytes})`);
|
|
109
111
|
return {
|
|
110
|
-
path: this.displayPath(full),
|
|
112
|
+
path: this.displayPath(full, context),
|
|
111
113
|
size: info.size,
|
|
112
114
|
sha256: sha256(content),
|
|
113
115
|
content: selected,
|
|
@@ -120,7 +122,7 @@ export class WorkspaceFileService {
|
|
|
120
122
|
|
|
121
123
|
async viewImage(args, context = {}) {
|
|
122
124
|
if (!args.path) throw new Error("path is required");
|
|
123
|
-
const full = await this.resolveExistingPath(args.path);
|
|
125
|
+
const full = await this.resolveExistingPath(args.path, context);
|
|
124
126
|
this.throwIfCancelled(context);
|
|
125
127
|
const { buffer, info } = await readBoundedFile(full, MAX_IMAGE_BYTES, "image");
|
|
126
128
|
this.throwIfCancelled(context);
|
|
@@ -130,7 +132,7 @@ export class WorkspaceFileService {
|
|
|
130
132
|
$mcp: {
|
|
131
133
|
content: [{ type: "image", data: buffer.toString("base64"), mimeType }],
|
|
132
134
|
structuredContent: {
|
|
133
|
-
path: this.displayPath(full),
|
|
135
|
+
path: this.displayPath(full, context),
|
|
134
136
|
size: info.size,
|
|
135
137
|
sha256: createHash("sha256").update(buffer).digest("hex"),
|
|
136
138
|
mime_type: mimeType,
|
|
@@ -147,7 +149,7 @@ export class WorkspaceFileService {
|
|
|
147
149
|
const content = String(args.content ?? "");
|
|
148
150
|
const bytes = Buffer.byteLength(content);
|
|
149
151
|
if (bytes > MAX_WRITE_BYTES) throw new Error(`content exceeds maximum write size (${bytes} > ${MAX_WRITE_BYTES})`);
|
|
150
|
-
const full = await this.resolveWritePath(args.path);
|
|
152
|
+
const full = await this.resolveWritePath(args.path, context);
|
|
151
153
|
const existing = await lstat(full).catch(() => null);
|
|
152
154
|
if (existing?.isSymbolicLink()) throw new Error("refusing to overwrite a symbolic link");
|
|
153
155
|
if (args.create_only && existing) throw new Error("file exists and create_only=true");
|
|
@@ -162,7 +164,7 @@ export class WorkspaceFileService {
|
|
|
162
164
|
createOnly: args.create_only === true,
|
|
163
165
|
expectedHash: args.expected_sha256 ? String(args.expected_sha256).toLowerCase() : undefined,
|
|
164
166
|
});
|
|
165
|
-
return { ok: true, path: this.displayPath(full), sha256: sha256(content), bytes };
|
|
167
|
+
return { ok: true, path: this.displayPath(full, context), sha256: sha256(content), bytes };
|
|
166
168
|
});
|
|
167
169
|
}
|
|
168
170
|
|
|
@@ -174,7 +176,7 @@ export class WorkspaceFileService {
|
|
|
174
176
|
const oldText = String(args.old_text ?? "");
|
|
175
177
|
const newText = String(args.new_text ?? "");
|
|
176
178
|
if (!oldText) throw new Error("old_text must not be empty");
|
|
177
|
-
const full = await this.resolveExistingPath(args.path);
|
|
179
|
+
const full = await this.resolveExistingPath(args.path, context);
|
|
178
180
|
const info = await lstat(full);
|
|
179
181
|
if (!info.isFile() || info.isSymbolicLink()) throw new Error("path is not a regular non-symbolic-link file");
|
|
180
182
|
const current = await readUtf8File(full);
|
|
@@ -187,7 +189,7 @@ export class WorkspaceFileService {
|
|
|
187
189
|
if (bytes > MAX_WRITE_BYTES) throw new Error(`edited content exceeds maximum write size (${bytes} > ${MAX_WRITE_BYTES})`);
|
|
188
190
|
this.throwIfCancelled(context);
|
|
189
191
|
await atomicWriteText(full, updated, info, { expectedHash: sha256(current) });
|
|
190
|
-
return { ok: true, path: this.displayPath(full), replacements: args.replace_all ? occurrences : 1, sha256: sha256(updated), bytes };
|
|
192
|
+
return { ok: true, path: this.displayPath(full, context), replacements: args.replace_all ? occurrences : 1, sha256: sha256(updated), bytes };
|
|
191
193
|
});
|
|
192
194
|
}
|
|
193
195
|
|
|
@@ -203,13 +205,13 @@ export class WorkspaceFileService {
|
|
|
203
205
|
for (const operation of parsed) {
|
|
204
206
|
this.throwIfCancelled(context);
|
|
205
207
|
if (operation.kind === "add") {
|
|
206
|
-
const target = await this.resolveWritePath(operation.path);
|
|
208
|
+
const target = await this.resolveWritePath(operation.path, context);
|
|
207
209
|
if (await lstat(target).catch(() => null)) throw new Error(`add target already exists: ${operation.path}`);
|
|
208
210
|
assertTextSize(operation.content, operation.path);
|
|
209
211
|
prepared.push({ kind: "add", source: null, target, content: operation.content, mode: 0o600 });
|
|
210
212
|
continue;
|
|
211
213
|
}
|
|
212
|
-
const source = await this.resolveExistingPath(operation.path);
|
|
214
|
+
const source = await this.resolveExistingPath(operation.path, context);
|
|
213
215
|
const sourceInfo = await lstat(source);
|
|
214
216
|
if (!sourceInfo.isFile() || sourceInfo.isSymbolicLink()) throw new Error(`patch source is not a regular file: ${operation.path}`);
|
|
215
217
|
const original = await readUtf8File(source);
|
|
@@ -219,7 +221,7 @@ export class WorkspaceFileService {
|
|
|
219
221
|
}
|
|
220
222
|
const content = applyUpdateHunks(original, operation.hunks, operation.path);
|
|
221
223
|
assertTextSize(content, operation.path);
|
|
222
|
-
const target = operation.moveTo ? await this.resolveWritePath(operation.moveTo) : source;
|
|
224
|
+
const target = operation.moveTo ? await this.resolveWritePath(operation.moveTo, context) : source;
|
|
223
225
|
if (target !== source && await lstat(target).catch(() => null)) throw new Error(`move target already exists: ${operation.moveTo}`);
|
|
224
226
|
prepared.push({ kind: operation.moveTo ? "move" : "update", source, target, content, originalHash: sha256(original), mode: sourceInfo.mode & 0o777 });
|
|
225
227
|
}
|
|
@@ -231,9 +233,9 @@ export class WorkspaceFileService {
|
|
|
231
233
|
...(transaction.warnings.length ? { warnings: transaction.warnings } : {}),
|
|
232
234
|
files: prepared.map((item) => ({
|
|
233
235
|
operation: item.kind,
|
|
234
|
-
path: this.displayPath(item.target || item.source),
|
|
235
|
-
|
|
236
|
-
|
|
236
|
+
path: this.displayPath(item.target || item.source, context),
|
|
237
|
+
...(item.kind === "move" ? { from: this.displayPath(item.source, context) } : {}),
|
|
238
|
+
...(item.content === undefined ? {} : { sha256: sha256(item.content) }),
|
|
237
239
|
})),
|
|
238
240
|
};
|
|
239
241
|
});
|
|
@@ -242,7 +244,7 @@ export class WorkspaceFileService {
|
|
|
242
244
|
async searchText(args, context = {}) {
|
|
243
245
|
const query = String(args.query || "");
|
|
244
246
|
if (!query) throw new Error("query is required");
|
|
245
|
-
const root = await this.resolveExistingPath(args.path || ".");
|
|
247
|
+
const root = await this.resolveExistingPath(args.path || ".", context);
|
|
246
248
|
const max = clampInteger(args.max_matches, 100, 1, 1000);
|
|
247
249
|
const maxFiles = clampInteger(args.max_files, 10000, 1, 100000);
|
|
248
250
|
let visitedFiles = 0;
|
|
@@ -250,7 +252,7 @@ export class WorkspaceFileService {
|
|
|
250
252
|
const rootInfo = await stat(root);
|
|
251
253
|
if (rootInfo.isFile()) {
|
|
252
254
|
await this.searchOneFile(root, query, matches, max, context);
|
|
253
|
-
return { query, root: this.displayPath(root), matches, visited_files: 1, truncated: matches.length >= max };
|
|
255
|
+
return { query, root: this.displayPath(root, context), matches, visited_files: 1, truncated: matches.length >= max };
|
|
254
256
|
}
|
|
255
257
|
if (!rootInfo.isDirectory()) throw new Error("path is not a file or directory");
|
|
256
258
|
const walkResult = await this.walk(root, async full => {
|
|
@@ -260,7 +262,7 @@ export class WorkspaceFileService {
|
|
|
260
262
|
await this.searchOneFile(full, query, matches, max, context);
|
|
261
263
|
return matches.length < max && visitedFiles < maxFiles;
|
|
262
264
|
}, context);
|
|
263
|
-
return { query, root: this.displayPath(root), matches, visited_files: visitedFiles, truncated: matches.length >= max || visitedFiles >= maxFiles || walkResult.truncated };
|
|
265
|
+
return { query, root: this.displayPath(root, context), matches, visited_files: visitedFiles, truncated: matches.length >= max || visitedFiles >= maxFiles || walkResult.truncated };
|
|
264
266
|
}
|
|
265
267
|
|
|
266
268
|
async searchOneFile(full, query, matches, max, context = {}) {
|
|
@@ -274,7 +276,7 @@ export class WorkspaceFileService {
|
|
|
274
276
|
const lines = text.split(/\r?\n/);
|
|
275
277
|
for (let index = 0; index < lines.length; index += 1) {
|
|
276
278
|
if (lines[index].includes(query)) {
|
|
277
|
-
matches.push({ path: this.displayPath(full), line: index + 1, text: lines[index].slice(0, 500) });
|
|
279
|
+
matches.push({ path: this.displayPath(full, context), line: index + 1, text: lines[index].slice(0, 500) });
|
|
278
280
|
if (matches.length >= max) break;
|
|
279
281
|
}
|
|
280
282
|
}
|
|
@@ -311,6 +313,7 @@ export async function readBoundedFile(filePath, maxBytes, label) {
|
|
|
311
313
|
try {
|
|
312
314
|
const info = await handle.stat();
|
|
313
315
|
if (!info.isFile()) throw new Error(`${label} is not a regular file`);
|
|
316
|
+
if (Number(info.nlink) > 1) throw new Error(`refusing to read ${label} with multiple hard links`);
|
|
314
317
|
if (info.size > maxBytes) throw new Error(`${label} exceeds maximum size (${info.size} > ${maxBytes})`);
|
|
315
318
|
const buffer = Buffer.alloc(info.size);
|
|
316
319
|
let offset = 0;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
export const ADMIN_AUTH_SCHEME: "
|
|
1
|
+
export const ADMIN_AUTH_SCHEME: "device-admin-signature-v1";
|
|
2
2
|
export const ADMIN_AUTH_TTL_SECONDS: number;
|
|
3
3
|
export function adminAuthTranscript(input: {
|
|
4
4
|
origin: unknown;
|
|
5
5
|
method: unknown;
|
|
6
6
|
pathname: unknown;
|
|
7
7
|
bodyHash: unknown;
|
|
8
|
+
keyId: unknown;
|
|
8
9
|
issuedAt: unknown;
|
|
9
10
|
nonce: unknown;
|
|
10
11
|
}): string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const ADMIN_AUTH_SCHEME = "
|
|
1
|
+
export const ADMIN_AUTH_SCHEME = "device-admin-signature-v1";
|
|
2
2
|
export const ADMIN_AUTH_TTL_SECONDS = 5 * 60;
|
|
3
3
|
|
|
4
4
|
export function adminAuthTranscript(input = {}) {
|
|
@@ -6,12 +6,12 @@ export function adminAuthTranscript(input = {}) {
|
|
|
6
6
|
const method = requiredToken(input.method, "method", /^[A-Z]{3,10}$/);
|
|
7
7
|
const pathname = requiredToken(input.pathname, "pathname", /^\/[A-Za-z0-9/_-]{1,255}$/);
|
|
8
8
|
const bodyHash = requiredToken(input.bodyHash, "body hash", /^[a-f0-9]{64}$/);
|
|
9
|
+
const keyId = requiredToken(input.keyId, "session key id", /^device_[A-Za-z0-9_-]{32}$/);
|
|
9
10
|
const issuedAt = requiredInteger(input.issuedAt, "issued at");
|
|
10
11
|
const nonce = requiredToken(input.nonce, "nonce", /^[A-Za-z0-9_-]{32,128}$/);
|
|
11
|
-
return [ADMIN_AUTH_SCHEME, origin, method, pathname, bodyHash, issuedAt, nonce].join("\0");
|
|
12
|
+
return [ADMIN_AUTH_SCHEME, origin, method, pathname, bodyHash, keyId, issuedAt, nonce].join("\0");
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
|
|
15
15
|
function requiredOrigin(value) {
|
|
16
16
|
let url;
|
|
17
17
|
try { url = new URL(String(value || "")); } catch { throw new Error("admin authentication origin is invalid"); }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const DEVICE_SESSION_CERTIFICATE_SCHEME: "device-session-certificate-v1";
|
|
2
|
+
export const DEVICE_SESSION_MAX_LIFETIME_SECONDS: number;
|
|
3
|
+
|
|
4
|
+
export function deviceSessionCertificateTranscript(input?: {
|
|
5
|
+
workerOrigin?: unknown;
|
|
6
|
+
server?: unknown;
|
|
7
|
+
version?: unknown;
|
|
8
|
+
rootKeyId?: unknown;
|
|
9
|
+
publicJwk?: unknown;
|
|
10
|
+
issuedAt?: unknown;
|
|
11
|
+
expiresAt?: unknown;
|
|
12
|
+
nonce?: unknown;
|
|
13
|
+
}): string;
|
|
14
|
+
|
|
15
|
+
export function canonicalPublicJwk(value: unknown): {
|
|
16
|
+
kty: "EC";
|
|
17
|
+
crv: "P-256";
|
|
18
|
+
x: string;
|
|
19
|
+
y: string;
|
|
20
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const DEVICE_SESSION_CERTIFICATE_SCHEME = "device-session-certificate-v1";
|
|
2
|
+
export const DEVICE_SESSION_MAX_LIFETIME_SECONDS = 24 * 60 * 60;
|
|
3
|
+
|
|
4
|
+
export function deviceSessionCertificateTranscript(input = {}) {
|
|
5
|
+
const publicJwk = canonicalPublicJwk(input.publicJwk);
|
|
6
|
+
return [
|
|
7
|
+
DEVICE_SESSION_CERTIFICATE_SCHEME,
|
|
8
|
+
requiredOrigin(input.workerOrigin),
|
|
9
|
+
requiredText(input.server, "server", 1, 128),
|
|
10
|
+
requiredText(input.version, "version", 1, 64),
|
|
11
|
+
requiredText(input.rootKeyId, "root key id", 16, 128),
|
|
12
|
+
JSON.stringify(publicJwk),
|
|
13
|
+
requiredInteger(input.issuedAt, "issued at"),
|
|
14
|
+
requiredInteger(input.expiresAt, "expires at"),
|
|
15
|
+
requiredText(input.nonce, "nonce", 24, 128),
|
|
16
|
+
].join("\0");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function canonicalPublicJwk(value) {
|
|
20
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("device session public key is invalid");
|
|
21
|
+
if (value.kty !== "EC" || value.crv !== "P-256" || typeof value.x !== "string" || typeof value.y !== "string" || value.d !== undefined) {
|
|
22
|
+
throw new Error("device session public key is invalid");
|
|
23
|
+
}
|
|
24
|
+
if (!/^[A-Za-z0-9_-]{42,44}$/.test(value.x) || !/^[A-Za-z0-9_-]{42,44}$/.test(value.y)) {
|
|
25
|
+
throw new Error("device session public key is invalid");
|
|
26
|
+
}
|
|
27
|
+
return { crv: "P-256", kty: "EC", x: value.x, y: value.y };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function requiredOrigin(value) {
|
|
31
|
+
let url;
|
|
32
|
+
try { url = new URL(String(value || "")); } catch { throw new Error("device session Worker origin is invalid"); }
|
|
33
|
+
const secure = url.protocol === "https:";
|
|
34
|
+
const loopback = url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
|
|
35
|
+
if ((!secure && !loopback) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
|
|
36
|
+
throw new Error("device session Worker origin is invalid");
|
|
37
|
+
}
|
|
38
|
+
return url.origin;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function requiredText(value, label, minimum, maximum) {
|
|
42
|
+
const text = String(value || "");
|
|
43
|
+
if (text.length < minimum || text.length > maximum || /[\0\r\n]/.test(text)) throw new Error(`device session ${label} is invalid`);
|
|
44
|
+
return text;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function requiredInteger(value, label) {
|
|
48
|
+
const number = Number(value);
|
|
49
|
+
if (!Number.isSafeInteger(number) || number <= 0) throw new Error(`device session ${label} is invalid`);
|
|
50
|
+
return String(number);
|
|
51
|
+
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"The local daemon may advertise the full catalog as its capability ceiling. In remote mode, the Worker relays only the intersection allowed by the authenticated account role; a connector host may then expose an even smaller subset that Machine Bridge cannot observe or override.",
|
|
19
19
|
"Use diagnose_runtime to distinguish a request that reached the daemon from local filesystem, process-spawn, shell, managed-job-storage, and resource failures. A tool call blocked before any response cannot be diagnosed by the server.",
|
|
20
20
|
"Never request or return secret-file contents when a local resource alias can be used. Resources are registered through the local machine-mcp CLI; under canonical full policy, generate_ssh_key_resource can generate and register an Ed25519 key without returning private content and may be injected by path, stdin, or environment without entering MCP arguments.",
|
|
21
|
-
"
|
|
21
|
+
"stage_job persists a validated non-running draft only. It is not an approval workflow and cannot be promoted from the terminal; execution requires owner-authorized start_job or an explicit local machine-mcp job submit PLAN.json operation.",
|
|
22
22
|
"For multi-step, remote, long-running, or cleanup-sensitive work, prefer one start_job call with argv steps, job-scoped temporary_files, and idempotent finally_steps. The detached runner continues after MCP disconnects; inspect it with read_job or the local machine-mcp job CLI.",
|
|
23
23
|
"Prefer sending remote shell programs through a process stdin rather than creating remote helper files. If temporary files are necessary, use {{temp:name}} or explicit finally_steps.",
|
|
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.",
|
|
@@ -1915,8 +1915,8 @@
|
|
|
1915
1915
|
},
|
|
1916
1916
|
{
|
|
1917
1917
|
"name": "stage_job",
|
|
1918
|
-
"title": "Stage managed job
|
|
1919
|
-
"description": "Validate and persist a managed-job
|
|
1918
|
+
"title": "Stage managed job draft",
|
|
1919
|
+
"description": "Validate and persist a durable managed-job draft without starting any process. This is a non-executing record only; a trusted owner client starts work with start_job after reviewing or reconstructing the plan.",
|
|
1920
1920
|
"availability": "write",
|
|
1921
1921
|
"annotations": {
|
|
1922
1922
|
"readOnlyHint": false,
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { ADMIN_AUTH_SCHEME, ADMIN_AUTH_TTL_SECONDS, adminAuthTranscript } from "../shared/admin-auth.mjs";
|
|
2
|
+
import { decodeBase64Url, verifyDeviceSessionCertificate, verifyP256Signature } from "./device-session-verifier.ts";
|
|
2
3
|
import { json, methodNotAllowed, parseRequestBody } from "./http.ts";
|
|
3
4
|
import { consumeBoundedNonce } from "./nonce-store.ts";
|
|
4
5
|
import {
|
|
5
6
|
accountByName, createAccount, publicAccount, replaceAccountPassword, revokeAccountCredentials,
|
|
6
|
-
|
|
7
|
+
updateAccount, type AccountRecord, type OAuthStore,
|
|
7
8
|
} from "./oauth-state.ts";
|
|
8
9
|
|
|
9
10
|
const BODY_LIMIT_BYTES = 64 * 1024;
|
|
@@ -16,18 +17,31 @@ export interface AccountAdminAuthorization {
|
|
|
16
17
|
|
|
17
18
|
export async function accountAdminAuthorized(
|
|
18
19
|
request: Request,
|
|
19
|
-
|
|
20
|
+
rootPublicKeyJson: string,
|
|
21
|
+
workerOrigin: string,
|
|
22
|
+
server: string,
|
|
23
|
+
version: string,
|
|
20
24
|
now = Math.floor(Date.now() / 1000),
|
|
21
25
|
): Promise<AccountAdminAuthorization | null> {
|
|
22
|
-
if (!
|
|
26
|
+
if (!rootPublicKeyJson) return null;
|
|
23
27
|
const scheme = request.headers.get("X-Bridge-Admin-Scheme") || "";
|
|
24
28
|
const issuedAt = Number(request.headers.get("X-Bridge-Admin-Time"));
|
|
25
29
|
const nonce = request.headers.get("X-Bridge-Admin-Nonce") || "";
|
|
26
30
|
const bodyHash = request.headers.get("X-Bridge-Admin-Body-SHA256") || "";
|
|
27
|
-
const
|
|
28
|
-
|
|
31
|
+
const keyId = request.headers.get("X-Bridge-Admin-Key") || "";
|
|
32
|
+
const suppliedSignature = decodeBase64Url(request.headers.get("X-Bridge-Admin-Signature") || "", 64);
|
|
33
|
+
if (scheme !== ADMIN_AUTH_SCHEME || !suppliedSignature) return null;
|
|
29
34
|
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_-]{
|
|
35
|
+
if (!/^[A-Za-z0-9_-]{32,128}$/.test(nonce) || !/^[a-f0-9]{64}$/.test(bodyHash) || !/^device_[A-Za-z0-9_-]{32}$/.test(keyId)) return null;
|
|
36
|
+
const certificate = await verifyDeviceSessionCertificate({
|
|
37
|
+
encodedCertificate: request.headers.get("X-Bridge-Device-Certificate") || "",
|
|
38
|
+
rootPublicKeyJson,
|
|
39
|
+
workerOrigin,
|
|
40
|
+
server,
|
|
41
|
+
version,
|
|
42
|
+
now,
|
|
43
|
+
});
|
|
44
|
+
if (!certificate || certificate.sessionKeyId !== keyId) return null;
|
|
31
45
|
let transcript: string;
|
|
32
46
|
try {
|
|
33
47
|
transcript = adminAuthTranscript({
|
|
@@ -35,25 +49,18 @@ export async function accountAdminAuthorized(
|
|
|
35
49
|
method: request.method.toUpperCase(),
|
|
36
50
|
pathname: new URL(request.url).pathname,
|
|
37
51
|
bodyHash,
|
|
52
|
+
keyId,
|
|
38
53
|
issuedAt,
|
|
39
54
|
nonce,
|
|
40
55
|
});
|
|
41
56
|
} catch {
|
|
42
57
|
return null;
|
|
43
58
|
}
|
|
44
|
-
|
|
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;
|
|
59
|
+
if (!(await verifyP256Signature(certificate.sessionPublicJwk, transcript, suppliedSignature))) return null;
|
|
53
60
|
const body = new Uint8Array(await request.clone().arrayBuffer());
|
|
54
61
|
if (body.byteLength > BODY_LIMIT_BYTES) return null;
|
|
55
62
|
const actualBodyHash = hex(new Uint8Array(await crypto.subtle.digest("SHA-256", body)));
|
|
56
|
-
if (
|
|
63
|
+
if (bodyHash !== actualBodyHash) return null;
|
|
57
64
|
return { nonce, expiresAt: Math.max(now, issuedAt) + ADMIN_AUTH_TTL_SECONDS };
|
|
58
65
|
}
|
|
59
66
|
|
|
@@ -161,12 +168,6 @@ function activeOwnerCount(store: OAuthStore): number {
|
|
|
161
168
|
return Object.values(store.accounts).filter((account) => account.active && account.role === "owner").length;
|
|
162
169
|
}
|
|
163
170
|
|
|
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
171
|
function hex(bytes: Uint8Array): string {
|
|
171
172
|
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
172
173
|
}
|
package/src/worker/authority.ts
CHANGED
|
@@ -86,8 +86,15 @@ export function accountAuthoritySnapshot(input: AccountIdentity & {
|
|
|
86
86
|
|
|
87
87
|
export function decorateProjectOverview(value: unknown, account: AccountIdentity): unknown {
|
|
88
88
|
if (!isRecord(value)) return value;
|
|
89
|
-
const daemonPolicy = isDaemonPolicy(value.
|
|
90
|
-
|
|
89
|
+
const daemonPolicy = isDaemonPolicy(value.daemonPolicy)
|
|
90
|
+
? value.daemonPolicy
|
|
91
|
+
: isDaemonPolicy(value.policy)
|
|
92
|
+
? value.policy
|
|
93
|
+
: null;
|
|
94
|
+
const daemonToolSource = Array.isArray(value.daemonTools) ? value.daemonTools : value.tools;
|
|
95
|
+
const daemonTools = Array.isArray(daemonToolSource)
|
|
96
|
+
? daemonToolSource.filter((item): item is string => typeof item === "string")
|
|
97
|
+
: [];
|
|
91
98
|
const effectiveTools = ["server_info", ...accountRoleToolNames(account.role, daemonTools)];
|
|
92
99
|
const authority = accountAuthoritySnapshot({ ...account, daemonPolicy, effectiveTools });
|
|
93
100
|
return {
|