privateer-agent 0.1.1 → 0.3.0

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.
Files changed (106) hide show
  1. package/README.md +143 -398
  2. package/bin/privateer-tui +86 -0
  3. package/bin/privateer.mjs +17 -6
  4. package/bin/pv +28 -0
  5. package/package.json +26 -23
  6. package/src/auth/privateer.ts +128 -1
  7. package/src/boot.ts +43 -0
  8. package/src/bridge/engineAdapter.ts +182 -0
  9. package/src/cli/chat.ts +243 -0
  10. package/src/config/paths.ts +24 -44
  11. package/src/config/permissionMode.ts +5 -0
  12. package/src/crypto/outboxSeal.ts +61 -0
  13. package/src/daemon/index.ts +405 -0
  14. package/src/daemon/ipc.ts +127 -0
  15. package/src/engine/errors.ts +95 -45
  16. package/src/engine/router.ts +11 -165
  17. package/src/ext/permissionGate.ts +216 -0
  18. package/src/main.ts +32 -0
  19. package/src/permissions/classify.ts +172 -0
  20. package/src/permissions/gate.ts +13 -11
  21. package/src/permissions/mode.ts +9 -1
  22. package/src/permissions/{uiGate.ts → modeGate.ts} +22 -6
  23. package/src/providers/account.ts +170 -0
  24. package/src/providers/catalog.ts +73 -61
  25. package/src/providers/genModelsJson.ts +97 -0
  26. package/src/remote/relayClient.ts +91 -4
  27. package/src/remote/remoteBridge.ts +152 -0
  28. package/src/routines/cron.ts +109 -0
  29. package/src/routines/delivery.ts +184 -0
  30. package/src/routines/schema.ts +84 -0
  31. package/src/routines/store.ts +248 -0
  32. package/src/routines/toolSelect.ts +50 -0
  33. package/src/routines/trigger.ts +41 -0
  34. package/src/session.ts +84 -251
  35. package/src/tools/routine.ts +129 -0
  36. package/src/tools/saveAttachment.ts +39 -42
  37. package/src/tools/sendFile.ts +75 -0
  38. package/src/util/attachmentStore.ts +18 -35
  39. package/src/util/redact.ts +33 -3
  40. package/LICENSE +0 -21
  41. package/src/agents/loader.ts +0 -49
  42. package/src/commands/custom.ts +0 -75
  43. package/src/commands/registry.ts +0 -499
  44. package/src/components/AgentGroupView.tsx +0 -104
  45. package/src/components/App.tsx +0 -1420
  46. package/src/components/ApprovalPrompt.tsx +0 -38
  47. package/src/components/Banner.tsx +0 -76
  48. package/src/components/Markdown.tsx +0 -183
  49. package/src/components/ModeHint.tsx +0 -40
  50. package/src/components/ModelPicker.tsx +0 -269
  51. package/src/components/Onboarding.tsx +0 -203
  52. package/src/components/PlanConfirm.tsx +0 -37
  53. package/src/components/PrivateerLogin.tsx +0 -109
  54. package/src/components/PromptInput.tsx +0 -602
  55. package/src/components/RewindPicker.tsx +0 -69
  56. package/src/components/Root.tsx +0 -95
  57. package/src/components/SessionPicker.tsx +0 -64
  58. package/src/components/StatusBar.tsx +0 -131
  59. package/src/components/TodoPanel.tsx +0 -36
  60. package/src/components/ToolCallView.tsx +0 -109
  61. package/src/components/Transcript.tsx +0 -203
  62. package/src/components/figures.ts +0 -14
  63. package/src/components/promptModel.ts +0 -73
  64. package/src/components/spinnerVerbs.ts +0 -46
  65. package/src/components/theme.ts +0 -55
  66. package/src/components/types.ts +0 -34
  67. package/src/components/useTeeShield.ts +0 -104
  68. package/src/components/useTerminalWidth.ts +0 -24
  69. package/src/components/useZdrShield.ts +0 -126
  70. package/src/config/load.ts +0 -115
  71. package/src/config/schema.ts +0 -94
  72. package/src/context/outputStyles.ts +0 -42
  73. package/src/context/projectInfo.ts +0 -59
  74. package/src/context/systemPrompt.ts +0 -167
  75. package/src/engine/QueryEngine.ts +0 -399
  76. package/src/hooks/engine.ts +0 -155
  77. package/src/main.tsx +0 -167
  78. package/src/mcp/client.ts +0 -236
  79. package/src/mcp/oauth.ts +0 -245
  80. package/src/memory/auto.ts +0 -146
  81. package/src/memory/checkpoints.ts +0 -227
  82. package/src/memory/store.ts +0 -127
  83. package/src/providers/attestation.ts +0 -149
  84. package/src/providers/capabilities.ts +0 -104
  85. package/src/providers/models.ts +0 -183
  86. package/src/providers/registry.ts +0 -71
  87. package/src/providers/resolve.ts +0 -78
  88. package/src/tools/bash.ts +0 -98
  89. package/src/tools/context.ts +0 -114
  90. package/src/tools/edit.ts +0 -67
  91. package/src/tools/exec.ts +0 -60
  92. package/src/tools/glob.ts +0 -39
  93. package/src/tools/grep.ts +0 -86
  94. package/src/tools/index.ts +0 -69
  95. package/src/tools/memory.ts +0 -53
  96. package/src/tools/processRegistry.ts +0 -77
  97. package/src/tools/read.ts +0 -42
  98. package/src/tools/task.ts +0 -52
  99. package/src/tools/todo.ts +0 -36
  100. package/src/tools/todoStore.ts +0 -31
  101. package/src/tools/walk.ts +0 -44
  102. package/src/tools/web.ts +0 -145
  103. package/src/tools/write.ts +0 -40
  104. package/src/util/images.ts +0 -356
  105. package/src/util/limit.ts +0 -32
  106. package/src/version.ts +0 -13
@@ -0,0 +1,172 @@
1
+ import { resolve, isAbsolute, relative } from "node:path";
2
+ import { isProtectedPath } from "./protected.ts";
3
+ import type { PermissionRequest } from "./gate.ts";
4
+
5
+ // NEW glue for the Pi rewrite. In 0.2 each tool built its own PermissionRequest
6
+ // before calling ctx.gate.request(). Pi's `tool_call` hook instead hands us
7
+ // { toolName, input }, so the classification — which kind of action is this, does
8
+ // it touch a protected/outside path — moves here. Field extraction is defensive
9
+ // across Pi's builtin input shapes (command/cmd, path/file_path/file).
10
+ //
11
+ // Returns null when the call needs no gate (a read-only builtin acting inside
12
+ // scope, or a known-safe meta tool): the hook then lets it run untouched, so
13
+ // ordinary in-cwd work has zero friction.
14
+
15
+ export interface ScopeOptions {
16
+ cwd: string;
17
+ confineToCwd?: boolean; // default true
18
+ allowedOutsideRoots?: string[];
19
+ }
20
+
21
+ function resolveInCwd(cwd: string, p: string): string {
22
+ return isAbsolute(p) ? p : resolve(cwd, p);
23
+ }
24
+
25
+ function isInsideDir(root: string, abs: string): boolean {
26
+ if (abs === root) return true;
27
+ const rel = relative(root, abs);
28
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
29
+ }
30
+
31
+ // Outside the agent's working-directory scope? Only when confinement is on and the
32
+ // path is neither inside cwd nor inside a session-approved outside root.
33
+ export function isOutsideScope(scope: ScopeOptions, abs: string): boolean {
34
+ if (scope.confineToCwd === false) return false;
35
+ if (isInsideDir(scope.cwd, abs)) return false;
36
+ return !(scope.allowedOutsideRoots ?? []).some((root) => isInsideDir(root, abs));
37
+ }
38
+
39
+ function str(v: unknown): string {
40
+ return typeof v === "string" ? v : v == null ? "" : String(v);
41
+ }
42
+
43
+ function firstPath(input: Record<string, unknown>): string {
44
+ return str(input.path ?? input.file_path ?? input.file ?? input.filename ?? input.dir ?? input.directory);
45
+ }
46
+
47
+ // Known-safe read-only / meta builtins that never mutate and never leave the
48
+ // machine: no gate regardless of arguments. Tunable — the conservative default for
49
+ // anything NOT listed here is to ask (see below). TODO(verify) against Pi's full
50
+ // builtin tool catalog as it's enumerated in Phase 5.
51
+ const NON_GATED = new Set([
52
+ "todo", "todowrite", "todo_write", "todoread", "think", "plan_note",
53
+ ]);
54
+
55
+ // Read-ish builtins: gated ONLY when the target resolves outside scope.
56
+ const READ_TOOLS = new Set(["read", "cat", "grep", "find", "glob", "ls", "tree", "view"]);
57
+ const EDIT_TOOLS = new Set(["edit", "edit_file", "str_replace", "str_replace_editor", "apply_patch", "patch"]);
58
+ const WRITE_TOOLS = new Set(["write", "write_file", "create_file", "create", "save_attachment"]);
59
+ const BASH_TOOLS = new Set(["bash", "shell", "run", "exec", "sh"]);
60
+
61
+ export function classifyToolCall(
62
+ toolName: string,
63
+ input: unknown,
64
+ scope: ScopeOptions,
65
+ ): PermissionRequest | null {
66
+ const name = toolName.toLowerCase();
67
+ const obj: Record<string, unknown> =
68
+ input && typeof input === "object" ? (input as Record<string, unknown>) : {};
69
+
70
+ if (NON_GATED.has(name)) return null;
71
+
72
+ // Creating a routine is a persistent mutation; surface the trigger + delivery, and
73
+ // force a human decision (alwaysAsk) when it grants off-machine egress.
74
+ if (name === "create_routine" || name === "routine") {
75
+ const label = str(obj.name) || "routine";
76
+ const trigger = obj.cron ? `cron ${str(obj.cron)}` : obj.at ? `at ${str(obj.at)}` : "(no trigger)";
77
+ const delivery = Array.isArray(obj.delivery) ? (obj.delivery as unknown[]).map(String) : ["file"];
78
+ const egress: string[] = [];
79
+ if (delivery.includes("email")) egress.push("email leaves the machine");
80
+ if (delivery.some((d) => d.startsWith("webhook:"))) egress.push("posts to a webhook off-machine");
81
+ return {
82
+ tool: toolName,
83
+ kind: "write",
84
+ title: "Create routine",
85
+ detail: `${label}: ${trigger} → ${delivery.join(",")}${egress.length ? ` [${egress.join("] [")}]` : ""}`,
86
+ alwaysAsk: egress.length > 0,
87
+ };
88
+ }
89
+
90
+ // Shell — the whole command is the detail (danger scanning runs on it).
91
+ if (BASH_TOOLS.has(name)) {
92
+ const command = str(obj.command ?? obj.cmd ?? obj.script);
93
+ return { tool: toolName, kind: "bash", title: "Run command", detail: command };
94
+ }
95
+
96
+ // Write — create/overwrite a file.
97
+ if (WRITE_TOOLS.has(name)) {
98
+ const p = firstPath(obj);
99
+ const abs = resolveInCwd(scope.cwd, p);
100
+ const outside = isOutsideScope(scope, abs);
101
+ return {
102
+ tool: toolName,
103
+ kind: "write",
104
+ title: outside ? "Write outside working directory" : "Write file",
105
+ detail: outside ? abs : p,
106
+ protected: isProtectedPath(abs),
107
+ outside,
108
+ path: abs,
109
+ };
110
+ }
111
+
112
+ // Edit — modify an existing file.
113
+ if (EDIT_TOOLS.has(name)) {
114
+ const p = firstPath(obj);
115
+ const abs = resolveInCwd(scope.cwd, p);
116
+ const outside = isOutsideScope(scope, abs);
117
+ return {
118
+ tool: toolName,
119
+ kind: "edit",
120
+ title: outside ? "Edit outside working directory" : "Edit file",
121
+ detail: outside ? abs : p,
122
+ protected: isProtectedPath(abs),
123
+ outside,
124
+ path: abs,
125
+ };
126
+ }
127
+
128
+ // Read-ish — no gate in scope; when the target is outside scope, prompt.
129
+ if (READ_TOOLS.has(name)) {
130
+ const p = firstPath(obj);
131
+ if (!p) return null; // e.g. grep with no explicit path → in-cwd, no gate
132
+ const abs = resolveInCwd(scope.cwd, p);
133
+ if (!isOutsideScope(scope, abs)) return null;
134
+ return {
135
+ tool: toolName,
136
+ kind: "read",
137
+ title: "Read outside working directory",
138
+ detail: abs,
139
+ outside: true,
140
+ path: abs,
141
+ };
142
+ }
143
+
144
+ // Network reads (web fetch / search / http).
145
+ if (name.includes("fetch") || name.includes("web") || name.includes("http") || name.includes("url")) {
146
+ return {
147
+ tool: toolName,
148
+ kind: "fetch",
149
+ title: "Fetch from the network",
150
+ detail: str(obj.url ?? obj.query ?? obj.q),
151
+ };
152
+ }
153
+
154
+ // Unknown / custom / MCP tool: we can't prove it's side-effect-free, so
155
+ // safe-by-default is to prompt. Classified as a bash-kind action (asks in
156
+ // default/acceptEdits, denies in plan, allows only under bypass). Phase 5 refines
157
+ // this with MCP destructiveHint → alwaysAsk and a per-tool policy map.
158
+ return {
159
+ tool: toolName,
160
+ kind: "bash",
161
+ title: `Run ${toolName}`,
162
+ detail: safeJson(obj),
163
+ };
164
+ }
165
+
166
+ function safeJson(v: unknown): string {
167
+ try {
168
+ return JSON.stringify(v);
169
+ } catch {
170
+ return String(v);
171
+ }
172
+ }
@@ -1,8 +1,10 @@
1
- // The permission gate is the seam between tools and the user's approval policy.
2
- // Tools call `gate.request(...)` before any mutation or shell execution; the gate
3
- // decides allow/deny based on the active permission mode (Phase 4) and may prompt
4
- // the user interactively. Phase 2 ships a pass-through gate so tools work
5
- // end-to-end; Phase 4 replaces it with the mode-aware interactive gate.
1
+ // Permission-gate vocabulary — the request shape the policy reasons about, and
2
+ // the decision it returns. Ported from tree-cli/src/permissions/gate.ts, trimmed
3
+ // to types only: in the 0.2 codebase each tool built a PermissionRequest and
4
+ // called ctx.gate.request(); in the Pi rewrite the gate is a `tool_call`
5
+ // extension hook that receives { toolName, input } and derives the request via
6
+ // classifyToolCall (see ./classify.ts). So the pass-through gate is gone; the
7
+ // live policy lives in ./mode.ts + ./modeGate.ts.
6
8
 
7
9
  export type PermissionDecision = "allow" | "deny";
8
10
 
@@ -14,22 +16,22 @@ export interface PermissionRequest {
14
16
  title: string; // short action label, e.g. "Run command"
15
17
  detail: string; // the command, or file path + change preview
16
18
  protected?: boolean; // target is a guarded file: never auto-approve, always prompt
19
+ // Always require a human decision, ABOVE bypass mode and the allowlist (like a
20
+ // dangerous shell command). Set for tools that declare themselves destructive,
21
+ // so even a "take no prisoners" run can't fire an irreversible action silently.
22
+ // The decision is never remembered.
23
+ alwaysAsk?: boolean;
17
24
  // Target resolves outside the working directory: never auto-approve (unless bypass),
18
25
  // always prompt. `path` carries the absolute target so "always" can remember its dir.
19
26
  outside?: boolean;
20
27
  path?: string;
21
28
  }
22
29
 
30
+ // Implemented by ModeGate (./modeGate.ts). The extension builds one per tool_call.
23
31
  export interface PermissionGate {
24
32
  request(req: PermissionRequest): Promise<PermissionDecision>;
25
33
  }
26
34
 
27
- export const autoApproveGate: PermissionGate = {
28
- async request() {
29
- return "allow";
30
- },
31
- };
32
-
33
35
  export class PermissionDeniedError extends Error {
34
36
  constructor(tool: string) {
35
37
  super(`Permission denied for ${tool}`);
@@ -1,7 +1,11 @@
1
- import type { PermissionMode } from "../config/schema.ts";
1
+ import type { PermissionMode } from "../config/permissionMode.ts";
2
2
  import type { PermissionRequest } from "./gate.ts";
3
3
  import { isDangerousCommand } from "./danger.ts";
4
4
 
5
+ // Ported verbatim from tree-cli/src/permissions/mode.ts (only the PermissionMode
6
+ // import path changed). This is the pure mode/allowlist policy the gate consults
7
+ // before ever involving the user.
8
+
5
9
  export type AutoDecision = "allow" | "deny" | "ask";
6
10
 
7
11
  // Is a bash command covered by the allowlist? Entries are command prefixes:
@@ -27,6 +31,10 @@ export function decideAuto(
27
31
  // Dangerous shell (destructive / secret-exfil) always confirms — this sits
28
32
  // above bypass and the allowlist so an injected command can't run silently.
29
33
  if (req.kind === "bash" && isDangerousCommand(req.detail, denylist)) return "ask";
34
+ // Explicitly destructive actions (e.g. a tool that declares destructiveHint)
35
+ // always confirm too — also above bypass, so "skip permissions" can't fire them
36
+ // blind.
37
+ if (req.alwaysAsk) return "ask";
30
38
  if (mode === "bypass") return "allow";
31
39
  // Access outside the working directory always confirms (the user has to explicitly
32
40
  // allow leaving cwd), even under acceptEdits or the allowlist.
@@ -1,9 +1,14 @@
1
1
  import { dirname } from "node:path";
2
- import type { PermissionMode } from "../config/schema.ts";
2
+ import type { PermissionMode } from "../config/permissionMode.ts";
3
3
  import type { PermissionGate, PermissionRequest, PermissionDecision } from "./gate.ts";
4
4
  import { decideAuto } from "./mode.ts";
5
5
  import { isDangerousCommand } from "./danger.ts";
6
6
 
7
+ // Ported verbatim from tree-cli/src/permissions/uiGate.ts (renamed modeGate;
8
+ // only import paths changed). The mode/allowlist/remote policy engine — the moat.
9
+ // In the Pi rewrite the extension (../ext/permissionGate.ts) builds one of these
10
+ // per tool_call, with `ask` bound to that call's ctx.ui (local) or the relay (remote).
11
+
7
12
  // What the interactive prompt can return. "always" means allow now and remember:
8
13
  // for bash, add the command to the session allowlist; for edits, switch to acceptEdits.
9
14
  export type AskOutcome = "allow" | "deny" | "always";
@@ -15,8 +20,7 @@ export interface ModeGateDeps {
15
20
  allowlist: string[]; // session-scoped, mutated in place on "always"
16
21
  denylist?: string[]; // dangerous-command patterns that always require a prompt
17
22
  // Out-of-cwd directories approved this session ("always" on an outside prompt),
18
- // mutated in place. Shared with the tool context so approved locations stop
19
- // re-prompting. The same array instance must be handed to the tools.
23
+ // mutated in place. Shared with the tool scope so approved locations stop re-prompting.
20
24
  allowedOutsideRoots?: string[];
21
25
  ask: Asker;
22
26
  // True while the active turn was injected by a remote controller (the app, via
@@ -25,6 +29,11 @@ export interface ModeGateDeps {
25
29
  // an unattended terminal can't silently run a remote party's bash or edits.
26
30
  // Hard denies (e.g. plan mode) are still honored without bothering the phone.
27
31
  getRemote?: () => boolean;
32
+ // True while the controller has toggled no-quarter (unattended) mode: remote
33
+ // turns auto-approve like bypass mode so the agent runs to completion without
34
+ // pinging the phone. Dangerous shell and alwaysAsk-destructive actions rank
35
+ // above bypass in decideAuto, so those still relay for an explicit Allow/Deny.
36
+ getNoQuarter?: () => boolean;
28
37
  }
29
38
 
30
39
  // The permission gate used by the live TUI. It first applies the mode/allowlist
@@ -43,14 +52,21 @@ export class ModeGate implements PermissionGate {
43
52
  // remembered — we don't let a remote operator mutate local allowlist/mode.
44
53
  if (this.deps.getRemote?.()) {
45
54
  if (auto === "deny") return "deny";
55
+ // No-quarter: re-evaluate as if in bypass mode. Dangerous/destructive
56
+ // actions still come back "ask" (they sit above bypass) and fall through
57
+ // to the relayed prompt; everything else runs unattended.
58
+ if (this.deps.getNoQuarter?.() && decideAuto(req, "bypass", this.deps.allowlist, denylist) === "allow") {
59
+ return "allow";
60
+ }
46
61
  return (await this.deps.ask(req)) === "deny" ? "deny" : "allow";
47
62
  }
48
63
 
49
64
  if (auto !== "ask") return auto;
50
65
 
51
- // A dangerous command can be approved once, but is never remembered: adding
52
- // it to the allowlist would let a later injected variant slip through.
53
- const dangerous = req.kind === "bash" && isDangerousCommand(req.detail, denylist);
66
+ // A dangerous command (or an always-ask destructive action) can be approved
67
+ // once, but is never remembered: adding it to the allowlist or relaxing the
68
+ // mode would let a later variant slip through.
69
+ const dangerous = req.alwaysAsk === true || (req.kind === "bash" && isDangerousCommand(req.detail, denylist));
54
70
 
55
71
  const outcome = await this.deps.ask(req);
56
72
  if (outcome === "deny") return "deny";
@@ -0,0 +1,170 @@
1
+ // The `privateer` account provider — run inference billed to the user's Privateer
2
+ // subscription instead of a BYO key. This is the one provider the plan flags as a
3
+ // genuine code-blocker (Appendix A): it can't be a static key — it uses a rotating,
4
+ // 401-refreshing child session against the account channel (`${server}/api/agent/v1`).
5
+ //
6
+ // We register it via Pi's OAuth path (ProviderConfigInput.oauth). Pi drives the
7
+ // credential lifecycle: it calls getApiKey(cred) per request and refreshToken(cred)
8
+ // when Date.now() >= cred.expires. We map a per-terminal child session onto that
9
+ // shape (see auth/privateer.ts spawn/refreshAccountCredentials). Because the machine
10
+ // login already exists (~/.privateer/credentials.json), login() just spawns a child;
11
+ // only a first-ever machine login runs the device-code flow.
12
+
13
+ import {
14
+ serverBaseUrl,
15
+ hasCredentials,
16
+ runDeviceLogin,
17
+ authedFetch,
18
+ spawnAccountCredentials,
19
+ refreshAccountCredentials,
20
+ } from "../auth/privateer.ts";
21
+ import { interpretReport, teePosture, type PrivacyTier } from "pi-privacy";
22
+
23
+ // Seed/fallback catalog: registered synchronously so the account provider has real
24
+ // models the instant it loads (before the live /api/models fetch resolves) — in
25
+ // particular the signed-in default, near/zai-org/GLM-5.1-FP8, resolves at startup
26
+ // without a "model not found" warning. The first entry is that default: a NEAR
27
+ // confidential-compute (TEE, attestable) model — the strongest privacy tier. Also the
28
+ // fallback list if the live listing can't be reached.
29
+ const DEFAULT_MODELS = [
30
+ "near/zai-org/GLM-5.1-FP8",
31
+ "anthropic/claude-sonnet-4.6",
32
+ "openai/gpt-5.5",
33
+ "deepseek/deepseek-v4-flash",
34
+ ];
35
+
36
+ function seedModel(id: string) {
37
+ return {
38
+ id,
39
+ name: id,
40
+ reasoning: false,
41
+ input: ["text"] as ("text" | "image")[],
42
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
43
+ contextWindow: 128000,
44
+ maxTokens: 16384,
45
+ };
46
+ }
47
+
48
+ // Fetch the account channel's enabled model catalog. `GET /api/models` is the server's
49
+ // public list of billable models (`{ models: [{ modelId }] }`) — the same set the app
50
+ // shows. (The `/api/agent/v1` base only implements chat/completions, no /models route.)
51
+ // Falls back to DEFAULT_MODELS on any failure.
52
+ export async function fetchAccountModels(): Promise<string[]> {
53
+ try {
54
+ const res = await fetch(`${serverBaseUrl()}/api/models`);
55
+ if (!res.ok) return DEFAULT_MODELS;
56
+ const data = (await res.json()) as { models?: { modelId?: string }[] };
57
+ const ids = (data.models ?? []).map((m) => m.modelId).filter((x): x is string => !!x);
58
+ return ids.length ? ids : DEFAULT_MODELS;
59
+ } catch {
60
+ return DEFAULT_MODELS;
61
+ }
62
+ }
63
+
64
+ // The Pi OAuth provider (Omit<OAuthProviderInterface, "id"> — Pi supplies the id from
65
+ // the provider name). login/refreshToken/getApiKey are the whole contract.
66
+ export const privateerOAuthProvider = {
67
+ name: "Privateer account",
68
+ usesCallbackServer: false,
69
+ async login(cb: { onDeviceCode?: (info: unknown) => void }) {
70
+ if (!hasCredentials()) {
71
+ await runDeviceLogin({
72
+ onCode: (code) =>
73
+ cb.onDeviceCode?.({
74
+ userCode: code.user_code,
75
+ verificationUri: code.verification_uri_complete ?? code.verification_uri ?? "",
76
+ intervalSeconds: code.interval,
77
+ expiresInSeconds: code.expires_in,
78
+ }),
79
+ });
80
+ }
81
+ return spawnAccountCredentials();
82
+ },
83
+ async refreshToken(creds: { refresh: string }) {
84
+ try {
85
+ return await refreshAccountCredentials(creds.refresh);
86
+ } catch {
87
+ // child token expired/reused → spawn a fresh one from the parent login.
88
+ return spawnAccountCredentials();
89
+ }
90
+ },
91
+ getApiKey(creds: { access: string }): string {
92
+ return creds.access;
93
+ },
94
+ };
95
+
96
+ // Which privacy channel an account model routes through: NEAR confidential-compute
97
+ // (TEE, attestable) for `near/`-prefixed ids, else a server-side ZDR channel.
98
+ // Ported from tree-cli resolve.ts.
99
+ export function privateerChannel(modelId: string): "tee" | "zdr" {
100
+ return modelId.startsWith("near/") ? "tee" : "zdr";
101
+ }
102
+
103
+ export interface AccountPosture {
104
+ tier: PrivacyTier;
105
+ teePosture?: "green" | "yellow" | "red";
106
+ error?: string;
107
+ }
108
+
109
+ // Posture for an account-channel model. For NEAR models the attestation is fetched
110
+ // through the SERVER proxy (the account's NEAR key stays server-side): the server
111
+ // mints the nonce AND returns the report, so the CLIENT contributes no freshness and
112
+ // can't bind the report to the live TLS key. That is a trust-the-server posture, not
113
+ // client-verified — materially weaker than the direct nearai path (which supplies its
114
+ // own randomNonce). So we must NOT promote it to `tee-verified`: that tier reads as
115
+ // cryptographically proven and, critically, disables pi-privacy's PII gate. We cap a
116
+ // green server posture at `tee-unverified` (honest yellow — the PII gate stays on) and
117
+ // still surface the raw teePosture for display. ZDR-channel models route to
118
+ // zero-retention endpoints server-side, which we can't observe here — a policy claim.
119
+ export async function accountPosture(modelId: string): Promise<AccountPosture> {
120
+ if (privateerChannel(modelId) === "zdr") {
121
+ return { tier: "zdr-policy" };
122
+ }
123
+ try {
124
+ const res = await authedFetch(
125
+ `${serverBaseUrl()}/api/models/near/attestation?model=${encodeURIComponent(modelId)}`,
126
+ );
127
+ if (!res.ok) return { tier: "tee-unverified", error: `HTTP ${res.status}` };
128
+ const data = (await res.json()) as { nonce?: string; report?: unknown };
129
+ const att = interpretReport(modelId, data.nonce ?? "", data.report ?? {});
130
+ const tp = teePosture(att);
131
+ // Cap at client-unverifiable: server-proxied green never earns `tee-verified`.
132
+ const tier: PrivacyTier = tp === "red" ? "standard" : "tee-unverified";
133
+ return { tier, teePosture: tp };
134
+ } catch (e) {
135
+ return { tier: "tee-unverified", error: (e as Error).message };
136
+ }
137
+ }
138
+
139
+ // Extension factory: registers the account provider when a machine login exists. No
140
+ // login → nothing registered (BYO-key only).
141
+ //
142
+ // Registration is SYNCHRONOUS (seeded with the fallback), then refined once the live
143
+ // catalog is fetched. This matters: Pi flushes provider registrations made during the
144
+ // synchronous extension-init pass before it binds extensions, so the `privateer`
145
+ // provider (and its OAuth login path) exist immediately — before the model picker can
146
+ // open. If we instead awaited the network fetch first, the registration could land
147
+ // after the picker built its list, and privateer models would be missing until reopen.
148
+ // registerProvider replaces the provider's models on the second call, and the picker's
149
+ // refresh() re-applies registered providers, so the full list appears once fetched.
150
+ export function makeAccountProvider() {
151
+ return (pi: {
152
+ registerProvider?: (name: string, config: unknown) => void;
153
+ }): void => {
154
+ if (!hasCredentials() || typeof pi.registerProvider !== "function") return;
155
+ const register = (ids: string[]): void =>
156
+ pi.registerProvider!("privateer", {
157
+ name: "Privateer account",
158
+ baseUrl: `${serverBaseUrl()}/api/agent/v1`,
159
+ api: "openai-completions",
160
+ oauth: privateerOAuthProvider,
161
+ models: ids.map(seedModel),
162
+ });
163
+ register(DEFAULT_MODELS); // immediate: provider exists this tick
164
+ void fetchAccountModels()
165
+ .then((ids) => ids.length && register(ids)) // refine to the live catalog
166
+ .catch(() => {
167
+ /* keep the fallback model */
168
+ });
169
+ };
170
+ }
@@ -1,66 +1,78 @@
1
- import { KNOWN_PROVIDERS, type ProviderName } from "../config/schema.ts";
2
- import { providerRequiresKey } from "./registry.ts";
1
+ // The provider landscape — the single map of how each of privateer's 21 providers
2
+ // becomes reachable under Pi. Written after verifying (2026-07-07) that:
3
+ // - pi-ai ships STATIC model catalogs for its 14 built-in providers, so they're
4
+ // selectable once a key is present — privateer emits NO models.json entry for them;
5
+ // - the `pi-privacy` extension registers the 5 privacy providers (tinfoil, nearai,
6
+ // venice, ollama, custom) at load — privateer emits NO entry for them either;
7
+ // - so the ONLY provider this generator must emit is `qwen` (config-only,
8
+ // non-privacy, no built-in catalog), and `privateer` (the account OAuth channel)
9
+ // is handled in code, not config (Phase 4).
10
+ //
11
+ // The plan (Appendix A) assumed we'd generate the whole models.json; Pi's built-in
12
+ // catalogs + pi-privacy made most of that redundant. This map keeps the accounting
13
+ // explicit so nothing silently falls through.
3
14
 
4
- // Human-facing metadata for each provider, used by the onboarding flow: a display
5
- // label, where to get an API key, and the model to default to when the provider is
6
- // chosen first. Keeps the registry (wiring) separate from presentation.
7
- export interface ProviderMeta {
8
- name: ProviderName;
9
- label: string;
10
- requiresKey: boolean;
11
- defaultModel: string; // "provider:model" picked when this provider is selected first
12
- keyHint: string; // where to obtain a key, or a note for keyless providers
13
- baseURLDefault?: string; // shown as the placeholder for keyless/local providers
15
+ export type ProviderSource =
16
+ | "pi-builtin" // pi-ai ships the provider + its model catalog (needs only a key)
17
+ | "pi-privacy" // registered by the pi-privacy extension at load
18
+ | "generate" // privateer must emit a models.json entry (config-only, no built-in)
19
+ | "account"; // the privateer account channel — OAuth in code, not config
20
+
21
+ export type ProviderApi = "openai-completions" | "openai-responses" | "anthropic-messages";
22
+
23
+ export interface ProviderEntry {
24
+ id: string;
25
+ source: ProviderSource;
26
+ // For `generate` providers: the config a models.json entry needs.
27
+ baseUrl?: string;
28
+ api?: ProviderApi;
29
+ keyEnv?: string; // env template ${...}; Pi resolves it (or auth.json wins)
30
+ compat?: Record<string, unknown>;
31
+ seedModels?: string[]; // provisional ids until live listing refines them
32
+ // Optional nuance override for a built-in (api/compat differs from pi-ai default).
33
+ // Not emitted yet — overriding a built-in can drop its model catalog; revisit once
34
+ // Pi's override-merge semantics are confirmed. Documented here so it's not lost.
35
+ overrideNote?: string;
14
36
  }
15
37
 
16
- export const PROVIDER_META: Record<ProviderName, ProviderMeta> = {
17
- anthropic: {
18
- name: "anthropic",
19
- label: "Anthropic",
20
- requiresKey: providerRequiresKey("anthropic"),
21
- defaultModel: "anthropic:claude-opus-4-8",
22
- keyHint: "console.anthropic.com/settings/keys",
23
- },
24
- openai: {
25
- name: "openai",
26
- label: "OpenAI",
27
- requiresKey: providerRequiresKey("openai"),
28
- defaultModel: "openai:gpt-4o",
29
- keyHint: "platform.openai.com/api-keys",
30
- },
31
- openrouter: {
32
- name: "openrouter",
33
- label: "OpenRouter",
34
- requiresKey: providerRequiresKey("openrouter"),
35
- defaultModel: "openrouter:anthropic/claude-opus-4.8",
36
- keyHint: "openrouter.ai/keys",
37
- },
38
- ollama: {
39
- name: "ollama",
40
- label: "Ollama (local)",
41
- requiresKey: providerRequiresKey("ollama"),
42
- defaultModel: "ollama:llama3.1",
43
- keyHint: "runs locally — no key needed",
44
- baseURLDefault: "http://localhost:11434/api",
38
+ // Ordered like KNOWN_PROVIDERS (tree-cli schema.ts) for a stable picker order.
39
+ export const PROVIDERS: ProviderEntry[] = [
40
+ { id: "openrouter", source: "pi-builtin", overrideNote: "ZDR routing handled by pi-privacy" },
41
+ { id: "anthropic", source: "pi-builtin" },
42
+ { id: "openai", source: "pi-builtin", overrideNote: "0.2 used chat-completions, not responses" },
43
+ { id: "google", source: "pi-builtin" },
44
+ { id: "xai", source: "pi-builtin" },
45
+ { id: "groq", source: "pi-builtin" },
46
+ { id: "mistral", source: "pi-builtin" },
47
+ { id: "zai", source: "pi-builtin", overrideNote: "0.2 baseUrl …/api/paas/v4; compat.thinkingFormat zai" },
48
+ { id: "moonshot", source: "pi-builtin" },
49
+ { id: "cerebras", source: "pi-builtin" },
50
+ { id: "fireworks", source: "pi-builtin" },
51
+ { id: "together", source: "pi-builtin", overrideNote: "compat.thinkingFormat together" },
52
+ { id: "deepseek", source: "pi-builtin", overrideNote: "compat.thinkingFormat deepseek" },
53
+ { id: "minimax", source: "pi-builtin", overrideNote: "0.2 used …/v1 openai-compat, not anthropic-messages" },
54
+ {
55
+ id: "qwen",
56
+ source: "generate",
57
+ baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
58
+ api: "openai-completions",
59
+ keyEnv: "${DASHSCOPE_API_KEY}",
60
+ compat: { thinkingFormat: "qwen" },
61
+ seedModels: ["qwen3-max", "qwen3-coder-plus", "qwen-max-latest"],
45
62
  },
46
- nearai: {
47
- name: "nearai",
48
- label: "NEAR AI (private TEE inference)",
49
- requiresKey: providerRequiresKey("nearai"),
50
- defaultModel: "nearai:zai-org/GLM-5.1-FP8",
51
- keyHint: "cloud.near.ai → API Keys",
52
- },
53
- privateer: {
54
- name: "privateer",
55
- // Default to a NEAR confidential-compute (TEE) model: it's the strongest
56
- // privacy guarantee, runs through the same billed agent endpoint, and was
57
- // verified to pass agent tool_calls. Switch to any listed model with /model.
58
- label: "Privateer account (billed to your subscription)",
59
- requiresKey: providerRequiresKey("privateer"),
60
- defaultModel: "privateer:near/deepseek-ai/DeepSeek-V4-Flash",
61
- keyHint: "sign in with /login — no API key needed",
62
- },
63
- };
63
+ { id: "ollama", source: "pi-privacy" },
64
+ { id: "nearai", source: "pi-privacy" },
65
+ { id: "tinfoil", source: "pi-privacy" },
66
+ { id: "venice", source: "pi-privacy" },
67
+ { id: "custom", source: "pi-privacy" },
68
+ { id: "privateer", source: "account" },
69
+ ];
64
70
 
65
- // Provider metadata in display order.
66
- export const PROVIDER_LIST: ProviderMeta[] = KNOWN_PROVIDERS.map((n) => PROVIDER_META[n]);
71
+ export const PROVIDER_BY_ID: Record<string, ProviderEntry> = Object.fromEntries(
72
+ PROVIDERS.map((p) => [p.id, p]),
73
+ );
74
+
75
+ // Providers this generator is responsible for emitting into models.json.
76
+ export function providersToGenerate(): ProviderEntry[] {
77
+ return PROVIDERS.filter((p) => p.source === "generate");
78
+ }