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
package/src/engine/errors.ts
CHANGED
|
@@ -17,8 +17,11 @@ interface ErrorFacts {
|
|
|
17
17
|
statusCode?: number;
|
|
18
18
|
providerMessage?: string;
|
|
19
19
|
code?: string; // provider's machine-readable error code, e.g. "DAILY_CAP_HIT"
|
|
20
|
+
errno?: string; // Node socket-level code, e.g. "ECONNREFUSED"
|
|
20
21
|
model?: string;
|
|
21
22
|
provider?: string;
|
|
23
|
+
url?: string; // the endpoint the failing request targeted
|
|
24
|
+
message?: string; // deepest non-empty Error message in the chain
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
const HOST_LABELS: Record<string, string> = {
|
|
@@ -26,70 +29,81 @@ const HOST_LABELS: Record<string, string> = {
|
|
|
26
29
|
"api.anthropic.com": "Anthropic",
|
|
27
30
|
"api.openai.com": "OpenAI",
|
|
28
31
|
"cloud-api.near.ai": "NEAR AI",
|
|
32
|
+
"localhost:11434": "Ollama",
|
|
33
|
+
"127.0.0.1:11434": "Ollama",
|
|
29
34
|
};
|
|
30
35
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
// richer error) until we reach the one with the useful fields.
|
|
37
|
-
function unwrap(err: unknown): unknown {
|
|
38
|
-
let cur = err;
|
|
39
|
-
for (let i = 0; i < 5; i++) {
|
|
40
|
-
if (!cur || typeof cur !== "object") break;
|
|
41
|
-
const e = cur as Record<string, unknown>;
|
|
42
|
-
const richer = e.statusCode == null && e.responseBody == null;
|
|
43
|
-
const inner = e.lastError ?? (richer ? e.cause : undefined);
|
|
44
|
-
if (!inner || inner === cur) break;
|
|
45
|
-
cur = inner;
|
|
46
|
-
}
|
|
47
|
-
return cur;
|
|
48
|
-
}
|
|
36
|
+
// Socket-level failures Node/undici report via an error `code`. Distinct from the
|
|
37
|
+
// provider's machine code (which comes out of the response body) — these mean the
|
|
38
|
+
// request never got a response at all.
|
|
39
|
+
const NETWORK_ERRNO =
|
|
40
|
+
/^(ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|EPIPE|ENETUNREACH|EHOSTUNREACH|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET)$/;
|
|
49
41
|
|
|
50
42
|
// Pull structured fields off an unknown error without trusting any one shape.
|
|
43
|
+
//
|
|
44
|
+
// The AI SDK nests the useful error: retry exhaustion throws AI_RetryError, whose
|
|
45
|
+
// `.lastError` is the APICallError carrying statusCode / url / responseBody, whose
|
|
46
|
+
// `.cause` on a connection failure is the socket error (often an AggregateError
|
|
47
|
+
// with an EMPTY message but an errno code). No single level has everything, so
|
|
48
|
+
// walk the whole chain and merge: HTTP-ish facts keep the first (shallowest)
|
|
49
|
+
// value found, while `message` keeps the deepest NON-EMPTY one — inner messages
|
|
50
|
+
// are more specific ("Cannot connect to API") than the wrapper's ("Failed after
|
|
51
|
+
// 3 attempts…"), but the socket error at the very bottom may have none at all.
|
|
51
52
|
function extract(err: unknown): ErrorFacts {
|
|
52
|
-
const
|
|
53
|
-
const statusCode = typeof e.statusCode === "number" ? e.statusCode : undefined;
|
|
53
|
+
const facts: ErrorFacts = {};
|
|
54
54
|
|
|
55
55
|
// The provider's own message + machine code, preferred over the SDK's wrapper
|
|
56
56
|
// text. Providers disagree on shape: OpenAI/OpenRouter nest under `error`, while
|
|
57
57
|
// the Privateer account backend returns a flat `{ message, code }` (e.g. a daily
|
|
58
58
|
// usage cap). Read both shapes; keep the first message/code we find.
|
|
59
|
-
let providerMessage: string | undefined;
|
|
60
|
-
let code: string | undefined;
|
|
61
59
|
const readBody = (body: unknown) => {
|
|
62
60
|
const b = body as
|
|
63
61
|
| { error?: { message?: unknown; code?: unknown }; message?: unknown; code?: unknown }
|
|
64
62
|
| undefined;
|
|
65
63
|
if (!b || typeof b !== "object") return;
|
|
66
64
|
const msg = b.error?.message ?? b.message;
|
|
67
|
-
if (providerMessage == null && typeof msg === "string") providerMessage = msg;
|
|
65
|
+
if (facts.providerMessage == null && typeof msg === "string") facts.providerMessage = msg;
|
|
68
66
|
const c = b.error?.code ?? b.code;
|
|
69
|
-
if (code == null && typeof c === "string") code = c;
|
|
67
|
+
if (facts.code == null && typeof c === "string") facts.code = c;
|
|
70
68
|
};
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
69
|
+
|
|
70
|
+
let cur: unknown = err;
|
|
71
|
+
for (let i = 0; i < 6 && cur && typeof cur === "object"; i++) {
|
|
72
|
+
const e = cur as Record<string, unknown>;
|
|
73
|
+
|
|
74
|
+
if (facts.statusCode == null && typeof e.statusCode === "number") facts.statusCode = e.statusCode;
|
|
75
|
+
readBody(e.data);
|
|
76
|
+
if (typeof e.responseBody === "string") {
|
|
77
|
+
try {
|
|
78
|
+
readBody(JSON.parse(e.responseBody));
|
|
79
|
+
} catch {
|
|
80
|
+
/* responseBody wasn't JSON — fall back to the wrapper message */
|
|
81
|
+
}
|
|
77
82
|
}
|
|
78
|
-
}
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
const reqBody = e.requestBodyValues as { model?: unknown } | undefined;
|
|
85
|
+
if (facts.model == null && typeof reqBody?.model === "string") facts.model = reqBody.model;
|
|
82
86
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
if (facts.url == null && typeof e.url === "string" && /^https?:/.test(e.url)) {
|
|
88
|
+
facts.url = e.url;
|
|
89
|
+
try {
|
|
90
|
+
facts.provider = HOST_LABELS[new URL(e.url).host];
|
|
91
|
+
} catch {
|
|
92
|
+
/* not a URL */
|
|
93
|
+
}
|
|
89
94
|
}
|
|
95
|
+
|
|
96
|
+
if (facts.errno == null && typeof e.code === "string" && NETWORK_ERRNO.test(e.code)) {
|
|
97
|
+
facts.errno = e.code;
|
|
98
|
+
}
|
|
99
|
+
if (typeof e.message === "string" && e.message.trim()) facts.message = e.message;
|
|
100
|
+
|
|
101
|
+
const next = e.lastError ?? e.cause;
|
|
102
|
+
if (!next || next === cur) break;
|
|
103
|
+
cur = next;
|
|
90
104
|
}
|
|
91
105
|
|
|
92
|
-
return
|
|
106
|
+
return facts;
|
|
93
107
|
}
|
|
94
108
|
|
|
95
109
|
// Machine codes the Privateer backend returns for a hard account cap (daily /
|
|
@@ -116,10 +130,9 @@ function rawMessage(err: unknown): string {
|
|
|
116
130
|
// Map a provider error to a friendly message + hint. Falls back to the raw
|
|
117
131
|
// (redacted) message for anything we don't recognize, so nothing is swallowed.
|
|
118
132
|
export function describeError(err: unknown): DescribedError {
|
|
119
|
-
const
|
|
120
|
-
const facts = extract(inner);
|
|
133
|
+
const facts = extract(err);
|
|
121
134
|
const status = facts.statusCode;
|
|
122
|
-
const text = facts.providerMessage ?? rawMessage(
|
|
135
|
+
const text = facts.providerMessage ?? facts.message ?? rawMessage(err);
|
|
123
136
|
const forModel = facts.model ? ` for ${facts.model}` : "";
|
|
124
137
|
const forProvider = facts.provider ? ` for ${facts.provider}` : "";
|
|
125
138
|
|
|
@@ -153,6 +166,16 @@ export function describeError(err: unknown): DescribedError {
|
|
|
153
166
|
hint: "Upgrade or top up your Privateer account, or run /provider to use your own API key.",
|
|
154
167
|
});
|
|
155
168
|
}
|
|
169
|
+
// Privateer machine-login expiry (thrown by the session spawn after the
|
|
170
|
+
// server rejects the parent refresh token). The stored credentials are
|
|
171
|
+
// already wiped; the only fix is a fresh /login, so say exactly that and
|
|
172
|
+
// never mark it retryable.
|
|
173
|
+
if (/privateer session expired/i.test(text)) {
|
|
174
|
+
return out({
|
|
175
|
+
message: "Your Privateer session expired — this terminal was signed out.",
|
|
176
|
+
hint: "Run /login to sign back in to your Privateer account.",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
156
179
|
if (status === 401 || status === 403) {
|
|
157
180
|
return out({
|
|
158
181
|
message: `Authentication failed${forProvider} (${status}).`,
|
|
@@ -185,9 +208,36 @@ export function describeError(err: unknown): DescribedError {
|
|
|
185
208
|
retryable: true,
|
|
186
209
|
});
|
|
187
210
|
}
|
|
188
|
-
if (
|
|
211
|
+
if (
|
|
212
|
+
facts.errno != null ||
|
|
213
|
+
/fetch failed|cannot connect|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|network/i.test(text)
|
|
214
|
+
) {
|
|
215
|
+
let origin: string | undefined;
|
|
216
|
+
let localhost = false;
|
|
217
|
+
if (facts.url) {
|
|
218
|
+
try {
|
|
219
|
+
const u = new URL(facts.url);
|
|
220
|
+
origin = u.origin;
|
|
221
|
+
localhost = /^(localhost$|127\.|0\.0\.0\.0$|\[::1\]$)/.test(u.hostname);
|
|
222
|
+
} catch {
|
|
223
|
+
/* not a URL */
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// A local inference server that refuses connections isn't a flaky network —
|
|
227
|
+
// it isn't running. Say which one and how to start it; retrying won't help
|
|
228
|
+
// until the user acts, so no `retryable` flag.
|
|
229
|
+
if (localhost) {
|
|
230
|
+
const label = facts.provider ?? "the local server";
|
|
231
|
+
return out({
|
|
232
|
+
message: `Cannot connect to ${label} at ${origin} — nothing is listening there.`,
|
|
233
|
+
hint:
|
|
234
|
+
facts.provider === "Ollama"
|
|
235
|
+
? "Start Ollama (run `ollama serve`, or open the Ollama app), then try again — or run /model to switch models."
|
|
236
|
+
: "Start the server (or check its base URL), then try again — or run /model to switch models.",
|
|
237
|
+
});
|
|
238
|
+
}
|
|
189
239
|
return out({
|
|
190
|
-
message:
|
|
240
|
+
message: `Network error reaching ${facts.provider ?? origin ?? "the provider"}.`,
|
|
191
241
|
hint: "Check your connection and try again.",
|
|
192
242
|
retryable: true,
|
|
193
243
|
});
|
package/src/engine/router.ts
CHANGED
|
@@ -1,165 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
export type
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
// when several could cover a turn's requirements.
|
|
13
|
-
const MODALITY_ROUTES: { name: RouteName; modality: Modality }[] = [
|
|
14
|
-
{ name: "vision", modality: "image" },
|
|
15
|
-
{ name: "document", modality: "document" },
|
|
16
|
-
{ name: "audio", modality: "audio" },
|
|
17
|
-
{ name: "video", modality: "video" },
|
|
18
|
-
];
|
|
19
|
-
|
|
20
|
-
// A resolved, ready-to-stream model plus the per-model knobs that travel with it
|
|
21
|
-
// (Anthropic caching/thinking depend on the model family, so they're per-route) and
|
|
22
|
-
// the set of input modalities it accepts (so the router can satisfy a turn's needs).
|
|
23
|
-
export interface Route {
|
|
24
|
-
spec: string; // "provider:model"
|
|
25
|
-
model: LanguageModel;
|
|
26
|
-
cacheControl: boolean;
|
|
27
|
-
thinkingBudget?: number;
|
|
28
|
-
label: string; // short display name for UI notices (the model id tail)
|
|
29
|
-
supports: Set<Modality>;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface RouteSet {
|
|
33
|
-
default: Route;
|
|
34
|
-
vision?: Route;
|
|
35
|
-
document?: Route;
|
|
36
|
-
audio?: Route;
|
|
37
|
-
video?: Route;
|
|
38
|
-
long?: Route;
|
|
39
|
-
fast?: Route;
|
|
40
|
-
longThreshold: number; // estimated tokens above which `long` applies
|
|
41
|
-
fastMaxChars: number; // prompt length at/below which `fast` applies
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// What the current turn looks like, used to pick a route.
|
|
45
|
-
export interface RouteSignals {
|
|
46
|
-
modalities: Set<Modality>; // input modalities anywhere in the conversation (sticky)
|
|
47
|
-
estTokens: number; // estimated tokens of the whole conversation
|
|
48
|
-
promptChars: number; // length of the new user prompt
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface RouteSelection {
|
|
52
|
-
name: RouteName;
|
|
53
|
-
route: Route;
|
|
54
|
-
reason?: string; // short human reason, for the UI notice
|
|
55
|
-
missing?: Modality[]; // modalities the chosen route can't satisfy (warn)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function covers(route: Route | undefined, required: Set<Modality>): route is Route {
|
|
59
|
-
if (!route) return false;
|
|
60
|
-
for (const m of required) if (!route.supports.has(m)) return false;
|
|
61
|
-
return true;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function missingFrom(route: Route, required: Set<Modality>): Modality[] {
|
|
65
|
-
return [...required].filter((m) => !route.supports.has(m));
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Candidate routes to consider for an attachment-bearing turn, in preference order:
|
|
69
|
-
// the dedicated modality routes first, then long/fast/default as fallbacks (they may
|
|
70
|
-
// happen to be multimodal). Deduped by identity.
|
|
71
|
-
function candidateRoutes(routes: RouteSet): Route[] {
|
|
72
|
-
const ordered = [
|
|
73
|
-
...MODALITY_ROUTES.map((r) => routes[r.name]),
|
|
74
|
-
routes.long,
|
|
75
|
-
routes.fast,
|
|
76
|
-
routes.default,
|
|
77
|
-
].filter((r): r is Route => Boolean(r));
|
|
78
|
-
return [...new Set(ordered)];
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Pick the model for this turn.
|
|
82
|
-
// • If the turn requires input modalities, choose the highest-preference route whose
|
|
83
|
-
// `supports` covers the whole union (capability is a hard requirement). If none
|
|
84
|
-
// fully covers, fall back to the route covering the most, flagging what's missing.
|
|
85
|
-
// • Otherwise apply the soft preferences: long (large context) > fast (short prompt)
|
|
86
|
-
// > default.
|
|
87
|
-
export function selectRoute(routes: RouteSet, s: RouteSignals): RouteSelection {
|
|
88
|
-
if (s.modalities.size > 0) {
|
|
89
|
-
const candidates = candidateRoutes(routes);
|
|
90
|
-
const full = candidates.find((r) => covers(r, s.modalities));
|
|
91
|
-
if (full) {
|
|
92
|
-
const name = routeNameOf(routes, full);
|
|
93
|
-
return { name, route: full, reason: reasonFor(s.modalities) };
|
|
94
|
-
}
|
|
95
|
-
// No single route covers everything — pick the one that covers the most and warn.
|
|
96
|
-
let best = routes.default;
|
|
97
|
-
let bestMissing = missingFrom(best, s.modalities);
|
|
98
|
-
for (const r of candidates) {
|
|
99
|
-
const miss = missingFrom(r, s.modalities);
|
|
100
|
-
if (miss.length < bestMissing.length) {
|
|
101
|
-
best = r;
|
|
102
|
-
bestMissing = miss;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
return {
|
|
106
|
-
name: routeNameOf(routes, best),
|
|
107
|
-
route: best,
|
|
108
|
-
reason: reasonFor(s.modalities),
|
|
109
|
-
missing: bestMissing,
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
if (routes.long && s.estTokens > routes.longThreshold) {
|
|
113
|
-
return { name: "long", route: routes.long, reason: "long context" };
|
|
114
|
-
}
|
|
115
|
-
if (routes.fast && s.promptChars <= routes.fastMaxChars) {
|
|
116
|
-
return { name: "fast", route: routes.fast, reason: "short prompt" };
|
|
117
|
-
}
|
|
118
|
-
return { name: "default", route: routes.default };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function reasonFor(modalities: Set<Modality>): string {
|
|
122
|
-
return `${[...modalities].join("+")} input`;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Recover the route name for a resolved Route (for the UI/event). Falls back to
|
|
126
|
-
// "default" if it isn't one of the named slots.
|
|
127
|
-
function routeNameOf(routes: RouteSet, route: Route): RouteName {
|
|
128
|
-
const names: RouteName[] = ["vision", "document", "audio", "video", "long", "fast"];
|
|
129
|
-
for (const n of names) if (routes[n] === route) return n;
|
|
130
|
-
return "default";
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const MEDIA_MODALITY: { test: RegExp; modality: Modality }[] = [
|
|
134
|
-
{ test: /^image\//, modality: "image" },
|
|
135
|
-
{ test: /^audio\//, modality: "audio" },
|
|
136
|
-
{ test: /^video\//, modality: "video" },
|
|
137
|
-
{ test: /^application\/pdf$/, modality: "document" },
|
|
138
|
-
];
|
|
139
|
-
|
|
140
|
-
function modalityOfMediaType(mediaType: string): Modality | null {
|
|
141
|
-
return MEDIA_MODALITY.find((m) => m.test.test(mediaType))?.modality ?? null;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// The input modalities present anywhere in the conversation. Keyed off the whole
|
|
145
|
-
// history (not just the new message) so that once an attachment is in context,
|
|
146
|
-
// follow-up turns stay on a model that can accept it rather than replaying it to one
|
|
147
|
-
// that can't.
|
|
148
|
-
export function requiredModalities(messages: ModelMessage[]): Set<Modality> {
|
|
149
|
-
const out = new Set<Modality>();
|
|
150
|
-
for (const m of messages) {
|
|
151
|
-
const content = m.content;
|
|
152
|
-
if (!Array.isArray(content)) continue;
|
|
153
|
-
for (const part of content) {
|
|
154
|
-
if (!part || typeof part !== "object" || !("type" in part)) continue;
|
|
155
|
-
const t = (part as { type: string }).type;
|
|
156
|
-
if (t === "image") out.add("image");
|
|
157
|
-
else if (t === "file") {
|
|
158
|
-
const mt = (part as { mediaType?: string }).mediaType;
|
|
159
|
-
const mod = mt ? modalityOfMediaType(mt) : null;
|
|
160
|
-
if (mod) out.add(mod);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
return out;
|
|
165
|
-
}
|
|
1
|
+
// Route/modality types referenced by the EngineEvent `routed` variant.
|
|
2
|
+
//
|
|
3
|
+
// STUB — the full router (tree-cli/src/engine/router.ts) is DELETE in the
|
|
4
|
+
// migration (Pi's model registry + our models.json generator replace it). Only
|
|
5
|
+
// the two type names survive as part of the relay wire vocabulary, so they live
|
|
6
|
+
// here as a types-only module until the `routed` event's producer is wired in a
|
|
7
|
+
// later phase. Kept structurally identical to the original so events.ts ports
|
|
8
|
+
// verbatim.
|
|
9
|
+
export type Modality = "text" | "image" | "audio" | "video" | "pdf";
|
|
10
|
+
|
|
11
|
+
export type RouteName = "default" | "vision" | "long-context" | "fast" | (string & {});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// The permission gate — the safe-by-default moat, as a Pi extension.
|
|
2
|
+
//
|
|
3
|
+
// Phase 2. Ties together the ported policy: a `tool_call` hook classifies the
|
|
4
|
+
// call (./…/classify.ts), runs it through the ModeGate policy engine
|
|
5
|
+
// (./…/modeGate.ts → decideAuto), and blocks the tool when the decision is deny.
|
|
6
|
+
// The gate SUSPENDS the turn while an approval is pending (spike-B proven), routes
|
|
7
|
+
// to a LOCAL prompt (ctx.ui) or a REMOTE approver (the relay, Phase 4), and is
|
|
8
|
+
// FAIL-CLOSED: any throw, timeout, or abort blocks the tool. A `tool_result` hook
|
|
9
|
+
// redacts secrets from output before it reaches the model / relay.
|
|
10
|
+
//
|
|
11
|
+
// The load-bearing decision path is `decideToolCall`, kept pure and Pi-free so the
|
|
12
|
+
// ported tests exercise it without a live session.
|
|
13
|
+
|
|
14
|
+
import type { PermissionMode } from "../config/permissionMode.ts";
|
|
15
|
+
import type { PermissionRequest } from "../permissions/gate.ts";
|
|
16
|
+
import { ModeGate, type AskOutcome } from "../permissions/modeGate.ts";
|
|
17
|
+
import { classifyToolCall } from "../permissions/classify.ts";
|
|
18
|
+
import { redactText } from "../util/redact.ts";
|
|
19
|
+
import { DEFAULT_DENYLIST } from "../permissions/danger.ts";
|
|
20
|
+
|
|
21
|
+
// The per-tool_call hook context we read (structural subset of Pi's ctx — kept
|
|
22
|
+
// local so the gate stays import-order-safe and unit-testable).
|
|
23
|
+
export interface ToolCallCtx {
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
hasUI?: boolean;
|
|
26
|
+
mode?: "tui" | "rpc" | "json" | "print" | string;
|
|
27
|
+
// Pi's ExtensionUIContext shape (verified against pi-coding-agent 0.80):
|
|
28
|
+
// select(title, options: string[]) → chosen string | undefined; confirm(title, message).
|
|
29
|
+
ui?: {
|
|
30
|
+
select?: (title: string, options: string[], opts?: unknown) => Promise<string | undefined>;
|
|
31
|
+
confirm?: (title: string, message: string, opts?: unknown) => Promise<boolean>;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Session-scoped state + askers. Mutable fields (mode/allowlist/allowedOutsideRoots)
|
|
36
|
+
// persist across tool_calls; the askers are invoked per call.
|
|
37
|
+
export interface GateController {
|
|
38
|
+
getMode(): PermissionMode;
|
|
39
|
+
setMode(mode: PermissionMode): void;
|
|
40
|
+
allowlist: string[];
|
|
41
|
+
allowedOutsideRoots: string[];
|
|
42
|
+
denylist?: string[];
|
|
43
|
+
cwd: string;
|
|
44
|
+
confineToCwd?: boolean;
|
|
45
|
+
getRemote?(): boolean;
|
|
46
|
+
getNoQuarter?(): boolean;
|
|
47
|
+
// Local interactive approval via the per-call ctx. Default provided below.
|
|
48
|
+
localAsk(req: PermissionRequest, ctx: ToolCallCtx): Promise<AskOutcome>;
|
|
49
|
+
// Remote (relay) approval. Optional until Phase 4; when absent, a remote turn
|
|
50
|
+
// falls back to localAsk.
|
|
51
|
+
remoteAsk?(req: PermissionRequest, signal?: AbortSignal): Promise<AskOutcome>;
|
|
52
|
+
// Secret redactor for tool output. Defaults to redactText.
|
|
53
|
+
redact?(text: string): string;
|
|
54
|
+
// Hard ceiling on waiting for a decision before failing closed.
|
|
55
|
+
approvalTimeoutMs?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface GateBlock {
|
|
59
|
+
block: true;
|
|
60
|
+
reason: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// THE decision path. Classify → policy → decision, fail-closed on any error.
|
|
64
|
+
// Returns a block directive to deny, or undefined to let the tool run.
|
|
65
|
+
export async function decideToolCall(
|
|
66
|
+
ctrl: GateController,
|
|
67
|
+
toolName: string,
|
|
68
|
+
input: unknown,
|
|
69
|
+
ctx: ToolCallCtx,
|
|
70
|
+
): Promise<GateBlock | undefined> {
|
|
71
|
+
const req = classifyToolCall(toolName, input, {
|
|
72
|
+
cwd: ctrl.cwd,
|
|
73
|
+
confineToCwd: ctrl.confineToCwd,
|
|
74
|
+
allowedOutsideRoots: ctrl.allowedOutsideRoots,
|
|
75
|
+
});
|
|
76
|
+
if (!req) return undefined; // no gate needed — read-only/in-scope/meta
|
|
77
|
+
|
|
78
|
+
// Route the ask: a remote-driven turn goes to the relay approver (if wired),
|
|
79
|
+
// otherwise the local UI. ModeGate decides *whether* to ask; this decides *who*.
|
|
80
|
+
const ask = (r: PermissionRequest): Promise<AskOutcome> =>
|
|
81
|
+
ctrl.getRemote?.() && ctrl.remoteAsk
|
|
82
|
+
? ctrl.remoteAsk(r, ctx.signal)
|
|
83
|
+
: ctrl.localAsk(r, ctx);
|
|
84
|
+
|
|
85
|
+
const gate = new ModeGate({
|
|
86
|
+
getMode: ctrl.getMode,
|
|
87
|
+
setMode: ctrl.setMode,
|
|
88
|
+
allowlist: ctrl.allowlist,
|
|
89
|
+
allowedOutsideRoots: ctrl.allowedOutsideRoots,
|
|
90
|
+
// Default to the built-in dangerous-command patterns so bypass / no-quarter /
|
|
91
|
+
// headless-subagent runs still force dangerous shell + secret-exfil to "ask"
|
|
92
|
+
// (→ headless deny). A controller can extend, but never silently disable, this.
|
|
93
|
+
denylist: ctrl.denylist ?? DEFAULT_DENYLIST,
|
|
94
|
+
ask,
|
|
95
|
+
getRemote: ctrl.getRemote,
|
|
96
|
+
getNoQuarter: ctrl.getNoQuarter,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
let decision: "allow" | "deny";
|
|
100
|
+
try {
|
|
101
|
+
decision = await withTimeout(gate.request(req), ctrl.approvalTimeoutMs, ctx.signal);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
// Fail closed: a thrown/aborted/timed-out approval blocks the tool.
|
|
104
|
+
return {
|
|
105
|
+
block: true,
|
|
106
|
+
reason: `Approval unavailable (${(err as Error)?.message ?? "error"}) — blocked by default`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (decision === "deny") {
|
|
110
|
+
return { block: true, reason: `${req.title} denied by permission gate` };
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Default local asker: prompt via ctx.ui when a UI is present; fail closed
|
|
116
|
+
// (deny) when headless (no UI) — the gate is the real safety, never auto-trust.
|
|
117
|
+
// TODO(verify) the exact ctx.ui.select/confirm API shape against Pi when the TUI
|
|
118
|
+
// is wired (Phase 6); coded defensively for now.
|
|
119
|
+
const ALLOW_ONCE = "Allow once";
|
|
120
|
+
const ALLOW_ALWAYS = "Allow and remember";
|
|
121
|
+
const DENY = "Deny";
|
|
122
|
+
|
|
123
|
+
export async function defaultLocalAsk(req: PermissionRequest, ctx: ToolCallCtx): Promise<AskOutcome> {
|
|
124
|
+
if (!ctx.hasUI || !ctx.ui) return "deny"; // headless → fail closed
|
|
125
|
+
const title = `${req.title}: ${req.detail}`;
|
|
126
|
+
if (typeof ctx.ui.select === "function") {
|
|
127
|
+
const choice = await ctx.ui.select(title, [ALLOW_ONCE, ALLOW_ALWAYS, DENY]);
|
|
128
|
+
if (choice === ALLOW_ONCE) return "allow";
|
|
129
|
+
if (choice === ALLOW_ALWAYS) return "always";
|
|
130
|
+
return "deny"; // Deny, or cancel (undefined)
|
|
131
|
+
}
|
|
132
|
+
if (typeof ctx.ui.confirm === "function") {
|
|
133
|
+
return (await ctx.ui.confirm("Permission", title)) ? "allow" : "deny";
|
|
134
|
+
}
|
|
135
|
+
return "deny";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Build the extension factory. Pass to DefaultResourceLoader({ extensionFactories }).
|
|
139
|
+
export function makePermissionGate(ctrl: GateController) {
|
|
140
|
+
const redact = ctrl.redact ?? redactText;
|
|
141
|
+
if (!ctrl.localAsk) ctrl.localAsk = defaultLocalAsk;
|
|
142
|
+
|
|
143
|
+
// Structural view of the Pi extension API surface we use (documented at
|
|
144
|
+
// pi.dev/docs/latest/extensions).
|
|
145
|
+
interface PiExtensionApi {
|
|
146
|
+
on(
|
|
147
|
+
event: "tool_call",
|
|
148
|
+
handler: (
|
|
149
|
+
event: { toolName: string; toolCallId: string; input: unknown },
|
|
150
|
+
ctx: ToolCallCtx,
|
|
151
|
+
) => Promise<GateBlock | undefined>,
|
|
152
|
+
): void;
|
|
153
|
+
on(
|
|
154
|
+
event: "tool_result",
|
|
155
|
+
handler: (
|
|
156
|
+
event: {
|
|
157
|
+
toolName: string;
|
|
158
|
+
toolCallId: string;
|
|
159
|
+
content: Array<{ type: string; text?: string; [k: string]: unknown }>;
|
|
160
|
+
details?: unknown;
|
|
161
|
+
isError?: boolean;
|
|
162
|
+
},
|
|
163
|
+
ctx: unknown,
|
|
164
|
+
) => { content?: unknown[] } | undefined,
|
|
165
|
+
): void;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return function permissionGate(pi: PiExtensionApi): void {
|
|
169
|
+
pi.on("tool_call", (event, ctx) => decideToolCall(ctrl, event.toolName, event.input, ctx));
|
|
170
|
+
|
|
171
|
+
// Redact secrets from tool output before it reaches the model / relay.
|
|
172
|
+
// tool_result delivers content PARTS (per the Pi extension contract), not a
|
|
173
|
+
// bare string — patch text parts and return a partial { content }; omitted
|
|
174
|
+
// fields keep their current values.
|
|
175
|
+
pi.on("tool_result", (event) => {
|
|
176
|
+
let touched = false;
|
|
177
|
+
const content = event.content.map((part) => {
|
|
178
|
+
if (typeof part.text === "string") {
|
|
179
|
+
const red = redact(part.text);
|
|
180
|
+
if (red !== part.text) touched = true;
|
|
181
|
+
return { ...part, text: red };
|
|
182
|
+
}
|
|
183
|
+
return part;
|
|
184
|
+
});
|
|
185
|
+
return touched ? { content } : undefined;
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function withTimeout<T>(p: Promise<T>, ms: number | undefined, signal?: AbortSignal): Promise<T> {
|
|
191
|
+
if (!ms && !signal) return p;
|
|
192
|
+
return new Promise<T>((resolve, reject) => {
|
|
193
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
194
|
+
const onAbort = () => {
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
reject(new Error("aborted"));
|
|
197
|
+
};
|
|
198
|
+
if (ms) timer = setTimeout(() => reject(new Error("approval timeout")), ms);
|
|
199
|
+
if (signal) {
|
|
200
|
+
if (signal.aborted) return onAbort();
|
|
201
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
202
|
+
}
|
|
203
|
+
p.then(
|
|
204
|
+
(v) => {
|
|
205
|
+
clearTimeout(timer);
|
|
206
|
+
signal?.removeEventListener("abort", onAbort);
|
|
207
|
+
resolve(v);
|
|
208
|
+
},
|
|
209
|
+
(e) => {
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
signal?.removeEventListener("abort", onAbort);
|
|
212
|
+
reject(e);
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Entrypoint. The ONE ordering rule: import ./boot.ts first (env +
|
|
2
|
+
// attestation dispatcher), then dynamically import everything Pi-touching so
|
|
3
|
+
// boot's side effects are guaranteed to have run before any Pi module loads.
|
|
4
|
+
//
|
|
5
|
+
// Phase 1 skeleton: this prints the resolved boot state so `npm start` proves
|
|
6
|
+
// the boot chain end-to-end (env pinned, dispatcher installed) without a TUI.
|
|
7
|
+
// Phases 4/6 replace the body with the daemon/relay wiring and the pi-tui app.
|
|
8
|
+
|
|
9
|
+
import "./boot.ts";
|
|
10
|
+
|
|
11
|
+
async function main() {
|
|
12
|
+
// Only import Pi-touching / config code AFTER boot has run.
|
|
13
|
+
const { agentDir, globalDir } = await import("./config/paths.ts");
|
|
14
|
+
const { capturedHosts } = await import("pi-privacy/attest");
|
|
15
|
+
|
|
16
|
+
const home = globalDir();
|
|
17
|
+
const agent = process.env.PI_CODING_AGENT_DIR ?? agentDir();
|
|
18
|
+
|
|
19
|
+
console.log("privateer-agent 0.3 — boot skeleton");
|
|
20
|
+
console.log(` PRIVATEER_HOME ${home}`);
|
|
21
|
+
console.log(` PI_CODING_AGENT_DIR ${agent}`);
|
|
22
|
+
console.log(` attestation dispatcher installed: yes`);
|
|
23
|
+
console.log(` hosts attested so far: ${capturedHosts().size}`);
|
|
24
|
+
console.log("");
|
|
25
|
+
console.log("Phases 1–2 skeleton in place (adapter + gate promoted from pi-spike).");
|
|
26
|
+
console.log("Next: wire a real headless session — see docs/pi-migration-plan.md §2.");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
main().catch((err) => {
|
|
30
|
+
console.error(err?.stack ?? err);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
});
|