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.
@@ -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
+ }
@@ -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> =>
@@ -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`.