privateer-agent 0.12.30 → 0.12.32
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/bin/privateer-launch.mjs +29 -0
- package/bin/privateer-splash.mjs +73 -11
- package/extensions/privateer-brand.ts +7 -0
- package/extensions/privateer-computer.ts +24 -0
- package/native/linux/privateer-computer.mjs +536 -0
- package/native/win/PrivateerComputer.ps1 +502 -0
- package/package.json +2 -1
- package/src/acp/run.ts +6 -1
- package/src/cli/chat.ts +7 -1
- package/src/computer/helper.ts +426 -0
- package/src/computer/preview.ts +124 -0
- package/src/computer/space.ts +289 -0
- package/src/config/computerControl.ts +67 -0
- package/src/config/moat.ts +46 -6
- package/src/config/moatManifest.json +1 -0
- package/src/config/privacyPolicy.ts +40 -1
- package/src/config/relayExposure.ts +26 -0
- package/src/ext/permissionGate.ts +25 -0
- package/src/harbor/index.ts +20 -0
- package/src/harbor/ipc.ts +4 -1
- package/src/permissions/classify.ts +92 -0
- package/src/permissions/gate.ts +55 -1
- package/src/permissions/mode.ts +10 -0
- package/src/providers/account.ts +6 -0
- package/src/providers/defaultModel.ts +42 -7
- package/src/remote/relayClient.ts +34 -1
- package/src/tools/computer.ts +442 -0
- package/src/util/gzipRequestBody.ts +150 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The platform helper — one long-lived child process that owns the screen and the
|
|
3
|
+
* input devices, spoken to in newline-delimited JSON over stdin/stdout.
|
|
4
|
+
*
|
|
5
|
+
* WHY A SEPARATE PROCESS AT ALL. Every OS puts GUI control behind a native API that
|
|
6
|
+
* Node cannot reach without a compiled addon: CGEvent/ScreenCaptureKit on macOS,
|
|
7
|
+
* SendInput/BitBlt on Windows, the X11 or Wayland protocols on Linux. The alternative
|
|
8
|
+
* to a helper is an npm native module (nut.js, robotjs), which means a prebuilt binary
|
|
9
|
+
* per platform AND per architecture riding in node_modules — fighting the pinned-Node
|
|
10
|
+
* self-contained bundles, code signing and notarization, and the Intel/ARM mac split
|
|
11
|
+
* that has already cost us one bad release. A helper we compile ourselves is one file
|
|
12
|
+
* per platform, signed with the app, with no install-time build step anywhere.
|
|
13
|
+
*
|
|
14
|
+
* WHY LONG-LIVED, not a process per action. Three reasons, all load-bearing:
|
|
15
|
+
* • TCC. macOS attributes Screen Recording and Accessibility grants to a process's
|
|
16
|
+
* signing identity; one persistent helper prompts the user once, where a
|
|
17
|
+
* process-per-click would make consent state harder to reason about and the
|
|
18
|
+
* latency of each grant check real.
|
|
19
|
+
* • Latency. A GUI loop is screenshot → think → click → screenshot, dozens of times.
|
|
20
|
+
* Paying ~80ms of process spawn on each one is most of a second per interaction.
|
|
21
|
+
* • The coordinate space. Resolution and downscale live in ONE conversation with ONE
|
|
22
|
+
* process, so the frame the model saw and the click that follows it cannot be
|
|
23
|
+
* served by two helpers that disagree (computer/space.ts explains why that matters
|
|
24
|
+
* more here than anywhere else).
|
|
25
|
+
*
|
|
26
|
+
* DEGRADING IS THE NORMAL CASE, NOT AN ERROR PATH. Phase 1 ships this client and no
|
|
27
|
+
* binaries; a platform with no helper must say so in a sentence the model can relay,
|
|
28
|
+
* the way `sfx.configured: false` and the sprite pipeline's ffmpeg probe already do —
|
|
29
|
+
* never fail at the moment of use with a spawn error, and never after having promised
|
|
30
|
+
* the user it would work.
|
|
31
|
+
*
|
|
32
|
+
* A FAILED PROBE IS RETRIED, NOT LATCHED. Same reasoning as
|
|
33
|
+
* services/sprites/frameExtract.js: a spawn can fail for reasons unrelated to the
|
|
34
|
+
* binary being absent (a grant not yet given, a helper still being installed by an
|
|
35
|
+
* update), and caching that would take screen control down until the process restarts.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
39
|
+
import { existsSync } from "node:fs";
|
|
40
|
+
import { dirname, join } from "node:path";
|
|
41
|
+
import { fileURLToPath } from "node:url";
|
|
42
|
+
import type { CapturePlan, Display } from "./space.ts";
|
|
43
|
+
|
|
44
|
+
/** Point the client at a specific helper binary. The desktop shell sets this to the copy inside its app bundle. */
|
|
45
|
+
export const HELPER_PATH_ENV = "PRIVATEER_COMPUTER_HELPER";
|
|
46
|
+
|
|
47
|
+
/** The name the helper ships under, on PATH or beside the bundle. */
|
|
48
|
+
const HELPER_BIN = process.platform === "win32" ? "privateer-computer.exe" : "privateer-computer";
|
|
49
|
+
|
|
50
|
+
/** A helper call that hasn't answered in this long is treated as wedged. Generous: a capture on a large display is real work. */
|
|
51
|
+
const CALL_TIMEOUT_MS = 20_000;
|
|
52
|
+
/** How long a failed spawn is remembered before we try again. */
|
|
53
|
+
const PROBE_COOLDOWN_MS = 30_000;
|
|
54
|
+
|
|
55
|
+
// ─── Wire protocol ───────────────────────────────────────────────────────────
|
|
56
|
+
// Requests carry an `id` the reply echoes; nothing here assumes replies arrive in
|
|
57
|
+
// order, because a capture and a grant check legitimately overlap.
|
|
58
|
+
|
|
59
|
+
export interface CaptureRequest {
|
|
60
|
+
op: "capture";
|
|
61
|
+
display: string;
|
|
62
|
+
/** The helper resizes natively to exactly this — see computer/space.ts on why the helper, not us. */
|
|
63
|
+
targetWidth: number;
|
|
64
|
+
targetHeight: number;
|
|
65
|
+
/**
|
|
66
|
+
* Optionally, a SECOND much smaller copy of the same grab, for the permission dialog.
|
|
67
|
+
*
|
|
68
|
+
* Asked for in the same call rather than taken separately, and that is the whole
|
|
69
|
+
* point: a second capture would be a second moment in time, so the picture a person
|
|
70
|
+
* approves a click against could show a screen the model never saw. One grab, two
|
|
71
|
+
* encodings — every helper already has the resize step.
|
|
72
|
+
*/
|
|
73
|
+
previewWidth?: number;
|
|
74
|
+
previewHeight?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type PointerAction = "move" | "click" | "double_click" | "down" | "up" | "drag" | "scroll";
|
|
78
|
+
export type MouseButton = "left" | "right" | "middle";
|
|
79
|
+
|
|
80
|
+
export interface PointerRequest {
|
|
81
|
+
op: "pointer";
|
|
82
|
+
action: PointerAction;
|
|
83
|
+
/**
|
|
84
|
+
* Which display the coordinates belong to. REQUIRED, and not a convenience: x/y are
|
|
85
|
+
* local to one display's own device pixels, because a global device-pixel space does
|
|
86
|
+
* not exist on a mixed-DPI desktop (computer/space.ts, Display.originX). The helper
|
|
87
|
+
* knows that display's bounds and scale and is the only thing that converts into the
|
|
88
|
+
* OS's global point space.
|
|
89
|
+
*/
|
|
90
|
+
display: string;
|
|
91
|
+
/** DEVICE pixels, origin at this display's top-left. From computer/space.ts and nowhere else. */
|
|
92
|
+
x: number;
|
|
93
|
+
y: number;
|
|
94
|
+
toX?: number;
|
|
95
|
+
toY?: number;
|
|
96
|
+
button?: MouseButton;
|
|
97
|
+
scrollX?: number;
|
|
98
|
+
scrollY?: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface KeyRequest {
|
|
102
|
+
op: "key";
|
|
103
|
+
action: "type" | "press";
|
|
104
|
+
/** Literal text to type, for action "type". */
|
|
105
|
+
text?: string;
|
|
106
|
+
/** A chord such as "cmd+shift+4" or a named key such as "return", for action "press". */
|
|
107
|
+
keys?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type HelperRequest =
|
|
111
|
+
| { op: "displays" }
|
|
112
|
+
| { op: "grants" }
|
|
113
|
+
| CaptureRequest
|
|
114
|
+
| PointerRequest
|
|
115
|
+
| KeyRequest;
|
|
116
|
+
|
|
117
|
+
/** What the OS currently permits. Every field is a fact about this machine, not a preference. */
|
|
118
|
+
export interface Grants {
|
|
119
|
+
/** Screen Recording (macOS TCC) or equivalent. False ⇒ capture returns a black or empty frame. */
|
|
120
|
+
screen: boolean;
|
|
121
|
+
/** Accessibility (macOS) / uiAccess (Windows). False ⇒ synthesized input is silently dropped by the OS. */
|
|
122
|
+
input: boolean;
|
|
123
|
+
/**
|
|
124
|
+
* A password field somewhere has secure input enabled. The OS suppresses synthesized
|
|
125
|
+
* keystrokes entirely while this is on, so a `type` would appear to succeed and put
|
|
126
|
+
* nothing anywhere. Reported so the refusal can name the real cause.
|
|
127
|
+
*/
|
|
128
|
+
secureInput: boolean;
|
|
129
|
+
/** pid of the frontmost application, for the self-click interlock in tools/computer.ts. */
|
|
130
|
+
frontmostPid?: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface CaptureResult {
|
|
134
|
+
mimeType: string;
|
|
135
|
+
/** base64 — handed to the model as an ImageContent block. */
|
|
136
|
+
data: string;
|
|
137
|
+
width: number;
|
|
138
|
+
height: number;
|
|
139
|
+
/** base64 PNG at the requested preview size, when one was asked for and produced. */
|
|
140
|
+
preview?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Why screen control isn't available here. Written for a user to read, not a log. */
|
|
144
|
+
export interface Unavailable {
|
|
145
|
+
available: false;
|
|
146
|
+
reason: string;
|
|
147
|
+
}
|
|
148
|
+
export interface Available {
|
|
149
|
+
available: true;
|
|
150
|
+
}
|
|
151
|
+
export type Availability = Available | Unavailable;
|
|
152
|
+
|
|
153
|
+
// ─── Locating the helper ─────────────────────────────────────────────────────
|
|
154
|
+
//
|
|
155
|
+
// THREE SHAPES, not one, and the difference is the platform's own. macOS needs a
|
|
156
|
+
// compiled binary (CGEvent has no scriptable equivalent), so it ships as a signed
|
|
157
|
+
// artifact inside the desktop app — the ONE helper the CLI does not carry. Windows and
|
|
158
|
+
// Linux are served by a PowerShell script and a Node script, which ride in this package
|
|
159
|
+
// and therefore work for the CLI as well as the app.
|
|
160
|
+
|
|
161
|
+
/** How to start the helper: a command, its arguments, and any environment it needs. */
|
|
162
|
+
export interface HelperCommand {
|
|
163
|
+
command: string;
|
|
164
|
+
args: string[];
|
|
165
|
+
env?: Record<string, string>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The mode a helper is being started in. `serve` is the long-lived protocol loop. */
|
|
169
|
+
export type HelperMode = "serve" | "grants";
|
|
170
|
+
|
|
171
|
+
function nativeDir(): string {
|
|
172
|
+
// src/computer → the package root, then native/. Present in the npm package (see the
|
|
173
|
+
// `files` list) and inside the desktop's bundled copy of this package.
|
|
174
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "native");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The Node to run a .mjs helper with.
|
|
179
|
+
*
|
|
180
|
+
* Under Electron `process.execPath` is the app, not Node — so it is re-entered as Node
|
|
181
|
+
* through ELECTRON_RUN_AS_NODE, the same accommodation desktop/scripts already make for
|
|
182
|
+
* the CLI shim. Getting this wrong would launch a second copy of the whole application
|
|
183
|
+
* per session, which is a spectacular failure rather than a quiet one, but a failure
|
|
184
|
+
* worth not having.
|
|
185
|
+
*/
|
|
186
|
+
function nodeCommand(): { command: string; env?: Record<string, string> } {
|
|
187
|
+
const electron = !!(process as any).versions?.electron;
|
|
188
|
+
return electron
|
|
189
|
+
? { command: process.execPath, env: { ELECTRON_RUN_AS_NODE: "1" } }
|
|
190
|
+
: { command: process.execPath };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function scriptCommand(script: string, mode: HelperMode): HelperCommand {
|
|
194
|
+
if (script.endsWith(".ps1")) {
|
|
195
|
+
// -NoProfile so a user's PowerShell profile cannot print into our protocol stream;
|
|
196
|
+
// -NonInteractive so nothing can block waiting for input that will never come;
|
|
197
|
+
// -ExecutionPolicy Bypass because the default policy refuses unsigned local scripts
|
|
198
|
+
// and this one ships inside a package rather than being signed on its own.
|
|
199
|
+
return {
|
|
200
|
+
command: "powershell.exe",
|
|
201
|
+
args: [
|
|
202
|
+
"-NoProfile",
|
|
203
|
+
"-NonInteractive",
|
|
204
|
+
"-ExecutionPolicy", "Bypass",
|
|
205
|
+
"-File", script,
|
|
206
|
+
mode === "serve" ? "-Serve" : "-Grants",
|
|
207
|
+
],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const node = nodeCommand();
|
|
211
|
+
return { command: node.command, args: [script, `--${mode}`], env: node.env };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Candidate paths for the compiled macOS helper, in the order they should win. */
|
|
215
|
+
function bundledCandidates(): string[] {
|
|
216
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
217
|
+
return [
|
|
218
|
+
join(root, "build-resources", "bin", process.platform, HELPER_BIN),
|
|
219
|
+
join(root, "bin", HELPER_BIN),
|
|
220
|
+
];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* How to start the helper on this machine, or undefined when there is none.
|
|
225
|
+
*
|
|
226
|
+
* The explicit override wins over everything, and on macOS that is the ordinary case:
|
|
227
|
+
* the desktop points it at the signed copy inside its own app bundle. That matters
|
|
228
|
+
* beyond tidiness — a signed helper inside the bundle inherits the app's TCC identity,
|
|
229
|
+
* where a stray `privateer-computer` on PATH is a different binary whose grants the user
|
|
230
|
+
* would have to give again, discovering the problem only as the feature not working.
|
|
231
|
+
*/
|
|
232
|
+
export function findHelper(mode: HelperMode = "serve"): HelperCommand | undefined {
|
|
233
|
+
const override = process.env[HELPER_PATH_ENV]?.trim();
|
|
234
|
+
if (override) {
|
|
235
|
+
if (!existsSync(override)) return undefined;
|
|
236
|
+
if (override.endsWith(".ps1") || override.endsWith(".mjs")) return scriptCommand(override, mode);
|
|
237
|
+
return { command: override, args: [`--${mode}`] };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (process.platform === "win32") {
|
|
241
|
+
const script = join(nativeDir(), "win", "PrivateerComputer.ps1");
|
|
242
|
+
return existsSync(script) ? scriptCommand(script, mode) : undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (process.platform === "linux") {
|
|
246
|
+
const script = join(nativeDir(), "linux", "privateer-computer.mjs");
|
|
247
|
+
return existsSync(script) ? scriptCommand(script, mode) : undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
for (const c of bundledCandidates()) {
|
|
251
|
+
if (existsSync(c)) return { command: c, args: [`--${mode}`] };
|
|
252
|
+
}
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Platform-specific truth about why there is no helper. Worth saying precisely, because
|
|
258
|
+
* "screen control unavailable" with no reason is what sends someone hunting through
|
|
259
|
+
* settings that are already correct.
|
|
260
|
+
*
|
|
261
|
+
* Note the Linux and Windows cases are about the helper SCRIPT being absent, which
|
|
262
|
+
* should not happen in a working install — the interesting Linux failures (no xdotool,
|
|
263
|
+
* a GNOME Wayland session) are reported by the helper itself, which is the only thing
|
|
264
|
+
* that can see them.
|
|
265
|
+
*/
|
|
266
|
+
function missingReason(): string {
|
|
267
|
+
switch (process.platform) {
|
|
268
|
+
case "darwin":
|
|
269
|
+
return (
|
|
270
|
+
"The macOS screen-control helper isn't installed with this build. It ships with the " +
|
|
271
|
+
"Privateer desktop app; the command-line agent does not carry it. Once installed it " +
|
|
272
|
+
"also needs Screen Recording and Accessibility permission in System Settings."
|
|
273
|
+
);
|
|
274
|
+
case "win32":
|
|
275
|
+
return "The Windows screen-control helper is missing from this installation.";
|
|
276
|
+
case "linux":
|
|
277
|
+
return "The Linux screen-control helper is missing from this installation.";
|
|
278
|
+
default:
|
|
279
|
+
return `Screen control has no helper for ${process.platform}.`;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ─── The client ──────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
interface Pending {
|
|
286
|
+
resolve: (v: any) => void;
|
|
287
|
+
reject: (e: Error) => void;
|
|
288
|
+
timer: NodeJS.Timeout;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* One helper connection. Created per session (tools/computer.ts holds it in a closure,
|
|
293
|
+
* never at module scope — the desktop runs several sessions in ONE process and a shared
|
|
294
|
+
* helper would let one window's coordinate state answer another window's click).
|
|
295
|
+
*/
|
|
296
|
+
export class ComputerHelper {
|
|
297
|
+
private child: ChildProcessWithoutNullStreams | undefined;
|
|
298
|
+
private pending = new Map<number, Pending>();
|
|
299
|
+
private nextId = 1;
|
|
300
|
+
private buffer = "";
|
|
301
|
+
private lastFailureAt = 0;
|
|
302
|
+
private lastFailure = "";
|
|
303
|
+
|
|
304
|
+
/** Whether a helper can be reached right now, and if not, why — in a sentence a user can act on. */
|
|
305
|
+
availability(): Availability {
|
|
306
|
+
if (this.child && !this.child.killed) return { available: true };
|
|
307
|
+
if (!findHelper()) return { available: false, reason: missingReason() };
|
|
308
|
+
if (this.lastFailure && Date.now() - this.lastFailureAt < PROBE_COOLDOWN_MS) {
|
|
309
|
+
return { available: false, reason: this.lastFailure };
|
|
310
|
+
}
|
|
311
|
+
return { available: true };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private start(): ChildProcessWithoutNullStreams {
|
|
315
|
+
if (this.child && !this.child.killed) return this.child;
|
|
316
|
+
const helper = findHelper("serve");
|
|
317
|
+
if (!helper) throw new Error(missingReason());
|
|
318
|
+
|
|
319
|
+
const child = spawn(helper.command, helper.args, {
|
|
320
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
321
|
+
env: helper.env ? { ...process.env, ...helper.env } : process.env,
|
|
322
|
+
});
|
|
323
|
+
this.child = child;
|
|
324
|
+
this.buffer = "";
|
|
325
|
+
|
|
326
|
+
child.stdout.setEncoding("utf8");
|
|
327
|
+
child.stdout.on("data", (chunk: string) => this.onData(chunk));
|
|
328
|
+
// The helper's stderr is diagnostics, never a reply. Kept off the model's context
|
|
329
|
+
// on purpose: it carries window titles and application names from whatever is on
|
|
330
|
+
// screen, which is exactly the untrusted text we don't want reaching a planner.
|
|
331
|
+
child.stderr.resume();
|
|
332
|
+
|
|
333
|
+
const fail = (message: string) => {
|
|
334
|
+
this.lastFailure = message;
|
|
335
|
+
this.lastFailureAt = Date.now();
|
|
336
|
+
this.child = undefined;
|
|
337
|
+
for (const [, p] of this.pending) {
|
|
338
|
+
clearTimeout(p.timer);
|
|
339
|
+
p.reject(new Error(message));
|
|
340
|
+
}
|
|
341
|
+
this.pending.clear();
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
child.on("error", (err) => fail(`The screen-control helper could not start: ${err.message}`));
|
|
345
|
+
child.on("exit", (code, signal) =>
|
|
346
|
+
fail(`The screen-control helper stopped (${signal ?? `exit ${code}`}).`),
|
|
347
|
+
);
|
|
348
|
+
return child;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private onData(chunk: string): void {
|
|
352
|
+
this.buffer += chunk;
|
|
353
|
+
let nl: number;
|
|
354
|
+
while ((nl = this.buffer.indexOf("\n")) >= 0) {
|
|
355
|
+
const line = this.buffer.slice(0, nl).trim();
|
|
356
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
357
|
+
if (!line) continue;
|
|
358
|
+
let msg: any;
|
|
359
|
+
try {
|
|
360
|
+
msg = JSON.parse(line);
|
|
361
|
+
} catch {
|
|
362
|
+
continue; // a helper that writes noise to stdout must not wedge every caller
|
|
363
|
+
}
|
|
364
|
+
const p = this.pending.get(msg?.id);
|
|
365
|
+
if (!p) continue;
|
|
366
|
+
this.pending.delete(msg.id);
|
|
367
|
+
clearTimeout(p.timer);
|
|
368
|
+
if (msg.ok === false) p.reject(new Error(String(msg.error ?? "the helper refused")));
|
|
369
|
+
else p.resolve(msg);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private call<T>(req: HelperRequest): Promise<T> {
|
|
374
|
+
const child = this.start();
|
|
375
|
+
const id = this.nextId++;
|
|
376
|
+
return new Promise<T>((resolve, reject) => {
|
|
377
|
+
const timer = setTimeout(() => {
|
|
378
|
+
this.pending.delete(id);
|
|
379
|
+
reject(new Error(`The screen-control helper did not answer ${req.op} in ${CALL_TIMEOUT_MS / 1000}s.`));
|
|
380
|
+
}, CALL_TIMEOUT_MS);
|
|
381
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
382
|
+
child.stdin.write(`${JSON.stringify({ id, ...req })}\n`);
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async displays(): Promise<Display[]> {
|
|
387
|
+
const r = await this.call<{ displays: Display[] }>({ op: "displays" });
|
|
388
|
+
return r.displays ?? [];
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async grants(): Promise<Grants> {
|
|
392
|
+
const r = await this.call<{ grants: Grants }>({ op: "grants" });
|
|
393
|
+
return r.grants ?? { screen: false, input: false, secureInput: false };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Capture `plan`'s display, downscaled by the helper to exactly the plan's agent-space size. */
|
|
397
|
+
async capture(plan: CapturePlan, preview?: { width: number; height: number }): Promise<CaptureResult> {
|
|
398
|
+
return this.call<CaptureResult>({
|
|
399
|
+
op: "capture",
|
|
400
|
+
display: plan.displayId,
|
|
401
|
+
targetWidth: plan.width,
|
|
402
|
+
targetHeight: plan.height,
|
|
403
|
+
...(preview ? { previewWidth: preview.width, previewHeight: preview.height } : {}),
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async pointer(req: Omit<PointerRequest, "op">): Promise<void> {
|
|
408
|
+
await this.call({ op: "pointer", ...req });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async key(req: Omit<KeyRequest, "op">): Promise<void> {
|
|
412
|
+
await this.call({ op: "key", ...req });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Stop the helper. Called when a session ends so a closed window leaves no process holding the screen. */
|
|
416
|
+
dispose(): void {
|
|
417
|
+
const child = this.child;
|
|
418
|
+
this.child = undefined;
|
|
419
|
+
for (const [, p] of this.pending) {
|
|
420
|
+
clearTimeout(p.timer);
|
|
421
|
+
p.reject(new Error("The session ended."));
|
|
422
|
+
}
|
|
423
|
+
this.pending.clear();
|
|
424
|
+
child?.kill();
|
|
425
|
+
}
|
|
426
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The approval preview sink — how the picture reaches the permission prompt.
|
|
3
|
+
*
|
|
4
|
+
* ── The problem this solves ──────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* The gate classifies a tool call from `{ toolName, input }` and nothing else
|
|
7
|
+
* (permissions/classify.ts). That is the right shape for every other kind: a path, a
|
|
8
|
+
* command and a URL are all IN the input. A click is not — `{x: 812, y: 344}` means
|
|
9
|
+
* nothing without the screenshot those coordinates are relative to, and that screenshot
|
|
10
|
+
* lives in the closure of tools/computer.ts, which the classifier cannot reach and
|
|
11
|
+
* should not learn about.
|
|
12
|
+
*
|
|
13
|
+
* So the frame travels through an object both halves are handed: the tools write the
|
|
14
|
+
* last capture in, the gate extension reads a preview out. One per session.
|
|
15
|
+
*
|
|
16
|
+
* ── Why not module state, one more time ──────────────────────────────────────
|
|
17
|
+
*
|
|
18
|
+
* It would be two lines shorter and it would be a real bug. The desktop runs one
|
|
19
|
+
* session PER WINDOW inside a single process, so a module-level "last frame" would let
|
|
20
|
+
* one window's screenshot illustrate another window's approval — showing a person a
|
|
21
|
+
* picture of the wrong screen and asking them to approve a click on it. That is worse
|
|
22
|
+
* than showing no picture at all, because it looks like it worked. The sink is created
|
|
23
|
+
* per session in buildMoat and handed to exactly the two things that need it, the same
|
|
24
|
+
* way relayFiles.bridge already is.
|
|
25
|
+
*
|
|
26
|
+
* ── Bounded on purpose ───────────────────────────────────────────────────────
|
|
27
|
+
*
|
|
28
|
+
* One frame per display, replaced on each capture, never accumulated. A GUI loop takes
|
|
29
|
+
* dozens of screenshots per task; keeping a history would grow a session's memory
|
|
30
|
+
* without anyone asking for it, and the only frame an approval can honestly be
|
|
31
|
+
* illustrated with is the most recent one for that display anyway — the model's
|
|
32
|
+
* coordinates are relative to that and nothing else.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { ApprovalPreview } from "../permissions/gate.ts";
|
|
36
|
+
|
|
37
|
+
/** One display's most recent frame, downscaled for a dialog. */
|
|
38
|
+
export interface PreviewFrame {
|
|
39
|
+
displayId: string;
|
|
40
|
+
/** base64 PNG at preview size, not the full frame the model received. */
|
|
41
|
+
data: string;
|
|
42
|
+
width: number;
|
|
43
|
+
height: number;
|
|
44
|
+
/** The AGENT-space dimensions the tool's coordinates are in. */
|
|
45
|
+
frameWidth: number;
|
|
46
|
+
frameHeight: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The pointer actions worth drawing a marker for. Others get the picture alone. */
|
|
50
|
+
const POINTER_ACTIONS = new Set(["click", "double_click", "right_click", "move", "drag", "scroll"]);
|
|
51
|
+
|
|
52
|
+
function num(v: unknown): number | undefined {
|
|
53
|
+
const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
|
|
54
|
+
return Number.isFinite(n) ? n : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class ComputerPreviewSink {
|
|
58
|
+
private frames = new Map<string, PreviewFrame>();
|
|
59
|
+
/** The display captured most recently, so an action that omits `display` still illustrates. */
|
|
60
|
+
private lastDisplayId: string | undefined;
|
|
61
|
+
|
|
62
|
+
/** Record a capture. Replaces whatever that display had. */
|
|
63
|
+
put(frame: PreviewFrame): void {
|
|
64
|
+
this.frames.set(frame.displayId, frame);
|
|
65
|
+
this.lastDisplayId = frame.displayId;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Forget everything — called when a session's helper is disposed. */
|
|
69
|
+
clear(): void {
|
|
70
|
+
this.frames.clear();
|
|
71
|
+
this.lastDisplayId = undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build the preview for a pending `computer_control` call, or undefined when there is
|
|
76
|
+
* nothing honest to show.
|
|
77
|
+
*
|
|
78
|
+
* Resolves the display exactly as the tool does — the named one, else the last
|
|
79
|
+
* captured — so the picture in the dialog is the picture the coordinates are against.
|
|
80
|
+
* If those two ever disagreed the dialog would be actively misleading, which is the
|
|
81
|
+
* one outcome worse than a text-only prompt.
|
|
82
|
+
*/
|
|
83
|
+
previewFor(toolName: string, input: unknown): ApprovalPreview | undefined {
|
|
84
|
+
if (toolName !== "computer_control") return undefined;
|
|
85
|
+
const obj: Record<string, unknown> =
|
|
86
|
+
input && typeof input === "object" ? (input as Record<string, unknown>) : {};
|
|
87
|
+
|
|
88
|
+
const action = typeof obj.action === "string" ? obj.action : undefined;
|
|
89
|
+
// `type`, `key` and `wait` have no location, and a picture with no marker invites
|
|
90
|
+
// the reader to look for one. They keep the text-only prompt, where the detail line
|
|
91
|
+
// already carries the whole decision (the literal text, verbatim).
|
|
92
|
+
if (!action || !POINTER_ACTIONS.has(action)) return undefined;
|
|
93
|
+
|
|
94
|
+
const displayId = typeof obj.display === "string" && obj.display ? obj.display : this.lastDisplayId;
|
|
95
|
+
const frame = displayId ? this.frames.get(displayId) : undefined;
|
|
96
|
+
|
|
97
|
+
const x = num(obj.x);
|
|
98
|
+
const y = num(obj.y);
|
|
99
|
+
const toX = num(obj.to_x);
|
|
100
|
+
const toY = num(obj.to_y);
|
|
101
|
+
|
|
102
|
+
// A frame with no coordinates, or coordinates with no frame, are both still worth
|
|
103
|
+
// sending: the picture alone tells someone which screen this is about, and a marker
|
|
104
|
+
// position with no picture is dropped harmlessly by the renderer.
|
|
105
|
+
if (!frame && x === undefined) return undefined;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
action,
|
|
109
|
+
...(frame
|
|
110
|
+
? {
|
|
111
|
+
image: {
|
|
112
|
+
data: frame.data,
|
|
113
|
+
width: frame.width,
|
|
114
|
+
height: frame.height,
|
|
115
|
+
frameWidth: frame.frameWidth,
|
|
116
|
+
frameHeight: frame.frameHeight,
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
: {}),
|
|
120
|
+
...(x !== undefined && y !== undefined ? { target: { x, y } } : {}),
|
|
121
|
+
...(action === "drag" && toX !== undefined && toY !== undefined ? { to: { x: toX, y: toY } } : {}),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|