@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
package/src/kernel.mjs
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// The persistent session. Runs as its own process so the host (MCP server or
|
|
2
|
+
// CLI daemon) can stop a runaway script without going down with it.
|
|
3
|
+
//
|
|
4
|
+
// stdin: {"id": n, "op": "eval", "code": "...", "title": "..."} | {"op": "abort"}
|
|
5
|
+
// stdout: {"id": n, "ok": bool, "content": [{type: "text"|"image", ...}]}
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import util from "node:util";
|
|
9
|
+
import vm from "node:vm";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { createApi } from "./api.mjs";
|
|
12
|
+
import { createBackend } from "./backends/index.mjs";
|
|
13
|
+
import { loadConfig, paths } from "./config.mjs";
|
|
14
|
+
import { transformCell } from "./transform.mjs";
|
|
15
|
+
|
|
16
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const client = process.env.GLOVEBOX_CLIENT || "An agent";
|
|
18
|
+
const imageMode = process.env.GLOVEBOX_IMAGE_MODE === "file" ? "file" : "inline";
|
|
19
|
+
|
|
20
|
+
// Anything a dependency prints must not corrupt the protocol on stdout.
|
|
21
|
+
const protocolOut = process.stdout.write.bind(process.stdout);
|
|
22
|
+
process.stdout.write = (chunk, ...rest) => process.stderr.write(chunk, ...rest);
|
|
23
|
+
|
|
24
|
+
function send(message) {
|
|
25
|
+
protocolOut(`${JSON.stringify(message)}\n`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class StoppedError extends Error {
|
|
29
|
+
constructor(reason) {
|
|
30
|
+
super(reason);
|
|
31
|
+
this.name = "Stopped";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let config = loadConfig();
|
|
36
|
+
let content = [];
|
|
37
|
+
// Text the API already printed this cell (reads, help). Returning or logging
|
|
38
|
+
// the same value again must not print it twice.
|
|
39
|
+
let printed = new Set();
|
|
40
|
+
let running = false;
|
|
41
|
+
let abortReason = null;
|
|
42
|
+
let cellTitle = null;
|
|
43
|
+
let cellNumber = 0;
|
|
44
|
+
|
|
45
|
+
function pushText(text) {
|
|
46
|
+
if (!running || text === "") return;
|
|
47
|
+
const last = content.at(-1);
|
|
48
|
+
if (last?.type === "text") last.text += `\n${text}`;
|
|
49
|
+
else content.push({ type: "text", text });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function show(value) {
|
|
53
|
+
return typeof value === "string" ? value : util.inspect(withoutPrinted(value), { depth: 4, maxArrayLength: 50, maxStringLength: 4000, breakLength: 120 });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A copy of `value` with already-printed strings replaced by a marker, e.g.
|
|
57
|
+
// the `text` of getAXStateAndScreenshot().
|
|
58
|
+
function withoutPrinted(value, depth = 0) {
|
|
59
|
+
if (typeof value === "string") return printed.has(value) ? "[printed above]" : value;
|
|
60
|
+
if (depth > 4 || value === null || typeof value !== "object") return value;
|
|
61
|
+
if (Array.isArray(value)) return value.map((v) => withoutPrinted(v, depth + 1));
|
|
62
|
+
if (Object.getPrototypeOf(value) !== Object.prototype) return value;
|
|
63
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, withoutPrinted(v, depth + 1)]));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const out = {
|
|
67
|
+
text(value) {
|
|
68
|
+
const text = show(value);
|
|
69
|
+
pushText(text);
|
|
70
|
+
if (running) printed.add(text);
|
|
71
|
+
},
|
|
72
|
+
async image(data, mimeType, label) {
|
|
73
|
+
if (!running) return;
|
|
74
|
+
if (imageMode === "file") {
|
|
75
|
+
fs.mkdirSync(paths.shots, { recursive: true, mode: 0o700 });
|
|
76
|
+
const ext = mimeType === "image/jpeg" ? "jpg" : "png";
|
|
77
|
+
const safe = label.replace(/[^A-Za-z0-9._-]+/g, "_");
|
|
78
|
+
const file = path.join(paths.shots, `${new Date().toISOString().replace(/[:.]/g, "-")}-${safe}.${ext}`);
|
|
79
|
+
fs.writeFileSync(file, Buffer.from(data, "base64"));
|
|
80
|
+
pushText(`Image saved: ${file}`);
|
|
81
|
+
} else {
|
|
82
|
+
content.push({ type: "image", mimeType, data });
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function log(entry) {
|
|
88
|
+
if (!config.log.enabled) return;
|
|
89
|
+
try {
|
|
90
|
+
fs.mkdirSync(paths.home, { recursive: true, mode: 0o700 });
|
|
91
|
+
fs.appendFileSync(paths.log, `${JSON.stringify({ ts: new Date().toISOString(), client, step: cellTitle ?? undefined, ...entry })}\n`, { mode: 0o600 });
|
|
92
|
+
} catch {
|
|
93
|
+
// Logging must never break an action.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function help(topic) {
|
|
98
|
+
const doc = fs.readFileSync(path.join(here, "..", "docs", "api.md"), "utf8");
|
|
99
|
+
if (!topic) return doc;
|
|
100
|
+
const sections = doc.split(/^(?=## )/m);
|
|
101
|
+
const hit = sections.filter((s) => s.toLowerCase().includes(String(topic).toLowerCase()));
|
|
102
|
+
return hit.length ? hit.join("\n") : `No help for "${topic}". Call help() for everything.`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const backend = createBackend({ client });
|
|
106
|
+
backend.on("escape", () => {
|
|
107
|
+
if (running) abortReason = "Stopped: the user pressed Escape. Do not continue GUI actions until the user asks.";
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const ctx = {
|
|
111
|
+
backend,
|
|
112
|
+
get config() {
|
|
113
|
+
return config;
|
|
114
|
+
},
|
|
115
|
+
out,
|
|
116
|
+
log,
|
|
117
|
+
help,
|
|
118
|
+
get cell() {
|
|
119
|
+
return cellNumber;
|
|
120
|
+
},
|
|
121
|
+
checkAbort() {
|
|
122
|
+
if (abortReason) throw new StoppedError(abortReason);
|
|
123
|
+
},
|
|
124
|
+
stop(reason) {
|
|
125
|
+
abortReason = reason;
|
|
126
|
+
throw new StoppedError(reason);
|
|
127
|
+
},
|
|
128
|
+
settle() {
|
|
129
|
+
return new Promise((resolve) => setTimeout(resolve, config.settleMs ?? 80));
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
function makeContext() {
|
|
134
|
+
const gb = createApi(ctx);
|
|
135
|
+
const consoleProxy = Object.fromEntries(
|
|
136
|
+
["log", "info", "warn", "error", "debug"].map((name) => [
|
|
137
|
+
name,
|
|
138
|
+
(...args) => pushText(args.filter((a) => !(typeof a === "string" && printed.has(a))).map(show).join(" ")),
|
|
139
|
+
]),
|
|
140
|
+
);
|
|
141
|
+
const sandbox = {
|
|
142
|
+
gb,
|
|
143
|
+
cua: gb,
|
|
144
|
+
help: gb.help,
|
|
145
|
+
sleep: gb.sleep,
|
|
146
|
+
console: consoleProxy,
|
|
147
|
+
nodeRepl: { write: (value) => out.text(value) },
|
|
148
|
+
setTimeout,
|
|
149
|
+
clearTimeout,
|
|
150
|
+
setInterval,
|
|
151
|
+
clearInterval,
|
|
152
|
+
queueMicrotask,
|
|
153
|
+
structuredClone,
|
|
154
|
+
URL,
|
|
155
|
+
TextEncoder,
|
|
156
|
+
TextDecoder,
|
|
157
|
+
Buffer,
|
|
158
|
+
fetch,
|
|
159
|
+
process: { platform: process.platform, env: {} },
|
|
160
|
+
};
|
|
161
|
+
return vm.createContext(sandbox, { name: "glovebox" });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let context = makeContext();
|
|
165
|
+
|
|
166
|
+
async function runCell(message) {
|
|
167
|
+
content = [];
|
|
168
|
+
printed = new Set();
|
|
169
|
+
cellNumber += 1;
|
|
170
|
+
running = true;
|
|
171
|
+
abortReason = null;
|
|
172
|
+
cellTitle = message.title || null;
|
|
173
|
+
config = loadConfig();
|
|
174
|
+
try {
|
|
175
|
+
const code = transformCell(String(message.code ?? ""));
|
|
176
|
+
const result = await vm.runInContext(code, context, { filename: "cell.js", timeout: 30_000 });
|
|
177
|
+
if (result !== undefined && typeof result !== "function" && !(typeof result === "string" && printed.has(result))) {
|
|
178
|
+
pushText(show(result));
|
|
179
|
+
}
|
|
180
|
+
send({ id: message.id, ok: true, content });
|
|
181
|
+
} catch (error) {
|
|
182
|
+
const text =
|
|
183
|
+
error instanceof Error
|
|
184
|
+
? error.name === "Stopped" || error.name === "SyntaxError"
|
|
185
|
+
? error.message
|
|
186
|
+
: `${error.name}: ${error.message}`
|
|
187
|
+
: `Thrown: ${show(error)}`;
|
|
188
|
+
pushText(text);
|
|
189
|
+
send({ id: message.id, ok: false, content });
|
|
190
|
+
} finally {
|
|
191
|
+
running = false;
|
|
192
|
+
cellTitle = null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let chain = Promise.resolve();
|
|
197
|
+
let buffer = "";
|
|
198
|
+
process.stdin.setEncoding("utf8");
|
|
199
|
+
process.stdin.on("data", (chunk) => {
|
|
200
|
+
buffer += chunk;
|
|
201
|
+
let newline;
|
|
202
|
+
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
203
|
+
const line = buffer.slice(0, newline);
|
|
204
|
+
buffer = buffer.slice(newline + 1);
|
|
205
|
+
if (!line) continue;
|
|
206
|
+
const message = JSON.parse(line);
|
|
207
|
+
if (message.op === "abort") {
|
|
208
|
+
if (running) abortReason = message.reason || "Stopped: the script timed out.";
|
|
209
|
+
} else if (message.op === "eval") {
|
|
210
|
+
chain = chain.then(() => runCell(message));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
process.stdin.on("end", () => process.exit(0));
|
|
215
|
+
process.on("unhandledRejection", (error) => {
|
|
216
|
+
process.stderr.write(`glovebox kernel: unhandled rejection: ${error?.stack ?? error}\n`);
|
|
217
|
+
});
|
package/src/keys.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// xdotool-style key chords ("ctrl+a", "super+c", "Return", "shift+Tab") parsed
|
|
2
|
+
// into a platform-neutral form. Backends map `key` names to native key codes.
|
|
3
|
+
|
|
4
|
+
const MODIFIERS = {
|
|
5
|
+
ctrl: "ctrl",
|
|
6
|
+
control: "ctrl",
|
|
7
|
+
shift: "shift",
|
|
8
|
+
alt: "alt",
|
|
9
|
+
option: "alt",
|
|
10
|
+
opt: "alt",
|
|
11
|
+
meta: "super",
|
|
12
|
+
super: "super",
|
|
13
|
+
cmd: "super",
|
|
14
|
+
command: "super",
|
|
15
|
+
win: "super",
|
|
16
|
+
fn: "fn",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Canonical names; anything else of length 1 is a literal character.
|
|
20
|
+
const NAMES = {
|
|
21
|
+
return: "Return",
|
|
22
|
+
enter: "Return",
|
|
23
|
+
kp_enter: "KP_Enter",
|
|
24
|
+
tab: "Tab",
|
|
25
|
+
escape: "Escape",
|
|
26
|
+
esc: "Escape",
|
|
27
|
+
space: "space",
|
|
28
|
+
backspace: "BackSpace",
|
|
29
|
+
delete: "Delete",
|
|
30
|
+
del: "Delete",
|
|
31
|
+
forward_delete: "Delete",
|
|
32
|
+
insert: "Insert",
|
|
33
|
+
home: "Home",
|
|
34
|
+
end: "End",
|
|
35
|
+
page_up: "Page_Up",
|
|
36
|
+
pageup: "Page_Up",
|
|
37
|
+
prior: "Page_Up",
|
|
38
|
+
page_down: "Page_Down",
|
|
39
|
+
pagedown: "Page_Down",
|
|
40
|
+
next: "Page_Down",
|
|
41
|
+
up: "Up",
|
|
42
|
+
down: "Down",
|
|
43
|
+
left: "Left",
|
|
44
|
+
right: "Right",
|
|
45
|
+
minus: "minus",
|
|
46
|
+
plus: "plus",
|
|
47
|
+
equal: "equal",
|
|
48
|
+
comma: "comma",
|
|
49
|
+
period: "period",
|
|
50
|
+
slash: "slash",
|
|
51
|
+
backslash: "backslash",
|
|
52
|
+
semicolon: "semicolon",
|
|
53
|
+
apostrophe: "apostrophe",
|
|
54
|
+
grave: "grave",
|
|
55
|
+
bracketleft: "bracketleft",
|
|
56
|
+
bracketright: "bracketright",
|
|
57
|
+
caps_lock: "Caps_Lock",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
for (let n = 1; n <= 20; n++) NAMES[`f${n}`] = `F${n}`;
|
|
61
|
+
|
|
62
|
+
export function parseChord(chord) {
|
|
63
|
+
if (typeof chord !== "string" || chord.length === 0) {
|
|
64
|
+
throw new TypeError("pressKey expects a key such as \"Return\" or \"ctrl+a\"");
|
|
65
|
+
}
|
|
66
|
+
// "ctrl++" means ctrl and the plus key.
|
|
67
|
+
const parts = chord === "+" ? ["+"] : chord.replace(/\+\+$/, "+plus").split("+").filter(Boolean);
|
|
68
|
+
const mods = [];
|
|
69
|
+
let key = null;
|
|
70
|
+
for (const [i, raw] of parts.entries()) {
|
|
71
|
+
const lower = raw.toLowerCase();
|
|
72
|
+
if (i < parts.length - 1) {
|
|
73
|
+
const mod = MODIFIERS[lower];
|
|
74
|
+
if (!mod) throw new TypeError(`Unknown modifier "${raw}" in "${chord}"`);
|
|
75
|
+
if (!mods.includes(mod)) mods.push(mod);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (MODIFIERS[lower] && parts.length === 1) {
|
|
79
|
+
// A bare modifier press.
|
|
80
|
+
key = MODIFIERS[lower];
|
|
81
|
+
} else if (NAMES[lower]) {
|
|
82
|
+
key = NAMES[lower];
|
|
83
|
+
} else if ([...raw].length === 1) {
|
|
84
|
+
key = raw;
|
|
85
|
+
} else {
|
|
86
|
+
throw new TypeError(`Unknown key "${raw}" in "${chord}"`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { key, mods };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Accepts "ctrl+a", "ctrl+a ctrl+c" (space-separated sequence), or an array.
|
|
93
|
+
export function parseKeys(input) {
|
|
94
|
+
const list = Array.isArray(input) ? input : String(input).trim().split(/\s+/);
|
|
95
|
+
return list.map(parseChord);
|
|
96
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Builds and launches "Glovebox Helper.app", the macOS backend.
|
|
2
|
+
//
|
|
3
|
+
// The helper is its own app bundle (not a child of the agent) so that macOS
|
|
4
|
+
// attributes Accessibility and Screen Recording to it. One grant then covers
|
|
5
|
+
// every agent and terminal on the machine.
|
|
6
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { homeDir, VERSION } from "./config.mjs";
|
|
14
|
+
|
|
15
|
+
const run = promisify(execFile);
|
|
16
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
export const HELPER_SOURCE = path.join(here, "..", "native", "macos", "GloveboxHelper.swift");
|
|
18
|
+
export const BUNDLE_ID = "io.github.vpatel9202.glovebox.helper";
|
|
19
|
+
|
|
20
|
+
export function appPath() {
|
|
21
|
+
return path.join(homeDir(), "Glovebox Helper.app");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function socketPath() {
|
|
25
|
+
return process.env.GLOVEBOX_SOCKET || path.join(homeDir(), "helper.sock");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Keyed on the helper source only: macOS ties privacy grants to the build,
|
|
29
|
+
// so the helper is rebuilt only when its own code changes.
|
|
30
|
+
function sourceHash() {
|
|
31
|
+
return createHash("sha256").update(fs.readFileSync(HELPER_SOURCE)).digest("hex").slice(0, 16);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function stampPath() {
|
|
35
|
+
return path.join(appPath(), "Contents", "Resources", "build-stamp");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isBuilt() {
|
|
39
|
+
try {
|
|
40
|
+
return fs.readFileSync(stampPath(), "utf8").trim() === sourceHash();
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function infoPlist() {
|
|
47
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
48
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
49
|
+
<plist version="1.0">
|
|
50
|
+
<dict>
|
|
51
|
+
<key>CFBundleIdentifier</key><string>${BUNDLE_ID}</string>
|
|
52
|
+
<key>CFBundleName</key><string>Glovebox Helper</string>
|
|
53
|
+
<key>CFBundleDisplayName</key><string>Glovebox Helper</string>
|
|
54
|
+
<key>CFBundleExecutable</key><string>glovebox-helper</string>
|
|
55
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
56
|
+
<key>CFBundleShortVersionString</key><string>${VERSION}</string>
|
|
57
|
+
<key>CFBundleVersion</key><string>${VERSION}</string>
|
|
58
|
+
<key>LSMinimumSystemVersion</key><string>14.0</string>
|
|
59
|
+
<key>LSUIElement</key><true/>
|
|
60
|
+
<key>NSHighResolutionCapable</key><true/>
|
|
61
|
+
</dict>
|
|
62
|
+
</plist>
|
|
63
|
+
`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// macOS ties privacy grants to the helper's designated requirement. An ad-hoc
|
|
67
|
+
// signature's requirement is the binary's hash, so every rebuild loses the
|
|
68
|
+
// grants. Signing with a local certificate makes the requirement
|
|
69
|
+
// "identifier + certificate", which survives rebuilds. The certificate does not
|
|
70
|
+
// need to be trusted for this; it only needs to be the same one each time.
|
|
71
|
+
export const SIGNING_NAME = "glovebox local signing";
|
|
72
|
+
|
|
73
|
+
// SHA-1 of the local signing identity, or null. Untrusted identities count.
|
|
74
|
+
export async function signingIdentity() {
|
|
75
|
+
try {
|
|
76
|
+
const { stdout } = await run("security", ["find-identity", "-p", "codesigning"]);
|
|
77
|
+
const line = stdout.split("\n").find((l) => l.includes(`"${SIGNING_NAME}"`));
|
|
78
|
+
return line?.match(/\b([0-9A-F]{40})\b/)?.[1] ?? null;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Creates the signing identity in the login keychain. Run only on the user's
|
|
85
|
+
// request (`glovebox signing --create`); it adds a key to their keychain.
|
|
86
|
+
export async function createSigningIdentity({ log = () => {} } = {}) {
|
|
87
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "glovebox-signing-"));
|
|
88
|
+
try {
|
|
89
|
+
const key = path.join(dir, "key.pem");
|
|
90
|
+
const cert = path.join(dir, "cert.pem");
|
|
91
|
+
const p12 = path.join(dir, "identity.p12");
|
|
92
|
+
const pass = randomBytes(16).toString("hex");
|
|
93
|
+
await run("/usr/bin/openssl", [
|
|
94
|
+
"req", "-x509", "-newkey", "rsa:2048", "-keyout", key, "-out", cert, "-days", "3650", "-nodes",
|
|
95
|
+
"-subj", `/CN=${SIGNING_NAME}`,
|
|
96
|
+
"-addext", "extendedKeyUsage=critical,codeSigning",
|
|
97
|
+
"-addext", "keyUsage=critical,digitalSignature",
|
|
98
|
+
"-addext", "basicConstraints=critical,CA:false",
|
|
99
|
+
]);
|
|
100
|
+
await run("/usr/bin/openssl", ["pkcs12", "-export", "-inkey", key, "-in", cert, "-out", p12, "-passout", `pass:${pass}`, "-name", SIGNING_NAME]);
|
|
101
|
+
// -T lets codesign use the key without a keychain prompt on every build.
|
|
102
|
+
await run("security", ["import", p12, "-k", path.join(os.homedir(), "Library", "Keychains", "login.keychain-db"), "-P", pass, "-T", "/usr/bin/codesign"]);
|
|
103
|
+
log(`Created "${SIGNING_NAME}" in your login keychain.`);
|
|
104
|
+
} finally {
|
|
105
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Compiles the helper with the Xcode command line tools and signs it with the
|
|
110
|
+
// local signing identity when there is one, else ad hoc.
|
|
111
|
+
export async function build({ log = () => {} } = {}) {
|
|
112
|
+
try {
|
|
113
|
+
await run("xcrun", ["--find", "swiftc"]);
|
|
114
|
+
} catch {
|
|
115
|
+
throw new Error("swiftc not found. Install the Xcode command line tools: xcode-select --install");
|
|
116
|
+
}
|
|
117
|
+
const app = appPath();
|
|
118
|
+
const macos = path.join(app, "Contents", "MacOS");
|
|
119
|
+
const resources = path.join(app, "Contents", "Resources");
|
|
120
|
+
fs.mkdirSync(macos, { recursive: true });
|
|
121
|
+
fs.mkdirSync(resources, { recursive: true });
|
|
122
|
+
fs.writeFileSync(path.join(app, "Contents", "Info.plist"), infoPlist());
|
|
123
|
+
log("Compiling Glovebox Helper (about a minute)...");
|
|
124
|
+
const binary = path.join(macos, "glovebox-helper");
|
|
125
|
+
await run("xcrun", ["swiftc", "-O", "-swift-version", "5", HELPER_SOURCE, "-o", binary], {
|
|
126
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
127
|
+
});
|
|
128
|
+
const identity = await signingIdentity();
|
|
129
|
+
await run("codesign", ["--force", "--sign", identity ?? "-", "--identifier", BUNDLE_ID, app]);
|
|
130
|
+
log(identity ? `Signed with "${SIGNING_NAME}"; permission grants survive rebuilds.` : "Signed ad hoc; a rebuild will need permissions granted again (see: glovebox signing).");
|
|
131
|
+
fs.writeFileSync(stampPath(), `${sourceHash()}\n`);
|
|
132
|
+
log(`Built ${app}`);
|
|
133
|
+
return app;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Starts the helper through LaunchServices so it is its own responsible process.
|
|
137
|
+
export async function launch({ requestPermissions = false } = {}) {
|
|
138
|
+
const args = ["-g", "-j", "-a", appPath()];
|
|
139
|
+
for (const name of ["GLOVEBOX_HOME", "GLOVEBOX_APPROVAL", "GLOVEBOX_SOCKET"]) {
|
|
140
|
+
if (process.env[name]) args.push("--env", `${name}=${process.env[name]}`);
|
|
141
|
+
}
|
|
142
|
+
if (requestPermissions) args.push("--args", "--request-permissions");
|
|
143
|
+
await run("open", args);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Stops the helper and waits for it to exit, so the next client does not
|
|
147
|
+
// connect to a process that is shutting down.
|
|
148
|
+
export async function quit() {
|
|
149
|
+
const pattern = path.join(appPath(), "Contents", "MacOS", "glovebox-helper");
|
|
150
|
+
try {
|
|
151
|
+
await run("pkill", ["-f", pattern]);
|
|
152
|
+
} catch {
|
|
153
|
+
return; // Not running.
|
|
154
|
+
}
|
|
155
|
+
for (let i = 0; i < 50; i++) {
|
|
156
|
+
try {
|
|
157
|
+
await run("pgrep", ["-f", pattern]);
|
|
158
|
+
} catch {
|
|
159
|
+
break; // Gone.
|
|
160
|
+
}
|
|
161
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
162
|
+
}
|
|
163
|
+
fs.rmSync(socketPath(), { force: true });
|
|
164
|
+
}
|
package/src/mcp.mjs
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// stdio MCP server exposing one persistent session as `js` and `js_reset`.
|
|
2
|
+
import { VERSION } from "./config.mjs";
|
|
3
|
+
import { loadConfig } from "./config.mjs";
|
|
4
|
+
import { Session } from "./session.mjs";
|
|
5
|
+
|
|
6
|
+
const PROTOCOLS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
7
|
+
|
|
8
|
+
export const JS_DESCRIPTION = `Run JavaScript in a persistent computer-use session. Put a whole step in one call: several actions, then one read. Top-level const/let/function/await persist across calls until js_reset (redeclaring is fine).
|
|
9
|
+
|
|
10
|
+
const calc = await gb.getApp("Calculator"); // name, bundle id, or "pid:123"; launches if needed, does not steal focus
|
|
11
|
+
await calc.pressKey("Escape"); // xdotool syntax: Return, Tab, ctrl+a, super+c (super = Cmd)
|
|
12
|
+
await calc.typeText("12*3="); // "\\n" presses Return
|
|
13
|
+
await calc.getAXState(); // text accessibility tree; later reads return only changes
|
|
14
|
+
|
|
15
|
+
Reads: getAXState({disableDiffing, frames, menus, window}) returns "<index> <role> \\"label\\" value=..." lines. frames:true adds @x,y wxh in image pixels. getScreenshot() and getAXStateAndScreenshot() are the only calls that return an image. Every read emits its output; pass {emit:false} to just get the value.
|
|
16
|
+
Actions: click(target, {button, count, modifiers}), doubleClick, rightClick, scroll(target, dy), drag(from, to), setValue(index, v), focus(index), selectText(index, {start, length}), performSecondaryAction(index, "Show Menu"), listActions(index), activate(), windows(), frame(index), center(index).
|
|
17
|
+
A target is an element index from the tree, or [x, y] pixels in the window image (same space as the screenshot and frames). Prefer indices; use pixels when the tree has nothing useful (remote desktops, canvases). Input stays in the background (focus and the user's cursor don't move); pass {foreground: true} to an action if an app ignores it.
|
|
18
|
+
gb.listApps() lists running apps. console.log adds text to the result. help() or help("click") prints the full API. If a result says "Stopped:" the user took over (switched apps or pressed Escape): stop and ask before continuing.`;
|
|
19
|
+
|
|
20
|
+
const TOOLS = [
|
|
21
|
+
{
|
|
22
|
+
name: "js",
|
|
23
|
+
description: JS_DESCRIPTION,
|
|
24
|
+
inputSchema: {
|
|
25
|
+
type: "object",
|
|
26
|
+
additionalProperties: false,
|
|
27
|
+
properties: {
|
|
28
|
+
code: { type: "string", description: "JavaScript to run. Top-level await is supported." },
|
|
29
|
+
title: { type: "string", description: "A few words naming this step, for the action log." },
|
|
30
|
+
timeout_ms: { type: "integer", description: "Time limit in milliseconds (default 120000)." },
|
|
31
|
+
},
|
|
32
|
+
required: ["code"],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "js_reset",
|
|
37
|
+
description: "Restart the session and drop every binding. Use when a script is stuck or you want a clean slate.",
|
|
38
|
+
inputSchema: { type: "object", additionalProperties: false, properties: {} },
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
export function serveMcp({ input = process.stdin, output = process.stdout } = {}) {
|
|
43
|
+
let session = null;
|
|
44
|
+
let clientName = "An agent";
|
|
45
|
+
|
|
46
|
+
const write = (message) => output.write(`${JSON.stringify(message)}\n`);
|
|
47
|
+
const reply = (id, result) => write({ jsonrpc: "2.0", id, result });
|
|
48
|
+
const fail = (id, code, message) => write({ jsonrpc: "2.0", id, error: { code, message } });
|
|
49
|
+
|
|
50
|
+
async function call(name, args) {
|
|
51
|
+
if (name === "js_reset") {
|
|
52
|
+
session?.reset();
|
|
53
|
+
return { content: [{ type: "text", text: "Session reset. All bindings are gone." }], isError: false };
|
|
54
|
+
}
|
|
55
|
+
if (name !== "js") throw Object.assign(new Error(`Unknown tool ${name}`), { rpc: -32602 });
|
|
56
|
+
if (typeof args?.code !== "string" || !args.code.trim()) {
|
|
57
|
+
return { content: [{ type: "text", text: "js needs non-empty code" }], isError: true };
|
|
58
|
+
}
|
|
59
|
+
session ??= new Session({ client: clientName, imageMode: "inline" });
|
|
60
|
+
const timeoutMs = Number.isInteger(args.timeout_ms) && args.timeout_ms > 0 ? args.timeout_ms : loadConfig().timeoutMs;
|
|
61
|
+
const result = await session.run(args.code, { title: args.title, timeoutMs });
|
|
62
|
+
const content = result.content?.length ? result.content : [{ type: "text", text: result.ok ? "(no output)" : "Script failed" }];
|
|
63
|
+
return { content, isError: !result.ok };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function handle(message) {
|
|
67
|
+
const { id, method, params } = message;
|
|
68
|
+
const isRequest = Object.hasOwn(message, "id") && method;
|
|
69
|
+
try {
|
|
70
|
+
switch (method) {
|
|
71
|
+
case "initialize": {
|
|
72
|
+
clientName = params?.clientInfo?.name || clientName;
|
|
73
|
+
const requested = params?.protocolVersion;
|
|
74
|
+
reply(id, {
|
|
75
|
+
protocolVersion: PROTOCOLS.includes(requested) ? requested : PROTOCOLS[0],
|
|
76
|
+
capabilities: { tools: {} },
|
|
77
|
+
serverInfo: { name: "glovebox", version: VERSION },
|
|
78
|
+
instructions:
|
|
79
|
+
"glovebox drives desktop apps through one persistent JavaScript session. Use the js tool for every GUI step and batch several actions per call.",
|
|
80
|
+
});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
case "ping":
|
|
84
|
+
if (isRequest) reply(id, {});
|
|
85
|
+
return;
|
|
86
|
+
case "tools/list":
|
|
87
|
+
reply(id, { tools: TOOLS });
|
|
88
|
+
return;
|
|
89
|
+
case "tools/call":
|
|
90
|
+
reply(id, await call(params?.name, params?.arguments ?? {}));
|
|
91
|
+
return;
|
|
92
|
+
default:
|
|
93
|
+
if (isRequest) fail(id, -32601, `Method not found: ${method}`);
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (isRequest) fail(id, error.rpc ?? -32603, error.message);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let buffer = "";
|
|
101
|
+
input.setEncoding("utf8");
|
|
102
|
+
input.on("data", (chunk) => {
|
|
103
|
+
buffer += chunk;
|
|
104
|
+
let newline;
|
|
105
|
+
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
106
|
+
const line = buffer.slice(0, newline).trim();
|
|
107
|
+
buffer = buffer.slice(newline + 1);
|
|
108
|
+
if (!line) continue;
|
|
109
|
+
let message;
|
|
110
|
+
try {
|
|
111
|
+
message = JSON.parse(line);
|
|
112
|
+
} catch {
|
|
113
|
+
fail(null, -32700, "Parse error");
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
void handle(message);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
const shutdown = () => {
|
|
120
|
+
session?.close();
|
|
121
|
+
process.exit(0);
|
|
122
|
+
};
|
|
123
|
+
input.on("end", shutdown);
|
|
124
|
+
process.on("SIGTERM", shutdown);
|
|
125
|
+
process.on("SIGINT", shutdown);
|
|
126
|
+
}
|