@vashp2029/glovebox 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/LICENSE +21 -0
- package/README.md +238 -2
- package/SECURITY.md +81 -0
- package/bin/glovebox.mjs +319 -0
- package/docs/agents.md +124 -0
- package/docs/api.md +146 -0
- package/docs/backend-protocol.md +118 -0
- package/docs/cli.md +150 -0
- package/docs/design.md +212 -0
- package/docs/recipes.md +162 -0
- package/docs/roadmap.md +167 -0
- package/docs/testing.md +79 -0
- package/native/macos/GloveboxHelper.swift +1654 -0
- package/package.json +28 -4
- package/skills/glovebox/SKILL.md +49 -0
- package/src/api.mjs +552 -0
- package/src/axformat.mjs +158 -0
- package/src/backends/index.mjs +27 -0
- package/src/backends/macos.mjs +106 -0
- package/src/config.mjs +92 -0
- package/src/daemon.mjs +127 -0
- package/src/geometry.mjs +38 -0
- package/src/kernel.mjs +217 -0
- package/src/keys.mjs +96 -0
- package/src/macos-helper.mjs +164 -0
- package/src/mcp.mjs +126 -0
- package/src/session.mjs +110 -0
- package/src/smoke.mjs +160 -0
- package/src/transform.mjs +131 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Backend protocol
|
|
2
|
+
|
|
3
|
+
A backend is the native piece for one OS. The scripting layer talks to it with
|
|
4
|
+
JSON lines: one request `{"id", "method", "params"}` per line, one reply
|
|
5
|
+
`{"id", "result"}` or `{"id", "error": {"message", "code"}}`, and unsolicited
|
|
6
|
+
events `{"event": "escape"}`. On macOS the transport is the helper's Unix
|
|
7
|
+
socket; a new backend may use a socket, a named pipe, or stdio.
|
|
8
|
+
|
|
9
|
+
All coordinates are global screen points with a top-left origin. The
|
|
10
|
+
scripting layer owns the image-pixel mapping: it picks each window's image
|
|
11
|
+
size and converts targets to screen points before calling the backend. The
|
|
12
|
+
one place a backend sees image dimensions is `screenshot`, which receives the
|
|
13
|
+
`width` and `height` to capture at and must return an image of exactly that
|
|
14
|
+
size. A backend does not choose a scale of its own.
|
|
15
|
+
|
|
16
|
+
The backend enforces approval: every method that names a `pid` (or launches
|
|
17
|
+
an app) must pass the policy in `config.json` before it acts, and refuses with
|
|
18
|
+
a coded error otherwise. Methods that only read the app list or the backend's
|
|
19
|
+
own state are not gated: `hello`, `ping`, `permissions`, `listApps`,
|
|
20
|
+
`resolveApp`, `forget`, `cursor`. See `design.md` and `SECURITY.md`.
|
|
21
|
+
|
|
22
|
+
| Method | Params | Result |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| `hello` | `client` (name shown in dialogs and the menu) | `{version, platform, pid}` |
|
|
25
|
+
| `ping` | | `{version, platform, pid, background}` |
|
|
26
|
+
| `permissions` | `prompt`, `inputMonitoring` | `{accessibility, screenRecording, escapeWatcher, inputMonitoring}` |
|
|
27
|
+
| `listApps` | | `[{name, bundleId, pid?, path?, running, active?}]` |
|
|
28
|
+
| `resolveApp` | `query` (name, bundle id, `pid:N`, path) | one app |
|
|
29
|
+
| `launch` | `bundleId`, `path`, `name`, `activate` | the running app |
|
|
30
|
+
| `activate` | `pid` | |
|
|
31
|
+
| `isActive` | `pid` | `{active}` |
|
|
32
|
+
| `windows` | `pid` | `[{index, title, frame: [x,y,w,h], scale, focused, main, minimized, subrole?, windowId?}]` |
|
|
33
|
+
| `axTree` | `pid`, `window`, `maxNodes`, `maxDepth`, `maxText`, `menus` | `{window, nodes, truncated}` |
|
|
34
|
+
| `hitTest` | `pid`, `x`, `y` | `{index, role, actions, frame}` or `{}` |
|
|
35
|
+
| `elementFrame` | `pid`, `index` | `{frame}` |
|
|
36
|
+
| `actionNames` | `pid`, `index` | `{actions}` |
|
|
37
|
+
| `performAction` | `pid`, `index`, `action` | |
|
|
38
|
+
| `setValue` | `pid`, `index`, `value`, `attribute?` | |
|
|
39
|
+
| `focusElement` | `pid`, `index` | |
|
|
40
|
+
| `selectText` | `pid`, `index`, `start`, `length` | |
|
|
41
|
+
| `keys` | `pid`, `chords: [{key, mods}]`, input options | input result |
|
|
42
|
+
| `type` | `pid`, `text`, input options | input result |
|
|
43
|
+
| `click` | `pid`, `x`, `y`, `button`, `count`, `mods`, input options | input result |
|
|
44
|
+
| `scroll` | `pid`, `x`, `y`, `dy`, `dx`, input options | input result |
|
|
45
|
+
| `drag` | `pid`, `path: [[x, y], ...]`, `button`, input options | input result |
|
|
46
|
+
| `move` | `pid` (for the gate), `x`, `y` | |
|
|
47
|
+
| `cursor` | `enabled`, `hide`, `x`, `y`, `windowId` | `{enabled}` |
|
|
48
|
+
| `makeKey` | `pid`, `windowId` | `{keyed}` |
|
|
49
|
+
| `screenshot` | `pid`, `windowId`, `width`, `height`, `format`, `quality` | `{data (base64), width, height, mimeType}` |
|
|
50
|
+
| `forget` | `pid` | |
|
|
51
|
+
|
|
52
|
+
`cursor` draws the agent cursor (a decoration; clients ignore its failures).
|
|
53
|
+
`move` moves the real pointer. `makeKey` makes a window key inside its app
|
|
54
|
+
without activating the app. `forget` drops the element table kept for `pid`.
|
|
55
|
+
The scripting layer does not call `move`, `makeKey`, or `forget` today; a
|
|
56
|
+
port may leave them out. `format` is `png` or `jpeg`; `quality` is 0 to 1.
|
|
57
|
+
|
|
58
|
+
`ping.background` reports which private functions the macOS helper found, as
|
|
59
|
+
booleans: `postEventRecord`, `postToPid`, `setWindowLocation`,
|
|
60
|
+
`setIntegerField`, `processSerialNumber`. Background input needs
|
|
61
|
+
`postEventRecord`, `setIntegerField`, and `processSerialNumber`.
|
|
62
|
+
`glovebox doctor` prints this; it is informational. Clients decide to fall
|
|
63
|
+
back from the error codes below, not from `ping`.
|
|
64
|
+
|
|
65
|
+
`permissions` returns `inputMonitoring` (listening for key events, which
|
|
66
|
+
Escape-to-stop needs on macOS) next to `accessibility` and `screenRecording`.
|
|
67
|
+
`prompt: true` asks the OS to prompt; with `inputMonitoring: true` it also
|
|
68
|
+
asks for Input Monitoring. `escapeWatcher` says whether an Escape watcher is
|
|
69
|
+
running.
|
|
70
|
+
|
|
71
|
+
## Background input
|
|
72
|
+
|
|
73
|
+
`keys`, `type`, `click`, `scroll`, and `drag` take `mode: "background"` with
|
|
74
|
+
the target `windowId` (from `windows`) to deliver input without activating
|
|
75
|
+
the app or moving the user's pointer. `windowLocation: "screen"` switches the
|
|
76
|
+
event location from window-local to screen coordinates. Without `mode`, input
|
|
77
|
+
goes out on the foreground route: the client activates the app first. A
|
|
78
|
+
background result is `{route, focusRestored}`, where `route` is `background`
|
|
79
|
+
or `background-skylight` (Chromium and Electron apps), and `focusRestored` is
|
|
80
|
+
true if the target took focus and the backend gave it back to the user's app.
|
|
81
|
+
|
|
82
|
+
A backend with no background route should answer `mode: "background"` with
|
|
83
|
+
`background_unavailable`; the client then falls back to foreground.
|
|
84
|
+
|
|
85
|
+
## Errors
|
|
86
|
+
|
|
87
|
+
Replies carry `{message, code}`. Codes the macOS helper uses:
|
|
88
|
+
|
|
89
|
+
| Code | Meaning |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `blocked` | The app is blocked by default (terminals, password managers, ...). |
|
|
92
|
+
| `denied` | On the `deny` list, or the user chose Deny. |
|
|
93
|
+
| `not_allowed` | `approval` is `allowlist` and the app is not on `allow`. |
|
|
94
|
+
| `background_unavailable` | The private functions background input needs are missing. |
|
|
95
|
+
| `background_unconfirmed` | The window could not be made key in the background. |
|
|
96
|
+
| `no_window` | No such window, or it is gone or not capturable. |
|
|
97
|
+
| `no_frame` | The element has no on-screen frame. |
|
|
98
|
+
| `stale_index` | The element index is not in the table; read the tree again. |
|
|
99
|
+
| `action_failed` | An accessibility action or attribute write failed. |
|
|
100
|
+
| `activate_failed` | The app would not come forward. |
|
|
101
|
+
| `not_found` | No app matches the query, or it cannot be located to launch. |
|
|
102
|
+
| `not_running` | No running app has that `pid`. |
|
|
103
|
+
| `permission` | A needed OS permission (Screen Recording) is missing. |
|
|
104
|
+
| `bad_key` | A key name has no key code. |
|
|
105
|
+
| `bad_request` | A parameter is missing or invalid, or the method is unknown. |
|
|
106
|
+
| `failed` | Anything else. |
|
|
107
|
+
|
|
108
|
+
Clients act on `background_unavailable` and `background_unconfirmed` by
|
|
109
|
+
falling back to the foreground route; every other code is an error.
|
|
110
|
+
|
|
111
|
+
Node shape in `axTree`: `{i, d, role, subrole?, title?, value?, desc?, help?,
|
|
112
|
+
placeholder?, id?, frame?, actions?, focused?, selected?, disabled?,
|
|
113
|
+
settable?}`. `i` must be stable for the same element for as long as the
|
|
114
|
+
backend runs. `role` uses the macOS AX names (`AXButton`, `AXTextField`, ...);
|
|
115
|
+
other backends map their native roles onto those.
|
|
116
|
+
|
|
117
|
+
Key names are xdotool keysyms after parsing (`Return`, `Tab`, `Page_Down`,
|
|
118
|
+
single characters); modifiers are `ctrl`, `shift`, `alt`, `super`, `fn`.
|
package/docs/cli.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# CLI reference
|
|
2
|
+
|
|
3
|
+
`glovebox` is one executable (`bin/glovebox.mjs`). This page lists every
|
|
4
|
+
subcommand and flag, the environment variables, the config file, the two MCP
|
|
5
|
+
tools, and how the helper and daemon start and stop. For attaching an agent
|
|
6
|
+
see `agents.md`; for the scripting API see `api.md`.
|
|
7
|
+
|
|
8
|
+
Run `glovebox` with no arguments, `help`, `-h`, or `--help` to print usage.
|
|
9
|
+
A failing command exits with status 1.
|
|
10
|
+
|
|
11
|
+
## Subcommands
|
|
12
|
+
|
|
13
|
+
| Command | What it does |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `glovebox setup [--rebuild] [--restart]` | Builds Glovebox Helper if it is missing or its source changed, then walks through Accessibility and Screen Recording, one at a time, and offers Input Monitoring. Exits 1 if the first two are not granted. macOS only. |
|
|
16
|
+
| `glovebox signing [--create]` | Reports whether the local signing certificate exists. With `--create`, makes it in the login keychain. Run `setup --rebuild` afterwards to re-sign. macOS only. |
|
|
17
|
+
| `glovebox doctor` | Checks Node (20.10+), the config, the helper build, that the helper answers, background input, Accessibility, Screen Recording, and Input Monitoring. Exits 1 on any `FAIL`. |
|
|
18
|
+
| `glovebox mcp` | Runs the stdio MCP server (tools `js`, `js_reset`). |
|
|
19
|
+
| `glovebox run [code] [-f file] [-s name] [-t title] [--timeout ms]` | Runs a script in a persistent CLI session and prints the result. |
|
|
20
|
+
| `glovebox reset [-s name]` | Drops a CLI session's bindings. |
|
|
21
|
+
| `glovebox stop` | Stops the CLI daemon and every session in it. |
|
|
22
|
+
| `glovebox smoke [--full]` | End-to-end test on Calculator. See `testing.md`. |
|
|
23
|
+
| `glovebox allow <app>...` | Adds apps to `allow`, and removes them from `deny`. |
|
|
24
|
+
| `glovebox deny <app>...` | Adds apps to `deny`, and removes them from `allow`. |
|
|
25
|
+
| `glovebox revoke <app>...` | Removes apps from both lists. |
|
|
26
|
+
| `glovebox approval <ask\|allowlist\|allow-all>` | Sets `approval` in the config. |
|
|
27
|
+
| `glovebox config` | Prints the effective configuration as JSON: defaults, then the config file, then `GLOVEBOX_APPROVAL`. |
|
|
28
|
+
| `glovebox log [-n N]` | Prints the last N (default 20) lines of the action log. |
|
|
29
|
+
| `glovebox version` | Prints the version. Also `--version` and `-v`. |
|
|
30
|
+
| `glovebox daemon` | Internal. Runs the CLI daemon; `run` starts it when needed. Do not call it directly. |
|
|
31
|
+
|
|
32
|
+
### Flags
|
|
33
|
+
|
|
34
|
+
| Flag | Applies to | Meaning |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `-f`, `--file path` | `run` | Read the script from a file. |
|
|
37
|
+
| `-s`, `--session name` | `run`, `reset` | Session name. Default `default`. Each name has its own bindings. `stop` ignores it. |
|
|
38
|
+
| `-t`, `--title text` | `run` | A few words naming the step, written to the action log as `step`. |
|
|
39
|
+
| `--timeout ms` | `run` | Time limit for the script. Default is `timeoutMs` from the config (120000). |
|
|
40
|
+
| `--rebuild` | `setup` | Rebuild the helper even if its source is unchanged. Quits a running helper first. |
|
|
41
|
+
| `--restart` | `setup` | Quit the running helper so it relaunches with new grants (needed after enabling Input Monitoring). |
|
|
42
|
+
| `--create` | `signing` | Create the certificate `glovebox local signing` if it does not exist. |
|
|
43
|
+
| `--full` | `smoke` | Also run the background click, drag, and scroll checks in TextEdit. |
|
|
44
|
+
| `-n N` | `log` | Number of lines. |
|
|
45
|
+
|
|
46
|
+
Notes:
|
|
47
|
+
|
|
48
|
+
- `run` takes code from the arguments (joined with spaces), else from
|
|
49
|
+
`-f file`, else from stdin. With none of them it exits with an error. Text
|
|
50
|
+
results print to stdout; screenshots are saved under
|
|
51
|
+
`~/.glovebox/screenshots/` and the path is printed. Exit status is 1 if the
|
|
52
|
+
script failed or was stopped.
|
|
53
|
+
- `run` starts the daemon if none is listening. `reset` and `stop` do not; with
|
|
54
|
+
no daemon they print `No daemon running.`
|
|
55
|
+
- `allow`, `deny`, and `revoke` match names and bundle ids case-insensitively,
|
|
56
|
+
rewrite `config.json`, and do not affect approvals a running session already
|
|
57
|
+
holds. Use `glovebox reset` or `js_reset` to make a session re-check.
|
|
58
|
+
- `approval allow-all` prints a warning: every app except the blocked ones is
|
|
59
|
+
then controllable without asking.
|
|
60
|
+
- `setup` and `signing` are macOS only; elsewhere they exit with an error.
|
|
61
|
+
|
|
62
|
+
## Environment variables
|
|
63
|
+
|
|
64
|
+
| Variable | Effect |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `GLOVEBOX_HOME` | State directory. Default `~/.glovebox`. Holds `config.json`, `actions.log`, `screenshots/`, `daemon.sock`, `helper.sock`, and `Glovebox Helper.app`. The helper receives it when glovebox launches it. |
|
|
67
|
+
| `GLOVEBOX_APPROVAL` | Overrides `approval` (`ask`, `allowlist`, `allow-all`) without editing the file. glovebox passes it to the helper when it launches it; a helper that is already running keeps the value it started with. |
|
|
68
|
+
| `GLOVEBOX_SOCKET` | Path of the helper's socket. Default `$GLOVEBOX_HOME/helper.sock`. Also passed to the helper at launch. It does not move the CLI daemon's socket. |
|
|
69
|
+
| `GLOVEBOX_CLIENT` | Name of the agent, shown in the approval dialog and menu-bar item and written to the log as `client`. `glovebox run` sends it when it creates a session; the default is `glovebox CLI (<session>)`. Over MCP the name is the client's own `clientInfo.name`. |
|
|
70
|
+
| `GLOVEBOX_DEBUG` | When set, the session process's stderr is copied to the host's stderr. Useful with `glovebox mcp`; the CLI daemon runs detached with no stderr. |
|
|
71
|
+
|
|
72
|
+
glovebox also sets `GLOVEBOX_IMAGE_MODE` (`inline` over MCP, `file` over the
|
|
73
|
+
CLI) on its session processes. It is internal.
|
|
74
|
+
|
|
75
|
+
## Config file
|
|
76
|
+
|
|
77
|
+
Location: `$GLOVEBOX_HOME/config.json` (default `~/.glovebox/config.json`),
|
|
78
|
+
created with mode 0600 by `allow`, `deny`, `revoke`, `approval`, or the
|
|
79
|
+
"Always Allow" button. A missing file means all defaults; a file that is not
|
|
80
|
+
valid JSON is an error. Any subset of keys may be set. Nested objects are
|
|
81
|
+
merged key by key; arrays and scalars replace the default.
|
|
82
|
+
|
|
83
|
+
| Key | Default | Meaning |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `approval` | `"ask"` | `ask`: native dialog on first use of each app per session. `allowlist`: never prompt; only apps in `allow` work. `allow-all`: no prompts; blocked apps still need `allow`. |
|
|
86
|
+
| `allow` | `[]` | App names or bundle ids that are always approved. Also unlocks blocked apps. |
|
|
87
|
+
| `deny` | `[]` | App names or bundle ids that are always refused. Wins over `allow`. |
|
|
88
|
+
| `input` | `"background"` | `background`: input reaches apps without focusing them, with a foreground fallback. `foreground`: always bring the app forward. |
|
|
89
|
+
| `foregroundApps` | `[]` | App names or bundle ids that always use the foreground route. |
|
|
90
|
+
| `cursor` | `true` | Draw the agent cursor where actions land. `false` turns it off. |
|
|
91
|
+
| `screenshot.maxLongSide` | `1568` | Longest side of a window image, in pixels. |
|
|
92
|
+
| `screenshot.maxPixels` | `1150000` | Pixel budget of a window image. |
|
|
93
|
+
| `screenshot.format` | `"png"` | `png` or `jpeg`. |
|
|
94
|
+
| `screenshot.quality` | `80` | JPEG quality as a percentage. Ignored for PNG. |
|
|
95
|
+
| `ax.maxNodes` | `800` | Elements per `getAXState` read. |
|
|
96
|
+
| `ax.maxDepth` | `40` | Tree depth per read. |
|
|
97
|
+
| `ax.maxTextLength` | `160` | Longest label or value kept, in characters. |
|
|
98
|
+
| `log.enabled` | `true` | Write `actions.log`. |
|
|
99
|
+
| `log.text` | `false` | Log typed text instead of only its length. |
|
|
100
|
+
| `daemon.idleMinutes` | `60` | The CLI daemon exits after this long without requests. `0` never exits. |
|
|
101
|
+
| `timeoutMs` | `120000` | Default script time limit for `js` and `run`. |
|
|
102
|
+
| `settleMs` | `80` | Pause after each action, in milliseconds. |
|
|
103
|
+
|
|
104
|
+
`screenshot.maxLongSide` and `maxPixels` set the image scale; see "Coordinates
|
|
105
|
+
and images" in `design.md`. Check order for each app: `deny`, `allow`, the
|
|
106
|
+
blocked list (`SECURITY.md`), then `approval`.
|
|
107
|
+
|
|
108
|
+
When changes apply: the session process reloads the config before every call,
|
|
109
|
+
and the helper reads it on every approval check. The CLI daemon reads it once
|
|
110
|
+
at startup, so `timeoutMs` and `daemon.idleMinutes` need `glovebox stop`.
|
|
111
|
+
|
|
112
|
+
## MCP tools
|
|
113
|
+
|
|
114
|
+
`glovebox mcp` speaks MCP over stdio and exposes two tools. The session starts
|
|
115
|
+
on the first `js` call and lives until `js_reset` or the connection closes.
|
|
116
|
+
|
|
117
|
+
| Tool | Parameters | Notes |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `js` | `code` (string, required), `title` (string), `timeout_ms` (integer) | Runs `code` in the persistent session. `title` names the step in the action log. `timeout_ms` defaults to `timeoutMs`; a value that is not a positive integer is ignored. Result content is text, plus inline images for screenshots; `isError` is true if the script threw or was stopped. |
|
|
120
|
+
| `js_reset` | none | Restarts the session and drops every binding. |
|
|
121
|
+
|
|
122
|
+
On timeout the script is asked to stop at its next action and the result says
|
|
123
|
+
`Stopped: the script ran longer than N ms.` If it has not stopped five seconds
|
|
124
|
+
later, the session process is killed and the bindings are lost.
|
|
125
|
+
|
|
126
|
+
## Helper and daemon lifecycle
|
|
127
|
+
|
|
128
|
+
Glovebox Helper (macOS):
|
|
129
|
+
|
|
130
|
+
- `setup` builds it into `$GLOVEBOX_HOME/Glovebox Helper.app`. It is rebuilt
|
|
131
|
+
only when its source changes; until then, or after an upgrade, clients fail
|
|
132
|
+
with a message to run `glovebox setup`.
|
|
133
|
+
- The first client request launches it through LaunchServices; no agent has to
|
|
134
|
+
start it. It listens on `helper.sock`, mode 0600, and refuses other users.
|
|
135
|
+
- While at least one client is connected it shows a menu-bar item with the
|
|
136
|
+
connected sessions and their approved apps, and the entries Stop Running
|
|
137
|
+
Scripts, Revoke Session Approvals, Hide Agent Cursor, and Quit Glovebox
|
|
138
|
+
Helper.
|
|
139
|
+
- It quits by itself after 30 minutes with no connected clients, checked once a
|
|
140
|
+
minute. The next client relaunches it. Quit Glovebox Helper, `setup
|
|
141
|
+
--restart`, and `setup --rebuild` also stop it.
|
|
142
|
+
|
|
143
|
+
CLI daemon:
|
|
144
|
+
|
|
145
|
+
- `glovebox run` starts it on first use, detached, listening on
|
|
146
|
+
`$GLOVEBOX_HOME/daemon.sock` (mode 0600).
|
|
147
|
+
- Each session name is its own session process, created by the first `run`
|
|
148
|
+
that names it.
|
|
149
|
+
- It exits after `daemon.idleMinutes` of no requests, or on `glovebox stop`.
|
|
150
|
+
Its sessions go with it.
|
package/docs/design.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Design
|
|
2
|
+
|
|
3
|
+
This is the design glovebox follows. It records what we chose and why, so
|
|
4
|
+
changes can argue with the reasons rather than the code.
|
|
5
|
+
|
|
6
|
+
## The bar
|
|
7
|
+
|
|
8
|
+
Measured against a real Codex computer-use trace (786 calls in one session,
|
|
9
|
+
median script about 130 characters), the properties that made that loop cheap:
|
|
10
|
+
|
|
11
|
+
1. One tool call holds a whole step: several actions, then one read.
|
|
12
|
+
2. The session persists. A handle bound in one call exists in the next, and
|
|
13
|
+
the model can define its own helpers.
|
|
14
|
+
3. The default read is accessibility text that diffs after the first read.
|
|
15
|
+
4. Screenshots are an explicit choice, never a side effect of an action.
|
|
16
|
+
5. Clicks land in the same pixel space as the image the model saw.
|
|
17
|
+
6. Pixel actions and element actions are both cheap, because some apps
|
|
18
|
+
(Citrix, canvases) expose a single accessibility node.
|
|
19
|
+
7. No documentation dump in tool results, and no idle teardown that throws
|
|
20
|
+
away bindings.
|
|
21
|
+
|
|
22
|
+
The shapes we compared against:
|
|
23
|
+
|
|
24
|
+
| | Codex (`@oai/sky`) | Claude computer use | Orca | glovebox |
|
|
25
|
+
|---|---|---|---|---|
|
|
26
|
+
| Calls per step | 1 script | 1 per action, or a fixed batch list | 1 CLI call per action | 1 script |
|
|
27
|
+
| Persistent bindings | yes | no | no | yes |
|
|
28
|
+
| Default read | AX text, diffed | screenshot plus AX summary | AX text plus screenshot | AX text, diffed |
|
|
29
|
+
| Model-defined helpers | yes | no | no | yes |
|
|
30
|
+
| Pixel space | screenshot pixels | screenshot frame | window coordinates | window image pixels |
|
|
31
|
+
| Background input | yes | yes | partial | yes (keys, clicks, drag, scroll; foreground fallback) |
|
|
32
|
+
| Drawn agent cursor | yes | yes | no | yes |
|
|
33
|
+
| Works without vendor app | no | no | no | yes |
|
|
34
|
+
|
|
35
|
+
Claude's tools and Orca's CLI both make the model pay a round trip per
|
|
36
|
+
action, and every state read carries an image or a full tree. Codex's REPL is
|
|
37
|
+
the right shape but is tied to OpenAI's signed binaries and service.
|
|
38
|
+
|
|
39
|
+
## Interface: one persistent JavaScript session
|
|
40
|
+
|
|
41
|
+
We expose a JavaScript session, not a set of action tools, because it is the
|
|
42
|
+
only shape that gets properties 1, 2, and 6 at once. Every agent we target
|
|
43
|
+
writes JavaScript fluently, and the surface stays close to the one Codex
|
|
44
|
+
models already know (`getApp`, `click`, `pressKey`, `typeText`,
|
|
45
|
+
`getAXState`), with `cua` kept as an alias for `gb`.
|
|
46
|
+
|
|
47
|
+
The same session is reachable two ways:
|
|
48
|
+
|
|
49
|
+
- **MCP** (`glovebox mcp`): two tools, `js` and `js_reset`. The tool
|
|
50
|
+
description is the cheat sheet (about 1.8 KB, sent once with `tools/list`).
|
|
51
|
+
`help()` prints the full reference only when asked.
|
|
52
|
+
- **CLI** (`glovebox run`): a background daemon keeps named sessions alive,
|
|
53
|
+
so agents that can only run shell commands get the same persistence.
|
|
54
|
+
Images go to files and the path is printed.
|
|
55
|
+
|
|
56
|
+
### Persistence
|
|
57
|
+
|
|
58
|
+
A cell is rewritten (with acorn) so that top-level `const`, `let`, `class`,
|
|
59
|
+
and `import` become assignments to context-level `var`s, function
|
|
60
|
+
declarations are hoisted, and the body runs in an async function. The last
|
|
61
|
+
expression is the result. This is what Node's REPL does, without its
|
|
62
|
+
problem that a rejected top-level `await` is printed and swallowed instead of
|
|
63
|
+
failing the call.
|
|
64
|
+
|
|
65
|
+
The session runs in its own process (the kernel), so the host can stop a
|
|
66
|
+
runaway script. A timeout first sets an abort flag that every action checks;
|
|
67
|
+
if the script still has not stopped after five seconds, the kernel is killed
|
|
68
|
+
and the result says the bindings were lost. MCP sessions live as long as the
|
|
69
|
+
connection. CLI sessions live until `glovebox reset`, `glovebox stop`, or an
|
|
70
|
+
idle limit (an hour by default).
|
|
71
|
+
|
|
72
|
+
## Reads and output
|
|
73
|
+
|
|
74
|
+
`getAXState` prints one line per element: index, role, label, value, id,
|
|
75
|
+
flags, and non-obvious actions, indented by depth. The format is close to
|
|
76
|
+
Codex's on purpose. Element indices are stable for the session, because the
|
|
77
|
+
backend keeps a table of the elements it has seen (`CFEqual` on macOS), so a
|
|
78
|
+
diff only shows real changes: `~` changed, `+` added, then a list of removed
|
|
79
|
+
indices. Frames are off by default (they are tokens most steps do not need)
|
|
80
|
+
and on with `{frames: true}`.
|
|
81
|
+
|
|
82
|
+
Reads emit into the result by default and return their value; `{emit:
|
|
83
|
+
false}` returns only. Actions emit nothing.
|
|
84
|
+
|
|
85
|
+
## Coordinates and images
|
|
86
|
+
|
|
87
|
+
Image space is per window: `(0, 0)` is the window's top-left corner, and one
|
|
88
|
+
image pixel is `1 / s` screen points, where
|
|
89
|
+
|
|
90
|
+
s = min(backing scale, maxLongSide / max(w, h), sqrt(maxPixels / (w * h)))
|
|
91
|
+
|
|
92
|
+
with defaults `maxLongSide = 1568` and `maxPixels = 1.15 M`. The scale depends
|
|
93
|
+
only on the window size and display, so screenshots, `frames`, `center()`, and
|
|
94
|
+
`click([x, y])` agree even when no screenshot was taken. The screenshot is
|
|
95
|
+
captured at exactly that size, so there is no resize without a matching
|
|
96
|
+
transform. The header of every read prints the image size.
|
|
97
|
+
|
|
98
|
+
## Input delivery
|
|
99
|
+
|
|
100
|
+
Input goes to apps in the background, the way Codex computer use does it:
|
|
101
|
+
the user's frontmost app keeps focus and the real cursor never moves.
|
|
102
|
+
|
|
103
|
+
- A plain left click on a pressable element (button, checkbox, link, menu
|
|
104
|
+
item) is sent as an accessibility press.
|
|
105
|
+
- Keys and typing: the helper makes the target window key *inside its app*
|
|
106
|
+
without activating the app, using two private SkyLight event records
|
|
107
|
+
(`SLPSPostEventRecordTo`: a focus record and a make-key pair, 248 bytes
|
|
108
|
+
each). It confirms through Accessibility that the window is key, then posts
|
|
109
|
+
the key events to the process (`SLEventPostToPid`). Plain
|
|
110
|
+
`CGEventPostToPid` is not enough: Calculator dropped those keys when another
|
|
111
|
+
app was frontmost, because a background app has no key window.
|
|
112
|
+
- Mouse clicks, drags, and scrolls: a focus record for the target window only,
|
|
113
|
+
then a `mouseMoved` and the button events posted to the process, each
|
|
114
|
+
stamped with the target pid and window (event fields 40, 51, 58, 91, 92)
|
|
115
|
+
and a window-local location (`CGEventSetWindowLocation`). AppKit apps get
|
|
116
|
+
the public `CGEventPostToPid`; Chromium/Electron apps get the SkyLight route
|
|
117
|
+
plus an off-screen primer click. Scroll events go through both routes.
|
|
118
|
+
Background events carry no click count unless one is set, and AppKit
|
|
119
|
+
ignores a mouse-down without it.
|
|
120
|
+
- A focus guard notes the frontmost app before each background action and
|
|
121
|
+
gives it focus back if the target took it anyway.
|
|
122
|
+
- Each private function is looked up at runtime. If one is missing (a future
|
|
123
|
+
macOS may remove it), the helper says so and glovebox falls back to the
|
|
124
|
+
foreground route: bring the app forward once per call, then post events.
|
|
125
|
+
On that route, switching to another app stops the script. `glovebox doctor`
|
|
126
|
+
reports whether background input is available, and `app.lastRoute` shows
|
|
127
|
+
which route the last action took.
|
|
128
|
+
- The record layouts and event fields come from open-source tools that do the
|
|
129
|
+
same: yabai, trycua's cua-driver, and Peekaboo (all MIT). Codex's service
|
|
130
|
+
links only public frameworks and resolves these calls by hashed name at
|
|
131
|
+
runtime, and Claude desktop's helper references the same calls, so this is
|
|
132
|
+
the established approach, not a novel hack. It is still private API.
|
|
133
|
+
- A drawn agent cursor (a borderless, click-through window ordered just above
|
|
134
|
+
the target window, not above everything) animates to each action's point.
|
|
135
|
+
It is included in screen recordings and hides after a few seconds idle.
|
|
136
|
+
- Pixel targets outside the window image are refused, so a bad coordinate
|
|
137
|
+
cannot click into another app.
|
|
138
|
+
- While any agent is connected the helper shows a menu-bar item listing the
|
|
139
|
+
sessions and their approved apps, with Stop Running Scripts, Revoke Session
|
|
140
|
+
Approvals, Hide Agent Cursor, and Quit.
|
|
141
|
+
|
|
142
|
+
Measured on macOS 27 with Firefox frontmost throughout (`glovebox smoke
|
|
143
|
+
--full`): background keys in Calculator, a pixel click that placed TextEdit's
|
|
144
|
+
caret at the start of the text, a drag that selected a line, and a scroll
|
|
145
|
+
that moved the scroll bar, with the frontmost app unchanged.
|
|
146
|
+
|
|
147
|
+
## Approval and safety
|
|
148
|
+
|
|
149
|
+
Computer use can click anything, so approval is part of the product:
|
|
150
|
+
|
|
151
|
+
- The gate lives in the helper, the process that holds the macOS grants, not
|
|
152
|
+
in the scripting layer. A client cannot skip it by talking to the socket.
|
|
153
|
+
- Default `approval: "ask"`: the first action in each app shows a native
|
|
154
|
+
dialog naming the agent (from the MCP `clientInfo`) and the app: Allow for
|
|
155
|
+
This Session, Always Allow (persisted to `config.json`), or Deny.
|
|
156
|
+
- `allowlist` mode never prompts. `allow-all` is an explicit opt-in.
|
|
157
|
+
- Terminals, password managers, Keychain Access, System Settings, Script
|
|
158
|
+
Editor, Automator, and the helper itself are blocked by default and never
|
|
159
|
+
offered in a dialog. Only an explicit `glovebox allow` unlocks one. The
|
|
160
|
+
full list is in `SECURITY.md`.
|
|
161
|
+
- Switching to another app stops the running script. A script brings an app
|
|
162
|
+
forward at most once per call; if the app is no longer in front later in
|
|
163
|
+
the same call, the user took over, and the script stops rather than taking
|
|
164
|
+
focus back. (An earlier version re-activated before every key and fought
|
|
165
|
+
the user for focus.)
|
|
166
|
+
- With Input Monitoring granted, Escape pressed by the user (glovebox marks
|
|
167
|
+
its own events) stops the running script. In testing on macOS 27, an
|
|
168
|
+
Accessibility-only global key monitor saw no key events, so Escape is
|
|
169
|
+
documented as needing Input Monitoring.
|
|
170
|
+
- Every action is appended to `~/.glovebox/actions.log` as one JSON object:
|
|
171
|
+
`ts`, `client`, `step` (the call's title, when given), then the action's own
|
|
172
|
+
fields (`app`, `action`, and for example `target`, `keys`, or `length`).
|
|
173
|
+
Typed text is logged as a length unless `log.text` is true.
|
|
174
|
+
- The helper's socket is mode 0600 and checks the peer's uid.
|
|
175
|
+
|
|
176
|
+
## Signing and permissions
|
|
177
|
+
|
|
178
|
+
macOS ties Accessibility, Screen Recording, and Input Monitoring grants to the
|
|
179
|
+
helper's designated requirement. An ad-hoc signature's requirement is the
|
|
180
|
+
binary's hash, so every rebuild lost the grants (and stale entries kept
|
|
181
|
+
showing as on). `glovebox signing --create` makes a self-signed code-signing
|
|
182
|
+
certificate in the login keychain; signed with it, the requirement becomes
|
|
183
|
+
"identifier + certificate", and grants survive rebuilds. The certificate
|
|
184
|
+
does not need to be trusted. Verified by rebuilding twice without re-granting.
|
|
185
|
+
|
|
186
|
+
## The macOS backend
|
|
187
|
+
|
|
188
|
+
Glovebox Helper is a small Swift app (`native/macos/GloveboxHelper.swift`)
|
|
189
|
+
built locally by `glovebox setup` with the Xcode command line tools. It is
|
|
190
|
+
signed with the local certificate from `glovebox signing --create` when one
|
|
191
|
+
exists, and ad hoc otherwise. It runs as its own app, launched through LaunchServices, so
|
|
192
|
+
macOS attributes Accessibility and Screen Recording to "Glovebox Helper"
|
|
193
|
+
rather than to whatever terminal or agent started it. One grant covers every
|
|
194
|
+
agent. (The first prototype ran the native code as a child process; macOS
|
|
195
|
+
then charges the permission to the parent, and some hosts disclaim
|
|
196
|
+
responsibility for their children, so the grant never applies.)
|
|
197
|
+
|
|
198
|
+
It uses AXUIElement for the tree and actions, CGEvent for input, and
|
|
199
|
+
ScreenCaptureKit for window capture, all public APIs. Background input
|
|
200
|
+
(see Input delivery) also uses private SkyLight functions, looked up at
|
|
201
|
+
runtime, with a foreground fallback when they are missing. It speaks JSON
|
|
202
|
+
lines over `~/.glovebox/helper.sock`. See `backend-protocol.md`.
|
|
203
|
+
|
|
204
|
+
## What we did not do
|
|
205
|
+
|
|
206
|
+
- **Wrap OpenAI's Sky service.** It works on a Mac with ChatGPT installed, but
|
|
207
|
+
only by launching OpenAI's signed binaries to satisfy the service's
|
|
208
|
+
sender check. That is fragile across updates, depends on a vendor install,
|
|
209
|
+
and works around an access control, which is a poor fit for a public tool.
|
|
210
|
+
- **One tool per action.** Every action becomes a model round trip.
|
|
211
|
+
- **Screenshots after every action.** Images are the most expensive thing in
|
|
212
|
+
the context; the model asks when it needs one.
|
package/docs/recipes.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# Recipes
|
|
2
|
+
|
|
3
|
+
Short scripts an agent can paste into one `js` call (or `glovebox run`). They
|
|
4
|
+
use only the API in `api.md`.
|
|
5
|
+
|
|
6
|
+
Only recipes 1 and 2, the Calculator and TextEdit flows, match what `glovebox
|
|
7
|
+
smoke` has exercised on a real machine (see `testing.md`). Every other recipe
|
|
8
|
+
is marked "not yet run on a real machine": it follows the documented API and
|
|
9
|
+
the code, but nobody has run it. Treat the details, such as app names and menu
|
|
10
|
+
labels, as things to check against the tree.
|
|
11
|
+
|
|
12
|
+
## 1. Calculator: one batched step
|
|
13
|
+
|
|
14
|
+
Bind the app, press keys, read once. This is the loop `glovebox smoke` runs.
|
|
15
|
+
|
|
16
|
+
```js
|
|
17
|
+
const calc = await gb.getApp("Calculator");
|
|
18
|
+
await calc.pressKey("Escape");
|
|
19
|
+
await calc.typeText("6*7=");
|
|
20
|
+
await calc.getAXState(); // the display line shows value="42"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Clicking by element index works in a later call, because `calc` persists. Find
|
|
24
|
+
the index in the tree (`14 button "7"` means index 14), then:
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
await calc.click(14);
|
|
28
|
+
await calc.getAXState(); // prints only what changed
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 2. TextEdit: click into the text and type
|
|
32
|
+
|
|
33
|
+
Uses the tree for the text area's box, a pixel click inside it, then typing.
|
|
34
|
+
Assumes TextEdit already has a document window open. This follows the
|
|
35
|
+
`glovebox smoke --full` flow.
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
const te = await gb.getApp("TextEdit");
|
|
39
|
+
const tree = await te.getAXState({ frames: true, disableDiffing: true, emit: false });
|
|
40
|
+
const [, x, y] = tree.match(/text area[^\n]*@(\d+),(\d+)/);
|
|
41
|
+
await te.click([Number(x) + 8, Number(y) + 10]); // inside the text area
|
|
42
|
+
await te.typeText("Hello from glovebox\n");
|
|
43
|
+
await te.getAXState();
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## 3. Open a menu item through the AX tree
|
|
47
|
+
|
|
48
|
+
Not yet run on a real machine. There is no menu API. `menus: true` adds the
|
|
49
|
+
menu bar to the tree, and menu bar items and menu items are pressed through
|
|
50
|
+
accessibility like buttons. This clicks the bar item, reads again, then
|
|
51
|
+
clicks the item.
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
const app = await gb.getApp("TextEdit");
|
|
55
|
+
const find = (tree, role, label) => {
|
|
56
|
+
const m = tree.match(new RegExp(`^\\s*(\\d+) ${role} "${label}"`, "m"));
|
|
57
|
+
if (!m) throw new Error(`No ${role} "${label}" in the tree`);
|
|
58
|
+
return Number(m[1]);
|
|
59
|
+
};
|
|
60
|
+
const clickMenu = async (app, menu, item) => {
|
|
61
|
+
let tree = await app.getAXState({ menus: true, disableDiffing: true, emit: false });
|
|
62
|
+
await app.click(find(tree, "menu bar item", menu));
|
|
63
|
+
tree = await app.getAXState({ menus: true, disableDiffing: true, emit: false });
|
|
64
|
+
await app.click(find(tree, "menu item", item));
|
|
65
|
+
};
|
|
66
|
+
await clickMenu(app, "Edit", "Select All");
|
|
67
|
+
await app.getAXState();
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## 4. Pixel fallback for Citrix and canvases
|
|
71
|
+
|
|
72
|
+
Not yet run on a real machine. When the tree is one node, take a screenshot,
|
|
73
|
+
read coordinates off the image, and click `[x, y]`. Pixels are the
|
|
74
|
+
screenshot's own: `(0, 0)` is the window's top-left corner.
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
const app = await gb.getApp("Citrix Viewer"); // any app with a one-node tree
|
|
78
|
+
const shot = await app.getScreenshot(); // the image comes back in the result
|
|
79
|
+
console.log(shot.width, shot.height);
|
|
80
|
+
await app.click([412, 230]); // a point you read off the image
|
|
81
|
+
await app.typeText("hello");
|
|
82
|
+
await app.getScreenshot(); // look again before the next step
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Re-take the screenshot if the window was resized: a click outside the window
|
|
86
|
+
image throws instead of landing in another app.
|
|
87
|
+
|
|
88
|
+
## 5. Define a helper once, reuse it
|
|
89
|
+
|
|
90
|
+
Not yet run on a real machine. Functions and constants persist across calls
|
|
91
|
+
until `js_reset`, so a repeated lookup becomes one name.
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
const clickLabel = async (app, label) => {
|
|
95
|
+
const tree = await app.getAXState({ emit: false, disableDiffing: true });
|
|
96
|
+
const m = tree.match(new RegExp(`^\\s*(\\d+) button "${label}"`, "m"));
|
|
97
|
+
if (!m) throw new Error(`No button "${label}"`);
|
|
98
|
+
await app.click(Number(m[1]));
|
|
99
|
+
};
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Later calls:
|
|
103
|
+
|
|
104
|
+
```js
|
|
105
|
+
const calc = await gb.getApp("Calculator");
|
|
106
|
+
for (const label of ["7", "Add", "1", "Equals"]) await clickLabel(calc, label);
|
|
107
|
+
await calc.getAXState();
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## 6. Set a field's value directly
|
|
111
|
+
|
|
112
|
+
Not yet run on a real machine. A line with `settable` in its flags can take
|
|
113
|
+
`setValue`, which is faster and more exact than typing. Read it back to check.
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
const tree = await app.getAXState({ disableDiffing: true });
|
|
117
|
+
// e.g. 9 text field "Name" (settable)
|
|
118
|
+
await app.setValue(9, "Ada Lovelace");
|
|
119
|
+
await app.getAXState();
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
If the app ignores the new value, fall back to `await app.focus(9)` then
|
|
123
|
+
`await app.typeText("Ada Lovelace")`. `help("setValue")` prints the reference.
|
|
124
|
+
|
|
125
|
+
## 7. Handle a "Stopped:" result
|
|
126
|
+
|
|
127
|
+
Not yet run on a real machine. A result that starts with `Stopped:` means the
|
|
128
|
+
user took over (switched apps, pressed Escape with Input Monitoring granted,
|
|
129
|
+
or the script hit its time limit). Do not retry or continue; tell the user and
|
|
130
|
+
ask. Inside a script, never swallow it (`clickLabel` is from recipe 5):
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
try {
|
|
134
|
+
for (const label of ["7", "Add", "1", "Equals"]) await clickLabel(calc, label);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error.name === "Stopped") throw error; // let the result say Stopped:
|
|
137
|
+
console.log(`click failed: ${error.message}`);
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Continue only after the user asks, and start with a fresh read:
|
|
142
|
+
`await calc.getAXState({ disableDiffing: true })`.
|
|
143
|
+
|
|
144
|
+
## 8. Force foreground for an app that ignores background input
|
|
145
|
+
|
|
146
|
+
Not yet run on a real machine. If background clicks or keys do nothing (some
|
|
147
|
+
Java, Tk, and game windows), bring the app forward for those actions. A plain
|
|
148
|
+
click on a button is pressed through accessibility and never needs this; it
|
|
149
|
+
matters for mouse clicks, keys, typing, scroll, and drag.
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
const app = await gb.getApp("Some Java App");
|
|
153
|
+
await app.click([120, 80], { foreground: true });
|
|
154
|
+
await app.typeText("hello", { foreground: true });
|
|
155
|
+
app.lastRoute; // "foreground"
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
To make every action on an app use the foreground route, pass
|
|
159
|
+
`gb.getApp(name, {foreground: true})` on the call that first binds it, or ask
|
|
160
|
+
the user to add the app to `foregroundApps` in the config (`cli.md`). In
|
|
161
|
+
foreground mode a script brings the app forward once per call and stops if
|
|
162
|
+
the user switches away.
|