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,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The coordinate space — the one thing in GUI control that has to be exactly right.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS ITS OWN MODULE, AND PURE. Three different pixel grids are in play at
|
|
5
|
+
* once and nothing in the system tells you when you have mixed them up:
|
|
6
|
+
*
|
|
7
|
+
* • DEVICE pixels — what the display actually has. A 16" Retina panel is 3456×2234.
|
|
8
|
+
* • POINTS — what macOS's input APIs take (CGEvent), half that on a 2× display.
|
|
9
|
+
* • AGENT space — the grid of the downscaled PNG the model is actually looking at.
|
|
10
|
+
* A 3456px-wide frame is ~5 MB of base64 and more pixels than any vision model
|
|
11
|
+
* resolves; every implementation downscales, so the model's coordinates are in a
|
|
12
|
+
* space that exists nowhere in the OS.
|
|
13
|
+
*
|
|
14
|
+
* The failure mode is not a crash. If capture scales and input does not, every click
|
|
15
|
+
* lands at roughly half or twice the intended place, the model sees a screenshot where
|
|
16
|
+
* nothing happened, and it tries again slightly differently — burning tokens and money
|
|
17
|
+
* while quietly clicking on whatever else is there. That is the single most expensive
|
|
18
|
+
* bug this feature can have, so the rule is:
|
|
19
|
+
*
|
|
20
|
+
* EVERY coordinate crossing the tool boundary is in AGENT space, and the ONLY
|
|
21
|
+
* conversion to device pixels happens here.
|
|
22
|
+
*
|
|
23
|
+
* The helper is told the target size up front and does the downscale natively (a
|
|
24
|
+
* CoreGraphics resize is far cheaper than shipping a full-resolution PNG down a pipe
|
|
25
|
+
* and resampling it in JS), so the image the model receives IS agent space by
|
|
26
|
+
* construction rather than by a second calculation that could disagree with this one.
|
|
27
|
+
*
|
|
28
|
+
* TWO factors, not one. Rounding the target size means `regionWidth / factor` is not
|
|
29
|
+
* generally an integer, so a single factor leaves a fractional error that grows toward
|
|
30
|
+
* the far edge of the screen — precisely where a click is most likely to miss (a menu
|
|
31
|
+
* bar item, a window's close button). Deriving the factor per-axis FROM the rounded
|
|
32
|
+
* size instead makes the mapping exact at both edges.
|
|
33
|
+
*
|
|
34
|
+
* PIXEL CENTRES, not corners. `a * factor` biases every click toward the top-left of
|
|
35
|
+
* the cell it names and can land exactly on a boundary; `(a + 0.5) * factor` lands in
|
|
36
|
+
* the middle of the agent pixel the model pointed at.
|
|
37
|
+
*
|
|
38
|
+
* ONE DISPLAY AT A TIME. Every coordinate here is local to a single display, because a
|
|
39
|
+
* global device-pixel space does not exist on a mixed-DPI desktop — see Display.originX
|
|
40
|
+
* for the collision that makes. Turning a display-local device pixel into something the
|
|
41
|
+
* OS will accept is the platform helper's job, and only its job.
|
|
42
|
+
*
|
|
43
|
+
* Pure — no I/O, no platform calls — so tests/computer.test.ts can pin the round trip
|
|
44
|
+
* over the display shapes that actually ship (1×, 2×, ultrawide, a small panel) rather
|
|
45
|
+
* than only the one on the developer's desk.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** A display as the platform helper reports it. All lengths in DEVICE pixels. */
|
|
49
|
+
export interface Display {
|
|
50
|
+
/** Stable within a session; what the tools' `display` parameter names. */
|
|
51
|
+
id: string;
|
|
52
|
+
/** Human label for the approval prompt — "Built-in Retina Display". */
|
|
53
|
+
label: string;
|
|
54
|
+
width: number;
|
|
55
|
+
height: number;
|
|
56
|
+
/** Device pixels per point. 2 on Retina, 1 on a plain panel, 1.5 on some Windows. */
|
|
57
|
+
scale: number;
|
|
58
|
+
/**
|
|
59
|
+
* Where this display sits in the global desktop, in POINTS — the space the operating
|
|
60
|
+
* system lays displays out in, and the space its input APIs take. A display placed to
|
|
61
|
+
* the LEFT of the primary has a negative originX.
|
|
62
|
+
*
|
|
63
|
+
* INFORMATIONAL ONLY: nothing in this module converts through it, and the tools never
|
|
64
|
+
* send it anywhere. It is here so a capability listing can say where a screen is.
|
|
65
|
+
*
|
|
66
|
+
* WHY NOT DEVICE PIXELS, which is what an earlier version of this file used. There is
|
|
67
|
+
* no coherent global device-pixel space on a mixed-DPI desktop. Multiply each
|
|
68
|
+
* display's point origin by its OWN scale and the ranges collide: a 2x laptop
|
|
69
|
+
* (3456px / 1728pt wide) at origin 0 owns device px 0-3455, while a 1x monitor placed
|
|
70
|
+
* beside it at point origin 1728 would claim 1728-4287 — so half of one screen's
|
|
71
|
+
* coordinates also name the other's, and a click meant for the second monitor lands
|
|
72
|
+
* on the first. Every coordinate below is therefore LOCAL to one display, and the
|
|
73
|
+
* platform helper — which knows that display's bounds and scale — is the only thing
|
|
74
|
+
* that converts into the OS's global space.
|
|
75
|
+
*/
|
|
76
|
+
originX: number;
|
|
77
|
+
originY: number;
|
|
78
|
+
primary: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The mapping between one captured frame and the display it came from. Built by
|
|
83
|
+
* planCapture, handed to the helper as the capture request, and kept for as long as
|
|
84
|
+
* the model might send coordinates back against that frame.
|
|
85
|
+
*/
|
|
86
|
+
export interface CapturePlan {
|
|
87
|
+
displayId: string;
|
|
88
|
+
/** Agent space: the exact pixel size of the image the model is shown. */
|
|
89
|
+
width: number;
|
|
90
|
+
height: number;
|
|
91
|
+
/** Agent px → device px, per axis. Derived from the rounded size; see the header. */
|
|
92
|
+
factorX: number;
|
|
93
|
+
factorY: number;
|
|
94
|
+
/** The captured region's size in device px (today: the whole display). */
|
|
95
|
+
regionWidth: number;
|
|
96
|
+
regionHeight: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A point in agent space — the grid of the frame the model was shown. */
|
|
100
|
+
export interface AgentPoint {
|
|
101
|
+
x: number;
|
|
102
|
+
y: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A point in ONE display's own device pixels, origin at that display's top-left. What
|
|
107
|
+
* the helper is given, alongside the display id — see Display.originX for why this is
|
|
108
|
+
* never a global coordinate.
|
|
109
|
+
*/
|
|
110
|
+
export interface DevicePoint {
|
|
111
|
+
x: number;
|
|
112
|
+
y: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The longest edge, in agent pixels, a captured frame is reduced to.
|
|
117
|
+
*
|
|
118
|
+
* 1400 is chosen against what the models can actually resolve rather than what looks
|
|
119
|
+
* nice: Claude tiles vision input at roughly 1.15k on the long edge and gains nothing
|
|
120
|
+
* above ~1568, and a 1400px frame of a 2× display is a 2:1 reduction — enough that
|
|
121
|
+
* menu-bar text is still legible when the model needs to read it. Raising this costs
|
|
122
|
+
* tokens on every single screenshot in a loop that takes many; lowering it makes small
|
|
123
|
+
* UI unreadable and the model start guessing.
|
|
124
|
+
*/
|
|
125
|
+
export const DEFAULT_MAX_EDGE = 1400;
|
|
126
|
+
|
|
127
|
+
/** Bounds on what a caller may ask for, so one tool call can't blow up a turn's budget. */
|
|
128
|
+
export const MIN_MAX_EDGE = 320;
|
|
129
|
+
export const MAX_MAX_EDGE = 2400;
|
|
130
|
+
|
|
131
|
+
export function clampMaxEdge(requested: number | undefined): number {
|
|
132
|
+
if (!Number.isFinite(requested as number)) return DEFAULT_MAX_EDGE;
|
|
133
|
+
return Math.min(MAX_MAX_EDGE, Math.max(MIN_MAX_EDGE, Math.round(requested as number)));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Work out the agent space for capturing `display` whole.
|
|
138
|
+
*
|
|
139
|
+
* Never upscales: a small display stays at its native size (factor 1) rather than being
|
|
140
|
+
* blown up to the max edge, which would cost tokens for pixels that carry no detail.
|
|
141
|
+
*/
|
|
142
|
+
export function planCapture(display: Display, maxEdge: number = DEFAULT_MAX_EDGE): CapturePlan {
|
|
143
|
+
const edge = clampMaxEdge(maxEdge);
|
|
144
|
+
const longest = Math.max(display.width, display.height);
|
|
145
|
+
const reduction = longest > edge ? longest / edge : 1;
|
|
146
|
+
|
|
147
|
+
// At least 1px each way — a degenerate display report must not produce a zero-sized
|
|
148
|
+
// plan whose factors are Infinity.
|
|
149
|
+
const width = Math.max(1, Math.round(display.width / reduction));
|
|
150
|
+
const height = Math.max(1, Math.round(display.height / reduction));
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
displayId: display.id,
|
|
154
|
+
width,
|
|
155
|
+
height,
|
|
156
|
+
factorX: display.width / width,
|
|
157
|
+
factorY: display.height / height,
|
|
158
|
+
regionWidth: display.width,
|
|
159
|
+
regionHeight: display.height,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The longest edge of the small copy sent with a permission prompt.
|
|
165
|
+
*
|
|
166
|
+
* Deliberately far below DEFAULT_MAX_EDGE. The model needs to read menu text; a human
|
|
167
|
+
* approving a click needs to recognise the window and see where the marker is, which
|
|
168
|
+
* 480px does at a glance. It also has to cross a relay to a phone for every single
|
|
169
|
+
* action — the full frame is ~1 MB of base64 and this is nearer 40 KB, and an approval
|
|
170
|
+
* that takes a second to arrive is one people stop reading.
|
|
171
|
+
*/
|
|
172
|
+
export const PREVIEW_MAX_EDGE = 480;
|
|
173
|
+
|
|
174
|
+
/** The preview's pixel size for a given plan, preserving its aspect. */
|
|
175
|
+
export function planPreview(plan: CapturePlan, maxEdge: number = PREVIEW_MAX_EDGE): { width: number; height: number } {
|
|
176
|
+
const longest = Math.max(plan.width, plan.height);
|
|
177
|
+
const reduction = longest > maxEdge ? longest / maxEdge : 1;
|
|
178
|
+
return {
|
|
179
|
+
width: Math.max(1, Math.round(plan.width / reduction)),
|
|
180
|
+
height: Math.max(1, Math.round(plan.height / reduction)),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Is this point inside the frame the model was actually shown? */
|
|
185
|
+
export function isInFrame(pt: AgentPoint, plan: CapturePlan): boolean {
|
|
186
|
+
return (
|
|
187
|
+
Number.isFinite(pt.x) &&
|
|
188
|
+
Number.isFinite(pt.y) &&
|
|
189
|
+
pt.x >= 0 &&
|
|
190
|
+
pt.y >= 0 &&
|
|
191
|
+
pt.x < plan.width &&
|
|
192
|
+
pt.y < plan.height
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Math.round produces -0 for any input in [-0.5, 0), which is what the top-left corner
|
|
198
|
+
* of a frame maps back to. It compares equal to 0 with `==` and survives JSON, so it
|
|
199
|
+
* would never have shown up in behaviour — but it is not 0 under Object.is or a strict
|
|
200
|
+
* deep-equal, so it turns any future test or cache key over a coordinate into a
|
|
201
|
+
* mismatch that reads as a real bug. Normalized once, here.
|
|
202
|
+
*/
|
|
203
|
+
function norm(v: number): number {
|
|
204
|
+
return v + 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* One agent pixel's device coordinate: the device pixel nearest the middle of the cell
|
|
209
|
+
* that agent pixel covers, CONSTRAINED to that cell.
|
|
210
|
+
*
|
|
211
|
+
* The constraint is the whole point, and two simpler versions both fail:
|
|
212
|
+
*
|
|
213
|
+
* • round(origin + (a + 0.5) * factor) overshoots at the far edge. A 1024-wide
|
|
214
|
+
* display at factor 1 sends its rightmost pixel (1023) to round(1023.5) = 1024 —
|
|
215
|
+
* the first pixel of the NEXT display. A click on a window's close button or a
|
|
216
|
+
* right-edge scrollbar lands on the monitor beside it.
|
|
217
|
+
* • floor(...) fixes that and breaks the round trip, because flooring the centre can
|
|
218
|
+
* leave the cell it came from: at factor 1.44036, agent row 1549 covers device
|
|
219
|
+
* [2231.12, 2232.56), its centre is 2231.84, and floor gives 2231 — which is row
|
|
220
|
+
* 1548. Off by one, over most of the screen rather than at its edge.
|
|
221
|
+
*
|
|
222
|
+
* Deriving the cell's integer bounds and clamping into them is exact for both. Since
|
|
223
|
+
* planCapture never upscales, factor >= 1 and every cell contains at least one integer,
|
|
224
|
+
* so the clamp always has something to land on.
|
|
225
|
+
*/
|
|
226
|
+
function cellDevice(index: number, factor: number): number {
|
|
227
|
+
const lo = Math.ceil(index * factor);
|
|
228
|
+
const hi = Math.ceil((index + 1) * factor) - 1;
|
|
229
|
+
const centre = Math.round((index + 0.5) * factor);
|
|
230
|
+
if (hi < lo) return lo; // a sub-pixel cell — impossible today, but not worth a NaN
|
|
231
|
+
return Math.min(hi, Math.max(lo, centre));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Agent space → the global desktop, in device pixels.
|
|
236
|
+
*
|
|
237
|
+
* THROWS on a point outside the frame rather than clamping to the edge. An edge-clamped
|
|
238
|
+
* click is a plausible-looking place that is not where the model aimed, and the model
|
|
239
|
+
* would read the resulting screenshot as "the click did something unexpected" instead
|
|
240
|
+
* of "my coordinates were wrong". An out-of-frame point is always a mistake, and saying
|
|
241
|
+
* so is what stops the retry loop this whole module exists to prevent.
|
|
242
|
+
*/
|
|
243
|
+
export function agentToDevice(pt: AgentPoint, plan: CapturePlan): DevicePoint {
|
|
244
|
+
if (!isInFrame(pt, plan)) {
|
|
245
|
+
throw new RangeError(
|
|
246
|
+
`(${pt.x}, ${pt.y}) is outside the captured frame, which is ${plan.width}×${plan.height}. ` +
|
|
247
|
+
`Coordinates must come from the most recent screen_capture of this display.`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
x: norm(cellDevice(pt.x, plan.factorX)),
|
|
252
|
+
y: norm(cellDevice(pt.y, plan.factorY)),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The inverse — which agent pixel's cell contains this device point. Used to report the
|
|
258
|
+
* pointer's current position back in the space the model is thinking in.
|
|
259
|
+
*
|
|
260
|
+
* `floor` of the offset, not a rounded centre: the cell for index a is
|
|
261
|
+
* [origin + a*factor, origin + (a+1)*factor), so this is the exact inverse of
|
|
262
|
+
* cellDevice above rather than an approximation that agrees with it most of the time.
|
|
263
|
+
*/
|
|
264
|
+
export function deviceToAgent(pt: DevicePoint, plan: CapturePlan): AgentPoint {
|
|
265
|
+
return {
|
|
266
|
+
x: norm(Math.floor(pt.x / plan.factorX)),
|
|
267
|
+
y: norm(Math.floor(pt.y / plan.factorY)),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Pick the display a tool call means: the one named, else the primary, else the first. */
|
|
272
|
+
export function resolveDisplay(displays: Display[], id: string | undefined): Display | undefined {
|
|
273
|
+
if (!displays.length) return undefined;
|
|
274
|
+
if (id) return displays.find((d) => d.id === id);
|
|
275
|
+
return displays.find((d) => d.primary) ?? displays[0];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** One line per display for computer_capabilities — sizes in the space the model uses. */
|
|
279
|
+
export function describeDisplays(displays: Display[], maxEdge: number = DEFAULT_MAX_EDGE): string[] {
|
|
280
|
+
return displays.map((d) => {
|
|
281
|
+
const plan = planCapture(d, maxEdge);
|
|
282
|
+
const scale = d.scale !== 1 ? ` @${d.scale}×` : "";
|
|
283
|
+
const primary = d.primary ? " (primary)" : "";
|
|
284
|
+
return (
|
|
285
|
+
` ${d.id}: ${d.label}${primary} — ${d.width}×${d.height} device px${scale}; ` +
|
|
286
|
+
`you will see it as ${plan.width}×${plan.height} and give coordinates in that space`
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* May this process drive the machine's screen, mouse and keyboard at all?
|
|
3
|
+
*
|
|
4
|
+
* THE DEFAULT IS OFF, and that is the one way this differs from every other switch in
|
|
5
|
+
* config/. relayExposure.ts defaults to ALLOWED because an unset variable there means
|
|
6
|
+
* "nobody has expressed a preference, and the surfaces that open a socket are already
|
|
7
|
+
* explicit acts". The opposite is true here: GUI control is not an extension of what
|
|
8
|
+
* the agent could already do, it is a way around the whole moat. The permission gate
|
|
9
|
+
* confines file operations to the working directory, refuses protected paths and
|
|
10
|
+
* pattern-matches dangerous shell — and a mouse can open Terminal and type any of it,
|
|
11
|
+
* click Allow on another application's consent dialog, or drive a browser that is
|
|
12
|
+
* already logged into the user's bank. Nothing in classify.ts can see any of that
|
|
13
|
+
* happen. So it exists only where a human has said, in as many words, that it may.
|
|
14
|
+
*
|
|
15
|
+
* ARMING IS A SEPARATE ACT FROM APPROVING. The gate still asks before every action
|
|
16
|
+
* (permissions/mode.ts — kind "computer" never auto-allows, not even under `bypass`).
|
|
17
|
+
* This switch decides something prior: whether the tools are REGISTERED at all. An
|
|
18
|
+
* unarmed session does not have a `computer_control` that gets denied, it has no such
|
|
19
|
+
* tool — the same distinction buildMoat already draws for web ("web off should mean the
|
|
20
|
+
* tool doesn't exist for that run, not that it exists and is denied"). A model with a
|
|
21
|
+
* tool it is refused on every call spends its context rediscovering that; a model
|
|
22
|
+
* without the tool says it cannot do that and moves on.
|
|
23
|
+
*
|
|
24
|
+
* WHAT THIS DOES NOT DEFEND AGAINST, stated plainly because the alternative is implying
|
|
25
|
+
* a guarantee we don't have: `--no-quarter` (PRIVATEER_NO_QUARTER) and the app's
|
|
26
|
+
* unattended toggle are still a total bypass of the gate, computer actions included.
|
|
27
|
+
* That is deliberate — a second, subtly-different total-bypass lever is worse than one
|
|
28
|
+
* everybody understands — but it means ARMING plus NO-QUARTER is a session that will
|
|
29
|
+
* move the mouse without asking. Both are explicit human acts, and the app's copy has
|
|
30
|
+
* to say so where the two can be turned on together.
|
|
31
|
+
*
|
|
32
|
+
* Read at CALL time rather than latched at boot, for the reason relayExposure.ts gives:
|
|
33
|
+
* a long-lived process whose parent re-stamps its environment (the desktop shell
|
|
34
|
+
* re-spawning a child with a different answer) must honour the new answer on its next
|
|
35
|
+
* tick rather than the one it happened to start with.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const ON = new Set(["1", "true", "on", "yes"]);
|
|
39
|
+
|
|
40
|
+
/** The environment variable the desktop shell and the CLI flag both write. */
|
|
41
|
+
export const COMPUTER_CONTROL_ENV = "PRIVATEER_COMPUTER_CONTROL";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether GUI control tools may be registered for this process.
|
|
45
|
+
*
|
|
46
|
+
* Unset, empty, or anything not in ON ⇒ false. Deliberately an allow-list rather than
|
|
47
|
+
* a deny-list: a typo ("PRIVATEER_COMPUTER_CONTROL=ture") must fail CLOSED. The
|
|
48
|
+
* inverse spelling would arm the machine on a misspelling, which is the wrong direction
|
|
49
|
+
* for the one capability that can click through another app's consent dialog.
|
|
50
|
+
*/
|
|
51
|
+
export function computerControlArmed(): boolean {
|
|
52
|
+
const v = process.env[COMPUTER_CONTROL_ENV]?.trim().toLowerCase();
|
|
53
|
+
return v ? ON.has(v) : false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What to tell a user who asked for something the tools would have done. Written for
|
|
58
|
+
* the model to relay verbatim, so it names the switch rather than the variable: someone
|
|
59
|
+
* reading it is in an app or a terminal, not editing an environment.
|
|
60
|
+
*/
|
|
61
|
+
export function computerControlDisarmedHint(): string {
|
|
62
|
+
return (
|
|
63
|
+
"Screen control is off for this machine. It is off by default because it lets an agent " +
|
|
64
|
+
"click and type anywhere, outside every other limit in place. Turn it on in Privateer → " +
|
|
65
|
+
"Settings → Screen control, or start the CLI with --allow-computer-control."
|
|
66
|
+
);
|
|
67
|
+
}
|
package/src/config/moat.ts
CHANGED
|
@@ -140,6 +140,21 @@ interface MoatCaps {
|
|
|
140
140
|
web: false | "account" | "guarded";
|
|
141
141
|
media: boolean;
|
|
142
142
|
mcp: boolean;
|
|
143
|
+
/**
|
|
144
|
+
* GUI control — screenshots, mouse and keyboard (src/tools/computer.ts). A CEILING
|
|
145
|
+
* like web and media, and ANDed with computerControlArmed(), which is OFF unless a
|
|
146
|
+
* human has said otherwise.
|
|
147
|
+
*
|
|
148
|
+
* FALSE FOR EVERY UNATTENDED KIND, and this is the row least safe to flip. The four
|
|
149
|
+
* headless kinds run with nobody watching, and permissions/mode.ts answers "ask" for
|
|
150
|
+
* every computer action — so a harbor routine granted these tools would not run
|
|
151
|
+
* unattended, it would WEDGE on a relayed prompt nobody is there to answer. That is
|
|
152
|
+
* the benign reading. The other one is that a scheduled run is the least supervised
|
|
153
|
+
* thing we ship and prompt injection reaches it through its own inputs, which is not
|
|
154
|
+
* a combination to give a mouse to. Attended kinds only: the desktop window and the
|
|
155
|
+
* dev REPL, both with a human at the keyboard who can see what moved.
|
|
156
|
+
*/
|
|
157
|
+
computer: boolean;
|
|
143
158
|
/** PRIVATEER.md project context + /init (extensions/privateer-context.ts). */
|
|
144
159
|
context?: boolean;
|
|
145
160
|
/** The folder's own skills, contributed from ~/.privateer rather than the user's tree. */
|
|
@@ -157,12 +172,12 @@ interface MoatCaps {
|
|
|
157
172
|
}
|
|
158
173
|
|
|
159
174
|
const CAPABILITIES: Record<MoatKind, MoatCaps> = {
|
|
160
|
-
"harbor-session": { web: "account", media: true, mcp: true },
|
|
161
|
-
"live-task": { web: false, media: true, mcp: false },
|
|
162
|
-
channels: { web: "account", media: true, mcp: false },
|
|
163
|
-
acp: { web: "account", media: true, mcp: false },
|
|
164
|
-
repl: { web: false, media: true, mcp: false },
|
|
165
|
-
desktop: { web: "guarded", media: true, mcp: true, context: true, spawnSkills: true, privacyRepairs: true },
|
|
175
|
+
"harbor-session": { web: "account", media: true, mcp: true, computer: false },
|
|
176
|
+
"live-task": { web: false, media: true, mcp: false, computer: false },
|
|
177
|
+
channels: { web: "account", media: true, mcp: false, computer: false },
|
|
178
|
+
acp: { web: "account", media: true, mcp: false, computer: false },
|
|
179
|
+
repl: { web: false, media: true, mcp: false, computer: true },
|
|
180
|
+
desktop: { web: "guarded", media: true, mcp: true, computer: true, context: true, spawnSkills: true, privacyRepairs: true },
|
|
166
181
|
};
|
|
167
182
|
|
|
168
183
|
/**
|
|
@@ -308,6 +323,31 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
|
|
|
308
323
|
const { makeComposeTools } = await import("../tools/videoCompose.ts");
|
|
309
324
|
factories.push(makeComposeTools());
|
|
310
325
|
|
|
326
|
+
// GUI control. TWO conditions, and they answer different questions: `caps.computer`
|
|
327
|
+
// is whether this KIND of session may ever have it, `computerControlArmed()` is
|
|
328
|
+
// whether this MACHINE's owner has turned it on. Omitted as a factory rather than
|
|
329
|
+
// registered-and-denied for the reason the web block above gives — a tool that
|
|
330
|
+
// refuses every call teaches the model to keep trying, where an absent one makes it
|
|
331
|
+
// say it cannot do that and move on. Read here, at build time, and the desktop
|
|
332
|
+
// rebuilds its session when the switch changes (agentSession's setModel path), so a
|
|
333
|
+
// user who arms it does not have to restart to be believed.
|
|
334
|
+
if (caps.computer) {
|
|
335
|
+
const { computerControlArmed } = await import("./computerControl.ts");
|
|
336
|
+
if (computerControlArmed()) {
|
|
337
|
+
const { makeComputerTools } = await import("../tools/computer.ts");
|
|
338
|
+
const { ComputerPreviewSink } = await import("../computer/preview.ts");
|
|
339
|
+
// ONE sink, created here and handed to both halves: the tools write each capture
|
|
340
|
+
// into it, and the gate reads a preview out when it asks about a click. This is
|
|
341
|
+
// the only place that pairing can be made, because it is the only place that
|
|
342
|
+
// holds both the gate and the tool factory — and it has to be per session (the
|
|
343
|
+
// desktop runs one per window in one process), which is exactly what this
|
|
344
|
+
// function already is. See computer/preview.ts for what module state would cost.
|
|
345
|
+
const preview = new ComputerPreviewSink();
|
|
346
|
+
opts.gate.computerPreview = preview;
|
|
347
|
+
factories.push(makeComputerTools(preview));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
311
351
|
if (caps.mcp) {
|
|
312
352
|
// Registers the tools from the shared agent/mcp.json — the same projection the app's
|
|
313
353
|
// MCP manager writes over the relay. No servers configured → a no-op. The specifier is
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
{ "entry": "extensions/privateer-privacy.ts", "name": "privateer-privacy", "note": "pi-privacy + account tier resolver" },
|
|
18
18
|
{ "entry": "extensions/privateer-connect.ts", "name": "privateer-connect", "note": "/connect — MCP connector manager" },
|
|
19
19
|
{ "entry": "extensions/privateer-media.ts", "name": "privateer-media", "note": "image/video/speech/music + ffmpeg compose" },
|
|
20
|
+
{ "entry": "extensions/privateer-computer.ts", "name": "privateer-computer", "note": "screen control — screenshots, mouse and keyboard (off unless armed with --allow-computer-control)" },
|
|
20
21
|
{ "entry": "extensions/privateer-desktop.ts", "name": "privateer-desktop", "note": "/desktop — open the Privateer desktop app" },
|
|
21
22
|
{ "entry": "extensions/privateer-hints.ts", "name": "privateer-hints", "note": "rotating tips in the working line + /hints" },
|
|
22
23
|
{ "entry": "extensions/privateer-update.ts", "name": "privateer-update", "note": "tool pack updates in place — banner flag + /update" },
|
|
@@ -35,6 +35,7 @@ import { addPiiAllow, piiAllowEntries, removePiiAllow } from "./piiAllow.ts";
|
|
|
35
35
|
import { cliPalette, detectScheme } from "../ui/palette.ts";
|
|
36
36
|
import { accountPosture, privateerChannel } from "../providers/account.ts";
|
|
37
37
|
import { hasCredentials } from "../auth/privateer.ts";
|
|
38
|
+
import { writePiDefaultModel } from "../providers/defaultModel.ts";
|
|
38
39
|
|
|
39
40
|
// Color-coat pi-privacy's auto-redact notice as the moat acting on your behalf: the red
|
|
40
41
|
// no-quarter flag (same glyph and color as the no-quarter banner in chat.ts and the gate
|
|
@@ -168,7 +169,45 @@ function registerPrivacyCommand(pi: any): void {
|
|
|
168
169
|
export function privacyExtension() {
|
|
169
170
|
const privacy = makePiPrivacyExtension(sharedPrivacyOptions());
|
|
170
171
|
return function privateerPrivacyCore(pi: any): void {
|
|
171
|
-
privacy(pi);
|
|
172
|
+
privacy(persistingModelPicks(pi));
|
|
172
173
|
registerPrivacyCommand(pi);
|
|
173
174
|
};
|
|
174
175
|
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* pi-privacy's `/models` picker switched the live session and stopped there, so the
|
|
179
|
+
* pick lasted exactly as long as the terminal and the next one launched on whatever
|
|
180
|
+
* settings.json still said. Pi persists a switch only for a caller that passes
|
|
181
|
+
* `{ persist: true }`, and the extension api it reaches setModel through forwards no
|
|
182
|
+
* options at all (see savedPiDefaultSpec in providers/defaultModel.ts).
|
|
183
|
+
*
|
|
184
|
+
* Pi's own selector splits the two — Enter switches for the session, ctrl+s makes it
|
|
185
|
+
* the default. OUR picker has no second key to press and never advertised a
|
|
186
|
+
* distinction, so a pick made in it means "this is my model": we persist it.
|
|
187
|
+
*
|
|
188
|
+
* Wrapped here rather than fixed inside pi-privacy so the rule sits with the rest of
|
|
189
|
+
* what Privateer configures on that extension, and so it stays scoped to the picker.
|
|
190
|
+
* The brand extension's sign-in switch shares the same api and deliberately does NOT
|
|
191
|
+
* get this — writePiDefaultModel says why an automatic switch must not persist.
|
|
192
|
+
*
|
|
193
|
+
* A Proxy, not a spread: the api object is a plain literal today, but a spread of a
|
|
194
|
+
* class instance would silently drop every method and take pi-privacy down with it.
|
|
195
|
+
* Forwarding leaves that failure mode impossible.
|
|
196
|
+
*/
|
|
197
|
+
function persistingModelPicks(pi: any): any {
|
|
198
|
+
if (typeof pi?.setModel !== "function") return pi;
|
|
199
|
+
const setModel = async (model: any) => {
|
|
200
|
+
const ok = await pi.setModel(model);
|
|
201
|
+
// false = the host has no key for that provider, so nothing switched and there is
|
|
202
|
+
// nothing to remember. Anything else (true, undefined) is a switch that happened.
|
|
203
|
+
if (ok !== false && model?.provider && model?.id) writePiDefaultModel(`${model.provider}/${model.id}`);
|
|
204
|
+
return ok;
|
|
205
|
+
};
|
|
206
|
+
return new Proxy(pi, {
|
|
207
|
+
get(target, prop, receiver) {
|
|
208
|
+
if (prop === "setModel") return setModel;
|
|
209
|
+
const value = Reflect.get(target, prop, receiver);
|
|
210
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Whether this process may open a cloud relay socket at all.
|
|
2
|
+
//
|
|
3
|
+
// Distinct from "is remote access on" — that is a decision each surface already
|
|
4
|
+
// makes for itself (`/remote-access` in the TUI, a running harbor's syncRelay).
|
|
5
|
+
// This is the machine's answer to a prior question: MAY that decision be yes?
|
|
6
|
+
//
|
|
7
|
+
// Unset means ALLOWED, and that default is what keeps every existing surface
|
|
8
|
+
// unchanged: a `privateer harbor` someone started by hand is a harbor they asked
|
|
9
|
+
// to be reachable, and the CLI's /remote-access has always been an explicit act.
|
|
10
|
+
// Nothing in this repo sets the variable.
|
|
11
|
+
//
|
|
12
|
+
// The desktop shell is the caller that does. There, remote access is a per-machine
|
|
13
|
+
// preference that defaults OFF (a GUI app that auto-starts a harbor would otherwise
|
|
14
|
+
// put a socket on the relay for a user who never asked for one), so the shell stamps
|
|
15
|
+
// its answer into the harbor child's environment.
|
|
16
|
+
//
|
|
17
|
+
// Read at CALL time rather than latched at boot, so a harbor that was already
|
|
18
|
+
// running when the shell re-spawned it with a different answer honours the new one
|
|
19
|
+
// on its next tick instead of the value it happened to start with.
|
|
20
|
+
const OFF = new Set(["0", "false", "off", "no"]);
|
|
21
|
+
|
|
22
|
+
export function relayExposureAllowed(): boolean {
|
|
23
|
+
const v = process.env.PRIVATEER_RELAY_EXPOSURE?.trim().toLowerCase();
|
|
24
|
+
if (!v) return true;
|
|
25
|
+
return !OFF.has(v);
|
|
26
|
+
}
|
|
@@ -75,6 +75,17 @@ export interface GateController {
|
|
|
75
75
|
redact?(text: string): string;
|
|
76
76
|
// Hard ceiling on waiting for a decision before failing closed.
|
|
77
77
|
approvalTimeoutMs?: number;
|
|
78
|
+
/**
|
|
79
|
+
* This session's screen-control preview sink (computer/preview.ts), when the session
|
|
80
|
+
* has screen control at all. Set by buildMoat, which creates the one object the
|
|
81
|
+
* computer tools write frames into and this reads them out of.
|
|
82
|
+
*
|
|
83
|
+
* It exists because a GUI approval is the one prompt that cannot be evaluated from
|
|
84
|
+
* words — see ApprovalPreview in permissions/gate.ts. Absent ⇒ computer approvals
|
|
85
|
+
* carry no picture and fall back to the text-only prompt, which is what every non-GUI
|
|
86
|
+
* kind has always been.
|
|
87
|
+
*/
|
|
88
|
+
computerPreview?: { previewFor(toolName: string, input: unknown): import("../permissions/gate.ts").ApprovalPreview | undefined };
|
|
78
89
|
}
|
|
79
90
|
|
|
80
91
|
export interface GateBlock {
|
|
@@ -127,6 +138,20 @@ export async function decideToolCall(
|
|
|
127
138
|
});
|
|
128
139
|
if (!req) return undefined; // no gate needed — read-only/in-scope/meta
|
|
129
140
|
|
|
141
|
+
// Attach the picture a GUI action is actually about. Done HERE rather than in
|
|
142
|
+
// classifyToolCall because the classifier is pure over {toolName, input} and has no
|
|
143
|
+
// route to a screenshot — see computer/preview.ts. Guarded on the kind so nothing
|
|
144
|
+
// else ever carries an image, and wrapped because a preview is a nicety: failing to
|
|
145
|
+
// build one must never fail the approval it was meant to illustrate.
|
|
146
|
+
if (req.kind === "computer" && ctrl.computerPreview) {
|
|
147
|
+
try {
|
|
148
|
+
const preview = ctrl.computerPreview.previewFor(toolName, input);
|
|
149
|
+
if (preview) req.preview = preview;
|
|
150
|
+
} catch {
|
|
151
|
+
/* no picture; the text-only prompt still stands */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
130
155
|
// Route the ask: a remote-driven turn goes to the relay approver (if wired),
|
|
131
156
|
// otherwise the local UI. ModeGate decides *whether* to ask; this decides *who*.
|
|
132
157
|
const ask = (r: PermissionRequest): Promise<AskOutcome> =>
|
package/src/harbor/index.ts
CHANGED
|
@@ -65,6 +65,7 @@ import { redactText, collectSecrets } from "../util/redact.ts";
|
|
|
65
65
|
import { startIpcServer, sendToHarbor, describeRelay, formatDuration, HarborAlreadyRunningError, type IpcRequest, type IpcResponse, type RelayStatus } from "./ipc.ts";
|
|
66
66
|
import { serializeBuild } from "./buildLock.ts";
|
|
67
67
|
import { isHosted, publishRelayPub, webEnabled, mediaEnabled } from "../config/hosted.ts";
|
|
68
|
+
import { relayExposureAllowed } from "../config/relayExposure.ts";
|
|
68
69
|
import { WEB_TOOL_NAMES } from "../tools/web.ts";
|
|
69
70
|
import { MEDIA_TOOL_NAMES } from "../tools/media.ts";
|
|
70
71
|
import { COMPOSE_TOOL_NAMES } from "../tools/videoCompose.ts";
|
|
@@ -360,6 +361,13 @@ export class Harbor {
|
|
|
360
361
|
// `relay` delivery — so the "Privateer Local Harbor" terminal is always reachable
|
|
361
362
|
// from the app for management (including creating the very first routine).
|
|
362
363
|
if (!hasCredentials()) return;
|
|
364
|
+
// …unless this machine says remote access is off. Checked HERE, beside the
|
|
365
|
+
// credential, because both are conditions on being reachable rather than on
|
|
366
|
+
// being useful: a harbor refused a socket still fires every routine on its
|
|
367
|
+
// schedule and still delivers results to the account outbox. Only the app's
|
|
368
|
+
// live management view goes away. Re-read each tick (see relayExposure.ts), so
|
|
369
|
+
// a shell that re-spawns us with a different answer is obeyed on the next one.
|
|
370
|
+
if (!relayExposureAllowed()) return;
|
|
363
371
|
this.relay = new RelayClient(
|
|
364
372
|
{
|
|
365
373
|
onPrompt: () => {},
|
|
@@ -1286,6 +1294,18 @@ export class Harbor {
|
|
|
1286
1294
|
// permanent state: tick() re-runs syncRelay(), so a harbor that came up signed
|
|
1287
1295
|
// out connects on its own once you sign in. Reported as "connecting" because
|
|
1288
1296
|
// that is what it now is.
|
|
1297
|
+
//
|
|
1298
|
+
// The machine switch is reported BEFORE the credential, and that order is the
|
|
1299
|
+
// honest one: a machine with remote access off is not waiting on a login, and
|
|
1300
|
+
// telling someone to sign in to fix a setting sends them to the wrong screen.
|
|
1301
|
+
if (!relayExposureAllowed()) {
|
|
1302
|
+
return {
|
|
1303
|
+
termId,
|
|
1304
|
+
connected: false,
|
|
1305
|
+
reason: "disabled",
|
|
1306
|
+
detail: "remote access is off for this computer — turn it on in the Privateer app",
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1289
1309
|
const signedIn = hasCredentials();
|
|
1290
1310
|
return {
|
|
1291
1311
|
termId,
|
package/src/harbor/ipc.ts
CHANGED
|
@@ -53,9 +53,12 @@ export type IpcRequest =
|
|
|
53
53
|
* terminated remote access was switched off from the app; needs a restart
|
|
54
54
|
* signed-out no account credentials on this machine
|
|
55
55
|
* refused the server said no (plan's agent cap, rejected ticket) — standing
|
|
56
|
+
* disabled remote access is off for this MACHINE (PRIVATEER_RELAY_EXPOSURE) —
|
|
57
|
+
* standing, and the one reason here that is a setting rather than a
|
|
58
|
+
* fault, so the app words it as an invitation rather than an error
|
|
56
59
|
* connecting genuinely still trying; this one really is worth a moment
|
|
57
60
|
*/
|
|
58
|
-
export type RelayReason = "terminated" | "signed-out" | "refused" | "connecting";
|
|
61
|
+
export type RelayReason = "terminated" | "signed-out" | "refused" | "disabled" | "connecting";
|
|
59
62
|
|
|
60
63
|
/**
|
|
61
64
|
* The harbor's view of its own relay connection, reported by `status`.
|