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.
- package/README.md +143 -398
- package/bin/privateer-tui +86 -0
- package/bin/privateer.mjs +17 -6
- package/bin/pv +28 -0
- package/package.json +26 -23
- package/src/auth/privateer.ts +128 -1
- package/src/boot.ts +43 -0
- package/src/bridge/engineAdapter.ts +182 -0
- package/src/cli/chat.ts +243 -0
- package/src/config/paths.ts +24 -44
- package/src/config/permissionMode.ts +5 -0
- package/src/crypto/outboxSeal.ts +61 -0
- package/src/daemon/index.ts +405 -0
- package/src/daemon/ipc.ts +127 -0
- package/src/engine/errors.ts +95 -45
- package/src/engine/router.ts +11 -165
- package/src/ext/permissionGate.ts +216 -0
- package/src/main.ts +32 -0
- package/src/permissions/classify.ts +172 -0
- package/src/permissions/gate.ts +13 -11
- package/src/permissions/mode.ts +9 -1
- package/src/permissions/{uiGate.ts → modeGate.ts} +22 -6
- package/src/providers/account.ts +170 -0
- package/src/providers/catalog.ts +73 -61
- package/src/providers/genModelsJson.ts +97 -0
- package/src/remote/relayClient.ts +91 -4
- package/src/remote/remoteBridge.ts +152 -0
- package/src/routines/cron.ts +109 -0
- package/src/routines/delivery.ts +184 -0
- package/src/routines/schema.ts +84 -0
- package/src/routines/store.ts +248 -0
- package/src/routines/toolSelect.ts +50 -0
- package/src/routines/trigger.ts +41 -0
- package/src/session.ts +84 -251
- package/src/tools/routine.ts +129 -0
- package/src/tools/saveAttachment.ts +39 -42
- package/src/tools/sendFile.ts +75 -0
- package/src/util/attachmentStore.ts +18 -35
- package/src/util/redact.ts +33 -3
- package/LICENSE +0 -21
- package/src/agents/loader.ts +0 -49
- package/src/commands/custom.ts +0 -75
- package/src/commands/registry.ts +0 -499
- package/src/components/AgentGroupView.tsx +0 -104
- package/src/components/App.tsx +0 -1420
- package/src/components/ApprovalPrompt.tsx +0 -38
- package/src/components/Banner.tsx +0 -76
- package/src/components/Markdown.tsx +0 -183
- package/src/components/ModeHint.tsx +0 -40
- package/src/components/ModelPicker.tsx +0 -269
- package/src/components/Onboarding.tsx +0 -203
- package/src/components/PlanConfirm.tsx +0 -37
- package/src/components/PrivateerLogin.tsx +0 -109
- package/src/components/PromptInput.tsx +0 -602
- package/src/components/RewindPicker.tsx +0 -69
- package/src/components/Root.tsx +0 -95
- package/src/components/SessionPicker.tsx +0 -64
- package/src/components/StatusBar.tsx +0 -131
- package/src/components/TodoPanel.tsx +0 -36
- package/src/components/ToolCallView.tsx +0 -109
- package/src/components/Transcript.tsx +0 -203
- package/src/components/figures.ts +0 -14
- package/src/components/promptModel.ts +0 -73
- package/src/components/spinnerVerbs.ts +0 -46
- package/src/components/theme.ts +0 -55
- package/src/components/types.ts +0 -34
- package/src/components/useTeeShield.ts +0 -104
- package/src/components/useTerminalWidth.ts +0 -24
- package/src/components/useZdrShield.ts +0 -126
- package/src/config/load.ts +0 -115
- package/src/config/schema.ts +0 -94
- package/src/context/outputStyles.ts +0 -42
- package/src/context/projectInfo.ts +0 -59
- package/src/context/systemPrompt.ts +0 -167
- package/src/engine/QueryEngine.ts +0 -399
- package/src/hooks/engine.ts +0 -155
- package/src/main.tsx +0 -167
- package/src/mcp/client.ts +0 -236
- package/src/mcp/oauth.ts +0 -245
- package/src/memory/auto.ts +0 -146
- package/src/memory/checkpoints.ts +0 -227
- package/src/memory/store.ts +0 -127
- package/src/providers/attestation.ts +0 -149
- package/src/providers/capabilities.ts +0 -104
- package/src/providers/models.ts +0 -183
- package/src/providers/registry.ts +0 -71
- package/src/providers/resolve.ts +0 -78
- package/src/tools/bash.ts +0 -98
- package/src/tools/context.ts +0 -114
- package/src/tools/edit.ts +0 -67
- package/src/tools/exec.ts +0 -60
- package/src/tools/glob.ts +0 -39
- package/src/tools/grep.ts +0 -86
- package/src/tools/index.ts +0 -69
- package/src/tools/memory.ts +0 -53
- package/src/tools/processRegistry.ts +0 -77
- package/src/tools/read.ts +0 -42
- package/src/tools/task.ts +0 -52
- package/src/tools/todo.ts +0 -36
- package/src/tools/todoStore.ts +0 -31
- package/src/tools/walk.ts +0 -44
- package/src/tools/web.ts +0 -145
- package/src/tools/write.ts +0 -40
- package/src/util/images.ts +0 -356
- package/src/util/limit.ts +0 -32
- 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
|
+
}
|
package/src/permissions/gate.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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}`);
|
package/src/permissions/mode.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import type { PermissionMode } from "../config/
|
|
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/
|
|
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
|
|
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
|
|
52
|
-
//
|
|
53
|
-
|
|
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
|
+
}
|
package/src/providers/catalog.ts
CHANGED
|
@@ -1,66 +1,78 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
},
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
},
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
+
}
|