privateer-agent 0.12.0 → 0.12.2

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/README.md CHANGED
@@ -275,8 +275,11 @@ a guarantee.
275
275
 
276
276
  ## Privateer account (billed inference)
277
277
 
278
- Instead of bringing your own key, run **`/signin`** to sign into a Privateer account — an
279
- app-brokered device flow where you approve a short code in the Privateer app, so wallet and
278
+ Instead of bringing your own key, run **`/signin`** to sign into a Privateer account. Your
279
+ browser opens straight onto an **Authorize this terminal?** page on privateer.pro check the
280
+ code on the page matches the one in your terminal and click Authorize; the terminal signs
281
+ itself in moments later. (Over SSH or on a headless box the terminal prints the link and code
282
+ to approve from the app instead — set `PRIVATEER_NO_BROWSER=1` to always do that.) Wallet and
280
283
  email accounts work identically and no password or key ever touches the terminal. Inference
281
284
  is then billed to your subscription and defaults to a **NEAR TEE** model. Sign out any time
282
285
  with `/signout`; manage linked terminals from the app.
@@ -293,10 +296,11 @@ and a management surface for it.
293
296
 
294
297
  ### Linking a terminal
295
298
 
296
- 1. Run **`privateer`** and **`/signin`**. It prints a short device code.
297
- 2. Open the app → **Link a terminal**, enter the code (or tap the deep link). No password or
298
- wallet key ever touches the terminal, and the app pins the terminal's public key on first
299
- link.
299
+ 1. Run **`privateer`** and **`/signin`**. Your browser opens an authorize page — check the
300
+ code matches the terminal's and click **Authorize**. (No browser handy? The terminal also
301
+ prints the link and code: open the app **Link a terminal** and enter it there.) No
302
+ password or wallet key ever touches the terminal, and the app pins the terminal's public
303
+ key on first link.
300
304
  3. In the terminal, turn on **`/remote-access`** (off by default). The terminal now shows
301
305
  **Online** in the app.
302
306
 
@@ -143,23 +143,83 @@ function runToCompletion(cmd, cmdArgs, opts = {}) {
143
143
  });
144
144
  }
145
145
 
146
+ // npm gives no usable progress, so on a TTY show a braille spinner while it runs
147
+ // and keep its output buffered — shown only if the install fails. Non-TTY (CI,
148
+ // piped) keeps the old passthrough behaviour. The global package is replaced in
149
+ // place, so re-reading our own package.json afterwards yields the NEW version.
150
+ function updateNpmPackage() {
151
+ const cmd = isWin ? "npm.cmd" : "npm";
152
+ const npmArgs = ["install", "-g", "privateer-agent@latest", "--no-fund", "--no-audit"];
153
+ if (!process.stdout.isTTY) {
154
+ console.log("Updating privateer-agent to the latest release…");
155
+ // npm is npm.cmd on Windows; Node >=18.20 needs a shell to spawn a .cmd (EINVAL otherwise).
156
+ runToCompletion(cmd, npmArgs, { shell: isWin });
157
+ return;
158
+ }
159
+ // Ask npm for the globally installed version — REPO/package.json would lie when
160
+ // this copy runs from somewhere other than the global root (e.g. an npx cache).
161
+ const globalVer = () => {
162
+ try {
163
+ const r = spawnSync(cmd, ["ls", "-g", "privateer-agent", "--depth=0", "--json"], { shell: isWin, encoding: "utf8" });
164
+ return JSON.parse(r.stdout).dependencies?.["privateer-agent"]?.version ?? null;
165
+ } catch { return null; }
166
+ };
167
+ const before = globalVer();
168
+ console.log(`\x1b[1m⚓ Updating Privateer\x1b[0m${before ? ` \x1b[2m(currently ${before})\x1b[0m` : ""}`);
169
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
170
+ let captured = "";
171
+ const child = spawn(cmd, [...npmArgs, "--loglevel=error"], { shell: isWin, env: process.env });
172
+ child.stdout.on("data", (d) => (captured += d));
173
+ child.stderr.on("data", (d) => (captured += d));
174
+ process.stdout.write("\x1b[?25l");
175
+ const t0 = Date.now();
176
+ let i = 0;
177
+ const timer = setInterval(() => {
178
+ const s = Math.round((Date.now() - t0) / 1000);
179
+ process.stdout.write(`\r \x1b[36m${frames[i++ % frames.length]}\x1b[0m updating privateer-agent@latest \x1b[2m${s}s\x1b[0m\x1b[K`);
180
+ }, 80);
181
+ const restore = () => { clearInterval(timer); process.stdout.write("\r\x1b[K\x1b[?25h"); };
182
+ child.on("exit", (code, signal) => {
183
+ restore();
184
+ if (code === 0) {
185
+ const after = globalVer();
186
+ if (before && after && before === after) {
187
+ console.log(`\x1b[32m✓\x1b[0m Already ship-shape — privateer-agent ${after} is the latest release.`);
188
+ } else {
189
+ console.log(`\x1b[32m✓\x1b[0m Updated privateer-agent${before ? ` ${before} →` : ""}${after ? ` ${after}` : ""}`);
190
+ console.log(`\nRun \x1b[1mprivateer\x1b[0m to set sail on the new release.`);
191
+ }
192
+ process.exit(0);
193
+ }
194
+ if (captured.trim()) process.stderr.write(captured);
195
+ if (signal) process.kill(process.pid, signal);
196
+ else process.exit(code ?? 1);
197
+ });
198
+ child.on("error", (e) => {
199
+ restore();
200
+ console.error(`privateer: failed to launch npm — ${e.message}`);
201
+ process.exit(1);
202
+ });
203
+ }
204
+
146
205
  // --- `privateer update` ----------------------------------------------------
147
206
  // Fetch the latest release and exit. Bundle installs re-run the download+extract
148
207
  // installer; npm installs update the global package.
149
208
  if (sub === "update") {
150
209
  if (BUNDLED) {
151
- console.log("Updating Privateer to the latest release…");
210
+ // PRIVATEER_UPDATE=1 flips the installer into update mode: weigh-anchor banner,
211
+ // "X → Y" version reporting, and an early exit (no download) when already current.
212
+ // ?update=1 tells the server this fetch is an update, not a fresh install.
213
+ const env = { ...process.env, PRIVATEER_UPDATE: "1" };
152
214
  if (isWin) {
153
- runToCompletion("powershell", ["-NoProfile", "-Command", "irm https://privateer.pro/install.ps1 | iex"]);
215
+ runToCompletion("powershell", ["-NoProfile", "-Command", "irm 'https://privateer.pro/install.ps1?update=1' | iex"], { env });
154
216
  } else {
155
- runToCompletion("sh", ["-c", "curl -fsSL https://privateer.pro/install.sh | sh"]);
217
+ runToCompletion("sh", ["-c", "curl -fsSL 'https://privateer.pro/install.sh?update=1' | sh"], { env });
156
218
  }
157
219
  } else {
158
- console.log("Updating privateer-agent to the latest release…");
159
- // npm is npm.cmd on Windows; Node >=18.20 needs a shell to spawn a .cmd (EINVAL otherwise).
160
- runToCompletion(isWin ? "npm.cmd" : "npm", ["install", "-g", "privateer-agent@latest"], { shell: isWin });
220
+ updateNpmPackage();
161
221
  }
162
- // runToCompletion exits via the child's exit handler.
222
+ // both paths exit via their child's exit handler.
163
223
  }
164
224
 
165
225
  // --- `privateer harbor [run|install|uninstall|status]` ---------------------
@@ -35,6 +35,7 @@ import {
35
35
  verificationLink,
36
36
  } from "../src/providers/account.ts";
37
37
  import { resolveSignedInModel, savedPiDefaultSpec } from "../src/providers/defaultModel.ts";
38
+ import { canOpenBrowser, openInBrowser } from "../src/util/openBrowser.ts";
38
39
  import { discoverContextFiles, onContextChanged } from "../src/context.ts";
39
40
  import { type Palette, paletteFor } from "../src/ui/palette.ts";
40
41
 
@@ -382,13 +383,22 @@ export default function privateerBrand(pi: any): void {
382
383
  // user copies into a browser. See providers/account.ts verificationLink.
383
384
  const uri = clean(verificationLink(code.verification_uri_complete ?? code.verification_uri));
384
385
  const userCode = clean(code.user_code);
386
+ // Browser-first sign-in: the URL carries the code, so the page opens straight
387
+ // onto the Authorize screen — the user clicks, never types. Best-effort and
388
+ // fire-and-forget: the copy below decides its wording SYNCHRONOUSLY off
389
+ // canOpenBrowser (SSH/headless → the old app-approve copy), and the printed
390
+ // link stays either way, so a launcher that silently fails costs nothing.
391
+ const opening = Boolean(uri) && canOpenBrowser();
392
+ if (opening) void openInBrowser(uri);
385
393
  ctx?.ui?.setWidget?.(
386
394
  "privateer-signin",
387
395
  [
388
396
  `${p.INK}⚓ Sign in to Privateer${p.RESET}`,
389
- `${p.DIM}Approve this terminal in the Privateer app:${p.RESET}`,
390
- ` code ${p.BOLD}${p.ACCENT}${userCode}${p.RESET}`,
391
- uri ? `${p.DIM} or open ${p.RESET}${p.INK}${uri}${p.RESET}` : "",
397
+ opening
398
+ ? `${p.DIM}Authorize this terminal in the browser window that just opened.${p.RESET}`
399
+ : `${p.DIM}Approve this terminal in the Privateer app:${p.RESET}`,
400
+ ` code ${p.BOLD}${p.ACCENT}${userCode}${p.RESET}${opening ? `${p.DIM} — check it matches the one in your browser${p.RESET}` : ""}`,
401
+ uri ? `${p.DIM} ${opening ? "no browser? open" : "or open"} ${p.RESET}${p.INK}${uri}${p.RESET}` : "",
392
402
  `${p.DIM} waiting for approval… ${p.RESET}${p.DIM}(esc to cancel · ${p.RESET}${p.INK}/login keys${p.DIM} to use your own API key instead)${p.RESET}`,
393
403
  ].filter(Boolean),
394
404
  { placement: "aboveEditor" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@ import {
25
25
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
26
26
  import { join } from "node:path";
27
27
  import { globalDir } from "../config/paths.ts";
28
+ import { canOpenBrowser, openInBrowser } from "../util/openBrowser.ts";
28
29
  import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
29
30
  import { ACCOUNT_DEFAULT_MODEL_ID, ACCOUNT_NEAR_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
30
31
  import {
@@ -398,15 +399,21 @@ export const privateerOAuthProvider = {
398
399
  try {
399
400
  await runDeviceLogin({
400
401
  signal: cb.signal,
401
- onCode: (code) =>
402
+ onCode: (code) => {
403
+ // Absolute url — the server's value is scheme-less and Pi renders this as
404
+ // a terminal hyperlink. See verificationLink.
405
+ const uri = verificationLink(code.verification_uri_complete ?? code.verification_uri);
406
+ // Browser-first: the URL carries the code, so the page lands on the
407
+ // Authorize screen and the user just clicks. Best-effort fire-and-forget —
408
+ // Pi's dialog keeps showing the code + link as the fallback either way.
409
+ if (uri && canOpenBrowser()) void openInBrowser(uri);
402
410
  cb.onDeviceCode?.({
403
411
  userCode: code.user_code,
404
- // Absolute url — the server's value is scheme-less and Pi renders this as
405
- // a terminal hyperlink. See verificationLink.
406
- verificationUri: verificationLink(code.verification_uri_complete ?? code.verification_uri),
412
+ verificationUri: uri,
407
413
  intervalSeconds: code.interval,
408
414
  expiresInSeconds: code.expires_in,
409
- }),
415
+ });
416
+ },
410
417
  });
411
418
  } catch (e) {
412
419
  // Normalize the cancel message to exactly "Login cancelled" (no period):
@@ -0,0 +1,90 @@
1
+ // Best-effort "open this URL in the user's default browser", for the sign-in flow.
2
+ //
3
+ // The device-code login used to only PRINT the verification link and wait; opening
4
+ // the browser ourselves turns /login into the one-click authorize flow (the page the
5
+ // server sends carries the code in its query string, so the user just clicks
6
+ // Authorize — no typing). Everything here is best-effort by design: the printed link
7
+ // stays in the widget as the fallback, so a failed or skipped open costs nothing.
8
+ //
9
+ // Two separate questions, two exports:
10
+ // canOpenBrowser() — SHOULD we try? Decides the widget copy up front ("check the
11
+ // code matches your browser" vs "approve in the Privateer app"), so it must be
12
+ // synchronous and conservative: an SSH session or a display-less Linux box would
13
+ // open the browser on the WRONG machine or not at all.
14
+ // openInBrowser() — actually try, detached, never throwing. The spawned launcher
15
+ // is unref()'d so a lingering handler can't hold the CLI's event loop open.
16
+
17
+ import { spawn } from "node:child_process";
18
+
19
+ export function canOpenBrowser(
20
+ env: Record<string, string | undefined> = process.env,
21
+ platform: NodeJS.Platform = process.platform,
22
+ ): boolean {
23
+ if (env.PRIVATEER_NO_BROWSER?.trim()) return false; // explicit escape hatch
24
+ // Remote shell: `open`/`xdg-open` would run on the far machine, not where the
25
+ // user's browser is. SSH_TTY covers interactive sessions; SSH_CONNECTION also
26
+ // survives `ssh host command` and some su/sudo transitions.
27
+ if (env.SSH_TTY || env.SSH_CONNECTION) return false;
28
+ // Headless Linux/BSD: no display server → nothing for xdg-open to hand the URL to.
29
+ if (platform !== "darwin" && platform !== "win32" && !env.DISPLAY && !env.WAYLAND_DISPLAY) return false;
30
+ return true;
31
+ }
32
+
33
+ // Only well-formed http(s) URLs are ever handed to a launcher — anything else
34
+ // (including a scheme-less server value that slipped past verificationLink) is
35
+ // refused rather than "fixed" here, so this can't be talked into opening file:// or
36
+ // custom-scheme handlers.
37
+ export function browsableUrl(raw: string | undefined): string | undefined {
38
+ const s = (raw ?? "").trim();
39
+ if (!s) return undefined;
40
+ try {
41
+ const u = new URL(s);
42
+ if (u.protocol !== "https:" && u.protocol !== "http:") return undefined;
43
+ return u.href;
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ export function openInBrowser(rawUrl: string): Promise<boolean> {
50
+ const url = browsableUrl(rawUrl);
51
+ if (!url) return Promise.resolve(false);
52
+
53
+ let cmd: string;
54
+ let args: string[];
55
+ if (process.platform === "darwin") {
56
+ cmd = "open";
57
+ args = [url];
58
+ } else if (process.platform === "win32") {
59
+ // `start` is a cmd built-in; the empty "" is its window-title slot so the URL
60
+ // isn't eaten as the title. cmd re-parses its arguments, so escape the one URL
61
+ // metacharacter cmd cares about (& splits commands); browsableUrl already
62
+ // guarantees there's no whitespace or quotes to break out with.
63
+ cmd = "cmd";
64
+ args = ["/c", "start", "", url.replace(/&/g, "^&")];
65
+ } else {
66
+ cmd = "xdg-open";
67
+ args = [url];
68
+ }
69
+
70
+ return new Promise((resolve) => {
71
+ let settled = false;
72
+ const done = (ok: boolean) => {
73
+ if (!settled) {
74
+ settled = true;
75
+ resolve(ok);
76
+ }
77
+ };
78
+ try {
79
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
80
+ child.once("error", () => done(false)); // launcher missing (e.g. no xdg-open)
81
+ child.once("exit", (code) => done(code === 0));
82
+ child.unref();
83
+ // Some launchers block until the browser exits; don't make the login widget
84
+ // wait on that — after a beat, assume the hand-off worked.
85
+ setTimeout(() => done(true), 2000).unref?.();
86
+ } catch {
87
+ done(false);
88
+ }
89
+ });
90
+ }