privateer-agent 0.12.29 → 0.12.31
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/extensions/privateer-brand.ts +7 -0
- package/extensions/privateer-computer.ts +24 -0
- package/extensions/privateer-context.ts +25 -11
- 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
package/bin/privateer-launch.mjs
CHANGED
|
@@ -165,6 +165,35 @@ if (NO_QUARTER) {
|
|
|
165
165
|
);
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
// `--allow-computer-control` — arm GUI control (screenshots, mouse, keyboard) for this
|
|
169
|
+
// session. Stripped before Pi's cli.js sees it, exactly like --no-quarter above.
|
|
170
|
+
//
|
|
171
|
+
// A FLAG AND NOT A SETTING, deliberately. Screen control is the one capability that
|
|
172
|
+
// reaches outside every other limit the gate enforces — a mouse can open a terminal and
|
|
173
|
+
// type what the denylist would have caught — so it is armed per session, by the person
|
|
174
|
+
// starting it, rather than left on in a config file from a week ago. Arming is not
|
|
175
|
+
// approving: every action still prompts (permissions/mode.ts), and the tools do not
|
|
176
|
+
// exist at all without this (config/computerControl.ts).
|
|
177
|
+
const ALLOW_COMPUTER = args.some((a) => a === "--allow-computer-control");
|
|
178
|
+
if (ALLOW_COMPUTER) {
|
|
179
|
+
for (let i = args.length - 1; i >= 0; i--) if (args[i] === "--allow-computer-control") args.splice(i, 1);
|
|
180
|
+
process.env.PRIVATEER_COMPUTER_CONTROL = "1";
|
|
181
|
+
process.stderr.write(
|
|
182
|
+
[
|
|
183
|
+
"",
|
|
184
|
+
" ⚓ \x1b[1;33mScreen control armed\x1b[0m — this session may see your screen and move your mouse.",
|
|
185
|
+
" Every action still asks first. It can reach anything you can: other apps, other windows,",
|
|
186
|
+
" a browser you are signed into. Your OS will also ask for screen and accessibility permission.",
|
|
187
|
+
NO_QUARTER
|
|
188
|
+
? " \x1b[1;31mNo quarter is ALSO on — screen actions will run with NO prompt.\x1b[0m"
|
|
189
|
+
: "",
|
|
190
|
+
"",
|
|
191
|
+
]
|
|
192
|
+
.filter(Boolean)
|
|
193
|
+
.join("\n") + "\n",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
168
197
|
const sub = args[0];
|
|
169
198
|
|
|
170
199
|
// `privateer --version` — report OUR version, not Pi's. Left to Pi's cli.js it would
|
|
@@ -615,6 +615,13 @@ export default function privateerBrand(pi: any): void {
|
|
|
615
615
|
ctx?.ui?.notify?.(`Signed in. Run /models to pick a model — ${spec} isn't loaded yet.`, "warning");
|
|
616
616
|
return;
|
|
617
617
|
}
|
|
618
|
+
// Not persisted, on purpose. Every DELIBERATE switch now writes itself into Pi's
|
|
619
|
+
// settings.json (see writePiDefaultModel in providers/defaultModel.ts), but this
|
|
620
|
+
// one is ours, not the user's — we move them onto the confidential model because
|
|
621
|
+
// they signed in. Writing it would pin a BYO-keyed user to `privateer/…` for
|
|
622
|
+
// good, and hand them a model with no credential the day they log out. The check
|
|
623
|
+
// above already stays off a saved pick; this leaves the unsaved case resolving
|
|
624
|
+
// fresh every launch, which is what it did before signing in.
|
|
618
625
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
619
626
|
try {
|
|
620
627
|
const ok = await pi.setModel(model);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// GUI control for Pi's TUI: see the screen, move the mouse, type.
|
|
2
|
+
//
|
|
3
|
+
// Registered ONLY when the machine has been armed — `privateer --allow-computer-control`,
|
|
4
|
+
// or the desktop's Screen control switch. Omitting the factory rather than hiding the
|
|
5
|
+
// tools is the same call privateer-media.ts makes for the same reason: a tool that
|
|
6
|
+
// exists and refuses every call teaches the model to keep retrying, where a tool that
|
|
7
|
+
// isn't there makes it say what the user would need to do and move on.
|
|
8
|
+
//
|
|
9
|
+
// A SUBAGENT CHILD NEVER GETS THESE, and unlike media there is no grant that lifts it.
|
|
10
|
+
// A child is a headless process with nobody to approve an action, and every computer
|
|
11
|
+
// action asks (permissions/mode.ts) — so the tools could only ever wedge on a prompt
|
|
12
|
+
// with no one to answer it. Media has childSpend.ts because a parent can meaningfully
|
|
13
|
+
// pre-authorize a bounded, billed call it named itself; there is no equivalent for
|
|
14
|
+
// "click wherever you decide to click", and inventing one would be inventing the
|
|
15
|
+
// unattended GUI agent this whole design is arranged to avoid.
|
|
16
|
+
import { makeComputerTools } from "../src/tools/computer.ts";
|
|
17
|
+
import { computerControlArmed } from "../src/config/computerControl.ts";
|
|
18
|
+
import { isSubagentChild } from "../src/remote/subagentRelay.ts";
|
|
19
|
+
|
|
20
|
+
export default function privateerComputer(pi: any): void {
|
|
21
|
+
if (!computerControlArmed()) return;
|
|
22
|
+
if (isSubagentChild()) return;
|
|
23
|
+
makeComputerTools()(pi);
|
|
24
|
+
}
|
|
@@ -23,24 +23,38 @@ import {
|
|
|
23
23
|
} from "../src/context.ts";
|
|
24
24
|
|
|
25
25
|
// Honor Pi's own "disable context files" switch, so --no-context-files / -nc silences
|
|
26
|
-
//
|
|
26
|
+
// everything this extension injects, not just PRIVATEER.md — otherwise the flag would
|
|
27
|
+
// half-work. A user who asks for a bare prompt gets a bare prompt.
|
|
27
28
|
const CONTEXT_FILES_DISABLED =
|
|
28
29
|
process.argv.includes("--no-context-files") || process.argv.includes("-nc");
|
|
29
30
|
|
|
30
31
|
export default function privateerContext(pi: any): void {
|
|
31
|
-
// Inject PRIVATEER.md into every turn's system prompt. The
|
|
32
|
-
// and chained across before_agent_start handlers, so
|
|
33
|
-
// the turn;
|
|
32
|
+
// Inject the runtime guidelines + PRIVATEER.md into every turn's system prompt. The
|
|
33
|
+
// prompt is rebuilt per turn and chained across before_agent_start handlers, so
|
|
34
|
+
// appending here is idempotent for the turn; each marker guard makes its own block a
|
|
35
|
+
// no-op if an earlier handler already added it.
|
|
36
|
+
//
|
|
37
|
+
// ONLY EVER APPEND, AND ONLY RETURN WHEN WE ADDED SOMETHING. Pi chains these handlers
|
|
38
|
+
// and a returned `systemPrompt` REPLACES what the chain has built so far, so both
|
|
39
|
+
// halves matter:
|
|
40
|
+
//
|
|
41
|
+
// • A host that doesn't populate `event.systemPrompt` must not be handed a prompt
|
|
42
|
+
// synthesised from "" — that gives back our two blocks as the ENTIRE system prompt
|
|
43
|
+
// and silently drops the real one. `typeof base !== "string"` is what separates
|
|
44
|
+
// "here is a prompt to chain onto" (possibly empty, legitimate) from "no field".
|
|
45
|
+
// • When both markers are already present (a re-entrant chain) we have nothing to
|
|
46
|
+
// contribute, and undefined leaves what is there alone.
|
|
34
47
|
pi.on("before_agent_start", (event: any) => {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (!
|
|
48
|
+
if (CONTEXT_FILES_DISABLED) return;
|
|
49
|
+
const base = event?.systemPrompt;
|
|
50
|
+
if (typeof base !== "string") return;
|
|
51
|
+
let prompt = base;
|
|
52
|
+
if (!prompt.includes(RUNTIME_GUIDELINES_MARKER)) prompt += runtimeGuidelinesBlock();
|
|
53
|
+
if (!prompt.includes(CONTEXT_BLOCK_MARKER)) {
|
|
40
54
|
const cwd = event?.systemPromptOptions?.cwd ?? process.cwd();
|
|
41
|
-
|
|
42
|
-
if (block) prompt += block;
|
|
55
|
+
prompt += contextBlock(cwd); // "" when there is no PRIVATEER.md anywhere
|
|
43
56
|
}
|
|
57
|
+
if (prompt === base) return; // nothing to add — leave the chain alone
|
|
44
58
|
return { systemPrompt: prompt };
|
|
45
59
|
});
|
|
46
60
|
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* privateer-computer (Linux) — the Linux screen-control helper.
|
|
4
|
+
*
|
|
5
|
+
* Same newline-delimited JSON protocol as the macOS and Windows helpers; the client is
|
|
6
|
+
* src/computer/helper.ts and the coordinate contract is src/computer/space.ts. Read that
|
|
7
|
+
* file before changing anything here.
|
|
8
|
+
*
|
|
9
|
+
* ── Why this one is a script and not a compiled binary ───────────────────────
|
|
10
|
+
*
|
|
11
|
+
* There is no Linux equivalent of CGEvent or SendInput — no single system API that every
|
|
12
|
+
* desktop implements. X11 has XTEST, Wayland deliberately has nothing (a client cannot
|
|
13
|
+
* see or synthesise input outside its own surface, which is a security property, not an
|
|
14
|
+
* oversight). What exists instead is a set of small, standard command-line tools that
|
|
15
|
+
* already hold the necessary privileges. Shelling out to those is not a shortcut around
|
|
16
|
+
* writing a real helper; it IS the Linux way to do this, and it means no compiler, no
|
|
17
|
+
* per-distro binary, and nothing to keep signed.
|
|
18
|
+
*
|
|
19
|
+
* The cost is that they must be installed, so every one is PROBED and the absence is
|
|
20
|
+
* reported as a sentence naming the package — the posture the sprite pipeline's ffmpeg
|
|
21
|
+
* probe already takes. A missing tool must never surface as a spawn error at the moment
|
|
22
|
+
* the user asks for something.
|
|
23
|
+
*
|
|
24
|
+
* ── What is supported, honestly ──────────────────────────────────────────────
|
|
25
|
+
*
|
|
26
|
+
* X11 — fully. xrandr to enumerate, xdotool for input, and any of
|
|
27
|
+
* ImageMagick / maim / ffmpeg to capture.
|
|
28
|
+
* Wayland — wlroots compositors only (Sway, Hyprland, river): wlr-randr + grim +
|
|
29
|
+
* ydotool. GNOME and KDE on Wayland expose no generic screenshot or input
|
|
30
|
+
* CLI at all — everything goes through xdg-desktop-portal, which is an
|
|
31
|
+
* interactive, per-request consent flow that cannot serve an agent loop.
|
|
32
|
+
* Those sessions are told exactly that rather than being left to fail.
|
|
33
|
+
*
|
|
34
|
+
* ── The exact-size rule ──────────────────────────────────────────────────────
|
|
35
|
+
*
|
|
36
|
+
* A capture MUST come back at exactly the requested size, because that size defines the
|
|
37
|
+
* coordinate space the model is given. Unlike CoreGraphics and System.Drawing, nothing
|
|
38
|
+
* here resizes natively — so when no resizer is installed the capture is REFUSED. It is
|
|
39
|
+
* tempting to return the native frame instead; that would put agent space and the
|
|
40
|
+
* picture into silent disagreement and every click would be off by the reduction factor.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { spawnSync } from 'node:child_process';
|
|
44
|
+
|
|
45
|
+
// ─── Frames ──────────────────────────────────────────────────────────────────
|
|
46
|
+
// stdout carries protocol frames ONLY; diagnostics go to stderr, or the client's line
|
|
47
|
+
// reader desynchronises.
|
|
48
|
+
|
|
49
|
+
function emit(obj) {
|
|
50
|
+
process.stdout.write(`${JSON.stringify(obj)}\n`);
|
|
51
|
+
}
|
|
52
|
+
const ok = (id, fields = {}) => emit({ id: id ?? null, ok: true, ...fields });
|
|
53
|
+
const fail = (id, error) => emit({ id: id ?? null, ok: false, error });
|
|
54
|
+
|
|
55
|
+
// ─── Tool discovery ──────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
const isWayland = !!process.env.WAYLAND_DISPLAY || process.env.XDG_SESSION_TYPE === 'wayland';
|
|
58
|
+
|
|
59
|
+
/** Is `bin` on PATH? Cached per process — PATH does not change under us. */
|
|
60
|
+
const haveCache = new Map();
|
|
61
|
+
function have(bin) {
|
|
62
|
+
if (!haveCache.has(bin)) {
|
|
63
|
+
const r = spawnSync('sh', ['-c', `command -v ${bin} >/dev/null 2>&1`]);
|
|
64
|
+
haveCache.set(bin, r.status === 0);
|
|
65
|
+
}
|
|
66
|
+
return haveCache.get(bin);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function run(cmd, args, opts = {}) {
|
|
70
|
+
return spawnSync(cmd, args, { maxBuffer: 128 * 1024 * 1024, ...opts });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The ImageMagick entry point, which changed name in v7. */
|
|
74
|
+
function magick() {
|
|
75
|
+
if (have('magick')) return ['magick'];
|
|
76
|
+
if (have('convert')) return ['convert'];
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Displays ────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* X11: `xrandr --listmonitors` is the right source rather than `xrandr --query`, because
|
|
84
|
+
* it reports the MONITOR layout (what the user sees as screens, including any the
|
|
85
|
+
* compositor has combined) with position, in one stable line each:
|
|
86
|
+
*
|
|
87
|
+
* 0: +*eDP-1 1920/344x1080/193+0+0 eDP-1
|
|
88
|
+
*
|
|
89
|
+
* The `*` marks the primary. The `/344` and `/193` are physical millimetres and are
|
|
90
|
+
* deliberately ignored — they are the panel's size, not its pixels.
|
|
91
|
+
*/
|
|
92
|
+
function x11Displays() {
|
|
93
|
+
const r = run('xrandr', ['--listmonitors']);
|
|
94
|
+
if (r.status !== 0) return [];
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const line of String(r.stdout).split('\n')) {
|
|
97
|
+
const m = line.match(/^\s*\d+:\s+\+(\*?)(\S+)\s+(\d+)\/\d+x(\d+)\/\d+\+(-?\d+)\+(-?\d+)/);
|
|
98
|
+
if (!m) continue;
|
|
99
|
+
out.push({
|
|
100
|
+
id: m[2],
|
|
101
|
+
label: m[2],
|
|
102
|
+
width: Number(m[3]),
|
|
103
|
+
height: Number(m[4]),
|
|
104
|
+
// X11 hands out one flat pixel grid; there is no per-monitor scale factor in the
|
|
105
|
+
// coordinate space xdotool works in, so this is always 1 and the agent never
|
|
106
|
+
// converts through it.
|
|
107
|
+
scale: 1,
|
|
108
|
+
originX: Number(m[5]),
|
|
109
|
+
originY: Number(m[6]),
|
|
110
|
+
primary: m[1] === '*',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Wayland (wlroots): `wlr-randr --json` where available, falling back to its text form.
|
|
118
|
+
* Both report logical size and scale; the agent's device pixels are logical x scale,
|
|
119
|
+
* which is what grim writes.
|
|
120
|
+
*/
|
|
121
|
+
function waylandDisplays() {
|
|
122
|
+
if (!have('wlr-randr')) return [];
|
|
123
|
+
const asJson = run('wlr-randr', ['--json']);
|
|
124
|
+
if (asJson.status === 0) {
|
|
125
|
+
try {
|
|
126
|
+
const parsed = JSON.parse(String(asJson.stdout));
|
|
127
|
+
return parsed
|
|
128
|
+
.filter((o) => o.enabled !== false)
|
|
129
|
+
.map((o, i) => {
|
|
130
|
+
const mode = (o.modes || []).find((m) => m.current) || {};
|
|
131
|
+
const scale = Number(o.scale) || 1;
|
|
132
|
+
return {
|
|
133
|
+
id: o.name,
|
|
134
|
+
label: o.description || o.name,
|
|
135
|
+
width: Math.round(Number(mode.width) || 0),
|
|
136
|
+
height: Math.round(Number(mode.height) || 0),
|
|
137
|
+
scale,
|
|
138
|
+
originX: Math.round(Number(o.position?.x) || 0),
|
|
139
|
+
originY: Math.round(Number(o.position?.y) || 0),
|
|
140
|
+
primary: i === 0,
|
|
141
|
+
};
|
|
142
|
+
})
|
|
143
|
+
.filter((d) => d.width > 0 && d.height > 0);
|
|
144
|
+
} catch {
|
|
145
|
+
/* fall through to the text parser */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Text form: an output name at column 0, then indented fields until the next name.
|
|
150
|
+
const r = run('wlr-randr', []);
|
|
151
|
+
if (r.status !== 0) return [];
|
|
152
|
+
const out = [];
|
|
153
|
+
let cur = null;
|
|
154
|
+
for (const line of String(r.stdout).split('\n')) {
|
|
155
|
+
const head = line.match(/^(\S+)\s+"(.*)"/);
|
|
156
|
+
if (head) {
|
|
157
|
+
if (cur) out.push(cur);
|
|
158
|
+
cur = { id: head[1], label: head[2] || head[1], width: 0, height: 0, scale: 1, originX: 0, originY: 0, primary: out.length === 0 };
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (!cur) continue;
|
|
162
|
+
const pos = line.match(/Position:\s*(-?\d+),(-?\d+)/);
|
|
163
|
+
if (pos) { cur.originX = Number(pos[1]); cur.originY = Number(pos[2]); }
|
|
164
|
+
const scale = line.match(/Scale:\s*([\d.]+)/);
|
|
165
|
+
if (scale) cur.scale = Number(scale[1]) || 1;
|
|
166
|
+
const mode = line.match(/(\d+)x(\d+)\s+px.*current/);
|
|
167
|
+
if (mode) { cur.width = Number(mode[1]); cur.height = Number(mode[2]); }
|
|
168
|
+
}
|
|
169
|
+
if (cur) out.push(cur);
|
|
170
|
+
return out.filter((d) => d.width > 0 && d.height > 0);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function displays() {
|
|
174
|
+
return isWayland ? waylandDisplays() : x11Displays();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function findDisplay(id) {
|
|
178
|
+
const all = displays();
|
|
179
|
+
if (!id) return all.find((d) => d.primary) ?? all[0];
|
|
180
|
+
return all.find((d) => d.id === id);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── Grants ──────────────────────────────────────────────────────────────────
|
|
184
|
+
//
|
|
185
|
+
// Linux has no TCC. "Granted" here means the tools that do the work are actually
|
|
186
|
+
// present — which is the same question from the user's point of view (can it act?) and
|
|
187
|
+
// the only one with an answer we can give honestly.
|
|
188
|
+
|
|
189
|
+
function screenTool() {
|
|
190
|
+
if (isWayland) return have('grim') ? 'grim' : null;
|
|
191
|
+
if (have('import')) return 'import';
|
|
192
|
+
if (have('maim')) return 'maim';
|
|
193
|
+
if (have('ffmpeg')) return 'ffmpeg';
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function inputTool() {
|
|
198
|
+
if (isWayland) return have('ydotool') ? 'ydotool' : null;
|
|
199
|
+
return have('xdotool') ? 'xdotool' : null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function grants() {
|
|
203
|
+
return {
|
|
204
|
+
screen: !!screenTool() && !!magick(),
|
|
205
|
+
input: !!inputTool(),
|
|
206
|
+
// No Linux equivalent of macOS secure input: nothing tells a client that a password
|
|
207
|
+
// field has focus. Reported false rather than guessed.
|
|
208
|
+
secureInput: false,
|
|
209
|
+
frontmostPid: frontmostPid(),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The pid of the focused window's process, for the caller's refuse-to-click-our-own-
|
|
215
|
+
* window interlock. X11 only — Wayland gives a client no way to ask what else is
|
|
216
|
+
* focused, which is the point of Wayland. Absent rather than wrong.
|
|
217
|
+
*/
|
|
218
|
+
function frontmostPid() {
|
|
219
|
+
if (isWayland || !have('xdotool')) return undefined;
|
|
220
|
+
const r = run('xdotool', ['getactivewindow', 'getwindowpid']);
|
|
221
|
+
if (r.status !== 0) return undefined;
|
|
222
|
+
const pid = Number(String(r.stdout).trim());
|
|
223
|
+
return Number.isFinite(pid) && pid > 0 ? pid : undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Why screen control cannot work here, as a sentence naming the fix. */
|
|
227
|
+
function missingReason() {
|
|
228
|
+
if (isWayland && !have('grim')) {
|
|
229
|
+
return (
|
|
230
|
+
'This is a Wayland session with no grim. Screen control on Wayland is supported on ' +
|
|
231
|
+
'wlroots compositors (Sway, Hyprland, river) — install grim, wlr-randr and ydotool. ' +
|
|
232
|
+
'GNOME and KDE on Wayland expose no screenshot or input command at all (everything ' +
|
|
233
|
+
'goes through xdg-desktop-portal, which asks the user per request), so an X11 ' +
|
|
234
|
+
'session is the only option there.'
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
if (!screenTool()) return 'No screen capture tool. Install ImageMagick, maim or ffmpeg.';
|
|
238
|
+
if (!magick()) return 'No image resizer. Install ImageMagick (it provides `magick`, or `convert` on v6).';
|
|
239
|
+
if (!inputTool()) {
|
|
240
|
+
return isWayland
|
|
241
|
+
? 'No ydotool, so the mouse and keyboard cannot be driven. Install ydotool and start ydotoold.'
|
|
242
|
+
: 'No xdotool, so the mouse and keyboard cannot be driven. Install xdotool.';
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ─── Capture ─────────────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
/** The monitor's pixels as a PNG, at native size, on stdout. */
|
|
250
|
+
function captureRaw(display) {
|
|
251
|
+
const geom = `${display.width}x${display.height}+${display.originX}+${display.originY}`;
|
|
252
|
+
if (isWayland) {
|
|
253
|
+
const r = run('grim', ['-o', display.id, '-']);
|
|
254
|
+
return r.status === 0 ? r.stdout : null;
|
|
255
|
+
}
|
|
256
|
+
const tool = screenTool();
|
|
257
|
+
if (tool === 'import') {
|
|
258
|
+
// +repage discards the crop's virtual canvas offset. Without it the resize below
|
|
259
|
+
// sees a canvas the size of the whole desktop with the crop placed inside it, and
|
|
260
|
+
// pads the output — so the picture would be right and its geometry wrong.
|
|
261
|
+
const r = run('import', ['-window', 'root', '-crop', geom, '+repage', 'png:-']);
|
|
262
|
+
return r.status === 0 ? r.stdout : null;
|
|
263
|
+
}
|
|
264
|
+
if (tool === 'maim') {
|
|
265
|
+
const r = run('maim', ['-g', geom, '-f', 'png', '/dev/stdout']);
|
|
266
|
+
return r.status === 0 ? r.stdout : null;
|
|
267
|
+
}
|
|
268
|
+
if (tool === 'ffmpeg') {
|
|
269
|
+
const r = run('ffmpeg', [
|
|
270
|
+
'-loglevel', 'error', '-f', 'x11grab',
|
|
271
|
+
'-video_size', `${display.width}x${display.height}`,
|
|
272
|
+
'-i', `${process.env.DISPLAY || ':0'}+${display.originX},${display.originY}`,
|
|
273
|
+
'-frames:v', '1', '-f', 'image2pipe', '-vcodec', 'png', '-',
|
|
274
|
+
]);
|
|
275
|
+
return r.status === 0 ? r.stdout : null;
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Resize to EXACTLY w x h. The `!` suffix is what makes it exact — without it
|
|
282
|
+
* ImageMagick preserves the aspect ratio and returns something a pixel or two off,
|
|
283
|
+
* which is precisely the silent disagreement the size contract exists to prevent.
|
|
284
|
+
* (The plan's aspect already matches the display's, so nothing is distorted; `!` only
|
|
285
|
+
* removes ImageMagick's rounding.)
|
|
286
|
+
*/
|
|
287
|
+
function resizeTo(png, w, h) {
|
|
288
|
+
const mk = magick();
|
|
289
|
+
if (!mk) return null;
|
|
290
|
+
const r = run(mk[0], [...mk.slice(1), 'png:-', '-resize', `${w}x${h}!`, 'png:-'], { input: png });
|
|
291
|
+
return r.status === 0 ? r.stdout : null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ─── Input ───────────────────────────────────────────────────────────────────
|
|
295
|
+
//
|
|
296
|
+
// Display-local pixels → the global desktop, the one conversion this file makes. On X11
|
|
297
|
+
// that global space is what xdotool takes directly. ydotool likewise works in absolute
|
|
298
|
+
// desktop coordinates.
|
|
299
|
+
|
|
300
|
+
const BUTTON = { left: 1, middle: 2, right: 3 };
|
|
301
|
+
|
|
302
|
+
function xdo(args) {
|
|
303
|
+
const r = run('xdotool', args);
|
|
304
|
+
if (r.status !== 0) throw new Error(String(r.stderr || 'xdotool failed').trim());
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function ydo(args) {
|
|
308
|
+
const r = run('ydotool', args);
|
|
309
|
+
if (r.status !== 0) {
|
|
310
|
+
const err = String(r.stderr || '').trim();
|
|
311
|
+
// The overwhelmingly common failure, and one whose bare message ("failed to open
|
|
312
|
+
// /dev/uinput") sends people to the wrong place.
|
|
313
|
+
if (/uinput|permission|socket/i.test(err)) {
|
|
314
|
+
throw new Error('ydotool cannot reach its daemon — start ydotoold and make sure your user can use /dev/uinput.');
|
|
315
|
+
}
|
|
316
|
+
throw new Error(err || 'ydotool failed');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function pointer(req, display) {
|
|
321
|
+
const gx = display.originX + Number(req.x || 0);
|
|
322
|
+
const gy = display.originY + Number(req.y || 0);
|
|
323
|
+
const button = BUTTON[req.button] || 1;
|
|
324
|
+
|
|
325
|
+
if (isWayland) {
|
|
326
|
+
ydo(['mousemove', '--absolute', '-x', String(gx), '-y', String(gy)]);
|
|
327
|
+
switch (req.action) {
|
|
328
|
+
case 'move': return;
|
|
329
|
+
// ydotool's click codes are a bitfield: 0x00-0x02 select the button, 0x40 is
|
|
330
|
+
// press and 0x80 is release, so 0xC0|n is a full click.
|
|
331
|
+
case 'click': return ydo(['click', `0x${(0xc0 | (button - 1)).toString(16)}`]);
|
|
332
|
+
case 'double_click':
|
|
333
|
+
ydo(['click', `0x${(0xc0 | (button - 1)).toString(16)}`]);
|
|
334
|
+
return ydo(['click', `0x${(0xc0 | (button - 1)).toString(16)}`]);
|
|
335
|
+
case 'drag': {
|
|
336
|
+
ydo(['click', `0x${(0x40 | (button - 1)).toString(16)}`]);
|
|
337
|
+
const tx = display.originX + Number(req.toX || 0);
|
|
338
|
+
const ty = display.originY + Number(req.toY || 0);
|
|
339
|
+
for (let i = 1; i <= 12; i++) {
|
|
340
|
+
const t = i / 12;
|
|
341
|
+
ydo(['mousemove', '--absolute', '-x', String(Math.round(gx + (tx - gx) * t)), '-y', String(Math.round(gy + (ty - gy) * t))]);
|
|
342
|
+
}
|
|
343
|
+
return ydo(['click', `0x${(0x80 | (button - 1)).toString(16)}`]);
|
|
344
|
+
}
|
|
345
|
+
case 'scroll': {
|
|
346
|
+
const dy = Number(req.scrollY || 0);
|
|
347
|
+
const dx = Number(req.scrollX || 0);
|
|
348
|
+
// ydotool's wheel is positive-up, and the tool's contract is negative-up.
|
|
349
|
+
if (dy) ydo(['mousemove', '--wheel', '-y', String(-dy)]);
|
|
350
|
+
if (dx) ydo(['mousemove', '--wheel', '-x', String(dx)]);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
default: throw new Error(`unknown pointer action "${req.action}"`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
xdo(['mousemove', String(gx), String(gy)]);
|
|
358
|
+
switch (req.action) {
|
|
359
|
+
case 'move': return;
|
|
360
|
+
case 'click': return xdo(['click', String(button)]);
|
|
361
|
+
case 'double_click': return xdo(['click', '--repeat', '2', String(button)]);
|
|
362
|
+
case 'drag': {
|
|
363
|
+
const tx = display.originX + Number(req.toX || 0);
|
|
364
|
+
const ty = display.originY + Number(req.toY || 0);
|
|
365
|
+
xdo(['mousedown', String(button)]);
|
|
366
|
+
// Interpolated for the same reason as the other two helpers: a press and release
|
|
367
|
+
// at the destination is ignored by anything that starts its gesture on the first
|
|
368
|
+
// motion event — drag-and-drop, sliders, text selection.
|
|
369
|
+
for (let i = 1; i <= 12; i++) {
|
|
370
|
+
const t = i / 12;
|
|
371
|
+
xdo(['mousemove', String(Math.round(gx + (tx - gx) * t)), String(Math.round(gy + (ty - gy) * t))]);
|
|
372
|
+
}
|
|
373
|
+
return xdo(['mouseup', String(button)]);
|
|
374
|
+
}
|
|
375
|
+
case 'scroll': {
|
|
376
|
+
const dy = Number(req.scrollY || 0);
|
|
377
|
+
const dx = Number(req.scrollX || 0);
|
|
378
|
+
// X11 wheel buttons: 4 up, 5 down, 6 left, 7 right. Negative scrolls UP.
|
|
379
|
+
if (dy) xdo(['click', '--repeat', String(Math.abs(dy)), dy > 0 ? '5' : '4']);
|
|
380
|
+
if (dx) xdo(['click', '--repeat', String(Math.abs(dx)), dx > 0 ? '7' : '6']);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
default: throw new Error(`unknown pointer action "${req.action}"`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Chord names → the spellings xdotool's `key` understands. */
|
|
388
|
+
const XDO_KEYS = {
|
|
389
|
+
return: 'Return', enter: 'Return', tab: 'Tab', space: 'space',
|
|
390
|
+
backspace: 'BackSpace', delete: 'Delete', forwarddelete: 'Delete',
|
|
391
|
+
escape: 'Escape', esc: 'Escape',
|
|
392
|
+
left: 'Left', right: 'Right', up: 'Up', down: 'Down',
|
|
393
|
+
home: 'Home', end: 'End', pageup: 'Prior', pagedown: 'Next',
|
|
394
|
+
};
|
|
395
|
+
const XDO_MODS = {
|
|
396
|
+
ctrl: 'ctrl', control: 'ctrl',
|
|
397
|
+
alt: 'alt', option: 'alt', opt: 'alt',
|
|
398
|
+
shift: 'shift',
|
|
399
|
+
// super is the Linux equivalent of cmd; accepted so a model that learned "cmd+s"
|
|
400
|
+
// elsewhere does not silently send nothing.
|
|
401
|
+
cmd: 'super', command: 'super', meta: 'super', super: 'super', win: 'super',
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
function keyAction(req) {
|
|
405
|
+
if (req.action === 'type') {
|
|
406
|
+
const text = String(req.text ?? '');
|
|
407
|
+
if (!text) return;
|
|
408
|
+
if (isWayland) return ydo(['type', text]);
|
|
409
|
+
// --clearmodifiers so a modifier the user is physically holding does not turn the
|
|
410
|
+
// typed text into a stream of shortcuts.
|
|
411
|
+
return xdo(['type', '--clearmodifiers', '--delay', '6', text]);
|
|
412
|
+
}
|
|
413
|
+
if (req.action !== 'press') throw new Error('unknown key action');
|
|
414
|
+
|
|
415
|
+
const spec = String(req.keys ?? '').toLowerCase().trim();
|
|
416
|
+
if (!spec) throw new Error('empty key combination');
|
|
417
|
+
const parts = spec.split('+');
|
|
418
|
+
const last = parts[parts.length - 1];
|
|
419
|
+
const mods = [];
|
|
420
|
+
for (const p of parts.slice(0, -1)) {
|
|
421
|
+
if (!XDO_MODS[p]) throw new Error(`unknown modifier "${p}" in "${spec}"`);
|
|
422
|
+
mods.push(XDO_MODS[p]);
|
|
423
|
+
}
|
|
424
|
+
let key = XDO_KEYS[last];
|
|
425
|
+
if (!key && /^f\d{1,2}$/.test(last)) key = last.toUpperCase();
|
|
426
|
+
if (!key && last.length === 1) key = last;
|
|
427
|
+
if (!key) throw new Error(`unknown key "${last}" in "${spec}"`);
|
|
428
|
+
|
|
429
|
+
const combo = [...mods, key].join('+');
|
|
430
|
+
if (isWayland) return ydo(['key', combo]);
|
|
431
|
+
return xdo(['key', '--clearmodifiers', combo]);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ─── Dispatch ────────────────────────────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
function handle(req) {
|
|
437
|
+
const id = req.id;
|
|
438
|
+
switch (req.op) {
|
|
439
|
+
case 'displays':
|
|
440
|
+
return ok(id, { displays: displays() });
|
|
441
|
+
|
|
442
|
+
case 'grants':
|
|
443
|
+
return ok(id, { grants: grants() });
|
|
444
|
+
|
|
445
|
+
case 'capture': {
|
|
446
|
+
const reason = missingReason();
|
|
447
|
+
if (reason && !screenTool()) return fail(id, reason);
|
|
448
|
+
const display = findDisplay(req.display);
|
|
449
|
+
if (!display) return fail(id, 'no such display');
|
|
450
|
+
if (!magick()) return fail(id, 'No image resizer. Install ImageMagick (it provides `magick`, or `convert` on v6).');
|
|
451
|
+
const raw = captureRaw(display);
|
|
452
|
+
if (!raw || !raw.length) return fail(id, 'the display could not be captured');
|
|
453
|
+
const w = Number(req.targetWidth) || display.width;
|
|
454
|
+
const h = Number(req.targetHeight) || display.height;
|
|
455
|
+
const scaled = resizeTo(raw, w, h);
|
|
456
|
+
// Refused rather than answered with the native frame — see the header.
|
|
457
|
+
if (!scaled || !scaled.length) return fail(id, 'the capture could not be resized to the requested size');
|
|
458
|
+
|
|
459
|
+
// A second, much smaller copy of the SAME grab for the permission dialog. Same
|
|
460
|
+
// grab deliberately: a separate capture would be a different moment, so someone
|
|
461
|
+
// could be approving a click against a screen the model never saw. Best-effort —
|
|
462
|
+
// the model's frame is already good and the dialog falls back to text.
|
|
463
|
+
const out = { mimeType: 'image/png', data: scaled.toString('base64'), width: w, height: h };
|
|
464
|
+
const pw = Number(req.previewWidth) || 0;
|
|
465
|
+
const ph = Number(req.previewHeight) || 0;
|
|
466
|
+
if (pw > 0 && ph > 0) {
|
|
467
|
+
const small = resizeTo(raw, pw, ph);
|
|
468
|
+
if (small && small.length) out.preview = small.toString('base64');
|
|
469
|
+
}
|
|
470
|
+
return ok(id, out);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
case 'pointer': {
|
|
474
|
+
if (!inputTool()) return fail(id, missingReason() ?? 'no input tool');
|
|
475
|
+
const display = findDisplay(req.display);
|
|
476
|
+
if (!display) return fail(id, 'no such display');
|
|
477
|
+
pointer(req, display);
|
|
478
|
+
return ok(id);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
case 'key': {
|
|
482
|
+
if (!inputTool()) return fail(id, missingReason() ?? 'no input tool');
|
|
483
|
+
keyAction(req);
|
|
484
|
+
return ok(id);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
default:
|
|
488
|
+
return fail(id, `unknown op "${req.op}"`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ─── Entry ───────────────────────────────────────────────────────────────────
|
|
493
|
+
|
|
494
|
+
const argv = process.argv.slice(2);
|
|
495
|
+
|
|
496
|
+
if (argv.includes('--version')) {
|
|
497
|
+
process.stdout.write('privateer-computer 1\n');
|
|
498
|
+
process.exit(0);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (argv.includes('--grants')) {
|
|
502
|
+
const reason = missingReason();
|
|
503
|
+
emit({ ok: true, grants: grants(), displays: displays(), ...(reason ? { reason } : {}) });
|
|
504
|
+
process.exit(0);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (!argv.includes('--serve')) {
|
|
508
|
+
process.stderr.write('usage: privateer-computer.mjs --serve | --grants | --version\n');
|
|
509
|
+
process.exit(2);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
let buffer = '';
|
|
513
|
+
process.stdin.setEncoding('utf8');
|
|
514
|
+
process.stdin.on('data', (chunk) => {
|
|
515
|
+
buffer += chunk;
|
|
516
|
+
let nl;
|
|
517
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
518
|
+
const line = buffer.slice(0, nl).trim();
|
|
519
|
+
buffer = buffer.slice(nl + 1);
|
|
520
|
+
if (!line) continue;
|
|
521
|
+
let req;
|
|
522
|
+
try {
|
|
523
|
+
req = JSON.parse(line);
|
|
524
|
+
} catch {
|
|
525
|
+
fail(null, 'malformed request');
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
try {
|
|
529
|
+
handle(req);
|
|
530
|
+
} catch (err) {
|
|
531
|
+
// One bad frame must not take the session's screen control with it.
|
|
532
|
+
fail(req?.id, err?.message ? String(err.message) : String(err));
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
process.stdin.on('end', () => process.exit(0));
|