machine-bridge-mcp 0.11.1 → 0.12.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.
- package/CHANGELOG.md +39 -0
- package/CONTRIBUTING.md +2 -2
- package/README.md +5 -5
- package/SECURITY.md +15 -5
- package/browser-extension/manifest.json +2 -2
- package/docs/AGENT_CONTEXT.md +1 -1
- package/docs/ARCHITECTURE.md +14 -8
- package/docs/AUDIT.md +110 -0
- package/docs/ENGINEERING.md +14 -4
- package/docs/LOCAL_AUTOMATION.md +5 -3
- package/docs/LOGGING.md +5 -3
- package/docs/MANAGED_JOBS.md +5 -1
- package/docs/OPERATIONS.md +12 -4
- package/docs/PRIVACY.md +5 -3
- package/docs/RELEASING.md +1 -1
- package/docs/TESTING.md +23 -8
- package/package.json +8 -8
- package/scripts/github-release.mjs +368 -0
- package/scripts/privacy-check.mjs +66 -5
- package/scripts/release-state.mjs +10 -0
- package/scripts/syntax-check.mjs +52 -0
- package/src/local/agent-context.mjs +19 -6
- package/src/local/app-automation.mjs +17 -9
- package/src/local/browser-bridge.mjs +94 -53
- package/src/local/cli.mjs +165 -81
- package/src/local/daemon-process.mjs +3 -67
- package/src/local/exclusive-file.mjs +94 -0
- package/src/local/job-runner.mjs +33 -17
- package/src/local/log.mjs +16 -1
- package/src/local/managed-jobs.mjs +187 -56
- package/src/local/process-identity.mjs +143 -0
- package/src/local/resource-operations.mjs +2 -3
- package/src/local/runtime.mjs +4 -2
- package/src/local/service-lifecycle.mjs +56 -0
- package/src/local/service.mjs +53 -25
- package/src/local/shell.mjs +23 -4
- package/src/local/state.mjs +228 -66
- package/src/shared/tool-catalog.json +10 -0
- package/src/worker/index.ts +1 -1
|
@@ -12,16 +12,23 @@ const findings = [];
|
|
|
12
12
|
for (const relativePath of candidates) {
|
|
13
13
|
if (relativePath === ".privacy-denylist") continue;
|
|
14
14
|
scanDenylistPath(relativePath, denylist, findings);
|
|
15
|
+
scanSensitivePath(relativePath, findings);
|
|
15
16
|
const fullPath = path.join(root, relativePath);
|
|
16
17
|
let info;
|
|
17
|
-
try { info = lstatSync(fullPath); } catch {
|
|
18
|
+
try { info = lstatSync(fullPath); } catch {
|
|
19
|
+
findings.push({ path: relativePath, line: 1, rule: "publication file metadata could not be read" });
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
18
22
|
if (info.isSymbolicLink()) {
|
|
19
23
|
findings.push({ path: relativePath, line: 1, rule: "symbolic link in publication surface" });
|
|
20
24
|
continue;
|
|
21
25
|
}
|
|
22
26
|
if (!info.isFile()) continue;
|
|
23
27
|
let buffer;
|
|
24
|
-
try { buffer = readFileSync(fullPath); } catch {
|
|
28
|
+
try { buffer = readFileSync(fullPath); } catch {
|
|
29
|
+
findings.push({ path: relativePath, line: 1, rule: "publication file content could not be read" });
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
25
32
|
if (buffer.length > 5 * 1024 * 1024) {
|
|
26
33
|
findings.push({ path: relativePath, line: 1, rule: "file exceeds privacy scanner size limit and requires manual review" });
|
|
27
34
|
continue;
|
|
@@ -36,6 +43,7 @@ for (const relativePath of candidates) {
|
|
|
36
43
|
continue;
|
|
37
44
|
}
|
|
38
45
|
if (relativePath !== selfPath) scanBuiltIn(relativePath, text, findings);
|
|
46
|
+
if (path.basename(relativePath).toLowerCase() === ".npmrc") scanNpmrc(relativePath, text, findings);
|
|
39
47
|
scanDenylist(relativePath, text, denylist, findings);
|
|
40
48
|
}
|
|
41
49
|
|
|
@@ -65,7 +73,9 @@ function collectCandidateFiles(directory) {
|
|
|
65
73
|
const relative = stack.pop();
|
|
66
74
|
const absolute = path.join(directory, relative);
|
|
67
75
|
let entries;
|
|
68
|
-
try { entries = readdirSync(absolute, { withFileTypes: true }); } catch {
|
|
76
|
+
try { entries = readdirSync(absolute, { withFileTypes: true }); } catch {
|
|
77
|
+
throw new Error("privacy fallback traversal could not read a repository directory");
|
|
78
|
+
}
|
|
69
79
|
for (const entry of entries) {
|
|
70
80
|
if (excluded.has(entry.name) || entry.name === ".privacy-denylist") continue;
|
|
71
81
|
const child = relative ? path.join(relative, entry.name) : entry.name;
|
|
@@ -92,10 +102,17 @@ function escapeRegExp(value) {
|
|
|
92
102
|
|
|
93
103
|
function scanBuiltIn(relativePath, text, out) {
|
|
94
104
|
const rules = [
|
|
95
|
-
["private key material", /-----BEGIN\s+(?:OPENSSH|RSA|EC|DSA)\s+PRIVATE\s+KEY-----/g],
|
|
105
|
+
["private key material", /-----BEGIN\s+(?:(?:OPENSSH|RSA|EC|DSA)\s+|ENCRYPTED\s+)?PRIVATE\s+KEY-----/g],
|
|
96
106
|
["AWS access key", /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g],
|
|
97
107
|
["GitHub access token", /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g],
|
|
108
|
+
["GitLab access token", /\bglpat-[A-Za-z0-9_-]{20,}\b/g],
|
|
109
|
+
["npm access token", /\bnpm_[A-Za-z0-9]{30,}\b/g],
|
|
110
|
+
["Slack access token", /\bxox[aboprs]-[A-Za-z0-9-]{10,}\b/g],
|
|
111
|
+
["Google API key", /\bAIza[A-Za-z0-9_-]{30,}\b/g],
|
|
112
|
+
["live payment API key", /\b(?:sk|rk|pk)_live_[A-Za-z0-9]{16,}\b/g],
|
|
98
113
|
["API secret token", /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g],
|
|
114
|
+
["JWT-like bearer token", /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g],
|
|
115
|
+
["URL with embedded credentials", /https?:\/\/[^\s/@:"'<>]+:[^\s/@"'<>]+@[^\s/"'<>]+/gi],
|
|
99
116
|
["absolute macOS/Linux home path", /(?:^|[\s"'=(:,])\/(?:Users|home)\/([^/\s"'<>]+)\//gm],
|
|
100
117
|
["absolute Windows home path", /(?:^|[\s"'=(:,])\b[A-Za-z]:\\Users\\([^\\\s"'<>]+)\\/gm],
|
|
101
118
|
];
|
|
@@ -105,6 +122,11 @@ function scanBuiltIn(relativePath, text, out) {
|
|
|
105
122
|
out.push({ path: relativePath, line: lineNumber(text, match.index || 0), rule });
|
|
106
123
|
}
|
|
107
124
|
}
|
|
125
|
+
const emailLike = /\b[A-Z0-9._%+-]+@([A-Z0-9.-]+\.[A-Z]{2,})\b/gi;
|
|
126
|
+
for (const match of text.matchAll(emailLike)) {
|
|
127
|
+
if (isReservedExampleHost(match[1]) || String(match[1]).toLowerCase().endsWith(".noreply.github.com")) continue;
|
|
128
|
+
out.push({ path: relativePath, line: lineNumber(text, match.index || 0), rule: "non-example email address" });
|
|
129
|
+
}
|
|
108
130
|
const sshLike = /\b[A-Za-z_][A-Za-z0-9._-]*@([A-Za-z0-9.-]+)\b/g;
|
|
109
131
|
for (const match of text.matchAll(sshLike)) {
|
|
110
132
|
if (isReservedExampleHost(match[1]) || !hasNearbySshContext(text, match.index || 0)) continue;
|
|
@@ -112,6 +134,40 @@ function scanBuiltIn(relativePath, text, out) {
|
|
|
112
134
|
}
|
|
113
135
|
}
|
|
114
136
|
|
|
137
|
+
|
|
138
|
+
function scanSensitivePath(relativePath, out) {
|
|
139
|
+
const normalized = String(relativePath).split(path.sep).join("/");
|
|
140
|
+
const base = path.posix.basename(normalized).toLowerCase();
|
|
141
|
+
const sensitive = base === ".dev.vars"
|
|
142
|
+
|| /^\.env(?:\.|$)/.test(base)
|
|
143
|
+
|| /\.(?:pem|p12|pfx|sqlite|sqlite3|db|log)$/i.test(base)
|
|
144
|
+
|| /^(?:id_rsa|id_dsa|id_ecdsa|id_ed25519)(?:\.|$)/i.test(base)
|
|
145
|
+
|| /^(?:credentials?|secrets?|cookies?|sessions?|tokens?)(?:\.|$)/i.test(base);
|
|
146
|
+
if (sensitive) out.push({ path: relativePath, line: 1, rule: "credential- or private-data-shaped publication filename" });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function scanNpmrc(relativePath, text, out) {
|
|
150
|
+
const forbiddenKey = /(?:^|:)(?:_auth|_authtoken|_password|username|email|certfile|keyfile|cafile)$/i;
|
|
151
|
+
const lines = text.split(/\r?\n/);
|
|
152
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
153
|
+
const line = lines[index].trim();
|
|
154
|
+
if (!line || line.startsWith("#") || line.startsWith(";")) continue;
|
|
155
|
+
const separator = line.indexOf("=");
|
|
156
|
+
if (separator < 1) {
|
|
157
|
+
out.push({ path: relativePath, line: index + 1, rule: "unparseable tracked .npmrc line requires manual review" });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const key = line.slice(0, separator).trim().toLowerCase();
|
|
161
|
+
const value = line.slice(separator + 1).trim();
|
|
162
|
+
if (forbiddenKey.test(key) || /(?:^|[:/])(?:_auth|_authtoken|_password|username|email|certfile|keyfile|cafile)$/i.test(key)) {
|
|
163
|
+
out.push({ path: relativePath, line: index + 1, rule: "tracked .npmrc contains authentication or identity configuration" });
|
|
164
|
+
}
|
|
165
|
+
if (/\$\{[^}]+\}/.test(value) || /https?:\/\/[^/@\s]+:[^/@\s]+@/i.test(value)) {
|
|
166
|
+
out.push({ path: relativePath, line: index + 1, rule: "tracked .npmrc contains environment interpolation or embedded credentials" });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
115
171
|
function scanDenylistPath(relativePath, entries, out) {
|
|
116
172
|
const lower = relativePath.toLocaleLowerCase("en-US");
|
|
117
173
|
for (const entry of entries) {
|
|
@@ -133,7 +189,9 @@ function scanDenylist(relativePath, text, entries, out) {
|
|
|
133
189
|
function loadDenylist(file) {
|
|
134
190
|
if (!existsSync(file)) return [];
|
|
135
191
|
let text;
|
|
136
|
-
try { text = readFileSync(file, "utf8"); } catch {
|
|
192
|
+
try { text = readFileSync(file, "utf8"); } catch {
|
|
193
|
+
throw new Error("local privacy denylist exists but could not be read");
|
|
194
|
+
}
|
|
137
195
|
return [...new Set(text.split(/\r?\n/)
|
|
138
196
|
.map((line) => line.trim())
|
|
139
197
|
.filter((line) => line && !line.startsWith("#") && line.length >= 3)
|
|
@@ -163,6 +221,9 @@ function isReservedExampleHost(host) {
|
|
|
163
221
|
|| value === "127.0.0.1"
|
|
164
222
|
|| value === "::1"
|
|
165
223
|
|| value === "example"
|
|
224
|
+
|| value === "example.com"
|
|
225
|
+
|| value === "example.net"
|
|
226
|
+
|| value === "example.org"
|
|
166
227
|
|| value.endsWith(".example")
|
|
167
228
|
|| value.endsWith(".example.com")
|
|
168
229
|
|| value.endsWith(".example.net")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function tagSyncError({ scope = "local", tag, head, commit }) {
|
|
2
|
+
const label = scope === "remote" ? "remote tag" : "local tag";
|
|
3
|
+
if (!commit) {
|
|
4
|
+
return `${label} ${tag} is missing; run npm run release:publish before npm publish`;
|
|
5
|
+
}
|
|
6
|
+
if (commit !== head) {
|
|
7
|
+
return `${label} ${tag} points to ${commit}, not HEAD ${head}`;
|
|
8
|
+
}
|
|
9
|
+
return "";
|
|
10
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { readdirSync } from "node:fs";
|
|
5
|
+
import { dirname, extname, join, relative, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
+
const roots = ["bin", "src/local", "scripts", "tests", "browser-extension"];
|
|
10
|
+
const files = roots.flatMap((entry) => collect(join(root, entry)))
|
|
11
|
+
.filter((file) => [".js", ".mjs"].includes(extname(file)))
|
|
12
|
+
.sort();
|
|
13
|
+
|
|
14
|
+
for (const file of files) {
|
|
15
|
+
const result = spawnSync(process.execPath, ["--check", file], {
|
|
16
|
+
cwd: root,
|
|
17
|
+
encoding: "utf8",
|
|
18
|
+
windowsHide: true,
|
|
19
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
20
|
+
});
|
|
21
|
+
if (result.error) throw result.error;
|
|
22
|
+
if (result.status !== 0) {
|
|
23
|
+
process.stderr.write(`syntax check failed: ${relative(root, file)}\n${result.stderr || result.stdout}`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (process.platform !== "win32") {
|
|
29
|
+
const shell = spawnSync("sh", ["-n", join(root, "mbm")], {
|
|
30
|
+
cwd: root,
|
|
31
|
+
encoding: "utf8",
|
|
32
|
+
windowsHide: true,
|
|
33
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
34
|
+
});
|
|
35
|
+
if (shell.error) throw shell.error;
|
|
36
|
+
if (shell.status !== 0) {
|
|
37
|
+
process.stderr.write(`shell syntax check failed: mbm\n${shell.stderr || shell.stdout}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log(`syntax check ok (${files.length} JavaScript files${process.platform === "win32" ? "" : "; mbm shell wrapper"})`);
|
|
43
|
+
|
|
44
|
+
function collect(directory) {
|
|
45
|
+
const files = [];
|
|
46
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
47
|
+
const target = join(directory, entry.name);
|
|
48
|
+
if (entry.isDirectory()) files.push(...collect(target));
|
|
49
|
+
else if (entry.isFile()) files.push(target);
|
|
50
|
+
}
|
|
51
|
+
return files;
|
|
52
|
+
}
|
|
@@ -112,12 +112,12 @@ export class AgentContextManager {
|
|
|
112
112
|
const discovered = await this.discoverSkills(state, { maxResults: MAX_SKILL_RESULTS }, context);
|
|
113
113
|
const maxSkills = clampInt(args.max_skills, 10, 1, 50);
|
|
114
114
|
const skillMatches = discovered.skills
|
|
115
|
-
.map((skill) => ({ skill, score: relevanceScore(task, `${skill.name} ${skill.description}
|
|
115
|
+
.map((skill) => ({ skill, score: relevanceScore(task, `${skill.name} ${skill.description}`, skill.name) }))
|
|
116
116
|
.filter((item) => item.score > 0)
|
|
117
117
|
.sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name))
|
|
118
118
|
.slice(0, maxSkills);
|
|
119
119
|
const commandMatches = [...state.commands.values()]
|
|
120
|
-
.map((command) => ({ command, score: relevanceScore(task, `${command.name} ${command.description} ${command.argv.join(" ")}
|
|
120
|
+
.map((command) => ({ command, score: relevanceScore(task, `${command.name} ${command.description} ${command.argv.join(" ")}`, command.name) }))
|
|
121
121
|
.filter((item) => item.score > 0)
|
|
122
122
|
.sort((a, b) => b.score - a.score || a.command.name.localeCompare(b.command.name))
|
|
123
123
|
.slice(0, 20);
|
|
@@ -718,7 +718,7 @@ function capabilityFingerprint(state, skills) {
|
|
|
718
718
|
}));
|
|
719
719
|
}
|
|
720
720
|
|
|
721
|
-
function relevanceScore(task, candidate) {
|
|
721
|
+
function relevanceScore(task, candidate, identity = "") {
|
|
722
722
|
const taskTokens = tokenize(task);
|
|
723
723
|
const candidateTokens = tokenize(candidate);
|
|
724
724
|
if (!taskTokens.size || !candidateTokens.size) return 0;
|
|
@@ -726,18 +726,31 @@ function relevanceScore(task, candidate) {
|
|
|
726
726
|
for (const token of taskTokens) {
|
|
727
727
|
if (candidateTokens.has(token)) score += token.length >= 6 ? 2 : 1;
|
|
728
728
|
}
|
|
729
|
-
const
|
|
730
|
-
const
|
|
731
|
-
|
|
729
|
+
const taskComparable = comparableText(task);
|
|
730
|
+
const candidateComparable = comparableText(candidate);
|
|
731
|
+
const identityComparable = comparableText(identity);
|
|
732
|
+
if (candidateComparable.includes(taskComparable) || taskComparable.includes(candidateComparable)) score += 4;
|
|
733
|
+
if (identityComparable.length >= 3 && ` ${taskComparable} `.includes(` ${identityComparable} `)) score += 12;
|
|
732
734
|
return score;
|
|
733
735
|
}
|
|
734
736
|
|
|
737
|
+
function comparableText(value) {
|
|
738
|
+
return String(value || "")
|
|
739
|
+
.toLowerCase()
|
|
740
|
+
.replace(/[^a-z0-9\p{Script=Han}]+/gu, " ")
|
|
741
|
+
.trim()
|
|
742
|
+
.replace(/\s+/g, " ");
|
|
743
|
+
}
|
|
744
|
+
|
|
735
745
|
function tokenize(value) {
|
|
736
746
|
const text = String(value || "").toLowerCase();
|
|
737
747
|
const tokens = new Set();
|
|
738
748
|
for (const raw of text.match(/[a-z0-9_][a-z0-9_.-]{1,}/g) || []) {
|
|
739
749
|
const token = raw.replace(/^[.-]+|[.-]+$/g, "");
|
|
740
750
|
if (token.length >= 2 && !TOKEN_STOP_WORDS.has(token)) tokens.add(token);
|
|
751
|
+
for (const part of token.split(/[._-]+/)) {
|
|
752
|
+
if (part.length >= 2 && !TOKEN_STOP_WORDS.has(part)) tokens.add(part);
|
|
753
|
+
}
|
|
741
754
|
}
|
|
742
755
|
for (const sequence of text.match(/[\p{Script=Han}]{1,}/gu) || []) {
|
|
743
756
|
if (!TOKEN_STOP_WORDS.has(sequence)) tokens.add(sequence);
|
|
@@ -109,6 +109,7 @@ export class AppAutomationManager {
|
|
|
109
109
|
maxDepth: clampInt(args.max_depth, 6, 1, MAX_UI_DEPTH),
|
|
110
110
|
maxElements: clampInt(args.max_elements, 200, 1, MAX_UI_ELEMENTS),
|
|
111
111
|
includeValues: args.include_values === true,
|
|
112
|
+
includeMenus: args.include_menus === true,
|
|
112
113
|
};
|
|
113
114
|
const result = await this.runJxa(payload, clampInt(args.timeout_seconds, 30, 1, 120), context);
|
|
114
115
|
return {
|
|
@@ -132,7 +133,9 @@ export class AppAutomationManager {
|
|
|
132
133
|
if (value !== null) throw new Error("value and value_resource are mutually exclusive");
|
|
133
134
|
value = await this.readResourceText(requiredResourceName(args.value_resource));
|
|
134
135
|
}
|
|
135
|
-
if (value !== null && value.length > MAX_TEXT_CHARS)
|
|
136
|
+
if (value !== null && (value.includes("\0") || value.length > MAX_TEXT_CHARS)) {
|
|
137
|
+
throw new Error(`application action value exceeds ${MAX_TEXT_CHARS} characters or contains a NUL byte`);
|
|
138
|
+
}
|
|
136
139
|
const payload = {
|
|
137
140
|
operation: "act",
|
|
138
141
|
application: processName,
|
|
@@ -141,6 +144,7 @@ export class AppAutomationManager {
|
|
|
141
144
|
value,
|
|
142
145
|
maxDepth: clampInt(args.max_depth, 8, 1, MAX_UI_DEPTH),
|
|
143
146
|
maxElements: MAX_UI_ELEMENTS,
|
|
147
|
+
includeMenus: args.include_menus === true,
|
|
144
148
|
};
|
|
145
149
|
const result = await this.runJxa(payload, clampInt(args.timeout_seconds, 30, 1, 120), context);
|
|
146
150
|
return {
|
|
@@ -166,9 +170,11 @@ export class AppAutomationManager {
|
|
|
166
170
|
undefined,
|
|
167
171
|
input,
|
|
168
172
|
);
|
|
173
|
+
const output = result.stdout.trim();
|
|
174
|
+
if (!output) throw new Error("macOS accessibility helper returned no JSON output");
|
|
169
175
|
let parsed;
|
|
170
176
|
try {
|
|
171
|
-
parsed = JSON.parse(
|
|
177
|
+
parsed = JSON.parse(output);
|
|
172
178
|
} catch {
|
|
173
179
|
throw new Error("macOS accessibility helper returned invalid JSON");
|
|
174
180
|
}
|
|
@@ -335,7 +341,7 @@ function describe(element, index, includeValues) {
|
|
|
335
341
|
return item;
|
|
336
342
|
}
|
|
337
343
|
function childrenOf(element) { return safe(() => element.uiElements(), []); }
|
|
338
|
-
function flatten(root, maxDepth, maxElements, includeValues) {
|
|
344
|
+
function flatten(root, maxDepth, maxElements, includeValues, includeMenus) {
|
|
339
345
|
const output = [];
|
|
340
346
|
const elements = [];
|
|
341
347
|
const stack = [{ element: root, depth: 0 }];
|
|
@@ -344,10 +350,12 @@ function flatten(root, maxDepth, maxElements, includeValues) {
|
|
|
344
350
|
const children = childrenOf(current.element);
|
|
345
351
|
for (let i = children.length - 1; i >= 0; i--) {
|
|
346
352
|
const child = children[i];
|
|
353
|
+
const item = describe(child, output.length, includeValues);
|
|
347
354
|
elements.push(child);
|
|
348
|
-
output.push(
|
|
355
|
+
output.push(item);
|
|
349
356
|
if (output.length >= maxElements) break;
|
|
350
|
-
|
|
357
|
+
const isMenuTree = String(item.role || '').startsWith('AXMenu');
|
|
358
|
+
if (current.depth + 1 < maxDepth && (includeMenus || !isMenuTree)) stack.push({ element: child, depth: current.depth + 1 });
|
|
351
359
|
}
|
|
352
360
|
}
|
|
353
361
|
return { output, elements, truncated: stack.length > 0 || output.length >= maxElements };
|
|
@@ -364,14 +372,14 @@ function main() {
|
|
|
364
372
|
const process = se.applicationProcesses.byName(payload.application);
|
|
365
373
|
if (!safe(() => process.exists(), false)) throw new Error('application process not found or Accessibility access denied');
|
|
366
374
|
if (payload.operation === 'inspect') {
|
|
367
|
-
const flattened = flatten(process, payload.maxDepth, payload.maxElements, payload.includeValues === true);
|
|
368
|
-
return { frontmost: safe(() => process.frontmost(), false), elements: flattened.output, truncated: flattened.truncated };
|
|
375
|
+
const flattened = flatten(process, payload.maxDepth, payload.maxElements, payload.includeValues === true, payload.includeMenus === true);
|
|
376
|
+
return { frontmost: safe(() => process.frontmost(), false), elements: flattened.output, truncated: flattened.truncated, menus_included: payload.includeMenus === true };
|
|
369
377
|
}
|
|
370
378
|
if (payload.action === 'activate') {
|
|
371
379
|
process.frontmost = true;
|
|
372
380
|
return { ok: true, matched: 1 };
|
|
373
381
|
}
|
|
374
|
-
const flattened = flatten(process, payload.maxDepth, payload.maxElements, true);
|
|
382
|
+
const flattened = flatten(process, payload.maxDepth, payload.maxElements, true, payload.includeMenus === true);
|
|
375
383
|
const matchesList = [];
|
|
376
384
|
for (let i = 0; i < flattened.output.length; i++) if (matches(flattened.output[i], payload.selector)) matchesList.push(i);
|
|
377
385
|
const chosen = payload.selector.index !== undefined ? matchesList[payload.selector.index] : matchesList[0];
|
|
@@ -396,5 +404,5 @@ function main() {
|
|
|
396
404
|
}
|
|
397
405
|
return { ok: true, matched: matchesList.length, selected_index: chosen, element: describe(element, chosen, false) };
|
|
398
406
|
}
|
|
399
|
-
try {
|
|
407
|
+
(() => { try { return JSON.stringify(main()); } catch (error) { return JSON.stringify({ error: String(error.message || error) }); } })()
|
|
400
408
|
`;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
4
|
import { mkdir } from "node:fs/promises";
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import { WebSocket, WebSocketServer } from "ws";
|
|
7
|
-
import {
|
|
7
|
+
import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
8
8
|
import { readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
9
|
-
import { ownerOnlyFile, packageRoot } from "./state.mjs";
|
|
9
|
+
import { assertStateMaintenanceAvailable, ownerOnlyFile, packageRoot } from "./state.mjs";
|
|
10
10
|
|
|
11
11
|
const DEFAULT_PORT = 39393;
|
|
12
12
|
const MAX_PORT_ATTEMPTS = 10;
|
|
@@ -200,9 +200,11 @@ export class BrowserBridgeManager {
|
|
|
200
200
|
const resource = this.readResourceBinary(name);
|
|
201
201
|
total += resource.buffer.length;
|
|
202
202
|
if (total > 5 * 1024 * 1024) throw new Error("browser upload resources exceed 5 MiB total");
|
|
203
|
+
const suppliedFilename = filenames[files.length];
|
|
204
|
+
const derivedFilename = resource.path.split(/[\\/]/).pop() || name;
|
|
203
205
|
files.push({
|
|
204
|
-
filename:
|
|
205
|
-
mime:
|
|
206
|
+
filename: normalizeUploadFilename(suppliedFilename || derivedFilename, { derived: !suppliedFilename }),
|
|
207
|
+
mime: normalizeMimeType(mimeTypes[files.length] || "application/octet-stream"),
|
|
206
208
|
data: resource.buffer.toString("base64"),
|
|
207
209
|
});
|
|
208
210
|
}
|
|
@@ -271,6 +273,7 @@ export class BrowserBridgeManager {
|
|
|
271
273
|
|
|
272
274
|
async ensureStarted(context = {}) {
|
|
273
275
|
this.throwIfCancelled(context);
|
|
276
|
+
if (this.stateRoot) assertStateMaintenanceAvailable(this.stateRoot);
|
|
274
277
|
this.stopping = false;
|
|
275
278
|
if (this.server || this.upstream?.readyState === 1) return;
|
|
276
279
|
if (!this.startPromise) this.startPromise = this.start();
|
|
@@ -356,15 +359,23 @@ export class BrowserBridgeManager {
|
|
|
356
359
|
resolvePromise(ok);
|
|
357
360
|
};
|
|
358
361
|
ws.on("message", (data) => {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
+
const parsed = parseBrowserSocketMessage(data);
|
|
363
|
+
if (!parsed.ok) {
|
|
364
|
+
closeProtocolSocket(ws, parsed.code, parsed.reason);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const message = parsed.message;
|
|
368
|
+
if (!settled) {
|
|
369
|
+
if (message.type !== "hello" || message.role !== "runtime") {
|
|
370
|
+
closeProtocolSocket(ws, 1002, "runtime hello required");
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
362
373
|
this.upstream = ws;
|
|
363
374
|
this.proxyExtensionConnected = message.extension_connected === true;
|
|
364
375
|
finish(true);
|
|
365
376
|
return;
|
|
366
377
|
}
|
|
367
|
-
this.handleUpstreamMessage(message);
|
|
378
|
+
if (!this.handleUpstreamMessage(message)) closeProtocolSocket(ws, 1002, "invalid broker protocol message");
|
|
368
379
|
});
|
|
369
380
|
ws.once("error", () => finish(false));
|
|
370
381
|
ws.once("close", () => {
|
|
@@ -398,7 +409,7 @@ export class BrowserBridgeManager {
|
|
|
398
409
|
}
|
|
399
410
|
if (this.socket && this.socket.readyState === 1) this.socket.close(4001, "superseded");
|
|
400
411
|
this.socket = ws;
|
|
401
|
-
ws.on("message", (data) => this.handleExtensionMessage(data));
|
|
412
|
+
ws.on("message", (data) => this.handleExtensionMessage(ws, data));
|
|
402
413
|
ws.on("close", () => {
|
|
403
414
|
if (this.socket !== ws) return;
|
|
404
415
|
this.socket = null;
|
|
@@ -415,11 +426,18 @@ export class BrowserBridgeManager {
|
|
|
415
426
|
this.broadcastRuntimeStatus(true);
|
|
416
427
|
}
|
|
417
428
|
|
|
418
|
-
handleExtensionMessage(data) {
|
|
419
|
-
if (
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
429
|
+
handleExtensionMessage(socket, data) {
|
|
430
|
+
if (this.socket !== socket) return;
|
|
431
|
+
const parsed = parseBrowserSocketMessage(data);
|
|
432
|
+
if (!parsed.ok) {
|
|
433
|
+
closeProtocolSocket(socket, parsed.code, parsed.reason);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const message = parsed.message;
|
|
437
|
+
if (message.type !== "response" || typeof message.id !== "string") {
|
|
438
|
+
closeProtocolSocket(socket, 1002, "invalid extension protocol message");
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
423
441
|
const pending = this.pending.get(message.id);
|
|
424
442
|
if (pending) {
|
|
425
443
|
clearTimeout(pending.timeout);
|
|
@@ -436,11 +454,14 @@ export class BrowserBridgeManager {
|
|
|
436
454
|
}
|
|
437
455
|
|
|
438
456
|
handleRuntimeClientMessage(socket, data) {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
457
|
+
const parsed = parseBrowserSocketMessage(data);
|
|
458
|
+
if (!parsed.ok) {
|
|
459
|
+
closeProtocolSocket(socket, parsed.code, parsed.reason);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const message = parsed.message;
|
|
463
|
+
if (message.type === "ping") return;
|
|
464
|
+
if (message.type === "cancel" && typeof message.id === "string") {
|
|
444
465
|
for (const [routedId, route] of this.proxyRoutes) {
|
|
445
466
|
if (route.socket !== socket || route.id !== message.id) continue;
|
|
446
467
|
clearTimeout(route.timeout);
|
|
@@ -449,7 +470,10 @@ export class BrowserBridgeManager {
|
|
|
449
470
|
}
|
|
450
471
|
return;
|
|
451
472
|
}
|
|
452
|
-
if (
|
|
473
|
+
if (message.type !== "request" || typeof message.id !== "string" || typeof message.method !== "string") {
|
|
474
|
+
closeProtocolSocket(socket, 1002, "invalid runtime protocol message");
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
453
477
|
if (!this.socket || this.socket.readyState !== 1) {
|
|
454
478
|
safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser extension is not connected" });
|
|
455
479
|
return;
|
|
@@ -469,13 +493,13 @@ export class BrowserBridgeManager {
|
|
|
469
493
|
const route = this.proxyRoutes.get(routedId);
|
|
470
494
|
if (!route) return;
|
|
471
495
|
this.proxyRoutes.delete(routedId);
|
|
472
|
-
try { this.socket?.send(JSON.stringify({ type: "cancel", id: routedId })); } catch {}
|
|
473
496
|
safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser broker request timed out" });
|
|
497
|
+
try { this.socket?.send(JSON.stringify({ type: "cancel", id: routedId })); } catch {}
|
|
474
498
|
}, timeoutMs);
|
|
475
499
|
timeout.unref?.();
|
|
476
500
|
this.proxyRoutes.set(routedId, { socket, id: message.id, timeout });
|
|
477
501
|
try {
|
|
478
|
-
this.socket.send(JSON.stringify({ ...message, id: routedId }));
|
|
502
|
+
this.socket.send(JSON.stringify({ ...message, id: routedId, timeout_ms: timeoutMs }));
|
|
479
503
|
} catch {
|
|
480
504
|
clearTimeout(timeout);
|
|
481
505
|
this.proxyRoutes.delete(routedId);
|
|
@@ -484,19 +508,19 @@ export class BrowserBridgeManager {
|
|
|
484
508
|
}
|
|
485
509
|
|
|
486
510
|
handleUpstreamMessage(message) {
|
|
487
|
-
if (
|
|
488
|
-
|
|
489
|
-
this.proxyExtensionConnected = message.extension_connected === true;
|
|
511
|
+
if (message.type === "status" && typeof message.extension_connected === "boolean") {
|
|
512
|
+
this.proxyExtensionConnected = message.extension_connected;
|
|
490
513
|
if (!this.proxyExtensionConnected) this.rejectPending("browser extension disconnected");
|
|
491
|
-
return;
|
|
514
|
+
return true;
|
|
492
515
|
}
|
|
493
|
-
if (message.type !== "response" || typeof message.id !== "string") return;
|
|
516
|
+
if (message.type !== "response" || typeof message.id !== "string") return false;
|
|
494
517
|
const pending = this.pending.get(message.id);
|
|
495
|
-
if (!pending) return;
|
|
518
|
+
if (!pending) return true;
|
|
496
519
|
clearTimeout(pending.timeout);
|
|
497
520
|
this.pending.delete(message.id);
|
|
498
521
|
if (message.ok === false) pending.reject(new Error(String(message.error || "browser operation failed").slice(0, 2000)));
|
|
499
522
|
else pending.resolve(message.result);
|
|
523
|
+
return true;
|
|
500
524
|
}
|
|
501
525
|
|
|
502
526
|
broadcastRuntimeStatus(connected) {
|
|
@@ -587,6 +611,7 @@ export class BrowserBridgeManager {
|
|
|
587
611
|
|
|
588
612
|
async function loadOrCreatePairing(stateRoot) {
|
|
589
613
|
if (!stateRoot) return { token: randomBytes(32).toString("base64url"), port: DEFAULT_PORT };
|
|
614
|
+
assertStateMaintenanceAvailable(stateRoot);
|
|
590
615
|
await mkdir(stateRoot, { recursive: true, mode: 0o700 });
|
|
591
616
|
const file = join(stateRoot, PAIRING_FILE);
|
|
592
617
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
@@ -600,18 +625,12 @@ async function loadOrCreatePairing(stateRoot) {
|
|
|
600
625
|
return parsed;
|
|
601
626
|
}
|
|
602
627
|
const value = { token: randomBytes(32).toString("base64url"), port: DEFAULT_PORT };
|
|
603
|
-
let fd;
|
|
604
628
|
try {
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
`, "utf8");
|
|
608
|
-
fsyncSync(fd);
|
|
609
|
-
closeSync(fd);
|
|
610
|
-
fd = undefined;
|
|
629
|
+
createExclusiveFileSync(file, `${JSON.stringify(value, null, 2)}
|
|
630
|
+
`, { mode: 0o600 });
|
|
611
631
|
ownerOnlyFile(file);
|
|
612
632
|
return value;
|
|
613
633
|
} catch (error) {
|
|
614
|
-
if (fd !== undefined) try { closeSync(fd); } catch {}
|
|
615
634
|
if (error?.code !== "EEXIST") throw error;
|
|
616
635
|
}
|
|
617
636
|
}
|
|
@@ -619,23 +638,11 @@ async function loadOrCreatePairing(stateRoot) {
|
|
|
619
638
|
}
|
|
620
639
|
|
|
621
640
|
async function savePairing(stateRoot, value) {
|
|
641
|
+
assertStateMaintenanceAvailable(stateRoot);
|
|
622
642
|
const file = join(stateRoot, PAIRING_FILE);
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
fd = openSync(temp, "wx", 0o600);
|
|
627
|
-
writeFileSync(fd, `${JSON.stringify(value, null, 2)}
|
|
628
|
-
`, "utf8");
|
|
629
|
-
fsyncSync(fd);
|
|
630
|
-
closeSync(fd);
|
|
631
|
-
fd = undefined;
|
|
632
|
-
replaceFileSync(temp, file);
|
|
633
|
-
ownerOnlyFile(file);
|
|
634
|
-
} catch (error) {
|
|
635
|
-
if (fd !== undefined) try { closeSync(fd); } catch {}
|
|
636
|
-
try { unlinkSync(temp); } catch {}
|
|
637
|
-
throw error;
|
|
638
|
-
}
|
|
643
|
+
replaceFileAtomicallySync(file, `${JSON.stringify(value, null, 2)}
|
|
644
|
+
`, { mode: 0o600 });
|
|
645
|
+
ownerOnlyFile(file);
|
|
639
646
|
}
|
|
640
647
|
|
|
641
648
|
function pairingHtml(port, token) {
|
|
@@ -657,6 +664,22 @@ function securityHeaders(contentType) {
|
|
|
657
664
|
};
|
|
658
665
|
}
|
|
659
666
|
|
|
667
|
+
function parseBrowserSocketMessage(data) {
|
|
668
|
+
if (Buffer.byteLength(data) > MAX_BROWSER_MESSAGE_BYTES) return { ok: false, code: 1009, reason: "message too large" };
|
|
669
|
+
let text;
|
|
670
|
+
try { text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(data)); }
|
|
671
|
+
catch { return { ok: false, code: 1007, reason: "invalid UTF-8" }; }
|
|
672
|
+
let message;
|
|
673
|
+
try { message = JSON.parse(text); }
|
|
674
|
+
catch { return { ok: false, code: 1007, reason: "invalid JSON" }; }
|
|
675
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) return { ok: false, code: 1002, reason: "invalid protocol message" };
|
|
676
|
+
return { ok: true, message };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function closeProtocolSocket(socket, code, reason) {
|
|
680
|
+
try { socket.close(code, reason); } catch {}
|
|
681
|
+
}
|
|
682
|
+
|
|
660
683
|
function safeSocketSend(socket, value) {
|
|
661
684
|
if (!socket || socket.readyState !== 1) return false;
|
|
662
685
|
try {
|
|
@@ -708,6 +731,24 @@ function validateNavigationUrl(value) {
|
|
|
708
731
|
return parsed.href;
|
|
709
732
|
}
|
|
710
733
|
|
|
734
|
+
function normalizeUploadFilename(value, { derived = false } = {}) {
|
|
735
|
+
let name = String(value || "");
|
|
736
|
+
if (derived) name = name.replace(/[\u0000-\u001f\u007f/\\]+/g, "_").trim();
|
|
737
|
+
if (!name || name === "." || name === ".." || name.length > 255 || /[\u0000-\u001f\u007f/\\]/.test(name)) {
|
|
738
|
+
if (derived) return "upload.bin";
|
|
739
|
+
throw new Error("filenames entries must be safe single-component filenames of at most 255 characters");
|
|
740
|
+
}
|
|
741
|
+
return name;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function normalizeMimeType(value) {
|
|
745
|
+
const mime = String(value || "").trim().toLowerCase();
|
|
746
|
+
if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(mime) || mime.length > 200) {
|
|
747
|
+
throw new Error("mime_types entries must be valid media types");
|
|
748
|
+
}
|
|
749
|
+
return mime;
|
|
750
|
+
}
|
|
751
|
+
|
|
711
752
|
function optionalStringArray(value, label, maxItems, maxLength) {
|
|
712
753
|
if (value === undefined || value === null) return [];
|
|
713
754
|
if (!Array.isArray(value) || value.length > maxItems) throw new Error(`${label} must be an array with at most ${maxItems} entries`);
|