machine-bridge-mcp 2.0.0 → 3.0.0-beta.11
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 +120 -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 +28 -13
- package/docs/AUDIT.md +101 -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 +14 -3
- package/docs/MANAGED_JOBS.md +24 -29
- package/docs/MULTI_ACCOUNT.md +98 -37
- package/docs/OPERATIONS.md +34 -23
- package/docs/OVERVIEW.md +7 -7
- package/docs/PROJECT_STANDARDS.md +9 -5
- package/docs/RELEASING.md +171 -64
- package/docs/TESTING.md +21 -9
- 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 +37 -6
- 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 +87 -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 +119 -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 +18 -44
- 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-resource-service.mjs +77 -0
- package/src/local/runtime-tool-handlers.mjs +8 -8
- package/src/local/runtime.mjs +85 -95
- 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/log-redaction.d.mts +9 -0
- package/src/shared/log-redaction.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 +23 -4
- 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/observability.ts +4 -2
- package/src/worker/websocket-protocol.ts +8 -2
|
@@ -29,9 +29,10 @@ const MAX_INSTRUCTION_FILE_BYTES = 512 * 1024;
|
|
|
29
29
|
const MAX_INSTRUCTION_FILES = 64;
|
|
30
30
|
|
|
31
31
|
export class AgentContextManager {
|
|
32
|
-
constructor({ workspace, policy, displayPath, resolveExistingPath, throwIfCancelled = () => {}, home = process.env.HOME || process.env.USERPROFILE || "", codexHome = process.env.CODEX_HOME || "" }) {
|
|
32
|
+
constructor({ workspace, policy, policyForContext = null, displayPath, resolveExistingPath, throwIfCancelled = () => {}, home = process.env.HOME || process.env.USERPROFILE || "", codexHome = process.env.CODEX_HOME || "" }) {
|
|
33
33
|
this.workspace = resolve(workspace);
|
|
34
34
|
this.policy = policy || {};
|
|
35
|
+
this.policyForContext = typeof policyForContext === "function" ? policyForContext : () => this.policy;
|
|
35
36
|
this.displayPath = displayPath;
|
|
36
37
|
this.resolveExistingPath = resolveExistingPath;
|
|
37
38
|
this.throwIfCancelled = throwIfCancelled;
|
|
@@ -44,23 +45,23 @@ export class AgentContextManager {
|
|
|
44
45
|
const includeContent = args.include_instruction_content !== false;
|
|
45
46
|
const skillLimit = clampInteger(args.max_skills, 100, 1, MAX_SKILL_RESULTS);
|
|
46
47
|
const discoveredSkills = await this.discoverSkills(state, { maxResults: MAX_SKILL_RESULTS }, context);
|
|
47
|
-
const contextSkills = contextSkillSummaries(discoveredSkills.skills, this.displayPath, skillLimit, MAX_CONTEXT_SKILL_SUMMARY_CHARS);
|
|
48
|
+
const contextSkills = contextSkillSummaries(discoveredSkills.skills, (value) => this.displayPath(value, context), skillLimit, MAX_CONTEXT_SKILL_SUMMARY_CHARS);
|
|
48
49
|
const result = {
|
|
49
|
-
target: this.displayPath(state.target),
|
|
50
|
-
scope_root: this.displayPath(state.scopeRoot),
|
|
50
|
+
target: this.displayPath(state.target, context),
|
|
51
|
+
scope_root: this.displayPath(state.scopeRoot, context),
|
|
51
52
|
precedence: "built-in defaults, automatic project facts, user-global guidance, then scope root to target directory; explicit files loaded later have higher precedence",
|
|
52
|
-
config_files: state.configFiles.map((file) => this.displayPath(file)),
|
|
53
|
+
config_files: state.configFiles.map((file) => this.displayPath(file, context)),
|
|
53
54
|
builtin_instructions: publicVirtualInstruction(state.builtinInstructions, includeContent),
|
|
54
55
|
automatic_project_context: publicVirtualInstruction(state.automaticProjectContext, includeContent),
|
|
55
56
|
model_instructions_file: state.modelInstructions ? {
|
|
56
|
-
path: this.displayPath(state.modelInstructions.path),
|
|
57
|
+
path: this.displayPath(state.modelInstructions.path, context),
|
|
57
58
|
bytes: state.modelInstructions.bytes,
|
|
58
59
|
sha256: state.modelInstructions.sha256,
|
|
59
60
|
...(includeContent ? { content: state.modelInstructions.content } : {}),
|
|
60
61
|
} : null,
|
|
61
62
|
instruction_files: state.instructions.map((item) => ({
|
|
62
63
|
scope: item.scope,
|
|
63
|
-
path: this.displayPath(item.path),
|
|
64
|
+
path: this.displayPath(item.path, context),
|
|
64
65
|
bytes: item.bytes,
|
|
65
66
|
sha256: item.sha256,
|
|
66
67
|
precedence: item.precedence,
|
|
@@ -68,9 +69,9 @@ export class AgentContextManager {
|
|
|
68
69
|
})),
|
|
69
70
|
skills: contextSkills.skills,
|
|
70
71
|
skills_truncated: discoveredSkills.truncated || contextSkills.truncated,
|
|
71
|
-
skill_warnings: publicSkillWarnings(discoveredSkills.warnings, this.displayPath),
|
|
72
|
+
skill_warnings: publicSkillWarnings(discoveredSkills.warnings, (value) => this.displayPath(value, context)),
|
|
72
73
|
instructions_truncated: state.instructionsTruncated,
|
|
73
|
-
commands: publicCommands(state.commands, this.displayPath),
|
|
74
|
+
commands: publicCommands(state.commands, (value) => this.displayPath(value, context)),
|
|
74
75
|
guidance: [
|
|
75
76
|
"Apply built-in instructions and automatic project context as lower-precedence defaults; explicit global/project instruction files loaded later take precedence.",
|
|
76
77
|
"Treat instruction_files as authoritative workspace guidance in the returned precedence order.",
|
|
@@ -78,7 +79,7 @@ export class AgentContextManager {
|
|
|
78
79
|
"Prefer run_local_command for registered repeatable commands. Use run_process or exec_command only when no registered command fits and policy permits it.",
|
|
79
80
|
],
|
|
80
81
|
};
|
|
81
|
-
if (includeContent) result.effective_instructions = renderEffectiveInstructions(effectiveInstructionItems(state), this.displayPath);
|
|
82
|
+
if (includeContent) result.effective_instructions = renderEffectiveInstructions(effectiveInstructionItems(state), (value) => this.displayPath(value, context));
|
|
82
83
|
return result;
|
|
83
84
|
}
|
|
84
85
|
|
|
@@ -86,11 +87,11 @@ export class AgentContextManager {
|
|
|
86
87
|
const state = await this.discoverState(args.path || ".", context);
|
|
87
88
|
const fingerprint = capabilityFingerprint(state, []);
|
|
88
89
|
return {
|
|
89
|
-
target: this.displayPath(state.target),
|
|
90
|
-
instructions: renderEffectiveInstructions(effectiveInstructionItems(state), this.displayPath),
|
|
90
|
+
target: this.displayPath(state.target, context),
|
|
91
|
+
instructions: renderEffectiveInstructions(effectiveInstructionItems(state), (value) => this.displayPath(value, context)),
|
|
91
92
|
builtin_instructions: publicVirtualInstruction(state.builtinInstructions, false),
|
|
92
93
|
automatic_project_context: publicVirtualInstruction(state.automaticProjectContext, false),
|
|
93
|
-
model_instructions_file: state.modelInstructions ? this.displayPath(state.modelInstructions.path) : null,
|
|
94
|
+
model_instructions_file: state.modelInstructions ? this.displayPath(state.modelInstructions.path, context) : null,
|
|
94
95
|
capability_refresh: {
|
|
95
96
|
strategy: "resolve_task_capabilities-rescans-on-every-call",
|
|
96
97
|
instruction_and_command_fingerprint: fingerprint,
|
|
@@ -125,7 +126,7 @@ export class AgentContextManager {
|
|
|
125
126
|
let selectedSkill = null;
|
|
126
127
|
if (selected && args.include_selected_skill !== false) {
|
|
127
128
|
const content = await readRegularUtf8(selected.entrypoint, MAX_SKILL_ENTRY_BYTES, "skill entrypoint");
|
|
128
|
-
selectedSkill = { ...publicSkill(selected, this.displayPath), instructions: content.text };
|
|
129
|
+
selectedSkill = { ...publicSkill(selected, (value) => this.displayPath(value, context)), instructions: content.text };
|
|
129
130
|
}
|
|
130
131
|
const recommendedTools = recommendTools(task, {
|
|
131
132
|
commandsAvailable: state.commands.size > 0,
|
|
@@ -135,20 +136,20 @@ export class AgentContextManager {
|
|
|
135
136
|
const refresh = capabilityFingerprint(state, discovered.skills);
|
|
136
137
|
return {
|
|
137
138
|
task,
|
|
138
|
-
target: this.displayPath(state.target),
|
|
139
|
-
effective_instructions: renderEffectiveInstructions(effectiveInstructionItems(state), this.displayPath),
|
|
139
|
+
target: this.displayPath(state.target, context),
|
|
140
|
+
effective_instructions: renderEffectiveInstructions(effectiveInstructionItems(state), (value) => this.displayPath(value, context)),
|
|
140
141
|
builtin_instructions: publicVirtualInstruction(state.builtinInstructions, false),
|
|
141
142
|
automatic_project_context: publicVirtualInstruction(state.automaticProjectContext, false),
|
|
142
|
-
model_instructions_file: state.modelInstructions ? this.displayPath(state.modelInstructions.path) : null,
|
|
143
|
-
instruction_files: state.instructions.map((item) => ({ path: this.displayPath(item.path), scope: item.scope, bytes: item.bytes, sha256: item.sha256, precedence: item.precedence })),
|
|
143
|
+
model_instructions_file: state.modelInstructions ? this.displayPath(state.modelInstructions.path, context) : null,
|
|
144
|
+
instruction_files: state.instructions.map((item) => ({ path: this.displayPath(item.path, context), scope: item.scope, bytes: item.bytes, sha256: item.sha256, precedence: item.precedence })),
|
|
144
145
|
instructions_truncated: state.instructionsTruncated,
|
|
145
146
|
refresh: { strategy: "rescan-on-every-call", fingerprint: refresh, generated_at: new Date().toISOString() },
|
|
146
147
|
selected_skill: selectedSkill,
|
|
147
|
-
skill_matches: skillMatches.map(({ skill, score }) => ({ ...publicSkill(skill, this.displayPath), score })),
|
|
148
|
-
command_matches: commandMatches.map(({ command, score }) => ({ ...publicCommands(new Map([[command.name, command]]), this.displayPath)[0], score })),
|
|
148
|
+
skill_matches: skillMatches.map(({ skill, score }) => ({ ...publicSkill(skill, (value) => this.displayPath(value, context)), score })),
|
|
149
|
+
command_matches: commandMatches.map(({ command, score }) => ({ ...publicCommands(new Map([[command.name, command]]), (value) => this.displayPath(value, context))[0], score })),
|
|
149
150
|
recommended_tools: recommendedTools,
|
|
150
151
|
host_semantics: "Machine Bridge can discover, rank, and load capabilities automatically. The MCP host remains responsible for deciding whether to call the recommended tools.",
|
|
151
|
-
warnings: publicSkillWarnings(discovered.warnings, this.displayPath),
|
|
152
|
+
warnings: publicSkillWarnings(discovered.warnings, (value) => this.displayPath(value, context)),
|
|
152
153
|
truncated: discovered.truncated,
|
|
153
154
|
};
|
|
154
155
|
}
|
|
@@ -171,11 +172,11 @@ export class AgentContextManager {
|
|
|
171
172
|
maxResults: clampInteger(args.max_results, 100, 1, MAX_SKILL_RESULTS),
|
|
172
173
|
}, context);
|
|
173
174
|
return {
|
|
174
|
-
target: this.displayPath(state.target),
|
|
175
|
-
scope_root: this.displayPath(state.scopeRoot),
|
|
176
|
-
skill_roots: state.skillRoots.map((root) => this.displayPath(root)),
|
|
177
|
-
skills: result.skills.map((skill) => publicSkill(skill, this.displayPath)),
|
|
178
|
-
warnings: publicSkillWarnings(result.warnings, this.displayPath),
|
|
175
|
+
target: this.displayPath(state.target, context),
|
|
176
|
+
scope_root: this.displayPath(state.scopeRoot, context),
|
|
177
|
+
skill_roots: state.skillRoots.map((root) => this.displayPath(root, context)),
|
|
178
|
+
skills: result.skills.map((skill) => publicSkill(skill, (value) => this.displayPath(value, context))),
|
|
179
|
+
warnings: publicSkillWarnings(result.warnings, (value) => this.displayPath(value, context)),
|
|
179
180
|
truncated: result.truncated,
|
|
180
181
|
};
|
|
181
182
|
}
|
|
@@ -184,14 +185,14 @@ export class AgentContextManager {
|
|
|
184
185
|
const requested = requiredString(args.skill, "skill");
|
|
185
186
|
const state = await this.discoverState(args.path || ".", context);
|
|
186
187
|
const result = await this.discoverSkills(state, { maxResults: MAX_SKILL_RESULTS }, context);
|
|
187
|
-
const matches = result.skills.filter((skill) => skill.id === requested || skill.name === requested || this.displayPath(skill.entrypoint) === requested);
|
|
188
|
+
const matches = result.skills.filter((skill) => skill.id === requested || skill.name === requested || this.displayPath(skill.entrypoint, context) === requested);
|
|
188
189
|
if (!matches.length) throw new Error(`local skill not found: ${requested}`);
|
|
189
190
|
if (matches.length > 1) throw new Error(`local skill name is ambiguous; use its id: ${requested}`);
|
|
190
191
|
const skill = matches[0];
|
|
191
192
|
const content = await readRegularUtf8(skill.entrypoint, MAX_SKILL_ENTRY_BYTES, "skill entrypoint");
|
|
192
193
|
const inventory = await listSkillFiles(skill.directory, clampInteger(args.max_files, 200, 1, MAX_SKILL_FILES), context, this.throwIfCancelled);
|
|
193
194
|
return {
|
|
194
|
-
skill: publicSkill(skill, this.displayPath),
|
|
195
|
+
skill: publicSkill(skill, (value) => this.displayPath(value, context)),
|
|
195
196
|
instructions: content.text,
|
|
196
197
|
files: inventory.files,
|
|
197
198
|
files_truncated: inventory.truncated,
|
|
@@ -202,9 +203,9 @@ export class AgentContextManager {
|
|
|
202
203
|
async listLocalCommands(args = {}, context = {}) {
|
|
203
204
|
const state = await this.discoverState(args.path || ".", context);
|
|
204
205
|
return {
|
|
205
|
-
target: this.displayPath(state.target),
|
|
206
|
-
scope_root: this.displayPath(state.scopeRoot),
|
|
207
|
-
commands: publicCommands(state.commands, this.displayPath),
|
|
206
|
+
target: this.displayPath(state.target, context),
|
|
207
|
+
scope_root: this.displayPath(state.scopeRoot, context),
|
|
208
|
+
commands: publicCommands(state.commands, (value) => this.displayPath(value, context)),
|
|
208
209
|
};
|
|
209
210
|
}
|
|
210
211
|
|
|
@@ -227,14 +228,15 @@ export class AgentContextManager {
|
|
|
227
228
|
|
|
228
229
|
async discoverState(inputPath, context = {}) {
|
|
229
230
|
this.throwIfCancelled(context);
|
|
231
|
+
const effectivePolicy = this.policyForContext(context);
|
|
230
232
|
this.workspace = await realpath(this.workspace);
|
|
231
|
-
const target = await realpath(await this.resolveExistingPath(inputPath));
|
|
233
|
+
const target = await realpath(await this.resolveExistingPath(inputPath, context));
|
|
232
234
|
const targetInfo = await stat(target);
|
|
233
235
|
const targetDir = targetInfo.isDirectory() ? target : dirname(target);
|
|
234
236
|
const scopeRoot = await findScopeRoot({
|
|
235
237
|
targetDir,
|
|
236
238
|
workspace: this.workspace,
|
|
237
|
-
unrestricted:
|
|
239
|
+
unrestricted: effectivePolicy.unrestrictedPaths === true,
|
|
238
240
|
});
|
|
239
241
|
const directories = directoriesBetween(scopeRoot, targetDir);
|
|
240
242
|
const state = {
|
|
@@ -243,7 +245,7 @@ export class AgentContextManager {
|
|
|
243
245
|
scopeRoot,
|
|
244
246
|
instructionFiles: [...DEFAULT_INSTRUCTION_FILES],
|
|
245
247
|
instructionMaxBytes: DEFAULT_INSTRUCTION_MAX_BYTES,
|
|
246
|
-
skillRoots: defaultSkillRoots(directories, this.home, this.codexHome,
|
|
248
|
+
skillRoots: defaultSkillRoots(directories, this.home, this.codexHome, effectivePolicy.unrestrictedPaths === true),
|
|
247
249
|
commands: new Map(),
|
|
248
250
|
builtinInstructionsEnabled: true,
|
|
249
251
|
automaticProjectContextEnabled: true,
|
|
@@ -264,7 +266,7 @@ export class AgentContextManager {
|
|
|
264
266
|
const globalConfig = join(this.home, GLOBAL_CONFIG_RELATIVE_PATH);
|
|
265
267
|
const config = await readOptionalConfig(globalConfig, this.home, false);
|
|
266
268
|
if (config) {
|
|
267
|
-
if (
|
|
269
|
+
if (effectivePolicy.unrestrictedPaths === true) this.applyConfig(state, config, globalConfig, this.home, { global: true, unrestricted: true });
|
|
268
270
|
else {
|
|
269
271
|
state.configFiles.push(globalConfig);
|
|
270
272
|
if (config.builtinInstructions !== null) state.builtinInstructionsEnabled = config.builtinInstructions;
|
|
@@ -284,20 +286,20 @@ export class AgentContextManager {
|
|
|
284
286
|
|
|
285
287
|
if (this.home) {
|
|
286
288
|
if (state.modelInstructionsFile) await this.collectModelInstructions(state, context);
|
|
287
|
-
if (
|
|
289
|
+
if (effectivePolicy.unrestrictedPaths === true && this.codexHome) await this.collectDirectoryInstruction(state, this.codexHome, context, "global");
|
|
288
290
|
}
|
|
289
291
|
|
|
290
292
|
for (const directory of directories) {
|
|
291
293
|
this.throwIfCancelled(context);
|
|
292
294
|
const configPath = join(directory, CONFIG_RELATIVE_PATH);
|
|
293
295
|
const config = await readOptionalConfig(configPath, directory, true);
|
|
294
|
-
if (config) this.applyConfig(state, config, configPath, directory, { global: false });
|
|
296
|
+
if (config) this.applyConfig(state, config, configPath, directory, { global: false, unrestricted: effectivePolicy.unrestrictedPaths === true });
|
|
295
297
|
await this.collectDirectoryInstruction(state, directory, context, "project");
|
|
296
298
|
}
|
|
297
299
|
return state;
|
|
298
300
|
}
|
|
299
301
|
|
|
300
|
-
applyConfig(state, config, configPath, baseDir, { global = false } = {}) {
|
|
302
|
+
applyConfig(state, config, configPath, baseDir, { global = false, unrestricted = false } = {}) {
|
|
301
303
|
state.configFiles.push(configPath);
|
|
302
304
|
if (config.builtinInstructions !== null) {
|
|
303
305
|
if (!global) throw new Error(`builtin_instructions is only allowed in the global agent config: ${configPath}`);
|
|
@@ -317,14 +319,14 @@ export class AgentContextManager {
|
|
|
317
319
|
if (state.instructionBytes >= state.instructionMaxBytes) state.instructionsTruncated = true;
|
|
318
320
|
}
|
|
319
321
|
if (config.skillRoots) {
|
|
320
|
-
state.skillRoots = config.skillRoots.map((value) => resolveConfiguredPath(value, baseDir, this.home, this.workspace,
|
|
322
|
+
state.skillRoots = config.skillRoots.map((value) => resolveConfiguredPath(value, baseDir, this.home, this.workspace, unrestricted));
|
|
321
323
|
}
|
|
322
324
|
for (const [name, definition] of config.commands) {
|
|
323
325
|
if (definition === null) {
|
|
324
326
|
state.commands.delete(name);
|
|
325
327
|
continue;
|
|
326
328
|
}
|
|
327
|
-
const cwd = resolveConfiguredPath(definition.cwd, baseDir, this.home, this.workspace,
|
|
329
|
+
const cwd = resolveConfiguredPath(definition.cwd, baseDir, this.home, this.workspace, unrestricted);
|
|
328
330
|
state.commands.set(name, {
|
|
329
331
|
name,
|
|
330
332
|
description: definition.description,
|
|
@@ -379,13 +381,14 @@ export class AgentContextManager {
|
|
|
379
381
|
}
|
|
380
382
|
|
|
381
383
|
async discoverSkills(state, options = {}, context = {}) {
|
|
384
|
+
const effectivePolicy = this.policyForContext(context);
|
|
382
385
|
return discoverLocalSkills({
|
|
383
386
|
skillRoots: state.skillRoots,
|
|
384
387
|
query: String(options.query || ""),
|
|
385
388
|
maxResults: clampInteger(options.maxResults, 100, 1, MAX_SKILL_RESULTS),
|
|
386
389
|
workspace: this.workspace,
|
|
387
|
-
unrestricted:
|
|
388
|
-
displayPath: this.displayPath,
|
|
390
|
+
unrestricted: effectivePolicy.unrestrictedPaths === true,
|
|
391
|
+
displayPath: (value) => this.displayPath(value, context),
|
|
389
392
|
context,
|
|
390
393
|
throwIfCancelled: this.throwIfCancelled,
|
|
391
394
|
});
|
|
@@ -102,9 +102,9 @@ export class AppAutomationManager {
|
|
|
102
102
|
if (target) argv.push(target);
|
|
103
103
|
command = { cmd: "open", argv };
|
|
104
104
|
} else if (this.platform === "win32") {
|
|
105
|
-
const
|
|
106
|
-
const targetFragment = target ? ` -ArgumentList
|
|
107
|
-
command = { cmd: "powershell.exe", argv: ["-NoProfile", "-NonInteractive", "-Command", `Start-Process -FilePath
|
|
105
|
+
const fileLiteral = powershellSingleQuotedLiteral(resolvedApplication);
|
|
106
|
+
const targetFragment = target ? ` -ArgumentList ${powershellSingleQuotedLiteral(target)}` : "";
|
|
107
|
+
command = { cmd: "powershell.exe", argv: ["-NoProfile", "-NonInteractive", "-Command", `Start-Process -FilePath ${fileLiteral}${targetFragment}`] };
|
|
108
108
|
} else if (resolvedApplication.toLowerCase().endsWith(".desktop")) {
|
|
109
109
|
command = { cmd: "gio", argv: ["launch", resolvedApplication, ...(target ? [target] : [])] };
|
|
110
110
|
} else {
|
|
@@ -113,7 +113,8 @@ export class AppAutomationManager {
|
|
|
113
113
|
: { cmd: resolvedApplication, argv: [] };
|
|
114
114
|
}
|
|
115
115
|
const result = await this.runProcess(command.cmd, command.argv, clampInt(args.timeout_seconds, 30, 1, 120) * 1000, false, 256 * 1024, context, undefined, null);
|
|
116
|
-
|
|
116
|
+
const publicResolvedApplication = /[\\/]/.test(resolvedApplication) ? this.displayPath(resolvedApplication) : resolvedApplication;
|
|
117
|
+
return { application, resolved_application: publicResolvedApplication, target, platform: this.platform, ...result };
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
async inspectApplication(args = {}, context = {}) {
|
|
@@ -417,3 +418,7 @@ function main() {
|
|
|
417
418
|
}
|
|
418
419
|
(() => { try { return JSON.stringify(main()); } catch (error) { return JSON.stringify({ error: String(error.message || error) }); } })()
|
|
419
420
|
`;
|
|
421
|
+
|
|
422
|
+
export function powershellSingleQuotedLiteral(value) {
|
|
423
|
+
return `'${String(value).replaceAll("'", "''")}'`;
|
|
424
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { BridgeError } from "./errors.mjs";
|
|
2
|
+
import { intersectPolicies, normalizePolicy, policyProfile } from "./policy.mjs";
|
|
3
|
+
|
|
4
|
+
const ACCOUNT_ID = /^acct_[A-Za-z0-9_-]{20,96}$/;
|
|
5
|
+
const CLIENT_ID = /^mcp_client_[A-Za-z0-9_-]{43}$/;
|
|
6
|
+
const FAMILY_ID = /^mcp_family_[A-Za-z0-9_-]{43}$/;
|
|
7
|
+
|
|
8
|
+
export function buildAuthorityContext({ authorization = {}, daemonPolicy, origin = "local" } = {}) {
|
|
9
|
+
if (origin !== "relay") {
|
|
10
|
+
const policy = normalizePolicy(daemonPolicy || {});
|
|
11
|
+
return Object.freeze({
|
|
12
|
+
origin: "local",
|
|
13
|
+
principal: Object.freeze({ kind: "local", role: "owner" }),
|
|
14
|
+
daemonPolicy: policy,
|
|
15
|
+
accountPolicy: policy,
|
|
16
|
+
effectivePolicy: policy,
|
|
17
|
+
owner: true,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const accountId = String(authorization.account_id || "");
|
|
22
|
+
const clientId = String(authorization.client_id || "");
|
|
23
|
+
const familyId = String(authorization.family_id || "");
|
|
24
|
+
const role = String(authorization.role || "").trim().toLowerCase();
|
|
25
|
+
const accountVersion = Number(authorization.account_version);
|
|
26
|
+
if (!ACCOUNT_ID.test(accountId) || !CLIENT_ID.test(clientId) || !FAMILY_ID.test(familyId) || !Number.isSafeInteger(accountVersion) || accountVersion <= 0) {
|
|
27
|
+
throw new BridgeError("authorization_denied", "relay operation is missing authenticated principal identity");
|
|
28
|
+
}
|
|
29
|
+
const accountPolicy = policyProfile(roleProfile(role), "explicit");
|
|
30
|
+
const normalizedDaemonPolicy = normalizePolicy(daemonPolicy || {});
|
|
31
|
+
const effectivePolicy = intersectPolicies(normalizedDaemonPolicy, accountPolicy, { origin: "effective" });
|
|
32
|
+
const principal = Object.freeze({
|
|
33
|
+
kind: "account",
|
|
34
|
+
accountId,
|
|
35
|
+
accountVersion,
|
|
36
|
+
clientId,
|
|
37
|
+
familyId,
|
|
38
|
+
role,
|
|
39
|
+
});
|
|
40
|
+
return Object.freeze({
|
|
41
|
+
origin: "relay",
|
|
42
|
+
principal,
|
|
43
|
+
daemonPolicy: normalizedDaemonPolicy,
|
|
44
|
+
accountPolicy,
|
|
45
|
+
effectivePolicy,
|
|
46
|
+
owner: role === "owner",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function policyForContext(context, fallbackPolicy) {
|
|
51
|
+
return normalizePolicy(context?.authority?.effectivePolicy || fallbackPolicy || {});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function principalForContext(context) {
|
|
55
|
+
const principal = context?.authority?.principal;
|
|
56
|
+
if (principal?.kind === "account") return principal;
|
|
57
|
+
return Object.freeze({ kind: "local", role: "owner" });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function principalBinding(context) {
|
|
61
|
+
const principal = principalForContext(context);
|
|
62
|
+
if (principal.kind !== "account") return { owner_kind: "local" };
|
|
63
|
+
return {
|
|
64
|
+
owner_kind: "account",
|
|
65
|
+
owner_account_id: principal.accountId,
|
|
66
|
+
owner_account_version: principal.accountVersion,
|
|
67
|
+
owner_client_id: principal.clientId,
|
|
68
|
+
owner_family_id: principal.familyId,
|
|
69
|
+
owner_role: principal.role,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function assertOwnedByContext(record, context, label = "object") {
|
|
74
|
+
const principal = principalForContext(context);
|
|
75
|
+
if (principal.kind !== "account" || principal.role === "owner") return;
|
|
76
|
+
if (
|
|
77
|
+
record?.owner_kind !== "account"
|
|
78
|
+
|| record.owner_account_id !== principal.accountId
|
|
79
|
+
|| record.owner_account_version !== principal.accountVersion
|
|
80
|
+
|| record.owner_client_id !== principal.clientId
|
|
81
|
+
|| record.owner_family_id !== principal.familyId
|
|
82
|
+
) {
|
|
83
|
+
throw new BridgeError("authorization_denied", `${label} belongs to another account or an obsolete account session`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function visibleToContext(record, context) {
|
|
88
|
+
try {
|
|
89
|
+
assertOwnedByContext(record, context);
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function roleProfile(role) {
|
|
97
|
+
const mapping = {
|
|
98
|
+
reviewer: "review",
|
|
99
|
+
editor: "edit",
|
|
100
|
+
operator: "agent",
|
|
101
|
+
owner: "full",
|
|
102
|
+
};
|
|
103
|
+
const profile = mapping[role];
|
|
104
|
+
if (!profile) throw new BridgeError("authorization_denied", `unknown account role: ${role}`);
|
|
105
|
+
return profile;
|
|
106
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { EXPECTED_EXTENSION_VERSION } from "./browser-extension-protocol.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { EXPECTED_EXTENSION_ID } from "./browser-extension-identity.mjs";
|
|
3
|
+
import { isAllowedLoopbackHost, pairingHtml, securityHeaders, sendJson } from "./browser-pairing-http.mjs";
|
|
3
4
|
|
|
4
5
|
export function handleBrowserBridgeHttp(request, response, {
|
|
5
6
|
port,
|
|
@@ -26,6 +27,8 @@ export function handleBrowserBridgeHttp(request, response, {
|
|
|
26
27
|
connected: extensionConnected(),
|
|
27
28
|
broker: "machine-bridge-browser",
|
|
28
29
|
expected_extension_version: EXPECTED_EXTENSION_VERSION,
|
|
30
|
+
expected_extension_id: EXPECTED_EXTENSION_ID,
|
|
31
|
+
extension_id: extension?.extension_id || "",
|
|
29
32
|
extension_protocol: extension?.protocol || null,
|
|
30
33
|
extension_version: extension?.version || "",
|
|
31
34
|
extension_capabilities: extension?.capabilities || [],
|
|
@@ -13,6 +13,7 @@ import { BrowserRequestRegistry } from "./browser-request-registry.mjs";
|
|
|
13
13
|
import { handleBrowserBridgeHttp } from "./browser-bridge-http.mjs";
|
|
14
14
|
import { BrowserBrokerRoutes } from "./browser-broker-routes.mjs";
|
|
15
15
|
import { startBrowserBrokerServer } from "./browser-broker-server.mjs";
|
|
16
|
+
import { EXPECTED_EXTENSION_ID } from "./browser-extension-identity.mjs";
|
|
16
17
|
|
|
17
18
|
const MAX_PORT_ATTEMPTS = 10;
|
|
18
19
|
const MAX_PENDING = 32;
|
|
@@ -49,13 +50,11 @@ export class BrowserBridgeManager {
|
|
|
49
50
|
extensionStatusInfo: () => this.extensionStatusInfo(),
|
|
50
51
|
extensionReloadRequired: () => this.extensionReloadRequired(),
|
|
51
52
|
});
|
|
52
|
-
// Public aliases remain for diagnostics and compatibility with existing behavioral tests.
|
|
53
|
-
this.runtimeClients = this.brokerRoutes.clients;
|
|
54
|
-
this.proxyRoutes = this.brokerRoutes.routes;
|
|
55
53
|
this.startPromise = null;
|
|
56
54
|
this.startGeneration = 0;
|
|
57
55
|
this.port = 0;
|
|
58
|
-
this.
|
|
56
|
+
this.extensionToken = "";
|
|
57
|
+
this.runtimeToken = "";
|
|
59
58
|
this.extensionPath = resolve(packageRoot, "browser-extension");
|
|
60
59
|
this.operationService = new BrowserOperationService({
|
|
61
60
|
authorizeTool: (tool) => this.authorizeTool(tool),
|
|
@@ -67,15 +66,19 @@ export class BrowserBridgeManager {
|
|
|
67
66
|
extensionConnected: this.extensionConnected(),
|
|
68
67
|
extensionInfo: this.extensionStatusInfo(),
|
|
69
68
|
extensionReloadRequired: this.extensionReloadRequired(),
|
|
69
|
+
...this.brokerRoutes.snapshot(),
|
|
70
70
|
}),
|
|
71
71
|
extensionPath: this.extensionPath,
|
|
72
72
|
expectedExtensionVersion: EXPECTED_EXTENSION_VERSION,
|
|
73
|
+
expectedExtensionId: EXPECTED_EXTENSION_ID,
|
|
73
74
|
runProcess: (...args) => this.runProcess(...args),
|
|
74
75
|
readResourceText: (name) => this.readResourceText(name),
|
|
75
76
|
readResourceBinary: (name) => this.readResourceBinary(name),
|
|
76
77
|
});
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
brokerDiagnostics() { return this.brokerRoutes.snapshot(); }
|
|
81
|
+
|
|
79
82
|
status(context = {}) { return this.operationService.status(context); }
|
|
80
83
|
|
|
81
84
|
pair(args = {}, context = {}) { return this.operationService.pair(args, context); }
|
|
@@ -133,14 +136,15 @@ export class BrowserBridgeManager {
|
|
|
133
136
|
try {
|
|
134
137
|
const pairing = await loadOrCreatePairing(this.stateRoot);
|
|
135
138
|
this.assertStartCurrent(generation);
|
|
136
|
-
this.
|
|
139
|
+
this.extensionToken = pairing.extensionToken;
|
|
140
|
+
this.runtimeToken = pairing.runtimeToken;
|
|
137
141
|
for (let offset = 0; offset < MAX_PORT_ATTEMPTS; offset += 1) {
|
|
138
142
|
const port = pairing.port + offset;
|
|
139
143
|
try {
|
|
140
144
|
await this.listen(port);
|
|
141
145
|
this.assertStartCurrent(generation);
|
|
142
146
|
if (port !== pairing.port && this.stateRoot) {
|
|
143
|
-
await savePairing(this.stateRoot, {
|
|
147
|
+
await savePairing(this.stateRoot, { schemaVersion: pairing.schemaVersion, extensionToken: this.extensionToken, runtimeToken: this.runtimeToken, port });
|
|
144
148
|
this.assertStartCurrent(generation);
|
|
145
149
|
}
|
|
146
150
|
return;
|
|
@@ -176,7 +180,8 @@ export class BrowserBridgeManager {
|
|
|
176
180
|
this.port = port;
|
|
177
181
|
const { server, wss } = await startBrowserBrokerServer({
|
|
178
182
|
port,
|
|
179
|
-
|
|
183
|
+
extensionToken: this.extensionToken,
|
|
184
|
+
runtimeToken: this.runtimeToken,
|
|
180
185
|
maxPayload: MAX_BROWSER_MESSAGE_BYTES,
|
|
181
186
|
onHttp: (request, response) => this.handleHttp(request, response),
|
|
182
187
|
onSocket: (socket, role) => this.acceptSocket(socket, role),
|
|
@@ -189,7 +194,7 @@ export class BrowserBridgeManager {
|
|
|
189
194
|
const url = `ws://127.0.0.1:${port}/runtime`;
|
|
190
195
|
return new Promise((resolvePromise) => {
|
|
191
196
|
let settled = false;
|
|
192
|
-
const ws = new WebSocket(url, [`mbm-runtime.${this.
|
|
197
|
+
const ws = new WebSocket(url, [`mbm-runtime.${this.runtimeToken}`], { maxPayload: MAX_BROWSER_MESSAGE_BYTES });
|
|
193
198
|
const timer = setTimeout(() => finish(false), 1500);
|
|
194
199
|
timer.unref?.();
|
|
195
200
|
const finish = (ok) => {
|
|
@@ -372,7 +377,7 @@ export class BrowserBridgeManager {
|
|
|
372
377
|
handleHttp(request, response) {
|
|
373
378
|
return handleBrowserBridgeHttp(request, response, {
|
|
374
379
|
port: this.port,
|
|
375
|
-
token: this.
|
|
380
|
+
token: this.extensionToken,
|
|
376
381
|
extensionConnected: () => this.extensionConnected(),
|
|
377
382
|
extensionStatusInfo: () => this.extensionStatusInfo(),
|
|
378
383
|
extensionReloadRequired: () => this.extensionReloadRequired(),
|
|
@@ -13,6 +13,10 @@ export class BrowserBrokerRoutes {
|
|
|
13
13
|
this.routes = new Map();
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
snapshot() {
|
|
17
|
+
return Object.freeze({ runtime_clients: this.clients.size, routed_requests: this.routes.size });
|
|
18
|
+
}
|
|
19
|
+
|
|
16
20
|
acceptClient(socket) {
|
|
17
21
|
this.clients.add(socket);
|
|
18
22
|
socket.on("message", (data) => this.handleClientMessage(socket, data));
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { WebSocketServer } from "ws";
|
|
3
|
-
import { isAllowedExtensionOrigin, isAllowedLoopbackHost } from "./browser-pairing-
|
|
3
|
+
import { isAllowedExtensionOrigin, isAllowedLoopbackHost } from "./browser-pairing-http.mjs";
|
|
4
|
+
import { EXPECTED_EXTENSION_ID } from "./browser-extension-identity.mjs";
|
|
4
5
|
|
|
5
|
-
export async function startBrowserBrokerServer({ port,
|
|
6
|
+
export async function startBrowserBrokerServer({ port, extensionToken, runtimeToken, maxPayload, onHttp, onSocket }) {
|
|
6
7
|
const server = createServer(onHttp);
|
|
7
8
|
const wss = new WebSocketServer({ noServer: true, maxPayload });
|
|
8
9
|
server.on("upgrade", (request, socket, head) => {
|
|
@@ -16,8 +17,8 @@ export async function startBrowserBrokerServer({ port, token, maxPayload, onHttp
|
|
|
16
17
|
const protocol = String(request.headers["sec-websocket-protocol"] || "");
|
|
17
18
|
const origin = String(request.headers.origin || "");
|
|
18
19
|
let role = "";
|
|
19
|
-
if (url.pathname === "/extension" && protocol === `mbm.${
|
|
20
|
-
if (url.pathname === "/runtime" && protocol === `mbm-runtime.${
|
|
20
|
+
if (url.pathname === "/extension" && protocol === `mbm.${extensionToken}` && isAllowedExtensionOrigin(origin, EXPECTED_EXTENSION_ID)) role = "extension";
|
|
21
|
+
if (url.pathname === "/runtime" && protocol === `mbm-runtime.${runtimeToken}` && !origin) role = "runtime";
|
|
21
22
|
if (!role) {
|
|
22
23
|
rejectUpgrade(socket, "401 Unauthorized");
|
|
23
24
|
return;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { packageRoot } from "./state.mjs";
|
|
5
|
+
|
|
6
|
+
const EXTENSION_ID_PATTERN = /^[a-p]{32}$/;
|
|
7
|
+
const PUBLIC_KEY_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
8
|
+
|
|
9
|
+
const manifest = readExtensionManifest();
|
|
10
|
+
export const EXPECTED_EXTENSION_PUBLIC_KEY = normalizeManifestPublicKey(manifest.key);
|
|
11
|
+
export const EXPECTED_EXTENSION_ID = extensionIdFromPublicKey(EXPECTED_EXTENSION_PUBLIC_KEY);
|
|
12
|
+
|
|
13
|
+
export function extensionIdFromPublicKey(value) {
|
|
14
|
+
const publicKey = normalizeManifestPublicKey(value);
|
|
15
|
+
const bytes = Buffer.from(publicKey, "base64");
|
|
16
|
+
const hexadecimal = createHash("sha256").update(bytes).digest("hex").slice(0, 32);
|
|
17
|
+
const extensionId = [...hexadecimal]
|
|
18
|
+
.map((character) => String.fromCharCode(97 + Number.parseInt(character, 16)))
|
|
19
|
+
.join("");
|
|
20
|
+
if (!EXTENSION_ID_PATTERN.test(extensionId)) throw new Error("browser extension public key produced an invalid extension id");
|
|
21
|
+
return extensionId;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeExtensionId(value) {
|
|
25
|
+
const extensionId = String(value || "").trim().toLowerCase();
|
|
26
|
+
return EXTENSION_ID_PATTERN.test(extensionId) ? extensionId : "";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeManifestPublicKey(value) {
|
|
30
|
+
const publicKey = String(value || "").trim();
|
|
31
|
+
if (!PUBLIC_KEY_PATTERN.test(publicKey) || publicKey.length < 128 || publicKey.length > 8192) {
|
|
32
|
+
throw new Error("browser extension manifest must contain a bounded base64 public key");
|
|
33
|
+
}
|
|
34
|
+
let bytes;
|
|
35
|
+
try { bytes = Buffer.from(publicKey, "base64"); }
|
|
36
|
+
catch { throw new Error("browser extension manifest public key is invalid base64"); }
|
|
37
|
+
if (bytes.length < 96 || bytes.length > 6144 || bytes.toString("base64") !== publicKey) {
|
|
38
|
+
throw new Error("browser extension manifest public key is not canonical DER base64");
|
|
39
|
+
}
|
|
40
|
+
return publicKey;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readExtensionManifest() {
|
|
44
|
+
const path = resolve(packageRoot, "browser-extension", "manifest.json");
|
|
45
|
+
const manifestValue = JSON.parse(readFileSync(path, "utf8"));
|
|
46
|
+
if (!manifestValue || typeof manifestValue !== "object" || Array.isArray(manifestValue)) {
|
|
47
|
+
throw new Error("browser extension manifest is invalid");
|
|
48
|
+
}
|
|
49
|
+
return manifestValue;
|
|
50
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import { packageRoot } from "./state.mjs";
|
|
6
|
+
import { EXPECTED_EXTENSION_ID, normalizeExtensionId } from "./browser-extension-identity.mjs";
|
|
6
7
|
|
|
7
8
|
export const BROWSER_EXTENSION_PROTOCOL = 3;
|
|
8
9
|
export const EXPECTED_EXTENSION_VERSION = extensionVersion();
|
|
@@ -11,13 +12,13 @@ const REQUIRED_EXTENSION_CAPABILITIES = Object.freeze([
|
|
|
11
12
|
]);
|
|
12
13
|
export const MAX_BROWSER_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
13
14
|
|
|
14
|
-
/** @typedef {{protocol: number, version: string, capabilities: string[]}} ExtensionInfo */
|
|
15
|
+
/** @typedef {{protocol: number, version: string, extension_id: string, capabilities: string[]}} ExtensionInfo */
|
|
15
16
|
/** @typedef {{readyState: number, close: (code?: number, reason?: string) => unknown, send: (value: string) => unknown}} ProtocolSocket */
|
|
16
17
|
|
|
17
18
|
/** @param {unknown} value @returns {ExtensionInfo | null} */
|
|
18
19
|
export function normalizeCompatibleExtensionInfo(value) {
|
|
19
20
|
const info = normalizeExtensionInfo(value);
|
|
20
|
-
if (!info || info.protocol !== BROWSER_EXTENSION_PROTOCOL || info.version !== EXPECTED_EXTENSION_VERSION) return null;
|
|
21
|
+
if (!info || info.protocol !== BROWSER_EXTENSION_PROTOCOL || info.version !== EXPECTED_EXTENSION_VERSION || info.extension_id !== EXPECTED_EXTENSION_ID) return null;
|
|
21
22
|
if (REQUIRED_EXTENSION_CAPABILITIES.some((capability) => !info.capabilities.includes(capability))) return null;
|
|
22
23
|
return info;
|
|
23
24
|
}
|
|
@@ -32,6 +33,9 @@ export function parseExtensionHello(message) {
|
|
|
32
33
|
if (info.version !== EXPECTED_EXTENSION_VERSION) {
|
|
33
34
|
throw new Error(`extension version mismatch; expected ${EXPECTED_EXTENSION_VERSION}; reload the extension`);
|
|
34
35
|
}
|
|
36
|
+
if (info.extension_id !== EXPECTED_EXTENSION_ID) {
|
|
37
|
+
throw new Error(`extension identity mismatch; expected ${EXPECTED_EXTENSION_ID}; reload the packaged extension`);
|
|
38
|
+
}
|
|
35
39
|
const missing = REQUIRED_EXTENSION_CAPABILITIES.filter((capability) => !info.capabilities.includes(capability));
|
|
36
40
|
if (missing.length) throw new Error(`extension capability mismatch; reload the extension (${missing.join(",")})`);
|
|
37
41
|
return info;
|
|
@@ -77,11 +81,12 @@ function normalizeExtensionInfo(value) {
|
|
|
77
81
|
const record = /** @type {Record<string, unknown>} */ (value);
|
|
78
82
|
const protocol = Number(record.protocol);
|
|
79
83
|
const version = typeof record.version === "string" && record.version.length <= 100 ? record.version : "";
|
|
84
|
+
const extension_id = normalizeExtensionId(record.extension_id);
|
|
80
85
|
const capabilities = Array.isArray(record.capabilities)
|
|
81
86
|
? [...new Set(record.capabilities.filter((entry) => typeof entry === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(entry)))].slice(0, 32)
|
|
82
87
|
: [];
|
|
83
|
-
if (!Number.isInteger(protocol) || protocol < 1 || !version) return null;
|
|
84
|
-
return { protocol, version, capabilities };
|
|
88
|
+
if (!Number.isInteger(protocol) || protocol < 1 || !version || !extension_id) return null;
|
|
89
|
+
return { protocol, version, extension_id, capabilities };
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
function extensionVersion() {
|