@moxxy/plugin-computer-control 0.26.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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +30 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +43 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/shell.d.ts +56 -0
  7. package/dist/shell.d.ts.map +1 -0
  8. package/dist/shell.js +189 -0
  9. package/dist/shell.js.map +1 -0
  10. package/dist/tools/applescript.d.ts +2 -0
  11. package/dist/tools/applescript.d.ts.map +1 -0
  12. package/dist/tools/applescript.js +38 -0
  13. package/dist/tools/applescript.js.map +1 -0
  14. package/dist/tools/click.d.ts +2 -0
  15. package/dist/tools/click.d.ts.map +1 -0
  16. package/dist/tools/click.js +50 -0
  17. package/dist/tools/click.js.map +1 -0
  18. package/dist/tools/clipboard.d.ts +2 -0
  19. package/dist/tools/clipboard.d.ts.map +1 -0
  20. package/dist/tools/clipboard.js +62 -0
  21. package/dist/tools/clipboard.js.map +1 -0
  22. package/dist/tools/key.d.ts +11 -0
  23. package/dist/tools/key.d.ts.map +1 -0
  24. package/dist/tools/key.js +135 -0
  25. package/dist/tools/key.js.map +1 -0
  26. package/dist/tools/open.d.ts +2 -0
  27. package/dist/tools/open.d.ts.map +1 -0
  28. package/dist/tools/open.js +76 -0
  29. package/dist/tools/open.js.map +1 -0
  30. package/dist/tools/screenshot.d.ts +2 -0
  31. package/dist/tools/screenshot.d.ts.map +1 -0
  32. package/dist/tools/screenshot.js +162 -0
  33. package/dist/tools/screenshot.js.map +1 -0
  34. package/dist/tools/type.d.ts +8 -0
  35. package/dist/tools/type.d.ts.map +1 -0
  36. package/dist/tools/type.js +57 -0
  37. package/dist/tools/type.js.map +1 -0
  38. package/package.json +65 -0
  39. package/skills/computer-control.md +159 -0
  40. package/src/index.ts +55 -0
  41. package/src/shell.test.ts +186 -0
  42. package/src/shell.ts +213 -0
  43. package/src/tools/applescript-serialize.test.ts +74 -0
  44. package/src/tools/applescript.ts +41 -0
  45. package/src/tools/click.ts +52 -0
  46. package/src/tools/clipboard.ts +63 -0
  47. package/src/tools/key.ts +147 -0
  48. package/src/tools/open.ts +81 -0
  49. package/src/tools/screenshot.ts +181 -0
  50. package/src/tools/type.ts +60 -0
  51. package/src/tools.test.ts +128 -0
@@ -0,0 +1,159 @@
1
+ ---
2
+ name: computer-control
3
+ description: Drive the user's Mac (mouse, keyboard, screenshot, clipboard, app launch) when the task can't be done with files/web alone.
4
+ triggers:
5
+ - "click on"
6
+ - "click the"
7
+ - "take a screenshot"
8
+ - "screenshot the"
9
+ - "screen capture"
10
+ - "screen shot"
11
+ - "open the app"
12
+ - "open app"
13
+ - "launch app"
14
+ - "switch to"
15
+ - "type into"
16
+ - "type this"
17
+ - "paste this"
18
+ - "what's on my screen"
19
+ - "what is on screen"
20
+ - "show me the screen"
21
+ - "control my computer"
22
+ - "automate"
23
+ - "for me on the screen"
24
+ - "use my mac"
25
+ - "drive the ui"
26
+ allowed-tools:
27
+ - computer_screenshot
28
+ - computer_click
29
+ - computer_type
30
+ - computer_key
31
+ - computer_open
32
+ - computer_clipboard
33
+ - computer_applescript
34
+ ---
35
+
36
+ # Computer control (macOS)
37
+
38
+ When the task requires driving the user's actual desktop — clicking a UI
39
+ button, typing into an open app, taking a screenshot, launching software —
40
+ use the `computer_*` tools. Each one prompts for permission **every time**;
41
+ the user explicitly approves each action. There is no "allow always" for
42
+ these by design.
43
+
44
+ ## macOS permission prerequisites
45
+
46
+ On first use the user will see a system dialog from macOS itself. Tell them
47
+ which one to expect:
48
+
49
+ - **Screen Recording** — required by `computer_screenshot`. Grant in System
50
+ Settings → Privacy & Security → Screen Recording.
51
+ - **Accessibility** — required by `computer_click`, `computer_type`,
52
+ `computer_key`, and most `computer_applescript` snippets that touch UI.
53
+ Grant in System Settings → Privacy & Security → Accessibility.
54
+
55
+ If a tool returns "(check Accessibility permission)" or "(check Screen
56
+ Recording permission)" in its error, surface that message verbatim and
57
+ stop — don't loop on the same failing call.
58
+
59
+ ## The standard loop: see → act → verify
60
+
61
+ Almost every UI automation follows this rhythm. Do it explicitly:
62
+
63
+ 1. **See** — call `computer_screenshot` to capture the current state.
64
+ Look at the image, identify the target element, note its pixel
65
+ coordinates from the top-left.
66
+ 2. **Act** — `computer_click` / `computer_type` / `computer_key` on the
67
+ coordinates / focused field.
68
+ 3. **Verify** — `computer_screenshot` again, confirm the expected
69
+ change. If not, diagnose before retrying.
70
+
71
+ **Do NOT skip the verify.** A 200ms animation, a popup, or a focus shift
72
+ can silently break the next step. The agent that screenshots after every
73
+ action is the agent that doesn't accidentally type a password into the
74
+ wrong field.
75
+
76
+ ## Tool reference (quick)
77
+
78
+ ```
79
+ computer_screenshot({ region?, maxDim?, format?, quality? })
80
+ → { mediaType, base64, byteLength, maxDim, format }
81
+ Default: full screen → 1280px JPEG @ q72 (~150 KB).
82
+ Override `maxDim`/`format`/`quality` only when you need pixel detail —
83
+ context-cost climbs fast for large/PNG images.
84
+
85
+ computer_click({ x, y, count? }) # count: 1=single, 2=double, 3=triple
86
+
87
+ computer_type({ text }) # types into whatever has focus
88
+ # CLICK FIRST to set focus
89
+
90
+ computer_key({ key, modifiers? }) # key: "a", "tab", "return", "f5", ...
91
+ # modifiers: ["cmd","shift","option","control"]
92
+
93
+ computer_open({ target?, app? }) # app: "Safari", target: URL or path
94
+
95
+ computer_clipboard({ action: "read" })
96
+ computer_clipboard({ action: "write", text })
97
+
98
+ computer_applescript({ script }) # escape hatch — anything else
99
+ ```
100
+
101
+ ## Common patterns
102
+
103
+ **Take a screenshot and describe it:**
104
+ ```
105
+ 1. computer_screenshot({})
106
+ 2. Look at the image — describe the active app, visible windows, any errors
107
+ ```
108
+
109
+ **Open an app and click a known button:**
110
+ ```
111
+ 1. computer_open({ app: "Safari" })
112
+ 2. (wait a moment for activation)
113
+ 3. computer_screenshot({}) # find the button's coordinates
114
+ 4. computer_click({ x: ..., y: ... })
115
+ 5. computer_screenshot({}) # verify
116
+ ```
117
+
118
+ **Paste text into the focused field:**
119
+ ```
120
+ 1. computer_clipboard({ action: "write", text: "..." })
121
+ 2. computer_key({ key: "v", modifiers: ["cmd"] })
122
+ ```
123
+
124
+ **Get the frontmost app name (via the escape hatch):**
125
+ ```
126
+ computer_applescript({
127
+ script: 'tell application "System Events" to get name of first application process whose frontmost is true'
128
+ })
129
+ ```
130
+
131
+ ## Don't
132
+
133
+ - **Don't click without screenshotting first.** Coordinates change between
134
+ turns; a button moves when the window resizes. One screenshot per
135
+ action group is the minimum.
136
+ - **Don't type into "focus" you didn't set.** `computer_type` sends keys
137
+ to whatever currently has keyboard focus. Click the target field first
138
+ (or call `computer_key` with cmd+l to focus an address bar, etc.).
139
+ - **Don't loop on a failed click.** If a click "succeeded" (exit 0) but
140
+ the next screenshot shows nothing changed, the coordinates were wrong.
141
+ Re-screenshot, re-find the target, try again — but stop after two
142
+ failed attempts and explain to the user.
143
+ - **Don't use computer_key for typing words.** `computer_key({ key: "h" })`
144
+ sends one keystroke. Use `computer_type({ text: "hello" })` instead.
145
+ - **Don't paste passwords / API keys via clipboard if the user has a
146
+ password manager.** Suggest they trigger the manager instead. The
147
+ clipboard is observable by every app.
148
+ - **Don't run open-ended `computer_applescript` snippets when a
149
+ dedicated tool fits.** The escape hatch is for the long tail.
150
+ - **Don't take screenshots the user didn't ask for.** Each one captures
151
+ whatever happens to be on screen — including messages, notifications,
152
+ unrelated windows. Take one when you need pixels for an action, not
153
+ out of curiosity.
154
+
155
+ ## Platforms other than macOS
156
+
157
+ This plugin currently only supports macOS. On Linux/Windows the tools
158
+ register but each handler throws `currently only supports macOS`. Tell
159
+ the user that explicitly instead of looping on failures.
package/src/index.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { definePlugin, type Plugin, type ToolDef } from '@moxxy/sdk';
2
+ import { IS_DARWIN } from './shell.js';
3
+ import { applescriptTool } from './tools/applescript.js';
4
+ import { clickTool } from './tools/click.js';
5
+ import { clipboardTool } from './tools/clipboard.js';
6
+ import { keyTool } from './tools/key.js';
7
+ import { openTool } from './tools/open.js';
8
+ import { screenshotTool } from './tools/screenshot.js';
9
+ import { typeTool } from './tools/type.js';
10
+
11
+ export {
12
+ applescriptTool,
13
+ clickTool,
14
+ clipboardTool,
15
+ keyTool,
16
+ openTool,
17
+ screenshotTool,
18
+ typeTool,
19
+ };
20
+
21
+ export const computerControlTools: ReadonlyArray<ToolDef> = [
22
+ screenshotTool,
23
+ clickTool,
24
+ typeTool,
25
+ keyTool,
26
+ openTool,
27
+ clipboardTool,
28
+ applescriptTool,
29
+ ];
30
+
31
+ /**
32
+ * `@moxxy/plugin-computer-control` — programmatic control of the host
33
+ * computer (mouse, keyboard, screenshot, clipboard, app launching,
34
+ * AppleScript escape hatch).
35
+ *
36
+ * Currently macOS-only: every tool shells out to built-in binaries
37
+ * (`screencapture`, `osascript`, `open`, `pbpaste`, `pbcopy`). On any
38
+ * other platform the plugin still registers — the tools' handlers
39
+ * throw a clear "macOS only" error — so the model's tool list stays
40
+ * stable across hosts (avoids "tool disappeared on Linux" confusion).
41
+ *
42
+ * Every tool is `permission: 'prompt'`. There is intentionally no
43
+ * "allow always" shortcut for these — granting blanket permission to
44
+ * drive the user's screen + keyboard is exactly the wrong default.
45
+ */
46
+ export const computerControlPlugin: Plugin = definePlugin({
47
+ name: '@moxxy/plugin-computer-control',
48
+ version: '0.0.0',
49
+ tools: [...computerControlTools],
50
+ });
51
+
52
+ export default computerControlPlugin;
53
+
54
+ // Re-export for callers that want a runtime gate.
55
+ export { IS_DARWIN };
@@ -0,0 +1,186 @@
1
+ import { MoxxyError } from '@moxxy/sdk';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { ensureDarwin, MAX_OUTPUT_BYTES, procFailureCause, runProcess } from './shell.js';
4
+
5
+ describe('runProcess stdout capture (chunked, O(n) concat-at-close)', () => {
6
+ it('captures the full stdout across many small chunks', async () => {
7
+ // A child that writes 5000 lines one at a time — exercises the multi-chunk
8
+ // 'data' path that used to re-copy the whole buffer per event.
9
+ const script =
10
+ "for (let i = 0; i < 5000; i++) { process.stdout.write('line ' + i + '\\n'); }";
11
+ const res = await runProcess(process.execPath, ['-e', script], { timeoutMs: 30_000 });
12
+
13
+ expect(res.exitCode).toBe(0);
14
+ const lines = res.stdout.split('\n');
15
+ // 5000 lines + a trailing '' from the final newline.
16
+ expect(lines).toHaveLength(5001);
17
+ expect(lines[0]).toBe('line 0');
18
+ expect(lines[4999]).toBe('line 4999');
19
+ expect(lines[5000]).toBe('');
20
+ });
21
+
22
+ it('captures binary-ish/multibyte stdout without corrupting it', async () => {
23
+ const script = "process.stdout.write('héllo 🦊 wörld');";
24
+ const res = await runProcess(process.execPath, ['-e', script], { timeoutMs: 30_000 });
25
+ expect(res.stdout).toBe('héllo 🦊 wörld');
26
+ });
27
+
28
+ it('writes input to stdin and reads it back', async () => {
29
+ const script =
30
+ "let s=''; process.stdin.on('data', d => s += d); process.stdin.on('end', () => process.stdout.write(s.toUpperCase()));";
31
+ const res = await runProcess(process.execPath, ['-e', script], { input: 'hello' });
32
+ expect(res.stdout).toBe('HELLO');
33
+ });
34
+
35
+ it('does not crash (EPIPE) when the child exits before stdin is fully written', async () => {
36
+ // The child exits immediately WITHOUT reading stdin; writing a large
37
+ // buffer to its closed stdin pipe emits 'error' (EPIPE) on the Writable.
38
+ // With no 'error' listener that throws as an unhandled rejection that can
39
+ // take down the parent — the swallow handler must keep us alive.
40
+ const big = 'x'.repeat(2 * 1024 * 1024);
41
+ const res = await runProcess(process.execPath, ['-e', 'process.exit(0)'], {
42
+ input: big,
43
+ timeoutMs: 30_000,
44
+ });
45
+ // We don't assert on stdout here — only that the call settles without an
46
+ // uncaught EPIPE. The child's own exit code is what matters.
47
+ expect(res.exitCode).toBe(0);
48
+ });
49
+ });
50
+
51
+ describe('runProcess exit/stderr/abort/timeout', () => {
52
+ afterEach(() => {
53
+ vi.useRealTimers();
54
+ });
55
+
56
+ it('captures stderr and maps a non-zero exit code', async () => {
57
+ const script = "process.stderr.write('boom'); process.exit(3);";
58
+ const res = await runProcess(process.execPath, ['-e', script], { timeoutMs: 30_000 });
59
+ expect(res.exitCode).toBe(3);
60
+ expect(res.stderr).toBe('boom');
61
+ expect(res.stdout).toBe('');
62
+ // A genuine non-zero exit is neither a timeout nor an abort.
63
+ expect(res.timedOut).toBe(false);
64
+ expect(res.aborted).toBe(false);
65
+ expect(procFailureCause(res, 30_000)).toBe('');
66
+ });
67
+
68
+ it('captures multibyte stderr without corruption (chunk-boundary safe)', async () => {
69
+ // A flood of 3-byte glyphs forces the stream to split a UTF-8 sequence
70
+ // mid-character across 'data' chunk boundaries (the ~8 KB pipe chunk size
71
+ // is not a multiple of 3, so a boundary lands inside a glyph). Decoding
72
+ // each chunk in isolation — the pre-fix behavior — emits replacement chars
73
+ // (U+FFFD); concat-at-close preserves the exact bytes. This matters for
74
+ // osascript/sips error text with non-ASCII app names.
75
+ // Exit only AFTER the write fully drains so the pipe isn't truncated by a
76
+ // premature process.exit (a harness artifact, not the behavior under test).
77
+ const count = 20000;
78
+ const script = `process.stderr.write('世'.repeat(${count}), () => process.exit(1));`;
79
+ const res = await runProcess(process.execPath, ['-e', script], { timeoutMs: 30_000 });
80
+ expect(res.exitCode).toBe(1);
81
+ expect(res.stderr).toBe('世'.repeat(count));
82
+ expect(res.stderr).not.toContain('�');
83
+ });
84
+
85
+ it('does not spawn and resolves as aborted when the signal is ALREADY aborted', async () => {
86
+ // DOM semantics: addEventListener('abort', ...) on an already-aborted
87
+ // signal never fires — so without an up-front check a cancelled turn would
88
+ // still run the child to completion. The child here would write a sentinel
89
+ // and exit 0 if it ran; we assert it never did.
90
+ const ctrl = new AbortController();
91
+ ctrl.abort();
92
+ const script = "process.stdout.write('SHOULD_NOT_RUN'); process.exit(0);";
93
+ const res = await runProcess(process.execPath, ['-e', script], {
94
+ signal: ctrl.signal,
95
+ timeoutMs: 30_000,
96
+ });
97
+ expect(res.aborted).toBe(true);
98
+ expect(res.stdout).toBe('');
99
+ expect(res.exitCode).toBe(-1);
100
+ expect(res.timedOut).toBe(false);
101
+ expect(res.tooLarge).toBe(false);
102
+ expect(procFailureCause(res)).toBe('aborted (turn cancelled)');
103
+ });
104
+
105
+ it('rejects when the binary does not exist (spawn error)', async () => {
106
+ await expect(
107
+ runProcess('definitely-not-a-real-binary-xyz', [], { timeoutMs: 30_000 }),
108
+ ).rejects.toThrow();
109
+ });
110
+
111
+ it('kills the child when the AbortSignal fires', async () => {
112
+ const ctrl = new AbortController();
113
+ // A child that would otherwise run for a long time.
114
+ const script = 'setTimeout(() => {}, 60_000);';
115
+ const promise = runProcess(process.execPath, ['-e', script], { signal: ctrl.signal });
116
+ // Give the child a tick to spawn, then abort.
117
+ await new Promise((r) => setTimeout(r, 50));
118
+ ctrl.abort();
119
+ const res = await promise;
120
+ // SIGTERM-killed child resolves via 'close' with a null code -> -1.
121
+ expect(res.exitCode).toBe(-1);
122
+ // ...but the `aborted` flag distinguishes it from a genuine -1 exit.
123
+ expect(res.aborted).toBe(true);
124
+ expect(res.timedOut).toBe(false);
125
+ expect(procFailureCause(res)).toBe('aborted (turn cancelled)');
126
+ });
127
+
128
+ it('kills the child when timeoutMs elapses', async () => {
129
+ const script = 'setTimeout(() => {}, 60_000);';
130
+ const res = await runProcess(process.execPath, ['-e', script], { timeoutMs: 100 });
131
+ expect(res.exitCode).toBe(-1);
132
+ // A timed-out child is flagged so callers can report a precise cause
133
+ // rather than a confusing bare `exit -1`.
134
+ expect(res.timedOut).toBe(true);
135
+ expect(res.aborted).toBe(false);
136
+ expect(res.tooLarge).toBe(false);
137
+ expect(procFailureCause(res, 100)).toBe('timed out after 100ms');
138
+ });
139
+
140
+ it('force-kills a child that floods stdout past MAX_OUTPUT_BYTES instead of OOMing', async () => {
141
+ // A runaway child that writes an unbounded stream. The byte cap must kill
142
+ // it well before it can accumulate gigabytes in the parent; we cap the
143
+ // child's own attempt above MAX_OUTPUT_BYTES so the test also terminates
144
+ // on its own if the cap somehow fails to fire.
145
+ const target = MAX_OUTPUT_BYTES + 4 * 1024 * 1024;
146
+ const script = `
147
+ const chunk = Buffer.alloc(1024 * 1024, 0x61);
148
+ let sent = 0;
149
+ const target = ${target};
150
+ function pump() {
151
+ while (sent < target) {
152
+ sent += chunk.length;
153
+ if (!process.stdout.write(chunk)) { process.stdout.once('drain', pump); return; }
154
+ }
155
+ }
156
+ pump();
157
+ `;
158
+ const res = await runProcess(process.execPath, ['-e', script], { timeoutMs: 30_000 });
159
+ expect(res.tooLarge).toBe(true);
160
+ // We STOP retaining chunks the instant the cap is tripped, so the captured
161
+ // output never exceeds the cap plus one already-buffered chunk — it does
162
+ // NOT keep growing toward `target` while the child is being killed.
163
+ expect(Buffer.byteLength(res.stdout, 'utf8')).toBeLessThanOrEqual(
164
+ MAX_OUTPUT_BYTES + 1024 * 1024,
165
+ );
166
+ expect(res.timedOut).toBe(false);
167
+ expect(procFailureCause(res, 30_000)).toContain('output exceeded');
168
+ });
169
+ });
170
+
171
+ describe('ensureDarwin', () => {
172
+ it('matches the current platform contract', () => {
173
+ if (process.platform === 'darwin') {
174
+ expect(() => ensureDarwin('screenshot')).not.toThrow();
175
+ } else {
176
+ expect(() => ensureDarwin('screenshot')).toThrow(MoxxyError);
177
+ try {
178
+ ensureDarwin('screenshot');
179
+ } catch (err) {
180
+ expect(err).toBeInstanceOf(MoxxyError);
181
+ expect((err as MoxxyError).code).toBe('TOOL_ERROR');
182
+ expect((err as MoxxyError).message).toContain('screenshot');
183
+ }
184
+ }
185
+ });
186
+ });
package/src/shell.ts ADDED
@@ -0,0 +1,213 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { MoxxyError } from '@moxxy/sdk';
3
+
4
+ export const IS_DARWIN = process.platform === 'darwin';
5
+
6
+ /**
7
+ * Hard ceiling on combined stdout+stderr a child may emit before it is
8
+ * force-killed. The wall-clock timeout caps duration but not throughput —
9
+ * a runaway or hostile child (e.g. `pbpaste` on a gigabyte clipboard, or an
10
+ * `osascript` snippet that streams unbounded output) can accumulate hundreds
11
+ * of MB well inside the timeout and OOM the host. 16 MB is far above any
12
+ * legitimate tool output here (the screenshot path caps its own encoded file
13
+ * separately) while still bounding worst-case memory.
14
+ */
15
+ export const MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
16
+
17
+ /** Grace period after SIGTERM before escalating to SIGKILL. */
18
+ const SIGKILL_GRACE_MS = 2_500;
19
+
20
+ export interface ProcResult {
21
+ readonly exitCode: number;
22
+ readonly stdout: string;
23
+ readonly stderr: string;
24
+ /**
25
+ * True when the child was force-killed because it exceeded `timeoutMs`.
26
+ * A timed-out child still resolves (via 'close') with `exitCode: -1`, so
27
+ * this flag is the only reliable way for callers to distinguish a timeout
28
+ * from a genuine non-zero exit and surface an actionable message.
29
+ */
30
+ readonly timedOut: boolean;
31
+ /** True when the child was force-killed because `opts.signal` aborted. */
32
+ readonly aborted: boolean;
33
+ /**
34
+ * True when the child was force-killed because its combined stdout+stderr
35
+ * exceeded `MAX_OUTPUT_BYTES`. The captured output is whatever had been
36
+ * read up to the cap.
37
+ */
38
+ readonly tooLarge: boolean;
39
+ }
40
+
41
+ /**
42
+ * Build a uniform failure suffix that names a timeout/abort when the process
43
+ * was force-killed, so a stuck `osascript` (etc.) is reported as a clear
44
+ * cause rather than a bare `exit -1`. Returns '' for a normal exit.
45
+ */
46
+ export function procFailureCause(proc: ProcResult, timeoutMs?: number): string {
47
+ if (proc.tooLarge) return `output exceeded ${MAX_OUTPUT_BYTES} bytes (killed)`;
48
+ if (proc.timedOut) {
49
+ return timeoutMs ? `timed out after ${timeoutMs}ms` : 'timed out';
50
+ }
51
+ if (proc.aborted) return 'aborted (turn cancelled)';
52
+ return '';
53
+ }
54
+
55
+ /**
56
+ * Spawn a process with array-form args (no shell). Returns stdout +
57
+ * stderr + exit code. Optional `input` is written to stdin. Optional
58
+ * `signal` propagates aborts from the tool ctx so a stuck `osascript`
59
+ * dies with the turn instead of hanging the parent.
60
+ *
61
+ * Never use this with string interpolation into a single command —
62
+ * each argument MUST be a separate array entry. The `bash -c` shape
63
+ * would re-introduce the shell-injection risk this helper exists to
64
+ * eliminate.
65
+ */
66
+ export function runProcess(
67
+ cmd: string,
68
+ args: ReadonlyArray<string>,
69
+ opts: {
70
+ readonly input?: string | Buffer;
71
+ readonly signal?: AbortSignal;
72
+ readonly timeoutMs?: number;
73
+ } = {},
74
+ ): Promise<ProcResult> {
75
+ return new Promise((resolve, reject) => {
76
+ // An ALREADY-aborted signal never emits 'abort' to a listener added after
77
+ // the fact (DOM semantics), so without this short-circuit a cancelled turn
78
+ // would still spawn the child and run it to completion — defeating the
79
+ // abort contract. Resolve immediately as an aborted no-op without spawning.
80
+ if (opts.signal?.aborted) {
81
+ resolve({
82
+ exitCode: -1,
83
+ stdout: '',
84
+ stderr: '',
85
+ timedOut: false,
86
+ aborted: true,
87
+ tooLarge: false,
88
+ });
89
+ return;
90
+ }
91
+ const child = spawn(cmd, [...args], { stdio: ['pipe', 'pipe', 'pipe'] });
92
+ // Collect BOTH stdout and stderr as chunks and concat ONCE at close.
93
+ // Re-concatenating the whole accumulated buffer on every 'data' event is
94
+ // O(n^2) and churns the GC; decoding each chunk with `.toString('utf8')`
95
+ // in isolation also corrupts any multibyte sequence (e.g. an osascript
96
+ // error containing a non-ASCII app name) that straddles a chunk boundary.
97
+ const stdoutChunks: Buffer[] = [];
98
+ const stderrChunks: Buffer[] = [];
99
+ let outputBytes = 0;
100
+ let settled = false;
101
+ let timedOut = false;
102
+ let aborted = false;
103
+ let tooLarge = false;
104
+ let killTimer: ReturnType<typeof setTimeout> | null = null;
105
+
106
+ // Force the child to die: SIGTERM first, then escalate to SIGKILL after a
107
+ // grace period for a child that traps/ignores SIGTERM (a wedged GUI helper
108
+ // or an osascript stuck behind an Accessibility prompt). Without the
109
+ // escalation the tool's timeout/abort isn't actually enforced — the parent
110
+ // hangs until the child closes on its own, which may be never.
111
+ const forceKill = (): void => {
112
+ try {
113
+ child.kill('SIGTERM');
114
+ } catch {
115
+ /* ignore */
116
+ }
117
+ if (killTimer) return;
118
+ killTimer = setTimeout(() => {
119
+ if (settled) return;
120
+ try {
121
+ child.kill('SIGKILL');
122
+ } catch {
123
+ /* ignore */
124
+ }
125
+ }, SIGKILL_GRACE_MS);
126
+ killTimer.unref?.();
127
+ };
128
+
129
+ const onAbort = (): void => {
130
+ if (settled) return;
131
+ aborted = true;
132
+ forceKill();
133
+ };
134
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
135
+
136
+ const timer = opts.timeoutMs
137
+ ? setTimeout(() => {
138
+ if (settled) return;
139
+ timedOut = true;
140
+ forceKill();
141
+ }, opts.timeoutMs)
142
+ : null;
143
+
144
+ child.stdout.on('data', (chunk: Buffer) => {
145
+ outputBytes += chunk.length;
146
+ // Once over the cap we stop retaining further chunks (the child is being
147
+ // killed) so a flood between the cap-trip and SIGTERM landing can't keep
148
+ // growing memory; we still account the bytes to keep `outputBytes` honest.
149
+ if (!tooLarge) stdoutChunks.push(chunk);
150
+ if (!tooLarge && outputBytes > MAX_OUTPUT_BYTES) {
151
+ tooLarge = true;
152
+ forceKill();
153
+ }
154
+ });
155
+ child.stderr.on('data', (chunk: Buffer) => {
156
+ outputBytes += chunk.length;
157
+ if (!tooLarge) stderrChunks.push(chunk);
158
+ if (!tooLarge && outputBytes > MAX_OUTPUT_BYTES) {
159
+ tooLarge = true;
160
+ forceKill();
161
+ }
162
+ });
163
+ // A child that exits/closes stdin before our write completes makes the
164
+ // stdin Writable emit 'error' (EPIPE/ECONNRESET). With no listener that
165
+ // throws and surfaces as an unhandled rejection that can take down the
166
+ // parent — swallow it; the child 'error'/'close' path reports the real
167
+ // failure. Same defensiveness on stdout/stderr for symmetry.
168
+ child.stdin.on('error', () => {});
169
+ child.stdout.on('error', () => {});
170
+ child.stderr.on('error', () => {});
171
+ const cleanup = (): void => {
172
+ if (timer) clearTimeout(timer);
173
+ if (killTimer) clearTimeout(killTimer);
174
+ opts.signal?.removeEventListener('abort', onAbort);
175
+ };
176
+ child.once('error', (err) => {
177
+ if (settled) return;
178
+ settled = true;
179
+ cleanup();
180
+ reject(err);
181
+ });
182
+ child.once('close', (code) => {
183
+ if (settled) return;
184
+ settled = true;
185
+ cleanup();
186
+ resolve({
187
+ exitCode: code ?? -1,
188
+ stdout: Buffer.concat(stdoutChunks).toString('utf8'),
189
+ stderr: Buffer.concat(stderrChunks).toString('utf8'),
190
+ timedOut,
191
+ aborted,
192
+ tooLarge,
193
+ });
194
+ });
195
+
196
+ if (opts.input !== undefined) {
197
+ child.stdin.end(opts.input);
198
+ } else {
199
+ child.stdin.end();
200
+ }
201
+ });
202
+ }
203
+
204
+ /** Throw a clear error when a tool is invoked on a non-darwin host. */
205
+ export function ensureDarwin(toolName: string): void {
206
+ if (!IS_DARWIN) {
207
+ throw new MoxxyError({
208
+ code: 'TOOL_ERROR',
209
+ message: `${toolName}: @moxxy/plugin-computer-control currently only supports macOS (process.platform = ${process.platform})`,
210
+ context: { tool: toolName, platform: process.platform },
211
+ });
212
+ }
213
+ }
@@ -0,0 +1,74 @@
1
+ import { MoxxyError } from '@moxxy/sdk';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { buildKeyScript } from './key.js';
4
+ import { toAppleScriptString } from './type.js';
5
+
6
+ describe('toAppleScriptString', () => {
7
+ it('wraps a plain string in quotes', () => {
8
+ expect(toAppleScriptString('hello')).toBe('"hello"');
9
+ });
10
+
11
+ it('escapes embedded double-quotes and backslashes', () => {
12
+ // `a"b` -> a\"b ; backslashes are doubled.
13
+ expect(toAppleScriptString('a"b')).toBe('"a\\"b"');
14
+ expect(toAppleScriptString('a\\b')).toBe('"a\\\\b"');
15
+ // A backslash followed by a quote: both escaped, order preserved.
16
+ expect(toAppleScriptString('\\"')).toBe('"\\\\\\""');
17
+ });
18
+
19
+ it('splits newlines into separate literals joined by AppleScript return', () => {
20
+ expect(toAppleScriptString('a\nb')).toBe('"a" & return & "b"');
21
+ expect(toAppleScriptString('one\ntwo\nthree')).toBe(
22
+ '"one" & return & "two" & return & "three"',
23
+ );
24
+ });
25
+ });
26
+
27
+ describe('buildKeyScript', () => {
28
+ it('uses key code for named keys with no modifiers', () => {
29
+ expect(buildKeyScript('return', [])).toBe('tell application "System Events" to key code 36');
30
+ expect(buildKeyScript('escape', [])).toBe('tell application "System Events" to key code 53');
31
+ });
32
+
33
+ it('is case-insensitive for named keys', () => {
34
+ expect(buildKeyScript('Return', [])).toBe('tell application "System Events" to key code 36');
35
+ });
36
+
37
+ it('appends a using-clause mapping short modifier names to AppleScript', () => {
38
+ expect(buildKeyScript('return', ['cmd'])).toBe(
39
+ 'tell application "System Events" to key code 36 using {command down}',
40
+ );
41
+ expect(buildKeyScript('c', ['cmd', 'shift'])).toBe(
42
+ 'tell application "System Events" to keystroke "c" using {command down, shift down}',
43
+ );
44
+ expect(buildKeyScript('a', ['option', 'control'])).toBe(
45
+ 'tell application "System Events" to keystroke "a" using {option down, control down}',
46
+ );
47
+ });
48
+
49
+ it('uses keystroke for single characters and escapes them', () => {
50
+ expect(buildKeyScript('/', [])).toBe('tell application "System Events" to keystroke "/"');
51
+ expect(buildKeyScript('"', [])).toBe('tell application "System Events" to keystroke "\\""');
52
+ });
53
+
54
+ it('routes a single literal whitespace char to its key code (chord-safe)', () => {
55
+ // ctrl+space must press the Space key, not type a space char with control
56
+ // held — so it goes through `key code 49`, not `keystroke " "`.
57
+ expect(buildKeyScript(' ', ['control'])).toBe(
58
+ 'tell application "System Events" to key code 49 using {control down}',
59
+ );
60
+ expect(buildKeyScript('\t', [])).toBe('tell application "System Events" to key code 48');
61
+ expect(buildKeyScript('\r', [])).toBe('tell application "System Events" to key code 36');
62
+ expect(buildKeyScript('\n', [])).toBe('tell application "System Events" to key code 36');
63
+ });
64
+
65
+ it('throws a TOOL_ERROR for an unknown multi-char key', () => {
66
+ expect(() => buildKeyScript('notakey', [])).toThrow(MoxxyError);
67
+ try {
68
+ buildKeyScript('notakey', []);
69
+ } catch (err) {
70
+ expect((err as MoxxyError).code).toBe('TOOL_ERROR');
71
+ expect((err as MoxxyError).message).toContain('unknown key "notakey"');
72
+ }
73
+ });
74
+ });