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 +10 -6
- package/bin/privateer-launch.mjs +67 -7
- package/extensions/privateer-brand.ts +13 -3
- package/package.json +1 -1
- package/src/providers/account.ts +12 -5
- package/src/util/openBrowser.ts +90 -0
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
|
|
279
|
-
|
|
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`**.
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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.
|
|
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",
|
package/src/providers/account.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|