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.
Files changed (241) hide show
  1. package/README.md +193 -109
  2. package/bin/darwin-arm64/macos-bridge +0 -0
  3. package/dist/mcp-desktop.js +5876 -0
  4. package/dist/scripts/codex-monitor-daemon.js +335 -0
  5. package/dist/scripts/export-help-center.js +112 -0
  6. package/dist/scripts/marketing-loop.js +117 -0
  7. package/dist/scripts/observer-daemon.js +288 -0
  8. package/dist/scripts/orchestrator-daemon.js +399 -0
  9. package/dist/scripts/supervisor-daemon.js +272 -0
  10. package/dist/scripts/threads-campaign.js +208 -0
  11. package/dist/scripts/worker-daemon.js +228 -0
  12. package/dist/src/agent/cli.js +82 -0
  13. package/dist/src/agent/loop.js +274 -0
  14. package/dist/src/community/fetcher.js +109 -0
  15. package/dist/src/community/index.js +6 -0
  16. package/dist/src/community/publisher.js +191 -0
  17. package/dist/src/community/remote-api.js +121 -0
  18. package/dist/src/community/types.js +3 -0
  19. package/dist/src/community/validator.js +95 -0
  20. package/{src/config.ts → dist/src/config.js} +5 -10
  21. package/dist/src/context-tracker.js +489 -0
  22. package/{src/index.ts → dist/src/index.js} +32 -52
  23. package/dist/src/ingestion/coverage-auditor.js +233 -0
  24. package/dist/src/ingestion/doc-parser.js +164 -0
  25. package/dist/src/ingestion/index.js +8 -0
  26. package/dist/src/ingestion/menu-scanner.js +152 -0
  27. package/dist/src/ingestion/reference-merger.js +186 -0
  28. package/dist/src/ingestion/shortcut-extractor.js +180 -0
  29. package/dist/src/ingestion/tutorial-extractor.js +170 -0
  30. package/dist/src/ingestion/types.js +3 -0
  31. package/dist/src/jobs/manager.js +305 -0
  32. package/dist/src/jobs/runner.js +806 -0
  33. package/dist/src/jobs/store.js +102 -0
  34. package/dist/src/jobs/types.js +30 -0
  35. package/dist/src/jobs/worker.js +97 -0
  36. package/dist/src/learning/engine.js +356 -0
  37. package/dist/src/learning/index.js +9 -0
  38. package/dist/src/learning/locator-policy.js +120 -0
  39. package/dist/src/learning/pattern-policy.js +89 -0
  40. package/dist/src/learning/recovery-policy.js +116 -0
  41. package/dist/src/learning/sensor-policy.js +115 -0
  42. package/dist/src/learning/timing-model.js +204 -0
  43. package/dist/src/learning/topology-policy.js +90 -0
  44. package/dist/src/learning/types.js +9 -0
  45. package/dist/src/logging/timeline-logger.js +48 -0
  46. package/dist/src/mcp/mcp-stdio-server.js +464 -0
  47. package/dist/src/mcp/server.js +363 -0
  48. package/dist/src/mcp-entry.js +60 -0
  49. package/dist/src/memory/playbook-seeds.js +200 -0
  50. package/dist/src/memory/recall.js +222 -0
  51. package/dist/src/memory/research.js +104 -0
  52. package/dist/src/memory/seeds.js +101 -0
  53. package/dist/src/memory/service.js +446 -0
  54. package/dist/src/memory/session.js +169 -0
  55. package/dist/src/memory/store.js +451 -0
  56. package/{src/runtime/locator-cache.ts → dist/src/memory/types.js} +1 -17
  57. package/dist/src/monitor/codex-monitor.js +382 -0
  58. package/dist/src/monitor/task-queue.js +97 -0
  59. package/dist/src/monitor/types.js +62 -0
  60. package/dist/src/native/bridge-client.js +412 -0
  61. package/{src/native/macos-bridge-client.ts → dist/src/native/macos-bridge-client.js} +0 -1
  62. package/dist/src/observer/state.js +199 -0
  63. package/dist/src/observer/types.js +43 -0
  64. package/dist/src/orchestrator/state.js +68 -0
  65. package/dist/src/orchestrator/types.js +22 -0
  66. package/dist/src/perception/ax-source.js +162 -0
  67. package/dist/src/perception/cdp-source.js +162 -0
  68. package/dist/src/perception/coordinator.js +771 -0
  69. package/dist/src/perception/frame-differ.js +287 -0
  70. package/dist/src/perception/index.js +22 -0
  71. package/dist/src/perception/manager.js +199 -0
  72. package/dist/src/perception/types.js +47 -0
  73. package/dist/src/perception/vision-source.js +399 -0
  74. package/dist/src/planner/deterministic.js +298 -0
  75. package/dist/src/planner/executor.js +870 -0
  76. package/dist/src/planner/goal-store.js +92 -0
  77. package/dist/src/planner/index.js +21 -0
  78. package/dist/src/planner/planner.js +520 -0
  79. package/dist/src/planner/tool-registry.js +71 -0
  80. package/dist/src/planner/types.js +22 -0
  81. package/dist/src/platform/explorer.js +213 -0
  82. package/dist/src/platform/help-center-markdown.js +527 -0
  83. package/dist/src/platform/learner.js +257 -0
  84. package/dist/src/playbook/engine.js +486 -0
  85. package/dist/src/playbook/index.js +20 -0
  86. package/dist/src/playbook/mcp-recorder.js +204 -0
  87. package/dist/src/playbook/recorder.js +536 -0
  88. package/dist/src/playbook/runner.js +408 -0
  89. package/dist/src/playbook/store.js +312 -0
  90. package/dist/src/playbook/types.js +17 -0
  91. package/dist/src/recovery/detectors.js +156 -0
  92. package/dist/src/recovery/engine.js +327 -0
  93. package/dist/src/recovery/index.js +20 -0
  94. package/dist/src/recovery/strategies.js +274 -0
  95. package/dist/src/recovery/types.js +20 -0
  96. package/dist/src/runtime/accessibility-adapter.js +430 -0
  97. package/dist/src/runtime/app-adapter.js +64 -0
  98. package/dist/src/runtime/applescript-adapter.js +305 -0
  99. package/dist/src/runtime/ax-role-map.js +96 -0
  100. package/dist/src/runtime/browser-adapter.js +52 -0
  101. package/dist/src/runtime/cdp-chrome-adapter.js +521 -0
  102. package/dist/src/runtime/composite-adapter.js +221 -0
  103. package/dist/src/runtime/execution-contract.js +159 -0
  104. package/dist/src/runtime/executor.js +286 -0
  105. package/dist/src/runtime/locator-cache.js +50 -0
  106. package/dist/src/runtime/planning-loop.js +63 -0
  107. package/dist/src/runtime/service.js +432 -0
  108. package/dist/src/runtime/session-manager.js +63 -0
  109. package/dist/src/runtime/state-observer.js +121 -0
  110. package/dist/src/runtime/vision-adapter.js +225 -0
  111. package/dist/src/state/app-map-types.js +72 -0
  112. package/dist/src/state/app-map.js +1974 -0
  113. package/dist/src/state/entity-tracker.js +108 -0
  114. package/dist/src/state/fusion.js +96 -0
  115. package/dist/src/state/index.js +21 -0
  116. package/dist/src/state/ladder-generator.js +236 -0
  117. package/dist/src/state/persistence.js +156 -0
  118. package/dist/src/state/types.js +17 -0
  119. package/dist/src/state/world-model.js +1456 -0
  120. package/dist/src/supervisor/locks.js +186 -0
  121. package/dist/src/supervisor/supervisor.js +403 -0
  122. package/dist/src/supervisor/types.js +30 -0
  123. package/dist/src/test-mcp-protocol.js +154 -0
  124. package/dist/src/types.js +17 -0
  125. package/dist/src/util/atomic-write.js +133 -0
  126. package/dist/src/util/sanitize.js +146 -0
  127. package/dist-app-maps/com.figma.Desktop.json +959 -0
  128. package/dist-app-maps/com.hnc.Discord.json +1146 -0
  129. package/dist-app-maps/notion.id.json +2831 -0
  130. package/dist-playbooks/canva-screenhand-carousel.json +445 -0
  131. package/dist-playbooks/codex-desktop.json +76 -0
  132. package/dist-playbooks/competitor-research-stack.json +122 -0
  133. package/dist-playbooks/davinci-color-grade.json +153 -0
  134. package/dist-playbooks/davinci-edit-timeline.json +162 -0
  135. package/dist-playbooks/davinci-render.json +114 -0
  136. package/dist-playbooks/devto.json +52 -0
  137. package/dist-playbooks/discord.json +41 -0
  138. package/dist-playbooks/google-flow-create-project.json +59 -0
  139. package/dist-playbooks/google-flow-edit-image.json +90 -0
  140. package/dist-playbooks/google-flow-edit-video.json +90 -0
  141. package/dist-playbooks/google-flow-generate-image.json +68 -0
  142. package/dist-playbooks/google-flow-generate-video.json +191 -0
  143. package/dist-playbooks/google-flow-open-project.json +48 -0
  144. package/dist-playbooks/google-flow-open-scenebuilder.json +64 -0
  145. package/dist-playbooks/google-flow-search-assets.json +64 -0
  146. package/dist-playbooks/instagram.json +57 -0
  147. package/dist-playbooks/linkedin.json +52 -0
  148. package/dist-playbooks/n8n.json +43 -0
  149. package/dist-playbooks/reddit.json +52 -0
  150. package/dist-playbooks/threads.json +59 -0
  151. package/dist-playbooks/x-twitter.json +59 -0
  152. package/dist-playbooks/youtube.json +59 -0
  153. package/dist-references/canva.json +646 -0
  154. package/dist-references/codex-desktop.json +305 -0
  155. package/dist-references/davinci-resolve-keyboard.json +594 -0
  156. package/dist-references/davinci-resolve-menu-map.json +1139 -0
  157. package/dist-references/davinci-resolve-menus-batch1.json +116 -0
  158. package/dist-references/davinci-resolve-menus-batch2.json +372 -0
  159. package/dist-references/davinci-resolve-menus-batch3.json +330 -0
  160. package/dist-references/davinci-resolve-menus-batch4.json +297 -0
  161. package/dist-references/davinci-resolve-shortcuts.json +333 -0
  162. package/dist-references/devto.json +317 -0
  163. package/dist-references/discord.json +549 -0
  164. package/dist-references/figma.json +1186 -0
  165. package/dist-references/finder.json +146 -0
  166. package/dist-references/google-ads-transparency.json +95 -0
  167. package/dist-references/google-flow.json +649 -0
  168. package/dist-references/instagram.json +341 -0
  169. package/dist-references/linkedin.json +324 -0
  170. package/dist-references/meta-ad-library.json +86 -0
  171. package/dist-references/n8n.json +387 -0
  172. package/dist-references/notes.json +27 -0
  173. package/dist-references/notion.json +163 -0
  174. package/dist-references/reddit.json +341 -0
  175. package/dist-references/threads.json +337 -0
  176. package/dist-references/x-twitter.json +403 -0
  177. package/dist-references/youtube.json +373 -0
  178. package/native/macos-bridge/Package.swift +1 -0
  179. package/native/macos-bridge/Sources/AccessibilityBridge.swift +257 -36
  180. package/native/macos-bridge/Sources/AppManagement.swift +212 -2
  181. package/native/macos-bridge/Sources/CoreGraphicsBridge.swift +348 -53
  182. package/native/macos-bridge/Sources/StreamCapture.swift +136 -0
  183. package/native/macos-bridge/Sources/VisionBridge.swift +165 -7
  184. package/native/macos-bridge/Sources/main.swift +169 -16
  185. package/native/windows-bridge/Program.cs +5 -0
  186. package/native/windows-bridge/ScreenCapture.cs +124 -0
  187. package/package.json +29 -4
  188. package/scripts/postinstall.cjs +127 -0
  189. package/.claude/commands/automate.md +0 -28
  190. package/.claude/commands/debug-ui.md +0 -19
  191. package/.claude/commands/screenshot.md +0 -15
  192. package/.github/FUNDING.yml +0 -1
  193. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -27
  194. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
  195. package/.mcp.json +0 -8
  196. package/DESKTOP_MCP_GUIDE.md +0 -92
  197. package/SECURITY.md +0 -44
  198. package/docs/architecture.md +0 -47
  199. package/install-skills.sh +0 -19
  200. package/mcp-bridge.ts +0 -271
  201. package/mcp-desktop.ts +0 -1221
  202. package/playbooks/instagram.json +0 -41
  203. package/playbooks/instagram_v2.json +0 -201
  204. package/playbooks/x_v1.json +0 -211
  205. package/scripts/devpost-live-loop.mjs +0 -421
  206. package/src/logging/timeline-logger.ts +0 -55
  207. package/src/mcp/server.ts +0 -449
  208. package/src/memory/recall.ts +0 -191
  209. package/src/memory/research.ts +0 -146
  210. package/src/memory/seeds.ts +0 -123
  211. package/src/memory/session.ts +0 -201
  212. package/src/memory/store.ts +0 -434
  213. package/src/memory/types.ts +0 -69
  214. package/src/native/bridge-client.ts +0 -239
  215. package/src/runtime/accessibility-adapter.ts +0 -487
  216. package/src/runtime/app-adapter.ts +0 -169
  217. package/src/runtime/applescript-adapter.ts +0 -376
  218. package/src/runtime/ax-role-map.ts +0 -102
  219. package/src/runtime/browser-adapter.ts +0 -129
  220. package/src/runtime/cdp-chrome-adapter.ts +0 -676
  221. package/src/runtime/composite-adapter.ts +0 -274
  222. package/src/runtime/executor.ts +0 -396
  223. package/src/runtime/planning-loop.ts +0 -81
  224. package/src/runtime/service.ts +0 -448
  225. package/src/runtime/session-manager.ts +0 -50
  226. package/src/runtime/state-observer.ts +0 -136
  227. package/src/runtime/vision-adapter.ts +0 -297
  228. package/src/types.ts +0 -297
  229. package/tests/bridge-client.test.ts +0 -176
  230. package/tests/browser-stealth.test.ts +0 -210
  231. package/tests/composite-adapter.test.ts +0 -64
  232. package/tests/mcp-server.test.ts +0 -151
  233. package/tests/memory-recall.test.ts +0 -339
  234. package/tests/memory-research.test.ts +0 -159
  235. package/tests/memory-seeds.test.ts +0 -120
  236. package/tests/memory-store.test.ts +0 -392
  237. package/tests/types.test.ts +0 -92
  238. package/tsconfig.check.json +0 -17
  239. package/tsconfig.json +0 -19
  240. package/vitest.config.ts +0 -8
  241. /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
+ }