@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.
- package/LICENSE +21 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/shell.d.ts +56 -0
- package/dist/shell.d.ts.map +1 -0
- package/dist/shell.js +189 -0
- package/dist/shell.js.map +1 -0
- package/dist/tools/applescript.d.ts +2 -0
- package/dist/tools/applescript.d.ts.map +1 -0
- package/dist/tools/applescript.js +38 -0
- package/dist/tools/applescript.js.map +1 -0
- package/dist/tools/click.d.ts +2 -0
- package/dist/tools/click.d.ts.map +1 -0
- package/dist/tools/click.js +50 -0
- package/dist/tools/click.js.map +1 -0
- package/dist/tools/clipboard.d.ts +2 -0
- package/dist/tools/clipboard.d.ts.map +1 -0
- package/dist/tools/clipboard.js +62 -0
- package/dist/tools/clipboard.js.map +1 -0
- package/dist/tools/key.d.ts +11 -0
- package/dist/tools/key.d.ts.map +1 -0
- package/dist/tools/key.js +135 -0
- package/dist/tools/key.js.map +1 -0
- package/dist/tools/open.d.ts +2 -0
- package/dist/tools/open.d.ts.map +1 -0
- package/dist/tools/open.js +76 -0
- package/dist/tools/open.js.map +1 -0
- package/dist/tools/screenshot.d.ts +2 -0
- package/dist/tools/screenshot.d.ts.map +1 -0
- package/dist/tools/screenshot.js +162 -0
- package/dist/tools/screenshot.js.map +1 -0
- package/dist/tools/type.d.ts +8 -0
- package/dist/tools/type.d.ts.map +1 -0
- package/dist/tools/type.js +57 -0
- package/dist/tools/type.js.map +1 -0
- package/package.json +65 -0
- package/skills/computer-control.md +159 -0
- package/src/index.ts +55 -0
- package/src/shell.test.ts +186 -0
- package/src/shell.ts +213 -0
- package/src/tools/applescript-serialize.test.ts +74 -0
- package/src/tools/applescript.ts +41 -0
- package/src/tools/click.ts +52 -0
- package/src/tools/clipboard.ts +63 -0
- package/src/tools/key.ts +147 -0
- package/src/tools/open.ts +81 -0
- package/src/tools/screenshot.ts +181 -0
- package/src/tools/type.ts +60 -0
- 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
|
+
});
|