@aexol/spectral 0.9.212 → 0.9.215

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 (110) hide show
  1. package/dist/backend/generated-images.d.ts +90 -0
  2. package/dist/backend/generated-images.d.ts.map +1 -0
  3. package/dist/backend/generated-images.js +214 -0
  4. package/dist/backend/machine-routing.d.ts +25 -0
  5. package/dist/backend/machine-routing.d.ts.map +1 -0
  6. package/dist/backend/machine-routing.js +35 -0
  7. package/dist/commands/serve.d.ts.map +1 -1
  8. package/dist/commands/serve.js +20 -2
  9. package/dist/extensions/browser/browser-service.d.ts +75 -1
  10. package/dist/extensions/browser/browser-service.d.ts.map +1 -1
  11. package/dist/extensions/browser/browser-service.js +600 -34
  12. package/dist/extensions/browser/index.d.ts +10 -4
  13. package/dist/extensions/browser/index.d.ts.map +1 -1
  14. package/dist/extensions/browser/index.js +44 -5
  15. package/dist/extensions/browser/stream/stream-tool.d.ts +10 -0
  16. package/dist/extensions/browser/stream/stream-tool.d.ts.map +1 -1
  17. package/dist/extensions/browser/stream/stream-tool.js +42 -14
  18. package/dist/extensions/browser/tools/core.d.ts.map +1 -1
  19. package/dist/extensions/browser/tools/core.js +63 -2
  20. package/dist/extensions/desktop-control/index.d.ts +55 -0
  21. package/dist/extensions/desktop-control/index.d.ts.map +1 -0
  22. package/dist/extensions/desktop-control/index.js +633 -0
  23. package/dist/extensions/desktop-control/keys.d.ts +33 -0
  24. package/dist/extensions/desktop-control/keys.d.ts.map +1 -0
  25. package/dist/extensions/desktop-control/keys.js +170 -0
  26. package/dist/extensions/desktop-control/linux.d.ts +26 -0
  27. package/dist/extensions/desktop-control/linux.d.ts.map +1 -0
  28. package/dist/extensions/desktop-control/linux.js +195 -0
  29. package/dist/extensions/desktop-control/macos.d.ts +53 -0
  30. package/dist/extensions/desktop-control/macos.d.ts.map +1 -0
  31. package/dist/extensions/desktop-control/macos.js +382 -0
  32. package/dist/extensions/desktop-control/safety.d.ts +89 -0
  33. package/dist/extensions/desktop-control/safety.d.ts.map +1 -0
  34. package/dist/extensions/desktop-control/safety.js +157 -0
  35. package/dist/extensions/desktop-control/types.d.ts +79 -0
  36. package/dist/extensions/desktop-control/types.d.ts.map +1 -0
  37. package/dist/extensions/desktop-control/types.js +10 -0
  38. package/dist/extensions/desktop-control/windows.d.ts +31 -0
  39. package/dist/extensions/desktop-control/windows.d.ts.map +1 -0
  40. package/dist/extensions/desktop-control/windows.js +276 -0
  41. package/dist/extensions/desktop-screenshot/index.d.ts +103 -0
  42. package/dist/extensions/desktop-screenshot/index.d.ts.map +1 -1
  43. package/dist/extensions/desktop-screenshot/index.js +363 -17
  44. package/dist/extensions/image-generation/index.d.ts.map +1 -1
  45. package/dist/extensions/image-generation/index.js +133 -34
  46. package/dist/extensions/seo/index.d.ts.map +1 -1
  47. package/dist/extensions/seo/index.js +4 -0
  48. package/dist/extensions/seo/tools/backend-bridge.d.ts +4 -0
  49. package/dist/extensions/seo/tools/backend-bridge.d.ts.map +1 -0
  50. package/dist/extensions/seo/tools/backend-bridge.js +378 -0
  51. package/dist/extensions/seo/tools/full-audit.d.ts +2 -0
  52. package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -1
  53. package/dist/extensions/seo/tools/full-audit.js +1 -1
  54. package/dist/extensions/seo/tools/report.d.ts +10 -0
  55. package/dist/extensions/seo/tools/report.d.ts.map +1 -1
  56. package/dist/extensions/seo/tools/report.js +1 -1
  57. package/dist/extensions/spectral-vision-fallback.d.ts.map +1 -1
  58. package/dist/extensions/spectral-vision-fallback.js +11 -1
  59. package/dist/generated/zeus/const.d.ts.map +1 -1
  60. package/dist/generated/zeus/const.js +121 -4
  61. package/dist/generated/zeus/index.d.ts +527 -0
  62. package/dist/generated/zeus/index.d.ts.map +1 -1
  63. package/dist/mcp/tool-registrar.d.ts.map +1 -1
  64. package/dist/mcp/tool-registrar.js +5 -2
  65. package/dist/mcp/types.d.ts +5 -0
  66. package/dist/mcp/types.d.ts.map +1 -1
  67. package/dist/relay/dispatcher.d.ts +12 -1
  68. package/dist/relay/dispatcher.d.ts.map +1 -1
  69. package/dist/relay/dispatcher.js +44 -42
  70. package/dist/sdk/ai/providers/faux.js +1 -1
  71. package/dist/sdk/ai/providers/openai-completions.d.ts.map +1 -1
  72. package/dist/sdk/ai/providers/openai-completions.js +57 -16
  73. package/dist/sdk/ai/providers/openrouter-images.d.ts.map +1 -1
  74. package/dist/sdk/ai/providers/openrouter-images.js +45 -9
  75. package/dist/sdk/ai/providers/simple-options.d.ts.map +1 -1
  76. package/dist/sdk/ai/providers/simple-options.js +2 -0
  77. package/dist/sdk/ai/types.d.ts +37 -1
  78. package/dist/sdk/ai/types.d.ts.map +1 -1
  79. package/dist/sdk/ai/utils/image-source.d.ts +121 -0
  80. package/dist/sdk/ai/utils/image-source.d.ts.map +1 -0
  81. package/dist/sdk/ai/utils/image-source.js +410 -0
  82. package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
  83. package/dist/sdk/coding-agent/core/extensions/native-extensions.js +14 -0
  84. package/dist/sdk/coding-agent/core/extensions/types.d.ts +9 -0
  85. package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
  86. package/dist/sdk/coding-agent/core/system-prompt.d.ts.map +1 -1
  87. package/dist/sdk/coding-agent/core/system-prompt.js +15 -9
  88. package/dist/sdk/coding-agent/modes/rpc/rpc-mode.d.ts.map +1 -1
  89. package/dist/sdk/coding-agent/modes/rpc/rpc-mode.js +16 -3
  90. package/dist/sdk/coding-agent/utils/image-resize.d.ts.map +1 -1
  91. package/dist/sdk/coding-agent/utils/image-resize.js +7 -3
  92. package/dist/server/agent-bridge.d.ts +14 -0
  93. package/dist/server/agent-bridge.d.ts.map +1 -1
  94. package/dist/server/agent-bridge.js +105 -26
  95. package/dist/server/handlers/queue.d.ts.map +1 -1
  96. package/dist/server/handlers/queue.js +11 -34
  97. package/dist/server/image-attachments.d.ts +109 -0
  98. package/dist/server/image-attachments.d.ts.map +1 -0
  99. package/dist/server/image-attachments.js +195 -0
  100. package/dist/server/session-stream.d.ts +67 -0
  101. package/dist/server/session-stream.d.ts.map +1 -1
  102. package/dist/server/session-stream.js +222 -23
  103. package/dist/server/storage.d.ts.map +1 -1
  104. package/dist/server/storage.js +10 -31
  105. package/dist/server/ui/terminal-confirm.d.ts +145 -0
  106. package/dist/server/ui/terminal-confirm.d.ts.map +1 -0
  107. package/dist/server/ui/terminal-confirm.js +333 -0
  108. package/dist/server/wire.d.ts +30 -5
  109. package/dist/server/wire.d.ts.map +1 -1
  110. package/package.json +1 -1
@@ -0,0 +1,382 @@
1
+ /**
2
+ * macOS adapter for desktop-control.
3
+ *
4
+ * Mouse/keyboard injection via JXA (`osascript -l JavaScript`) using the ObjC
5
+ * bridge to CoreGraphics: CGEventCreateMouseEvent + CGEventPost,
6
+ * CGEventCreateKeyboardEvent for keys, CGEventCreateScrollWheelEvent for
7
+ * scrolling. Numeric enum constants are inlined (kCGEventLeftMouseDown = 1,
8
+ * kCGHIDEventTap = 0, ...) so the script never depends on enum bridging.
9
+ *
10
+ * type_text uses CGEvent key events for ASCII; any non-ASCII text falls back
11
+ * to System Events `keystroke` via AppleScript (handles unicode).
12
+ *
13
+ * Every JXA action ends with a CGEventGetLocation read-back (cursor position)
14
+ * that the safety layer appends to the tool result — CGEventPost itself never
15
+ * errors, so the read-back is how silent injection failures surface.
16
+ *
17
+ * checkPermissions also preflights Screen Recording via
18
+ * CGPreflightScreenCaptureAccess (read-only) so mutating actions fail with
19
+ * fix instructions BEFORE the BEFORE screenshot would silently capture the
20
+ * desktop wallpaper.
21
+ *
22
+ * Accessibility (TCC) check + frontmost app query go through a read-only
23
+ * System Events query: `name of first application process whose frontmost is
24
+ * true`. When accessibility is missing, osascript fails with
25
+ * "osascript is not allowed assistive access" and we return guidance pointing
26
+ * to System Settings > Privacy & Security > Accessibility.
27
+ */
28
+ import { execFile } from "node:child_process";
29
+ import { unlinkSync, writeFileSync } from "node:fs";
30
+ import { tmpdir } from "node:os";
31
+ import { join } from "node:path";
32
+ import { randomUUID } from "node:crypto";
33
+ import { MACOS_KEYCODES, MACOS_MODIFIER_FLAGS, MACOS_SHIFTED, isModifierToken, } from "./keys.js";
34
+ const TIMEOUT_MS = 10_000;
35
+ const JXA_PRELUDE = `ObjC.import('CoreGraphics');
36
+ var HID = 0; // kCGHIDEventTap
37
+ var EVT_MOVED = 5, L_DOWN = 1, L_UP = 2, R_DOWN = 3, R_UP = 4, M_DOWN = 25, M_UP = 26;
38
+ var DRAG = { 1: 6, 2: 7, 25: 27 }; // down event type -> dragged event type
39
+ var FLAG_SHIFT = 131072, FLAG_CTRL = 262144, FLAG_ALT = 524288, FLAG_CMD = 1048576;
40
+ var UNIT_LINE = 1; // kCGScrollEventUnitLine
41
+ var CLICK_STATE = 1; // kCGMouseEventClickState field id
42
+ function mouseEvent(type, x, y, btn, clickState) {
43
+ var ev = $.CGEventCreateMouseEvent($(), type, { x: x, y: y }, btn);
44
+ if (clickState > 1) { $.CGEventSetIntegerValueField(ev, CLICK_STATE, clickState); }
45
+ return ev;
46
+ }
47
+ function keyEvent(code, isDown, flags) {
48
+ var ev = $.CGEventCreateKeyboardEvent($(), code, isDown);
49
+ if (flags) { $.CGEventSetFlags(ev, flags); }
50
+ return ev;
51
+ }
52
+ function post(ev) { $.CGEventPost(HID, ev); }
53
+ function pointer() {
54
+ var p = $.CGEventGetLocation($()); // NULL event -> merged HID state = current cursor location
55
+ var x = Math.round(p.x), y = Math.round(p.y);
56
+ return (isNaN(x) || isNaN(y)) ? "" : x + "," + y; // "" -> caller skips the read-back
57
+ }`;
58
+ const MOUSE_BTN = {
59
+ left: { btn: 0, down: "L_DOWN", up: "L_UP" },
60
+ right: { btn: 1, down: "R_DOWN", up: "R_UP" },
61
+ middle: { btn: 2, down: "M_DOWN", up: "M_UP" },
62
+ };
63
+ const DRAG_STEPS = 10;
64
+ // ---------------------------------------------------------------------------
65
+ // JXA builders (exported for unit tests — pure, never executed by tests)
66
+ // ---------------------------------------------------------------------------
67
+ export function buildMouseJxa(action) {
68
+ switch (action.kind) {
69
+ case "mouse_move":
70
+ return `${JXA_PRELUDE}
71
+ post(mouseEvent(EVT_MOVED, ${action.x}, ${action.y}, 0, 0));
72
+ pointer();`;
73
+ case "mouse_click": {
74
+ const b = MOUSE_BTN[action.button];
75
+ return `${JXA_PRELUDE}
76
+ post(mouseEvent(EVT_MOVED, ${action.x}, ${action.y}, ${b.btn}, 0));
77
+ for (var i = 1; i <= ${action.clickCount}; i++) {
78
+ post(mouseEvent(${b.down}, ${action.x}, ${action.y}, ${b.btn}, i));
79
+ post(mouseEvent(${b.up}, ${action.x}, ${action.y}, ${b.btn}, i));
80
+ }
81
+ pointer();`;
82
+ }
83
+ case "mouse_drag": {
84
+ const b = MOUSE_BTN.left;
85
+ return `${JXA_PRELUDE}
86
+ post(mouseEvent(EVT_MOVED, ${action.fromX}, ${action.fromY}, ${b.btn}, 0));
87
+ post(mouseEvent(${b.down}, ${action.fromX}, ${action.fromY}, ${b.btn}, 1));
88
+ for (var i = 1; i <= ${DRAG_STEPS}; i++) {
89
+ var t = i / ${DRAG_STEPS};
90
+ post(mouseEvent(DRAG[${b.down}], ${action.fromX} + (${action.toX} - ${action.fromX}) * t, ${action.fromY} + (${action.toY} - ${action.fromY}) * t, ${b.btn}, 1));
91
+ }
92
+ post(mouseEvent(${b.up}, ${action.toX}, ${action.toY}, ${b.btn}, 1));
93
+ pointer();`;
94
+ }
95
+ case "scroll": {
96
+ // CG positive wheel values scroll UP, so invert dy; dx positive = right.
97
+ // When x/y are given the cursor is moved there first, so the wheel lands
98
+ // under the requested coordinates instead of wherever the pointer was.
99
+ const wheel1 = -action.dy;
100
+ const wheel2 = action.dx;
101
+ const move = action.x !== undefined
102
+ ? `post(mouseEvent(EVT_MOVED, ${action.x}, ${action.y}, 0, 0));\n`
103
+ : "";
104
+ return `${JXA_PRELUDE}
105
+ ${move}var ev = $.CGEventCreateScrollWheelEvent($(), UNIT_LINE, 2, ${wheel1}, ${wheel2});
106
+ post(ev);
107
+ pointer();`;
108
+ }
109
+ }
110
+ }
111
+ /** Build the key sequence [[keycode, shift], ...] for ASCII text; null when any char is unmappable. */
112
+ export function buildAsciiTypeSeq(text) {
113
+ const seq = [];
114
+ for (const ch of text) {
115
+ if (ch === "\n")
116
+ seq.push([MACOS_KEYCODES.return, false]);
117
+ else if (ch === "\t")
118
+ seq.push([MACOS_KEYCODES.tab, false]);
119
+ else if (ch === " ")
120
+ seq.push([MACOS_KEYCODES.space, false]);
121
+ else if (ch in MACOS_SHIFTED)
122
+ seq.push([MACOS_KEYCODES[MACOS_SHIFTED[ch]], true]);
123
+ else if (ch in MACOS_KEYCODES && !isModifierToken(ch))
124
+ seq.push([MACOS_KEYCODES[ch], false]);
125
+ else
126
+ return null; // non-ASCII or unknown -> caller falls back to AppleScript keystroke
127
+ }
128
+ return seq;
129
+ }
130
+ export function buildTypeJxa(seq) {
131
+ const rows = seq.map(([code, shift]) => `[${code},${shift ? "true" : "false"}]`).join(",");
132
+ return `${JXA_PRELUDE}
133
+ var seq = [${rows}];
134
+ for (var i = 0; i < seq.length; i++) {
135
+ var f = seq[i][1] ? FLAG_SHIFT : 0;
136
+ post(keyEvent(seq[i][0], true, f));
137
+ post(keyEvent(seq[i][0], false, f));
138
+ }
139
+ pointer();`;
140
+ }
141
+ /** Tokens: leading modifiers + exactly one key. Throws on unsupported keys. */
142
+ export function resolveChordTokens(tokens) {
143
+ const flags = tokens.slice(0, -1).reduce((acc, t) => {
144
+ if (!isModifierToken(t))
145
+ throw new Error(`unsupported hotkey token "${t}" (expected modifier)`);
146
+ return acc | MACOS_MODIFIER_FLAGS[t];
147
+ }, 0);
148
+ const keyName = tokens[tokens.length - 1];
149
+ const code = MACOS_KEYCODES[keyName];
150
+ if (code === undefined) {
151
+ throw new Error(`unsupported key "${keyName}" for macOS CGEvent injection (supported: letters, digits, f1-f12, return/tab/space/escape/backspace/delete, arrows, home/end/pageup/pagedown, punctuation)`);
152
+ }
153
+ return { key: code, flags };
154
+ }
155
+ export function buildHotkeyJxa(key, flags) {
156
+ return `${JXA_PRELUDE}
157
+ var codes = [${key}];
158
+ var flags = ${flags};
159
+ for (var i = 0; i < codes.length; i++) { post(keyEvent(codes[i], true, flags)); }
160
+ for (var i = codes.length - 1; i >= 0; i--) { post(keyEvent(codes[i], false, flags)); }
161
+ pointer();`;
162
+ }
163
+ // ---------------------------------------------------------------------------
164
+ // AppleScript builders (System Events — unicode typing + window focus)
165
+ // ---------------------------------------------------------------------------
166
+ function escapeAppleScriptString(s) {
167
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
168
+ }
169
+ /** System Events keystroke; \n -> `return`, \t -> `tab` via concatenation. */
170
+ export function buildKeystrokeAppleScript(text) {
171
+ const segments = text.split("\n").map((seg) => escapeAppleScriptString(seg)
172
+ .split("\t")
173
+ .map((t) => `"${t}"`)
174
+ .join(" & tab & "));
175
+ const expr = segments.map((s) => (s === '""' ? '""' : s)).join(" & return & ");
176
+ return `tell application "System Events"
177
+ \tkeystroke ${expr}
178
+ end tell`;
179
+ }
180
+ export function buildFocusAppleScript(owner, title) {
181
+ const ownerEscaped = escapeAppleScriptString(owner);
182
+ if (!title) {
183
+ return `tell application "System Events"
184
+ \tset frontmost of (first application process whose name is "${ownerEscaped}") to true
185
+ end tell`;
186
+ }
187
+ // System Events windows carry no CGWindowNumber — match the concrete window
188
+ // by title (exact, then case-insensitive substring), AXRaise it, and keep
189
+ // "set frontmost" as the always-available fallback. A missing process fails
190
+ // with a readable error instead of a bare AppleScript -1728.
191
+ const titleEscaped = escapeAppleScriptString(title);
192
+ return `tell application "System Events"
193
+ \tset procs to (application processes whose name is "${ownerEscaped}")
194
+ \tif (count of procs) is 0 then error "no application process named \\"${ownerEscaped}\\" - is it running? (list_windows shows live windows)"
195
+ \ttell item 1 of procs
196
+ \t\tset frontmost to true
197
+ \t\tset matches to (windows whose name is "${titleEscaped}")
198
+ \t\tif (count of matches) is 0 then set matches to (windows whose name contains "${titleEscaped}")
199
+ \t\tif (count of matches) > 0 then perform action "AXRaise" of item 1 of matches
200
+ \tend tell
201
+ end tell`;
202
+ }
203
+ // ---------------------------------------------------------------------------
204
+ // Runners + adapter
205
+ // ---------------------------------------------------------------------------
206
+ function runOsa(args) {
207
+ return new Promise((resolve, reject) => {
208
+ execFile("osascript", args, { timeout: TIMEOUT_MS }, (err, stdout, stderr) => {
209
+ if (err) {
210
+ const tail = String(stderr || err.message).trim().split("\n").pop() ?? err.message;
211
+ reject(new Error(`osascript: ${tail}`));
212
+ }
213
+ else {
214
+ resolve(stdout);
215
+ }
216
+ });
217
+ });
218
+ }
219
+ /** Runs the JXA script from a 0600 temp file; returns trimmed stdout (post-action read-back) or null. */
220
+ async function runJxa(script) {
221
+ const path = join(tmpdir(), `desktop-control-${randomUUID()}.jxa`);
222
+ // 0600: temp scripts may contain typed text; always unlink after execution.
223
+ writeFileSync(path, script, { encoding: "utf-8", mode: 0o600 });
224
+ try {
225
+ const out = await runOsa(["-l", "JavaScript", path]);
226
+ return out.trim() || null;
227
+ }
228
+ finally {
229
+ try {
230
+ unlinkSync(path);
231
+ }
232
+ catch { /* best effort — already gone */ }
233
+ }
234
+ }
235
+ async function runAppleScript(script) {
236
+ const path = join(tmpdir(), `desktop-control-${randomUUID()}.applescript`);
237
+ // 0600: temp scripts may contain typed text; always unlink after execution.
238
+ writeFileSync(path, script, { encoding: "utf-8", mode: 0o600 });
239
+ try {
240
+ await runOsa([path]);
241
+ return null; // AppleScript paths carry no pointer read-back (frontmost before->after covers them)
242
+ }
243
+ finally {
244
+ try {
245
+ unlinkSync(path);
246
+ }
247
+ catch { /* best effort — already gone */ }
248
+ }
249
+ }
250
+ /**
251
+ * Read-only probe: frontmost app via System Events. Doubles as the TCC
252
+ * accessibility/automation check — no input is ever injected by this call.
253
+ */
254
+ function systemEventsProbe() {
255
+ return new Promise((resolve) => {
256
+ execFile("osascript", ["-e", 'tell application "System Events" to name of first application process whose frontmost is true'], { timeout: TIMEOUT_MS }, (err, stdout, stderr) => {
257
+ if (err) {
258
+ resolve({ ok: false, error: accessibilityError(String(stderr || err.message)) });
259
+ }
260
+ else {
261
+ resolve({ ok: true, name: stdout.trim() });
262
+ }
263
+ });
264
+ });
265
+ }
266
+ export function accessibilityError(rawStderr) {
267
+ const s = rawStderr.toLowerCase();
268
+ if (s.includes("assistive access") || s.includes("accessib")) {
269
+ return ("macOS blocked desktop control: Accessibility permission is missing.\n" +
270
+ "Fix: System Settings > Privacy & Security > Accessibility -> add (and enable) the app that runs " +
271
+ "spectral (your terminal, or the Electron host). Then retry. " +
272
+ "You can verify from the terminal with: osascript -e 'tell application \"System Events\" to get name of first process whose frontmost is true'");
273
+ }
274
+ if (s.includes("not allowed to send apple events") || s.includes("-1743") || s.includes("automat")) {
275
+ return ("macOS blocked desktop control: Automation permission is missing (osascript -> System Events).\n" +
276
+ "Fix: System Settings > Privacy & Security > Automation -> allow your terminal/host to control System Events.");
277
+ }
278
+ return `macOS System Events query failed: ${rawStderr.trim().split("\n").pop() ?? "unknown error"}`;
279
+ }
280
+ // ---------------------------------------------------------------------------
281
+ // Screen Recording preflight (read-only)
282
+ // ---------------------------------------------------------------------------
283
+ export const SCREEN_RECORDING_ERROR = "macOS blocked the safety screenshots: Screen Recording permission is missing.\n" +
284
+ "Without it the BEFORE/AFTER captures silently show only the desktop wallpaper, so the action was refused before mutating anything.\n" +
285
+ "Fix: System Settings > Privacy & Security > Screen Recording -> add (and enable) the app that runs " +
286
+ "spectral (your terminal, or the Electron host). Then retry.";
287
+ /**
288
+ * One-shot JXA probe: CGPreflightScreenCaptureAccess() — true when Screen
289
+ * Recording TCC is granted. The function is missing from CoreGraphics'
290
+ * bridgesupport on current macOS, so it is bound explicitly via ObjC.bindFunction.
291
+ */
292
+ export function buildScreenCapturePreflightJxa() {
293
+ return (`ObjC.import('CoreGraphics'); ` +
294
+ `ObjC.bindFunction('CGPreflightScreenCaptureAccess', ['bool', []]); ` +
295
+ `$.CGPreflightScreenCaptureAccess()`);
296
+ }
297
+ /**
298
+ * False only when macOS definitively denies capture; null when the probe is
299
+ * unavailable (e.g. macOS < 10.15) or ambiguous — never block on an unreadable
300
+ * probe, the AFTER screenshot still gives the model feedback in that case.
301
+ */
302
+ async function screenCaptureAllowed() {
303
+ try {
304
+ const out = (await runOsa(["-l", "JavaScript", "-e", buildScreenCapturePreflightJxa()])).trim();
305
+ return out === "true" ? true : out === "false" ? false : null;
306
+ }
307
+ catch {
308
+ return null;
309
+ }
310
+ }
311
+ function plan(action, detail) {
312
+ return `osascript -l JavaScript <tempfile> # JXA CGEvent: ${detail}`;
313
+ }
314
+ export const macosAdapter = {
315
+ platform: "darwin",
316
+ capabilities: ["mouse_move", "mouse_click", "mouse_drag", "scroll", "type_text", "hotkey", "focus_window"],
317
+ async checkPermissions() {
318
+ // Both probes are read-only and run in parallel. checkPermissions is only
319
+ // invoked by the mutating envelope, so the Screen Recording preflight runs
320
+ // exactly where the BEFORE screenshot needs it (and nowhere else).
321
+ const [probe, capture] = await Promise.all([systemEventsProbe(), screenCaptureAllowed()]);
322
+ if (!probe.ok)
323
+ return { ok: false, error: probe.error };
324
+ if (capture === false)
325
+ return { ok: false, error: SCREEN_RECORDING_ERROR };
326
+ return { ok: true };
327
+ },
328
+ async frontmostApp() {
329
+ const probe = await systemEventsProbe();
330
+ return probe.ok ? probe.name ?? null : null;
331
+ },
332
+ buildPlan(action) {
333
+ switch (action.kind) {
334
+ case "mouse_move":
335
+ return plan(action, `CGEventCreateMouseEvent(null, kCGEventMouseMoved, {x:${action.x},y:${action.y}}, 0) + CGEventPost + CGEventGetLocation read-back`);
336
+ case "mouse_click":
337
+ return plan(action, `CGEventCreateMouseEvent(null, kCGEvent${action.button}Down/Up, {x:${action.x},y:${action.y}}, clickState=${action.clickCount}) + CGEventPost + CGEventGetLocation read-back`);
338
+ case "mouse_drag":
339
+ return plan(action, `LeftDown@(${action.fromX},${action.fromY}) + ${DRAG_STEPS}x Dragged -> LeftUp@(${action.toX},${action.toY}) + CGEventPost + CGEventGetLocation read-back`);
340
+ case "scroll":
341
+ return plan(action, `${action.x !== undefined ? `MouseMoved to (${action.x},${action.y}) + ` : ""}` +
342
+ `CGEventCreateScrollWheelEvent(unit=line, wheel1=${-action.dy}, wheel2=${action.dx}) + CGEventPost + CGEventGetLocation read-back`);
343
+ case "type_text": {
344
+ const seq = buildAsciiTypeSeq(action.text);
345
+ return seq
346
+ ? plan(action, `${seq.length}x CGEventCreateKeyboardEvent(keycode+shift flags) + CGEventPost`)
347
+ : `osascript <tempfile> # System Events keystroke (unicode fallback) for ${action.text.length} chars`;
348
+ }
349
+ case "hotkey":
350
+ return plan(action, `CGEventCreateKeyboardEvent(keycode for ${action.tokens.join("+")}) + CGEventSetFlags + CGEventPost`);
351
+ case "focus_window":
352
+ return action.title
353
+ ? `osascript <tempfile> # System Events: set frontmost of process "${action.owner ?? action.title}" + AXRaise on window "${action.title}"`
354
+ : `osascript <tempfile> # System Events: set frontmost of process "${action.owner ?? action.title}" to true`;
355
+ }
356
+ },
357
+ async execute(action) {
358
+ switch (action.kind) {
359
+ case "mouse_move":
360
+ case "mouse_click":
361
+ case "mouse_drag":
362
+ case "scroll":
363
+ return runJxa(buildMouseJxa(action));
364
+ case "type_text": {
365
+ const seq = buildAsciiTypeSeq(action.text);
366
+ if (seq)
367
+ return runJxa(buildTypeJxa(seq));
368
+ return runAppleScript(buildKeystrokeAppleScript(action.text));
369
+ }
370
+ case "hotkey": {
371
+ const { key, flags } = resolveChordTokens(action.tokens);
372
+ return runJxa(buildHotkeyJxa(key, flags));
373
+ }
374
+ case "focus_window": {
375
+ if (!action.owner) {
376
+ throw new Error('focus_window requires a resolved window owner (use list_windows, then retry with an exact title)');
377
+ }
378
+ return runAppleScript(buildFocusAppleScript(action.owner, action.title));
379
+ }
380
+ }
381
+ },
382
+ };
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Safety layer for the desktop-control extension.
3
+ *
4
+ * - Session policy: by DEFAULT there is no confirmation prompt at all —
5
+ * enabling the extension (enabledNativeExtensions contains
6
+ * "desktop-control") is the explicit user consent, and every mutating
7
+ * action runs immediately (including headless/serve/RPC sessions).
8
+ * Opt-in SPECTRAL_DESKTOP_REQUIRE_CONFIRM=1|true restores the interactive
9
+ * confirmation gate: the first mutating call asks once (ctx.hasUI); an
10
+ * explicit user decision applies to the whole session (grant or lockout).
11
+ * AUTO-deny causes — headless, non-TTY terminal, dialog timeout,
12
+ * cancelled/unsupported client — deny only the current action with a
13
+ * precise reason and never lock the session.
14
+ * - Dry-run: SPECTRAL_DESKTOP_DRY_RUN=1|true returns action plans without
15
+ * touching the OS (no injection, no screenshots).
16
+ * - Allowlist: SPECTRAL_DESKTOP_ALLOWLIST="Safari, Code" restricts mutating
17
+ * actions to sessions where the frontmost app (or the focus target's owner)
18
+ * matches one of the comma-separated names (case-insensitive, substring).
19
+ */
20
+ import type { DesktopAction } from "./types.js";
21
+ export declare const DESKTOP_CONTROL_TOOLS: readonly ["desktop_mouse_move", "desktop_mouse_click", "desktop_mouse_drag", "desktop_scroll", "desktop_type_text", "desktop_press_key", "desktop_hotkey", "desktop_focus_window"];
22
+ export type DesktopControlToolName = (typeof DESKTOP_CONTROL_TOOLS)[number];
23
+ export declare function isDesktopControlTool(name: string): boolean;
24
+ export declare function isDryRun(env?: NodeJS.ProcessEnv): boolean;
25
+ /** Opt-in confirmation gate: SPECTRAL_DESKTOP_REQUIRE_CONFIRM=1|true restores the per-session prompt. */
26
+ export declare function isRequireConfirm(env?: NodeJS.ProcessEnv): boolean;
27
+ export declare function parseAllowlist(raw: string | undefined): string[];
28
+ /** Case-insensitive substring match in both directions ("Safari" matches "com.apple.Safari"). */
29
+ export declare function matchesAllowlist(app: string, allowlist: string[]): boolean;
30
+ export interface PolicyDecision {
31
+ allowed: boolean;
32
+ reason?: string;
33
+ }
34
+ /**
35
+ * Why a confirmation dialog ended without an explicit human decision.
36
+ * These are AUTO-deny causes: they deny the current action only and must
37
+ * never lock the whole session (unlike an explicit user denial).
38
+ */
39
+ export type ConfirmFailureKind = "no_tty" | "timeout" | "cancelled";
40
+ export interface ConfirmFailure {
41
+ kind: ConfirmFailureKind;
42
+ /** For kind "timeout": how long the dialog waited before timing out (ms). */
43
+ timeoutMs?: number;
44
+ }
45
+ export interface ConfirmAllowed {
46
+ allowed: true;
47
+ }
48
+ export interface ConfirmDenied {
49
+ allowed: false;
50
+ /**
51
+ * Why the dialog did not end in an explicit human denial. Omit (or return
52
+ * a plain `false`) when a real user answered "No" in an interactive dialog.
53
+ */
54
+ failure?: ConfirmFailure;
55
+ }
56
+ /**
57
+ * Result of the `confirm` callback: a plain boolean (legacy — always treated
58
+ * as an explicit interactive user decision) or a rich result that can report
59
+ * AUTO-deny causes (`failure`) so the session is not locked by them.
60
+ */
61
+ export type ConfirmResult = boolean | ConfirmAllowed | ConfirmDenied;
62
+ export interface AuthorizeInput {
63
+ dryRun: boolean;
64
+ hasUI: boolean;
65
+ /** One-line description of the action for the confirmation dialog. */
66
+ actionSummary: string;
67
+ confirm?: (title: string, message: string) => Promise<ConfirmResult>;
68
+ }
69
+ /** Precise auto-deny message per cause (headless/no-UI are handled in authorize). */
70
+ export declare function autoDenyReason(failure: ConfirmFailure): string;
71
+ /**
72
+ * Session-scoped allow/deny state machine for mutating desktop actions.
73
+ * Pure logic — the confirm callback is injected so tests can stub it.
74
+ * Only used when SPECTRAL_DESKTOP_REQUIRE_CONFIRM is enabled (opt-in).
75
+ *
76
+ * Only an EXPLICIT user denial (the confirm callback reporting a human "No")
77
+ * moves the session into the locked "denied" state. AUTO-deny causes — no UI,
78
+ * non-TTY terminal, dialog timeout, cancelled/unsupported client — deny only
79
+ * the current action (fail-closed) and leave the session undecided.
80
+ */
81
+ export declare class DesktopActionPolicy {
82
+ private state;
83
+ get decided(): boolean;
84
+ get isGranted(): boolean;
85
+ authorize(input: AuthorizeInput): Promise<PolicyDecision>;
86
+ }
87
+ /** One-line human-readable action summary (used in confirm dialogs, plans and result text). */
88
+ export declare function describeAction(action: DesktopAction): string;
89
+ //# sourceMappingURL=safety.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"safety.d.ts","sourceRoot":"","sources":["../../../src/extensions/desktop-control/safety.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,eAAO,MAAM,qBAAqB,oLASxB,CAAC;AAEX,MAAM,MAAM,sBAAsB,GAAG,CAAC,OAAO,qBAAqB,CAAC,CAAC,MAAM,CAAC,CAAC;AAE5E,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAE1D;AAED,wBAAgB,QAAQ,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CAGtE;AAED,yGAAyG;AACzG,wBAAgB,gBAAgB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CAG9E;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAGhE;AAED,iGAAiG;AACjG,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,OAAO,CAO1E;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,GAAG,QAAQ,GAAG,SAAS,GAAG,WAAW,CAAC;AAEpE,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,kBAAkB,CAAC;IACzB,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,IAAI,CAAC;CACf;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,KAAK,CAAC;IACf;;;OAGG;IACH,OAAO,CAAC,EAAE,cAAc,CAAC;CAC1B;AAED;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,cAAc,GAAG,aAAa,CAAC;AAErE,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,OAAO,CAAC;IAChB,KAAK,EAAE,OAAO,CAAC;IACf,sEAAsE;IACtE,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,aAAa,CAAC,CAAC;CACtE;AAQD,qFAAqF;AACrF,wBAAgB,cAAc,CAAC,OAAO,EAAE,cAAc,GAAG,MAAM,CAmB9D;AAED;;;;;;;;;GASG;AACH,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,KAAK,CAAmD;IAEhE,IAAI,OAAO,IAAI,OAAO,CAErB;IAED,IAAI,SAAS,IAAI,OAAO,CAEvB;IAEK,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;CA8ChE;AAOD,+FAA+F;AAC/F,wBAAgB,cAAc,CAAC,MAAM,EAAE,aAAa,GAAG,MAAM,CAiB5D"}
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Safety layer for the desktop-control extension.
3
+ *
4
+ * - Session policy: by DEFAULT there is no confirmation prompt at all —
5
+ * enabling the extension (enabledNativeExtensions contains
6
+ * "desktop-control") is the explicit user consent, and every mutating
7
+ * action runs immediately (including headless/serve/RPC sessions).
8
+ * Opt-in SPECTRAL_DESKTOP_REQUIRE_CONFIRM=1|true restores the interactive
9
+ * confirmation gate: the first mutating call asks once (ctx.hasUI); an
10
+ * explicit user decision applies to the whole session (grant or lockout).
11
+ * AUTO-deny causes — headless, non-TTY terminal, dialog timeout,
12
+ * cancelled/unsupported client — deny only the current action with a
13
+ * precise reason and never lock the session.
14
+ * - Dry-run: SPECTRAL_DESKTOP_DRY_RUN=1|true returns action plans without
15
+ * touching the OS (no injection, no screenshots).
16
+ * - Allowlist: SPECTRAL_DESKTOP_ALLOWLIST="Safari, Code" restricts mutating
17
+ * actions to sessions where the frontmost app (or the focus target's owner)
18
+ * matches one of the comma-separated names (case-insensitive, substring).
19
+ */
20
+ export const DESKTOP_CONTROL_TOOLS = [
21
+ "desktop_mouse_move",
22
+ "desktop_mouse_click",
23
+ "desktop_mouse_drag",
24
+ "desktop_scroll",
25
+ "desktop_type_text",
26
+ "desktop_press_key",
27
+ "desktop_hotkey",
28
+ "desktop_focus_window",
29
+ ];
30
+ export function isDesktopControlTool(name) {
31
+ return DESKTOP_CONTROL_TOOLS.includes(name);
32
+ }
33
+ export function isDryRun(env = process.env) {
34
+ const v = env.SPECTRAL_DESKTOP_DRY_RUN;
35
+ return v === "1" || v === "true";
36
+ }
37
+ /** Opt-in confirmation gate: SPECTRAL_DESKTOP_REQUIRE_CONFIRM=1|true restores the per-session prompt. */
38
+ export function isRequireConfirm(env = process.env) {
39
+ const v = env.SPECTRAL_DESKTOP_REQUIRE_CONFIRM;
40
+ return v === "1" || v === "true";
41
+ }
42
+ export function parseAllowlist(raw) {
43
+ if (!raw)
44
+ return [];
45
+ return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
46
+ }
47
+ /** Case-insensitive substring match in both directions ("Safari" matches "com.apple.Safari"). */
48
+ export function matchesAllowlist(app, allowlist) {
49
+ if (allowlist.length === 0)
50
+ return true;
51
+ const a = app.toLowerCase();
52
+ return allowlist.some((entry) => {
53
+ const n = entry.toLowerCase();
54
+ return a.includes(n) || n.includes(a);
55
+ });
56
+ }
57
+ const USER_DENIED_REASON = "User denied desktop control for this session. Start a new session to ask again, or set SPECTRAL_DESKTOP_DRY_RUN=1 to preview action plans without executing.";
58
+ const NOT_LOCKED_SUFFIX = "The current action was denied (fail-closed), but the session is not locked — the next desktop action will ask for confirmation again.";
59
+ /** Precise auto-deny message per cause (headless/no-UI are handled in authorize). */
60
+ export function autoDenyReason(failure) {
61
+ switch (failure.kind) {
62
+ case "no_tty":
63
+ return ("Desktop control was not confirmed: the confirmation terminal is not interactive (stdin is not a TTY), so no user could answer the prompt. " +
64
+ NOT_LOCKED_SUFFIX +
65
+ " Run `spectral serve` in an interactive terminal, or set SPECTRAL_DESKTOP_DRY_RUN=1 to preview action plans without executing.");
66
+ case "timeout":
67
+ return (`Desktop control was not confirmed: the confirmation dialog timed out after ${failure.timeoutMs ?? "the configured"} ms (SPECTRAL_CONFIRM_TIMEOUT_MS) without a user answer. ` +
68
+ NOT_LOCKED_SUFFIX);
69
+ case "cancelled":
70
+ return ("Desktop control was not confirmed: the confirmation dialog was cancelled or is not supported by the client (e.g. RPC mode), so no user decision was made. " +
71
+ NOT_LOCKED_SUFFIX);
72
+ }
73
+ }
74
+ /**
75
+ * Session-scoped allow/deny state machine for mutating desktop actions.
76
+ * Pure logic — the confirm callback is injected so tests can stub it.
77
+ * Only used when SPECTRAL_DESKTOP_REQUIRE_CONFIRM is enabled (opt-in).
78
+ *
79
+ * Only an EXPLICIT user denial (the confirm callback reporting a human "No")
80
+ * moves the session into the locked "denied" state. AUTO-deny causes — no UI,
81
+ * non-TTY terminal, dialog timeout, cancelled/unsupported client — deny only
82
+ * the current action (fail-closed) and leave the session undecided.
83
+ */
84
+ export class DesktopActionPolicy {
85
+ state = "undecided";
86
+ get decided() {
87
+ return this.state !== "undecided";
88
+ }
89
+ get isGranted() {
90
+ return this.state === "granted";
91
+ }
92
+ async authorize(input) {
93
+ if (input.dryRun)
94
+ return { allowed: true, reason: "dry-run" };
95
+ if (this.state === "granted")
96
+ return { allowed: true };
97
+ if (this.state === "denied") {
98
+ return {
99
+ allowed: false,
100
+ reason: "Desktop control was denied by the user for this session. Re-enable it in settings (enabledNativeExtensions) or start a new session to ask again.",
101
+ };
102
+ }
103
+ if (!input.hasUI) {
104
+ return {
105
+ allowed: false,
106
+ reason: "Desktop control is denied in headless mode (no interactive UI to confirm real input injection). " +
107
+ NOT_LOCKED_SUFFIX +
108
+ " Run `spectral serve` in an interactive terminal, or set SPECTRAL_DESKTOP_DRY_RUN=1 to preview action plans without executing.",
109
+ };
110
+ }
111
+ if (!input.confirm) {
112
+ return {
113
+ allowed: false,
114
+ reason: "No confirmation UI available for desktop control. " +
115
+ NOT_LOCKED_SUFFIX +
116
+ " Run spectral in a mode with an interactive UI, or set SPECTRAL_DESKTOP_DRY_RUN=1 to preview action plans without executing.",
117
+ };
118
+ }
119
+ const result = await input.confirm("Desktop control", `Allow REAL input injection: ${input.actionSummary}?\n` +
120
+ "The action runs on your desktop and returns before/after screenshots. Granting it applies to the whole session.");
121
+ if (result === true || (typeof result === "object" && result.allowed)) {
122
+ this.state = "granted";
123
+ return { allowed: true };
124
+ }
125
+ const failure = typeof result === "object" ? result.failure : undefined;
126
+ if (!failure) {
127
+ // Explicit human denial in an interactive dialog — locks the session.
128
+ this.state = "denied";
129
+ return { allowed: false, reason: USER_DENIED_REASON };
130
+ }
131
+ // Auto-deny (no human decision): fail closed for this action only.
132
+ return { allowed: false, reason: autoDenyReason(failure) };
133
+ }
134
+ }
135
+ function truncate(s, max) {
136
+ const oneLine = s.replace(/\r?\n/g, "\\n").replace(/\t/g, "\\t");
137
+ return oneLine.length > max ? oneLine.slice(0, max - 1) + "…" : oneLine;
138
+ }
139
+ /** One-line human-readable action summary (used in confirm dialogs, plans and result text). */
140
+ export function describeAction(action) {
141
+ switch (action.kind) {
142
+ case "mouse_move":
143
+ return `mouse_move x=${action.x} y=${action.y}`;
144
+ case "mouse_click":
145
+ return `mouse_click x=${action.x} y=${action.y} button=${action.button} clickCount=${action.clickCount}`;
146
+ case "mouse_drag":
147
+ return `mouse_drag from=(${action.fromX},${action.fromY}) to=(${action.toX},${action.toY})`;
148
+ case "scroll":
149
+ return `scroll dy=${action.dy} dx=${action.dx}${action.x !== undefined ? ` at=(${action.x},${action.y})` : ""}`;
150
+ case "type_text":
151
+ return `type_text "${truncate(action.text, 80)}" (${action.text.length} chars)`;
152
+ case "hotkey":
153
+ return `hotkey ${action.tokens.join("+")}`;
154
+ case "focus_window":
155
+ return `focus_window "${action.title}"`;
156
+ }
157
+ }