privateer-agent 0.12.1 → 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/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
|
|
|
@@ -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
|
+
}
|