screenhand 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +193 -109
- package/bin/darwin-arm64/macos-bridge +0 -0
- package/dist/mcp-desktop.js +5876 -0
- package/dist/scripts/codex-monitor-daemon.js +335 -0
- package/dist/scripts/export-help-center.js +112 -0
- package/dist/scripts/marketing-loop.js +117 -0
- package/dist/scripts/observer-daemon.js +288 -0
- package/dist/scripts/orchestrator-daemon.js +399 -0
- package/dist/scripts/supervisor-daemon.js +272 -0
- package/dist/scripts/threads-campaign.js +208 -0
- package/dist/scripts/worker-daemon.js +228 -0
- package/dist/src/agent/cli.js +82 -0
- package/dist/src/agent/loop.js +274 -0
- package/dist/src/community/fetcher.js +109 -0
- package/dist/src/community/index.js +6 -0
- package/dist/src/community/publisher.js +191 -0
- package/dist/src/community/remote-api.js +121 -0
- package/dist/src/community/types.js +3 -0
- package/dist/src/community/validator.js +95 -0
- package/{src/config.ts → dist/src/config.js} +5 -10
- package/dist/src/context-tracker.js +489 -0
- package/{src/index.ts → dist/src/index.js} +32 -52
- package/dist/src/ingestion/coverage-auditor.js +233 -0
- package/dist/src/ingestion/doc-parser.js +164 -0
- package/dist/src/ingestion/index.js +8 -0
- package/dist/src/ingestion/menu-scanner.js +152 -0
- package/dist/src/ingestion/reference-merger.js +186 -0
- package/dist/src/ingestion/shortcut-extractor.js +180 -0
- package/dist/src/ingestion/tutorial-extractor.js +170 -0
- package/dist/src/ingestion/types.js +3 -0
- package/dist/src/jobs/manager.js +305 -0
- package/dist/src/jobs/runner.js +806 -0
- package/dist/src/jobs/store.js +102 -0
- package/dist/src/jobs/types.js +30 -0
- package/dist/src/jobs/worker.js +97 -0
- package/dist/src/learning/engine.js +356 -0
- package/dist/src/learning/index.js +9 -0
- package/dist/src/learning/locator-policy.js +120 -0
- package/dist/src/learning/pattern-policy.js +89 -0
- package/dist/src/learning/recovery-policy.js +116 -0
- package/dist/src/learning/sensor-policy.js +115 -0
- package/dist/src/learning/timing-model.js +204 -0
- package/dist/src/learning/topology-policy.js +90 -0
- package/dist/src/learning/types.js +9 -0
- package/dist/src/logging/timeline-logger.js +48 -0
- package/dist/src/mcp/mcp-stdio-server.js +464 -0
- package/dist/src/mcp/server.js +363 -0
- package/dist/src/mcp-entry.js +60 -0
- package/dist/src/memory/playbook-seeds.js +200 -0
- package/dist/src/memory/recall.js +222 -0
- package/dist/src/memory/research.js +104 -0
- package/dist/src/memory/seeds.js +101 -0
- package/dist/src/memory/service.js +446 -0
- package/dist/src/memory/session.js +169 -0
- package/dist/src/memory/store.js +451 -0
- package/{src/runtime/locator-cache.ts → dist/src/memory/types.js} +1 -17
- package/dist/src/monitor/codex-monitor.js +382 -0
- package/dist/src/monitor/task-queue.js +97 -0
- package/dist/src/monitor/types.js +62 -0
- package/dist/src/native/bridge-client.js +412 -0
- package/{src/native/macos-bridge-client.ts → dist/src/native/macos-bridge-client.js} +0 -1
- package/dist/src/observer/state.js +199 -0
- package/dist/src/observer/types.js +43 -0
- package/dist/src/orchestrator/state.js +68 -0
- package/dist/src/orchestrator/types.js +22 -0
- package/dist/src/perception/ax-source.js +162 -0
- package/dist/src/perception/cdp-source.js +162 -0
- package/dist/src/perception/coordinator.js +771 -0
- package/dist/src/perception/frame-differ.js +287 -0
- package/dist/src/perception/index.js +22 -0
- package/dist/src/perception/manager.js +199 -0
- package/dist/src/perception/types.js +47 -0
- package/dist/src/perception/vision-source.js +399 -0
- package/dist/src/planner/deterministic.js +298 -0
- package/dist/src/planner/executor.js +870 -0
- package/dist/src/planner/goal-store.js +92 -0
- package/dist/src/planner/index.js +21 -0
- package/dist/src/planner/planner.js +520 -0
- package/dist/src/planner/tool-registry.js +71 -0
- package/dist/src/planner/types.js +22 -0
- package/dist/src/platform/explorer.js +213 -0
- package/dist/src/platform/help-center-markdown.js +527 -0
- package/dist/src/platform/learner.js +257 -0
- package/dist/src/playbook/engine.js +486 -0
- package/dist/src/playbook/index.js +20 -0
- package/dist/src/playbook/mcp-recorder.js +204 -0
- package/dist/src/playbook/recorder.js +536 -0
- package/dist/src/playbook/runner.js +408 -0
- package/dist/src/playbook/store.js +312 -0
- package/dist/src/playbook/types.js +17 -0
- package/dist/src/recovery/detectors.js +156 -0
- package/dist/src/recovery/engine.js +327 -0
- package/dist/src/recovery/index.js +20 -0
- package/dist/src/recovery/strategies.js +274 -0
- package/dist/src/recovery/types.js +20 -0
- package/dist/src/runtime/accessibility-adapter.js +430 -0
- package/dist/src/runtime/app-adapter.js +64 -0
- package/dist/src/runtime/applescript-adapter.js +305 -0
- package/dist/src/runtime/ax-role-map.js +96 -0
- package/dist/src/runtime/browser-adapter.js +52 -0
- package/dist/src/runtime/cdp-chrome-adapter.js +521 -0
- package/dist/src/runtime/composite-adapter.js +221 -0
- package/dist/src/runtime/execution-contract.js +159 -0
- package/dist/src/runtime/executor.js +286 -0
- package/dist/src/runtime/locator-cache.js +50 -0
- package/dist/src/runtime/planning-loop.js +63 -0
- package/dist/src/runtime/service.js +432 -0
- package/dist/src/runtime/session-manager.js +63 -0
- package/dist/src/runtime/state-observer.js +121 -0
- package/dist/src/runtime/vision-adapter.js +225 -0
- package/dist/src/state/app-map-types.js +72 -0
- package/dist/src/state/app-map.js +1974 -0
- package/dist/src/state/entity-tracker.js +108 -0
- package/dist/src/state/fusion.js +96 -0
- package/dist/src/state/index.js +21 -0
- package/dist/src/state/ladder-generator.js +236 -0
- package/dist/src/state/persistence.js +156 -0
- package/dist/src/state/types.js +17 -0
- package/dist/src/state/world-model.js +1456 -0
- package/dist/src/supervisor/locks.js +186 -0
- package/dist/src/supervisor/supervisor.js +403 -0
- package/dist/src/supervisor/types.js +30 -0
- package/dist/src/test-mcp-protocol.js +154 -0
- package/dist/src/types.js +17 -0
- package/dist/src/util/atomic-write.js +133 -0
- package/dist/src/util/sanitize.js +146 -0
- package/dist-app-maps/com.figma.Desktop.json +959 -0
- package/dist-app-maps/com.hnc.Discord.json +1146 -0
- package/dist-app-maps/notion.id.json +2831 -0
- package/dist-playbooks/canva-screenhand-carousel.json +445 -0
- package/dist-playbooks/codex-desktop.json +76 -0
- package/dist-playbooks/competitor-research-stack.json +122 -0
- package/dist-playbooks/davinci-color-grade.json +153 -0
- package/dist-playbooks/davinci-edit-timeline.json +162 -0
- package/dist-playbooks/davinci-render.json +114 -0
- package/dist-playbooks/devto.json +52 -0
- package/dist-playbooks/discord.json +41 -0
- package/dist-playbooks/google-flow-create-project.json +59 -0
- package/dist-playbooks/google-flow-edit-image.json +90 -0
- package/dist-playbooks/google-flow-edit-video.json +90 -0
- package/dist-playbooks/google-flow-generate-image.json +68 -0
- package/dist-playbooks/google-flow-generate-video.json +191 -0
- package/dist-playbooks/google-flow-open-project.json +48 -0
- package/dist-playbooks/google-flow-open-scenebuilder.json +64 -0
- package/dist-playbooks/google-flow-search-assets.json +64 -0
- package/dist-playbooks/instagram.json +57 -0
- package/dist-playbooks/linkedin.json +52 -0
- package/dist-playbooks/n8n.json +43 -0
- package/dist-playbooks/reddit.json +52 -0
- package/dist-playbooks/threads.json +59 -0
- package/dist-playbooks/x-twitter.json +59 -0
- package/dist-playbooks/youtube.json +59 -0
- package/dist-references/canva.json +646 -0
- package/dist-references/codex-desktop.json +305 -0
- package/dist-references/davinci-resolve-keyboard.json +594 -0
- package/dist-references/davinci-resolve-menu-map.json +1139 -0
- package/dist-references/davinci-resolve-menus-batch1.json +116 -0
- package/dist-references/davinci-resolve-menus-batch2.json +372 -0
- package/dist-references/davinci-resolve-menus-batch3.json +330 -0
- package/dist-references/davinci-resolve-menus-batch4.json +297 -0
- package/dist-references/davinci-resolve-shortcuts.json +333 -0
- package/dist-references/devto.json +317 -0
- package/dist-references/discord.json +549 -0
- package/dist-references/figma.json +1186 -0
- package/dist-references/finder.json +146 -0
- package/dist-references/google-ads-transparency.json +95 -0
- package/dist-references/google-flow.json +649 -0
- package/dist-references/instagram.json +341 -0
- package/dist-references/linkedin.json +324 -0
- package/dist-references/meta-ad-library.json +86 -0
- package/dist-references/n8n.json +387 -0
- package/dist-references/notes.json +27 -0
- package/dist-references/notion.json +163 -0
- package/dist-references/reddit.json +341 -0
- package/dist-references/threads.json +337 -0
- package/dist-references/x-twitter.json +403 -0
- package/dist-references/youtube.json +373 -0
- package/native/macos-bridge/Package.swift +1 -0
- package/native/macos-bridge/Sources/AccessibilityBridge.swift +257 -36
- package/native/macos-bridge/Sources/AppManagement.swift +212 -2
- package/native/macos-bridge/Sources/CoreGraphicsBridge.swift +348 -53
- package/native/macos-bridge/Sources/StreamCapture.swift +136 -0
- package/native/macos-bridge/Sources/VisionBridge.swift +165 -7
- package/native/macos-bridge/Sources/main.swift +169 -16
- package/native/windows-bridge/Program.cs +5 -0
- package/native/windows-bridge/ScreenCapture.cs +124 -0
- package/package.json +29 -4
- package/scripts/postinstall.cjs +127 -0
- package/.claude/commands/automate.md +0 -28
- package/.claude/commands/debug-ui.md +0 -19
- package/.claude/commands/screenshot.md +0 -15
- package/.github/FUNDING.yml +0 -1
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -27
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
- package/.mcp.json +0 -8
- package/DESKTOP_MCP_GUIDE.md +0 -92
- package/SECURITY.md +0 -44
- package/docs/architecture.md +0 -47
- package/install-skills.sh +0 -19
- package/mcp-bridge.ts +0 -271
- package/mcp-desktop.ts +0 -1221
- package/playbooks/instagram.json +0 -41
- package/playbooks/instagram_v2.json +0 -201
- package/playbooks/x_v1.json +0 -211
- package/scripts/devpost-live-loop.mjs +0 -421
- package/src/logging/timeline-logger.ts +0 -55
- package/src/mcp/server.ts +0 -449
- package/src/memory/recall.ts +0 -191
- package/src/memory/research.ts +0 -146
- package/src/memory/seeds.ts +0 -123
- package/src/memory/session.ts +0 -201
- package/src/memory/store.ts +0 -434
- package/src/memory/types.ts +0 -69
- package/src/native/bridge-client.ts +0 -239
- package/src/runtime/accessibility-adapter.ts +0 -487
- package/src/runtime/app-adapter.ts +0 -169
- package/src/runtime/applescript-adapter.ts +0 -376
- package/src/runtime/ax-role-map.ts +0 -102
- package/src/runtime/browser-adapter.ts +0 -129
- package/src/runtime/cdp-chrome-adapter.ts +0 -676
- package/src/runtime/composite-adapter.ts +0 -274
- package/src/runtime/executor.ts +0 -396
- package/src/runtime/planning-loop.ts +0 -81
- package/src/runtime/service.ts +0 -448
- package/src/runtime/session-manager.ts +0 -50
- package/src/runtime/state-observer.ts +0 -136
- package/src/runtime/vision-adapter.ts +0 -297
- package/src/types.ts +0 -297
- package/tests/bridge-client.test.ts +0 -176
- package/tests/browser-stealth.test.ts +0 -210
- package/tests/composite-adapter.test.ts +0 -64
- package/tests/mcp-server.test.ts +0 -151
- package/tests/memory-recall.test.ts +0 -339
- package/tests/memory-research.test.ts +0 -159
- package/tests/memory-seeds.test.ts +0 -120
- package/tests/memory-store.test.ts +0 -392
- package/tests/types.test.ts +0 -92
- package/tsconfig.check.json +0 -17
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -8
- /package/{playbooks → dist-references}/devpost.json +0 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Copyright (C) 2025 Clazro Technology Private Limited
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
//
|
|
4
|
+
// This file is part of ScreenHand.
|
|
5
|
+
//
|
|
6
|
+
// ScreenHand is free software: you can redistribute it and/or modify
|
|
7
|
+
// it under the terms of the GNU Affero General Public License as
|
|
8
|
+
// published by the Free Software Foundation, version 3.
|
|
9
|
+
//
|
|
10
|
+
// ScreenHand is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with ScreenHand. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { createInterface } from "node:readline";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const projectRoot = path.resolve(__dirname, "..");
|
|
23
|
+
const tsxBin = path.join(projectRoot, "node_modules", ".bin", "tsx");
|
|
24
|
+
const TIMEOUT_MS = 10_000;
|
|
25
|
+
const proc = spawn(tsxBin, [path.join(projectRoot, "src/mcp-entry.ts")], {
|
|
26
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
27
|
+
env: { ...process.env, SCREENHAND_ADAPTER: "placeholder" },
|
|
28
|
+
cwd: projectRoot,
|
|
29
|
+
});
|
|
30
|
+
let stderrBuf = "";
|
|
31
|
+
proc.stderr.on("data", (d) => { stderrBuf += d.toString(); });
|
|
32
|
+
// MCP SDK v1.27 uses newline-delimited JSON (NDJSON), not Content-Length framing
|
|
33
|
+
function send(msg) {
|
|
34
|
+
proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
35
|
+
}
|
|
36
|
+
const rl = createInterface({ input: proc.stdout });
|
|
37
|
+
const lineQueue = [];
|
|
38
|
+
let lineWaiter = null;
|
|
39
|
+
rl.on("line", (line) => {
|
|
40
|
+
if (lineWaiter) {
|
|
41
|
+
const w = lineWaiter;
|
|
42
|
+
lineWaiter = null;
|
|
43
|
+
w(line);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
lineQueue.push(line);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
function readResponse() {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const timer = setTimeout(() => {
|
|
52
|
+
lineWaiter = null;
|
|
53
|
+
reject(new Error(`Timeout. stderr: ${stderrBuf.slice(-300)}`));
|
|
54
|
+
}, TIMEOUT_MS);
|
|
55
|
+
const handle = (line) => {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
resolve(JSON.parse(line));
|
|
58
|
+
};
|
|
59
|
+
const queued = lineQueue.shift();
|
|
60
|
+
if (queued) {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
resolve(JSON.parse(queued));
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
lineWaiter = handle;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function fail(msg) {
|
|
70
|
+
console.error("FAIL:", msg);
|
|
71
|
+
proc.kill();
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
// Wait for server to start
|
|
76
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
77
|
+
// 1. Initialize
|
|
78
|
+
console.log("Sending initialize...");
|
|
79
|
+
send({
|
|
80
|
+
jsonrpc: "2.0",
|
|
81
|
+
id: 1,
|
|
82
|
+
method: "initialize",
|
|
83
|
+
params: {
|
|
84
|
+
protocolVersion: "2024-11-05",
|
|
85
|
+
capabilities: {},
|
|
86
|
+
clientInfo: { name: "test-client", version: "1.0" },
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
const initResp = await readResponse();
|
|
90
|
+
const initResult = initResp.result;
|
|
91
|
+
if (!initResult)
|
|
92
|
+
fail(`No init result: ${JSON.stringify(initResp)}`);
|
|
93
|
+
console.log("=== Initialize ===");
|
|
94
|
+
console.log(` Protocol: ${initResult.protocolVersion}`);
|
|
95
|
+
console.log(` Server: ${JSON.stringify(initResult.serverInfo)}`);
|
|
96
|
+
// 2. Send initialized notification
|
|
97
|
+
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
98
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
99
|
+
// 3. List tools
|
|
100
|
+
console.log("\nListing tools...");
|
|
101
|
+
send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
|
|
102
|
+
const toolsResp = await readResponse();
|
|
103
|
+
const toolsResult = toolsResp.result;
|
|
104
|
+
if (!toolsResult)
|
|
105
|
+
fail(`No tools result: ${JSON.stringify(toolsResp)}`);
|
|
106
|
+
const tools = toolsResult.tools ?? [];
|
|
107
|
+
console.log("=== Tools ===");
|
|
108
|
+
for (const tool of tools) {
|
|
109
|
+
console.log(` ${tool.name}: ${(tool.description ?? "").slice(0, 70)}`);
|
|
110
|
+
}
|
|
111
|
+
console.log(`\n Total: ${tools.length} tools`);
|
|
112
|
+
if (tools.length < 10)
|
|
113
|
+
fail(`Expected 16 tools, got ${tools.length}`);
|
|
114
|
+
// 4. Test session_start
|
|
115
|
+
console.log("\nCalling session_start...");
|
|
116
|
+
send({
|
|
117
|
+
jsonrpc: "2.0",
|
|
118
|
+
id: 3,
|
|
119
|
+
method: "tools/call",
|
|
120
|
+
params: { name: "session_start", arguments: {} },
|
|
121
|
+
});
|
|
122
|
+
const sessionResp = await readResponse();
|
|
123
|
+
const sessionResult = sessionResp.result;
|
|
124
|
+
if (!sessionResult)
|
|
125
|
+
fail(`No session result: ${JSON.stringify(sessionResp)}`);
|
|
126
|
+
const sessionContent = sessionResult.content;
|
|
127
|
+
const sessionData = JSON.parse(sessionContent?.[0]?.text ?? "{}");
|
|
128
|
+
console.log("=== session_start ===");
|
|
129
|
+
console.log(` Session ID: ${sessionData.sessionId}`);
|
|
130
|
+
console.log(` Profile: ${sessionData.profile}`);
|
|
131
|
+
if (!sessionData.sessionId)
|
|
132
|
+
fail("No sessionId returned");
|
|
133
|
+
// 5. Test app_list (should work with placeholder)
|
|
134
|
+
console.log("\nCalling app_list...");
|
|
135
|
+
send({
|
|
136
|
+
jsonrpc: "2.0",
|
|
137
|
+
id: 4,
|
|
138
|
+
method: "tools/call",
|
|
139
|
+
params: { name: "app_list", arguments: { sessionId: sessionData.sessionId } },
|
|
140
|
+
});
|
|
141
|
+
const appResp = await readResponse();
|
|
142
|
+
const appResult = appResp.result;
|
|
143
|
+
console.log("=== app_list ===");
|
|
144
|
+
const appContent = appResult?.content;
|
|
145
|
+
const isError = appResult?.isError;
|
|
146
|
+
console.log(` isError: ${isError ?? false}`);
|
|
147
|
+
console.log(` Response: ${(appContent?.[0]?.text ?? "").slice(0, 100)}`);
|
|
148
|
+
proc.kill();
|
|
149
|
+
console.log("\nAll tests passed!");
|
|
150
|
+
process.exit(0);
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
fail(e instanceof Error ? e.message : String(e));
|
|
154
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Copyright (C) 2025 Clazro Technology Private Limited
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
//
|
|
4
|
+
// This file is part of ScreenHand.
|
|
5
|
+
//
|
|
6
|
+
// ScreenHand is free software: you can redistribute it and/or modify
|
|
7
|
+
// it under the terms of the GNU Affero General Public License as
|
|
8
|
+
// published by the Free Software Foundation, version 3.
|
|
9
|
+
//
|
|
10
|
+
// ScreenHand is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with ScreenHand. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Copyright (C) 2025 Clazro Technology Private Limited
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
//
|
|
4
|
+
// This file is part of ScreenHand.
|
|
5
|
+
//
|
|
6
|
+
// ScreenHand is free software: you can redistribute it and/or modify
|
|
7
|
+
// it under the terms of the GNU Affero General Public License as
|
|
8
|
+
// published by the Free Software Foundation, version 3.
|
|
9
|
+
//
|
|
10
|
+
// ScreenHand is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with ScreenHand. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
/**
|
|
18
|
+
* Atomic file writes — temp file + rename to prevent corruption on crash.
|
|
19
|
+
*
|
|
20
|
+
* Also provides corrupt-file recovery: if the primary file is unreadable,
|
|
21
|
+
* falls back to the `.bak` backup created on the previous successful write.
|
|
22
|
+
*/
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import crypto from "node:crypto";
|
|
26
|
+
/**
|
|
27
|
+
* Write data atomically: write to a temp file in the same directory,
|
|
28
|
+
* then rename over the target. On POSIX, rename is atomic within the
|
|
29
|
+
* same filesystem, so readers always see either the old or new content.
|
|
30
|
+
*
|
|
31
|
+
* Also keeps a single `.bak` of the previous version for recovery.
|
|
32
|
+
*/
|
|
33
|
+
export function writeFileAtomicSync(filePath, data) {
|
|
34
|
+
const dir = path.dirname(filePath);
|
|
35
|
+
const tmp = path.join(dir, `.${path.basename(filePath)}.${crypto.randomBytes(4).toString("hex")}.tmp`);
|
|
36
|
+
try {
|
|
37
|
+
fs.writeFileSync(tmp, data, { mode: 0o644 });
|
|
38
|
+
// Back up current file before overwriting (ignore if it doesn't exist yet)
|
|
39
|
+
// V3: Check for symlinks before backup to prevent data exfiltration
|
|
40
|
+
try {
|
|
41
|
+
const stat = fs.lstatSync(filePath);
|
|
42
|
+
if (stat.isSymbolicLink()) {
|
|
43
|
+
// Target is a symlink — skip backup and remove the symlink before writing
|
|
44
|
+
fs.unlinkSync(filePath);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
fs.copyFileSync(filePath, filePath + ".bak");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// No existing file to back up (ENOENT) — fine
|
|
52
|
+
}
|
|
53
|
+
fs.renameSync(tmp, filePath);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
// Clean up temp file on failure
|
|
57
|
+
try {
|
|
58
|
+
fs.unlinkSync(tmp);
|
|
59
|
+
}
|
|
60
|
+
catch { /* ignore */ }
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Async variant — same temp+rename approach but non-blocking.
|
|
66
|
+
* Falls back to sync rename (rename is fast, effectively atomic).
|
|
67
|
+
*/
|
|
68
|
+
export function writeFileAtomic(filePath, data, callback) {
|
|
69
|
+
const dir = path.dirname(filePath);
|
|
70
|
+
const tmp = path.join(dir, `.${path.basename(filePath)}.${crypto.randomBytes(4).toString("hex")}.tmp`);
|
|
71
|
+
fs.writeFile(tmp, data, { mode: 0o644 }, (writeErr) => {
|
|
72
|
+
if (writeErr) {
|
|
73
|
+
try {
|
|
74
|
+
fs.unlinkSync(tmp);
|
|
75
|
+
}
|
|
76
|
+
catch { /* ignore */ }
|
|
77
|
+
callback(writeErr);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Back up current file (best-effort, sync is fine for a copy)
|
|
81
|
+
// V3: Check for symlinks before backup to prevent data exfiltration
|
|
82
|
+
try {
|
|
83
|
+
const stat = fs.lstatSync(filePath);
|
|
84
|
+
if (stat.isSymbolicLink()) {
|
|
85
|
+
fs.unlinkSync(filePath);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
fs.copyFileSync(filePath, filePath + ".bak");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch { /* ignore — ENOENT means no file to back up */ }
|
|
92
|
+
fs.rename(tmp, filePath, (renameErr) => {
|
|
93
|
+
if (renameErr) {
|
|
94
|
+
try {
|
|
95
|
+
fs.unlinkSync(tmp);
|
|
96
|
+
}
|
|
97
|
+
catch { /* ignore */ }
|
|
98
|
+
}
|
|
99
|
+
callback(renameErr);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Read a JSON file with corrupt-file recovery.
|
|
105
|
+
* If the primary file fails to parse, tries the `.bak` backup.
|
|
106
|
+
* Returns the parsed object, or null if both are unreadable.
|
|
107
|
+
*/
|
|
108
|
+
export function readJsonWithRecovery(filePath) {
|
|
109
|
+
// Try primary file
|
|
110
|
+
const primary = tryParseJsonFile(filePath);
|
|
111
|
+
if (primary !== null)
|
|
112
|
+
return primary;
|
|
113
|
+
// Primary is missing or corrupt — try backup
|
|
114
|
+
const backup = tryParseJsonFile(filePath + ".bak");
|
|
115
|
+
if (backup !== null) {
|
|
116
|
+
// Restore backup as primary so next read is fast
|
|
117
|
+
try {
|
|
118
|
+
fs.copyFileSync(filePath + ".bak", filePath);
|
|
119
|
+
}
|
|
120
|
+
catch { /* ignore */ }
|
|
121
|
+
return backup;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
function tryParseJsonFile(filePath) {
|
|
126
|
+
try {
|
|
127
|
+
const data = fs.readFileSync(filePath, "utf-8");
|
|
128
|
+
return JSON.parse(data);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Copyright (C) 2025 Clazro Technology Private Limited
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
/**
|
|
4
|
+
* Shared sanitization utilities for redacting sensitive data from tool outputs.
|
|
5
|
+
* Used by both the world model (state persistence) and MCP tool responses.
|
|
6
|
+
*/
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
const MAX_STRING_LENGTH = 1000;
|
|
10
|
+
const ALLOWED_URL_PROTOCOLS = new Set(["http:", "https:", "about:", "chrome:", "chrome-extension:"]);
|
|
11
|
+
const SENSITIVE_URL_PARAMS = new Set([
|
|
12
|
+
"code", "token", "access_token", "refresh_token", "id_token",
|
|
13
|
+
"secret", "password", "key", "api_key", "apikey", "auth",
|
|
14
|
+
"session", "session_id", "sessionid", "state", "nonce",
|
|
15
|
+
]);
|
|
16
|
+
const SENSITIVE_LABEL_PATTERNS = [
|
|
17
|
+
// email:password in window titles (e.g. "user@example.com:P@ssw0rd! - Chrome")
|
|
18
|
+
[/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+:[^\s]+/g, "[CREDENTIALS_REDACTED]"],
|
|
19
|
+
// Bearer tokens
|
|
20
|
+
[/Bearer\s+[A-Za-z0-9\-._~+/]+=*/g, "[BEARER_REDACTED]"],
|
|
21
|
+
];
|
|
22
|
+
/**
|
|
23
|
+
* Sanitize untrusted strings: truncate + strip control characters.
|
|
24
|
+
*/
|
|
25
|
+
export function sanitizeString(s) {
|
|
26
|
+
// eslint-disable-next-line no-control-regex
|
|
27
|
+
const stripped = s.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
|
|
28
|
+
return stripped.length > MAX_STRING_LENGTH
|
|
29
|
+
? stripped.slice(0, MAX_STRING_LENGTH)
|
|
30
|
+
: stripped;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Sanitize a URL: validate protocol + redact sensitive query params.
|
|
34
|
+
*/
|
|
35
|
+
export function sanitizeUrl(url) {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = new URL(url);
|
|
38
|
+
if (!ALLOWED_URL_PROTOCOLS.has(parsed.protocol))
|
|
39
|
+
return "about:blocked";
|
|
40
|
+
let redacted = false;
|
|
41
|
+
for (const paramName of parsed.searchParams.keys()) {
|
|
42
|
+
if (SENSITIVE_URL_PARAMS.has(paramName.toLowerCase())) {
|
|
43
|
+
parsed.searchParams.set(paramName, "[REDACTED]");
|
|
44
|
+
redacted = true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return redacted ? parsed.toString() : url;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return "about:blocked";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Check if a string looks like a token/key (32+ chars, 3+ character classes).
|
|
55
|
+
*/
|
|
56
|
+
function looksLikeToken(s) {
|
|
57
|
+
if (s.length < 32)
|
|
58
|
+
return false;
|
|
59
|
+
const hasUpper = /[A-Z]/.test(s);
|
|
60
|
+
const hasLower = /[a-z]/.test(s);
|
|
61
|
+
const hasDigit = /[0-9]/.test(s);
|
|
62
|
+
const hasSpecial = /[\-._~+/]/.test(s);
|
|
63
|
+
const classes = [hasUpper, hasLower, hasDigit, hasSpecial].filter(Boolean).length;
|
|
64
|
+
return classes >= 3;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Redact sensitive patterns from labels/titles: credentials, bearer tokens, long token strings.
|
|
68
|
+
*/
|
|
69
|
+
export function redactSensitiveLabel(label) {
|
|
70
|
+
let result = label;
|
|
71
|
+
for (const [pattern, replacement] of SENSITIVE_LABEL_PATTERNS) {
|
|
72
|
+
result = result.replace(pattern, replacement);
|
|
73
|
+
}
|
|
74
|
+
result = result.replace(/[A-Za-z0-9\-._~+/]{32,}={0,2}/g, (match) => looksLikeToken(match) ? "[TOKEN_REDACTED]" : match);
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Redact PII patterns from text for persistence paths (Option C: redact on write, not on read).
|
|
79
|
+
* Catches: email addresses, phone numbers, and the local user's name parts.
|
|
80
|
+
* Applied to: memory actions/strategies, playbook exports, timeline logs.
|
|
81
|
+
* NOT applied to: live tool responses (ocr, ui_tree, screenshot, browser_dom, world_state).
|
|
82
|
+
*/
|
|
83
|
+
export function redactPII(text) {
|
|
84
|
+
let result = text;
|
|
85
|
+
// Email addresses
|
|
86
|
+
result = result.replace(/[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g, "[EMAIL_REDACTED]");
|
|
87
|
+
// Phone numbers — international and domestic formats
|
|
88
|
+
result = result.replace(/(?:\+\d{1,3}[\s\-]?)?\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}/g, "[PHONE_REDACTED]");
|
|
89
|
+
// Local user name parts (from OS)
|
|
90
|
+
result = redactUsername(result);
|
|
91
|
+
// Credential patterns already handled by redactSensitiveLabel
|
|
92
|
+
result = redactSensitiveLabel(result);
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Redact the macOS username from strings (e.g. menu labels like "Log Out username").
|
|
97
|
+
*/
|
|
98
|
+
let _cachedNameParts = null;
|
|
99
|
+
/** Collect all name parts to redact: short username, full name from id -F, home dir name. */
|
|
100
|
+
function getNameParts() {
|
|
101
|
+
if (_cachedNameParts !== null)
|
|
102
|
+
return _cachedNameParts;
|
|
103
|
+
const parts = new Set();
|
|
104
|
+
// Short username from env
|
|
105
|
+
const username = process.env.USER || process.env.USERNAME || "";
|
|
106
|
+
if (username.length >= 2)
|
|
107
|
+
parts.add(username);
|
|
108
|
+
// Home directory basename (e.g. /Users/khushi → khushi)
|
|
109
|
+
try {
|
|
110
|
+
const home = os.homedir();
|
|
111
|
+
const homeName = home.split("/").pop() || "";
|
|
112
|
+
if (homeName.length >= 2)
|
|
113
|
+
parts.add(homeName);
|
|
114
|
+
}
|
|
115
|
+
catch { /* ignore */ }
|
|
116
|
+
// Full display name from id -F (macOS only)
|
|
117
|
+
try {
|
|
118
|
+
const fullName = execSync("id -F", { encoding: "utf-8", timeout: 2000, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
119
|
+
if (fullName.length >= 2) {
|
|
120
|
+
parts.add(fullName);
|
|
121
|
+
// Also add individual name words (e.g. "khushi goyal" → "khushi", "goyal")
|
|
122
|
+
for (const word of fullName.split(/\s+/)) {
|
|
123
|
+
if (word.length >= 2)
|
|
124
|
+
parts.add(word);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch { /* not macOS or id -F unavailable */ }
|
|
129
|
+
// Sort longest first so longer matches take priority
|
|
130
|
+
_cachedNameParts = [...parts].sort((a, b) => b.length - a.length);
|
|
131
|
+
return _cachedNameParts;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Redact the macOS username, full display name, and individual name parts from strings.
|
|
135
|
+
* Also catches common patterns like "Log Out <name>" where the name may not match env vars.
|
|
136
|
+
*/
|
|
137
|
+
export function redactUsername(text) {
|
|
138
|
+
let result = text;
|
|
139
|
+
for (const part of getNameParts()) {
|
|
140
|
+
result = result.replaceAll(part, "[USER]");
|
|
141
|
+
}
|
|
142
|
+
// Catch "Log Out <name>" pattern — macOS always formats this as "Log Out <full display name>"
|
|
143
|
+
// The name part may already be partially redacted (e.g. "[USER] goyal"), so match everything after "Log Out "
|
|
144
|
+
result = result.replace(/Log Out [^\n:]+/g, "Log Out [USER]");
|
|
145
|
+
return result;
|
|
146
|
+
}
|