privateer-agent 0.12.1 → 0.12.3
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/auth/privateer.ts +8 -2
- package/src/providers/account.ts +20 -6
- package/src/providers/catalog.ts +1 -1
- package/src/providers/genModelsJson.ts +8 -1
- 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.3",
|
|
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/auth/privateer.ts
CHANGED
|
@@ -451,8 +451,14 @@ export async function runDeviceLogin(opts: {
|
|
|
451
451
|
// Pass the server's own message through so the user learns what to actually do.
|
|
452
452
|
async function spawnFailure(res: Response): Promise<Error> {
|
|
453
453
|
if (res.status === 401) {
|
|
454
|
-
|
|
455
|
-
|
|
454
|
+
// Announce only the signed-in → signed-out transition: concurrent launch-time
|
|
455
|
+
// spawns (warmSession + the account-channel seed) can both 401 on the same dead
|
|
456
|
+
// login, and whichever lands second must stay silent. Same guard as
|
|
457
|
+
// handleServerRevoke.
|
|
458
|
+
if (hasCredentials()) {
|
|
459
|
+
clearCredentials();
|
|
460
|
+
notifySessionExpired();
|
|
461
|
+
}
|
|
456
462
|
return new Error("Your Privateer session expired. Run /login to sign in again.");
|
|
457
463
|
}
|
|
458
464
|
let message: string | undefined;
|
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 {
|
|
@@ -55,6 +56,10 @@ const DEFAULT_MODELS = [
|
|
|
55
56
|
"anthropic/claude-sonnet-5",
|
|
56
57
|
"openai/gpt-5.6-sol",
|
|
57
58
|
"deepseek/deepseek-v4-flash",
|
|
59
|
+
// 2026-08 releases, confirmed live on OpenRouter (ZDR-covered ids reach the
|
|
60
|
+
// account catalog automatically; these seeds just make them resolve at launch).
|
|
61
|
+
"moonshotai/kimi-k3",
|
|
62
|
+
"z-ai/glm-5.2",
|
|
58
63
|
];
|
|
59
64
|
|
|
60
65
|
function seedModel(id: string) {
|
|
@@ -398,15 +403,21 @@ export const privateerOAuthProvider = {
|
|
|
398
403
|
try {
|
|
399
404
|
await runDeviceLogin({
|
|
400
405
|
signal: cb.signal,
|
|
401
|
-
onCode: (code) =>
|
|
406
|
+
onCode: (code) => {
|
|
407
|
+
// Absolute url — the server's value is scheme-less and Pi renders this as
|
|
408
|
+
// a terminal hyperlink. See verificationLink.
|
|
409
|
+
const uri = verificationLink(code.verification_uri_complete ?? code.verification_uri);
|
|
410
|
+
// Browser-first: the URL carries the code, so the page lands on the
|
|
411
|
+
// Authorize screen and the user just clicks. Best-effort fire-and-forget —
|
|
412
|
+
// Pi's dialog keeps showing the code + link as the fallback either way.
|
|
413
|
+
if (uri && canOpenBrowser()) void openInBrowser(uri);
|
|
402
414
|
cb.onDeviceCode?.({
|
|
403
415
|
userCode: code.user_code,
|
|
404
|
-
|
|
405
|
-
// a terminal hyperlink. See verificationLink.
|
|
406
|
-
verificationUri: verificationLink(code.verification_uri_complete ?? code.verification_uri),
|
|
416
|
+
verificationUri: uri,
|
|
407
417
|
intervalSeconds: code.interval,
|
|
408
418
|
expiresInSeconds: code.expires_in,
|
|
409
|
-
})
|
|
419
|
+
});
|
|
420
|
+
},
|
|
410
421
|
});
|
|
411
422
|
} catch (e) {
|
|
412
423
|
// Normalize the cancel message to exactly "Login cancelled" (no period):
|
|
@@ -809,7 +820,10 @@ export async function armAccountCredential(
|
|
|
809
820
|
// so staying silent leaves the user to discover it as a bare "No API key found for
|
|
810
821
|
// privateer" on their first prompt.
|
|
811
822
|
const c = ctx as SeedContext;
|
|
812
|
-
|
|
823
|
+
// If the credentials vanished during the call, the spawn hit a 401 and
|
|
824
|
+
// onSessionExpired already announced the sign-out — a second line here would
|
|
825
|
+
// just repeat it (we returned early above if we STARTED signed out).
|
|
826
|
+
if (opts.notify !== false && c?.hasUI && hasCredentials()) {
|
|
813
827
|
c.ui?.notify?.(`Privateer account channel unavailable — ${(e as Error).message}`, "error");
|
|
814
828
|
}
|
|
815
829
|
return false;
|
package/src/providers/catalog.ts
CHANGED
|
@@ -59,7 +59,7 @@ export const PROVIDERS: ProviderEntry[] = [
|
|
|
59
59
|
api: "openai-completions",
|
|
60
60
|
keyEnv: "${DASHSCOPE_API_KEY}",
|
|
61
61
|
compat: { thinkingFormat: "qwen" },
|
|
62
|
-
seedModels: ["qwen3-max", "qwen3-coder-plus", "qwen-max-latest"],
|
|
62
|
+
seedModels: ["qwen3.8-max-preview", "qwen3-max", "qwen3-coder-plus", "qwen-max-latest"],
|
|
63
63
|
},
|
|
64
64
|
{ id: "ollama", source: "pi-privacy" },
|
|
65
65
|
{ id: "nearai", source: "pi-privacy" },
|
|
@@ -36,6 +36,13 @@ export interface ModelsJson {
|
|
|
36
36
|
providers: Record<string, ModelsJsonProvider>;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Per-id context-window overrides for seeds whose real window is far from the
|
|
40
|
+
// Appendix A.4 default (kept until the live listing refines them). Qwen3.8-Max
|
|
41
|
+
// serves ~1M tokens (983,616 advertised at launch, 2026-08-03).
|
|
42
|
+
const SEED_CONTEXT: Record<string, number> = {
|
|
43
|
+
"qwen3.8-max-preview": 1_000_000,
|
|
44
|
+
};
|
|
45
|
+
|
|
39
46
|
function seedModel(id: string, compat?: Record<string, unknown>): ModelsJsonModel {
|
|
40
47
|
return {
|
|
41
48
|
id,
|
|
@@ -43,7 +50,7 @@ function seedModel(id: string, compat?: Record<string, unknown>): ModelsJsonMode
|
|
|
43
50
|
reasoning: false,
|
|
44
51
|
input: ["text"],
|
|
45
52
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
46
|
-
contextWindow: 128000,
|
|
53
|
+
contextWindow: SEED_CONTEXT[id] ?? 128000,
|
|
47
54
|
maxTokens: 16384,
|
|
48
55
|
...(compat ? { compat } : {}),
|
|
49
56
|
};
|
|
@@ -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
|
+
}
|