privateer-agent 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/privateer-tui +7 -1
- package/bin/privateer.mjs +0 -0
- package/extensions/privateer-account.ts +6 -0
- package/extensions/privateer-brand.ts +274 -0
- package/extensions/privateer-gate.ts +140 -0
- package/extensions/privateer-posture.ts +42 -0
- package/extensions/privateer-privacy.ts +15 -0
- package/extensions/privateer-tools.ts +9 -0
- package/package.json +3 -2
package/bin/privateer-tui
CHANGED
|
@@ -83,4 +83,10 @@ else
|
|
|
83
83
|
MODEL="openrouter/openai/gpt-4o-mini"
|
|
84
84
|
fi
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
# Dev convenience: load provider keys from the repo's .env if present. A real install
|
|
87
|
+
# has none — so only pass the flag when the file exists (using --env-file-if-exists
|
|
88
|
+
# unconditionally would print "…/.env not found. Continuing without it." on every
|
|
89
|
+
# launch, muddying the startup banner). No .env → rely on the ambient environment.
|
|
90
|
+
ENV_ARGS=""
|
|
91
|
+
[ -f "$REPO/.env" ] && ENV_ARGS="--env-file=$REPO/.env"
|
|
92
|
+
exec "$NODE_BIN" ${ENV_ARGS:+"$ENV_ARGS"} "$CLI" --model "$MODEL" "$@"
|
package/bin/privateer.mjs
CHANGED
|
File without changes
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The Privateer account provider as a Pi extension (for `-e` into Pi's TUI).
|
|
2
|
+
// Registers the `privateer` subscription channel when a machine login exists; Pi's
|
|
3
|
+
// own OAuth /login flow drives the child-session credential.
|
|
4
|
+
import { makeAccountProvider } from "../src/providers/account.ts";
|
|
5
|
+
|
|
6
|
+
export default makeAccountProvider();
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// Privateer branding for Pi's TUI + the account (sign-in) surface. Three concerns
|
|
2
|
+
// live together here because they share one piece of state — whether this terminal
|
|
3
|
+
// is signed in to a Privateer account:
|
|
4
|
+
//
|
|
5
|
+
// 1. A branded startup header — the anchor mark + "✻ PRIVATEER" wordmark + the
|
|
6
|
+
// "Chart your own course privately." tagline (ported from tree-cli's Banner).
|
|
7
|
+
// 2. A live status-bar badge (⚓ account) showing the sign-in state at a glance.
|
|
8
|
+
// 3. Sign-in UX — /signin, /signout, and a /privateer hub — which drive the
|
|
9
|
+
// device-code flow the account channel needs.
|
|
10
|
+
//
|
|
11
|
+
// Pi already owns /login and /logout for PROVIDER auth (and /whoami), so we do NOT
|
|
12
|
+
// shadow them. /signin drives the Privateer *account* device flow directly — which
|
|
13
|
+
// has to exist separately, because the `privateer` inference provider only registers
|
|
14
|
+
// once credentials exist, so a first-time user can't reach it through provider auth.
|
|
15
|
+
//
|
|
16
|
+
// On a successful /signin we hot-register the account provider so privateer/* models
|
|
17
|
+
// appear immediately, without a restart.
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import * as priv from "../src/auth/privateer.ts";
|
|
22
|
+
import { makeAccountProvider } from "../src/providers/account.ts";
|
|
23
|
+
|
|
24
|
+
const VERSION: string = (() => {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
27
|
+
} catch {
|
|
28
|
+
return "0.3.0";
|
|
29
|
+
}
|
|
30
|
+
})();
|
|
31
|
+
|
|
32
|
+
// ── palette (Privateer "Open Water" ocean-blue brand) ────────────────────────
|
|
33
|
+
// 256-color (8-bit), NOT 24-bit truecolor: macOS Terminal.app doesn't support
|
|
34
|
+
// truecolor and mangles it (the old indigo/cyan came out green). These indices are
|
|
35
|
+
// universally supported. Navy (the logo mark) is too dark to read on a dark terminal,
|
|
36
|
+
// so we use the app's ocean blues — the same "Open Water" gradient as the brand.
|
|
37
|
+
const ESC = "\x1b[";
|
|
38
|
+
const RESET = `${ESC}0m`;
|
|
39
|
+
const BOLD = `${ESC}1m`;
|
|
40
|
+
const c = (n: number): string => `${ESC}38;5;${n}m`;
|
|
41
|
+
const OCEAN = c(39); // ≈ #00afff — primary ocean blue (anchor, wordmark "P")
|
|
42
|
+
const OCEAN_LIGHT = c(81); // ≈ #5fd7ff — light sky accent (wordmark, version, path)
|
|
43
|
+
const BORDER = c(32); // ≈ #0087d7 — deeper ocean, the frame
|
|
44
|
+
const DIM = `${ESC}90m`;
|
|
45
|
+
const GREEN = `${ESC}32m`;
|
|
46
|
+
const YELLOW = `${ESC}33m`;
|
|
47
|
+
|
|
48
|
+
// The Privateer mark in ASCII: a padlock (with keyhole) atop an anchor — "bring your
|
|
49
|
+
// own model" meets lock-and-key privacy, echoing the app's anchor+padlock logo. Every
|
|
50
|
+
// line is the same visible width (11) so the text column beside it stays aligned.
|
|
51
|
+
const ANCHOR = [
|
|
52
|
+
" .-. ", // shackle arch
|
|
53
|
+
" | | ", // shackle legs
|
|
54
|
+
" .-----. ", // lock body top (the shackle's base)
|
|
55
|
+
" | o | ", // lock body + keyhole
|
|
56
|
+
" '--+--' ", // lock body base, shank exits
|
|
57
|
+
" ---+--- ", // stock (crossbar)
|
|
58
|
+
" \\ | / ", // arms
|
|
59
|
+
" \\_|_/ ", // flukes
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
// Visible width = characters after stripping SGR escapes. Everything we render inside
|
|
63
|
+
// the box is ASCII or a BMP width-1 symbol, so a plain length is exact here.
|
|
64
|
+
function vlen(s: string): number {
|
|
65
|
+
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Strip ESC/C0/C1 control bytes from any value we did NOT author before it lands in a
|
|
69
|
+
// render line. Account email/id/pubkey come from the server (via credentials.json),
|
|
70
|
+
// the device user_code + verification_uri come off the device-code response, and cwd
|
|
71
|
+
// comes from the filesystem — none is guaranteed free of raw escape sequences, which
|
|
72
|
+
// would otherwise reach the terminal unsanitized. Our own SGR color codes are added
|
|
73
|
+
// AFTER cleaning the untrusted substring, so they're preserved; normal printable text
|
|
74
|
+
// (including Unicode) is untouched.
|
|
75
|
+
// eslint-disable-next-line no-control-regex
|
|
76
|
+
const CONTROL_RE = new RegExp("[\\u0000-\\u001f\\u007f-\\u009f]", "g");
|
|
77
|
+
function clean(s: unknown): string {
|
|
78
|
+
return String(s ?? "").replace(CONTROL_RE, "");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function shortCwd(): string {
|
|
82
|
+
const cwd = process.cwd();
|
|
83
|
+
const home = homedir();
|
|
84
|
+
const path = cwd === home || cwd.startsWith(home + "/") ? "~" + cwd.slice(home.length) : cwd;
|
|
85
|
+
return clean(path);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The account line under the tagline — three states, ported from tree-cli's Banner:
|
|
89
|
+
// - signed in → "connected as <account>"
|
|
90
|
+
// - signed out AND the current model bills to a Privateer account → it can't run
|
|
91
|
+
// until they sign in, so say so plainly (warning)
|
|
92
|
+
// - signed out on their own key → a quiet tease that /signin adds more
|
|
93
|
+
function accountLine(modelProvider?: string): string {
|
|
94
|
+
const u = priv.currentUser();
|
|
95
|
+
if (u) {
|
|
96
|
+
const label = clean(u.email ?? (u.solanaPublicKey ? u.solanaPublicKey.slice(0, 6) + "…" : u.id));
|
|
97
|
+
return `${GREEN}connected${DIM} as ${RESET}${OCEAN_LIGHT}${label}${RESET}`;
|
|
98
|
+
}
|
|
99
|
+
if (modelProvider === "privateer") {
|
|
100
|
+
return `${YELLOW}not signed in · /signin to use this model${RESET}`;
|
|
101
|
+
}
|
|
102
|
+
return `${DIM}not signed in · ${OCEAN_LIGHT}/signin${DIM} to connect your account${RESET}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Compose the framed banner: anchor column + text column, inside a rounded accent box.
|
|
106
|
+
function renderBanner(width: number, modelProvider?: string): string[] {
|
|
107
|
+
// Leading blanks drop the text block so the wordmark sits beside the lock body and
|
|
108
|
+
// the shackle rises above it (one entry per anchor line — 8 total).
|
|
109
|
+
const right = [
|
|
110
|
+
"",
|
|
111
|
+
"",
|
|
112
|
+
`${BOLD}${OCEAN_LIGHT}✻ ${OCEAN}P${OCEAN_LIGHT}RIVATEER${RESET}`,
|
|
113
|
+
`${DIM}Chart your own course privately.${RESET}`,
|
|
114
|
+
"",
|
|
115
|
+
accountLine(modelProvider),
|
|
116
|
+
`${DIM}privateer-agent ${OCEAN_LIGHT}v${VERSION}${RESET}`,
|
|
117
|
+
`${OCEAN_LIGHT}${shortCwd()}${RESET}`,
|
|
118
|
+
];
|
|
119
|
+
// Build the body rows (anchor + gutter + text).
|
|
120
|
+
const rows = ANCHOR.map((a, i) => `${OCEAN}${a}${RESET} ${right[i] ?? ""}`);
|
|
121
|
+
const cap = Math.max(20, width - 4); // 2 border cells + 2 padding
|
|
122
|
+
const inner = Math.min(cap, Math.max(...rows.map(vlen)));
|
|
123
|
+
const bar = "─".repeat(inner + 2);
|
|
124
|
+
const out = [`${BORDER}╭${bar}╮${RESET}`];
|
|
125
|
+
for (const row of rows) {
|
|
126
|
+
const pad = Math.max(0, inner - vlen(row));
|
|
127
|
+
out.push(`${BORDER}│${RESET} ${row}${" ".repeat(pad)} ${BORDER}│${RESET}`);
|
|
128
|
+
}
|
|
129
|
+
out.push(`${BORDER}╰${bar}╯${RESET}`);
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// A Pi header Component (setHeader factory return). Static banner; captures the model
|
|
134
|
+
// provider so the account line reflects the picked model.
|
|
135
|
+
function headerComponent(modelProvider?: string) {
|
|
136
|
+
return {
|
|
137
|
+
render: (width: number): string[] => renderBanner(width, modelProvider),
|
|
138
|
+
invalidate() {},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Short account label for the footer badge.
|
|
143
|
+
function accountBadge(): string {
|
|
144
|
+
const u = priv.currentUser();
|
|
145
|
+
if (!u) return "⚓ guest";
|
|
146
|
+
const label = clean(u.email ? u.email.split("@")[0] : u.solanaPublicKey ? u.solanaPublicKey.slice(0, 6) + "…" : u.id);
|
|
147
|
+
return `⚓ ${label}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export default function privateerBrand(pi: any): void {
|
|
151
|
+
let currentModelProvider: string | undefined;
|
|
152
|
+
let ctxRef: any = null;
|
|
153
|
+
let greeted = false;
|
|
154
|
+
|
|
155
|
+
const setHeader = (ctx: any) =>
|
|
156
|
+
ctx?.ui?.setHeader?.(() => headerComponent(currentModelProvider));
|
|
157
|
+
|
|
158
|
+
const refresh = (ctx: any) => {
|
|
159
|
+
if (!ctx?.hasUI) return;
|
|
160
|
+
setHeader(ctx);
|
|
161
|
+
ctx?.ui?.setStatus?.("account", accountBadge());
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
async function doSignIn(ctx: any): Promise<void> {
|
|
165
|
+
if (priv.hasCredentials()) {
|
|
166
|
+
const u = priv.currentUser();
|
|
167
|
+
return ctx?.ui?.notify?.(
|
|
168
|
+
`Already signed in as ${u?.email ?? u?.id}. Run /signout to switch accounts.`,
|
|
169
|
+
"info",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
ctx?.ui?.notify?.("Connecting to Privateer — requesting a device code…", "info");
|
|
173
|
+
try {
|
|
174
|
+
const user = await priv.runDeviceLogin({
|
|
175
|
+
onCode: (code: any) => {
|
|
176
|
+
const uri = clean(code.verification_uri_complete ?? code.verification_uri ?? "");
|
|
177
|
+
const userCode = clean(code.user_code);
|
|
178
|
+
ctx?.ui?.setWidget?.(
|
|
179
|
+
"privateer-signin",
|
|
180
|
+
[
|
|
181
|
+
`${OCEAN_LIGHT}⚓ Sign in to Privateer${RESET}`,
|
|
182
|
+
`${DIM}Approve this terminal in the Privateer app:${RESET}`,
|
|
183
|
+
` code ${BOLD}${OCEAN}${userCode}${RESET}`,
|
|
184
|
+
uri ? `${DIM} or open ${RESET}${OCEAN_LIGHT}${uri}${RESET}` : "",
|
|
185
|
+
`${DIM} waiting for approval…${RESET}`,
|
|
186
|
+
].filter(Boolean),
|
|
187
|
+
{ placement: "aboveEditor" },
|
|
188
|
+
);
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
ctx?.ui?.setWidget?.("privateer-signin", undefined);
|
|
192
|
+
// Hot-register the account provider so privateer/* models appear without a restart.
|
|
193
|
+
try {
|
|
194
|
+
await makeAccountProvider()(pi);
|
|
195
|
+
} catch {
|
|
196
|
+
/* provider list fetch failed — models appear on next launch */
|
|
197
|
+
}
|
|
198
|
+
refresh(ctx);
|
|
199
|
+
ctx?.ui?.notify?.(`Signed in as ${user.email ?? user.id}. Your Privateer models are ready.`, "info");
|
|
200
|
+
} catch (e) {
|
|
201
|
+
ctx?.ui?.setWidget?.("privateer-signin", undefined);
|
|
202
|
+
ctx?.ui?.notify?.((e as Error).message || "Sign-in failed.", "error");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function doSignOut(ctx: any): Promise<void> {
|
|
207
|
+
if (!priv.hasCredentials()) return ctx?.ui?.notify?.("Not signed in.", "info");
|
|
208
|
+
const u = priv.currentUser();
|
|
209
|
+
await priv.logout();
|
|
210
|
+
refresh(ctx);
|
|
211
|
+
ctx?.ui?.notify?.(`Signed out${u?.email ? ` (${u.email})` : ""}. Drop anchor for now.`, "info");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function showStatus(ctx: any): void {
|
|
215
|
+
const u = priv.currentUser();
|
|
216
|
+
ctx?.ui?.notify?.(
|
|
217
|
+
u
|
|
218
|
+
? `Signed in to Privateer as ${u.email ?? u.id}.`
|
|
219
|
+
: "Not signed in. Run /signin to connect your Privateer account.",
|
|
220
|
+
"info",
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
pi.on("session_start", (_e: any, ctx: any) => {
|
|
225
|
+
ctxRef = ctx;
|
|
226
|
+
currentModelProvider = ctx?.model?.provider ?? currentModelProvider;
|
|
227
|
+
if (!ctx?.hasUI) return; // headless (print/json): no banner or prompts
|
|
228
|
+
ctx?.ui?.setTitle?.("Privateer");
|
|
229
|
+
refresh(ctx);
|
|
230
|
+
if (!priv.hasCredentials() && !greeted) {
|
|
231
|
+
greeted = true;
|
|
232
|
+
ctx?.ui?.notify?.(
|
|
233
|
+
"Not signed in — run /signin to connect your Privateer account (adds hosted models + remote access).",
|
|
234
|
+
"info",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Keep the header's account line in sync with the picked model (the "this model
|
|
240
|
+
// needs sign-in" state depends on the current provider).
|
|
241
|
+
pi.on("model_select", (e: any, ctx: any) => {
|
|
242
|
+
ctxRef = ctx;
|
|
243
|
+
const next = e?.model?.provider;
|
|
244
|
+
if (next && next !== currentModelProvider) {
|
|
245
|
+
currentModelProvider = next;
|
|
246
|
+
setHeader(ctx);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// The machine login was invalidated server-side (TTL lapsed or revoked in the app):
|
|
251
|
+
// announce it and reflect it in the badge/header immediately.
|
|
252
|
+
priv.onSessionExpired(() => {
|
|
253
|
+
refresh(ctxRef);
|
|
254
|
+
ctxRef?.ui?.notify?.("Your Privateer session expired. Run /signin to sign back in.", "warning");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
pi.registerCommand?.("signin", {
|
|
258
|
+
description: "Sign in to your Privateer account (device-code flow)",
|
|
259
|
+
handler: (_args: string, ctx: any) => doSignIn(ctx),
|
|
260
|
+
});
|
|
261
|
+
pi.registerCommand?.("signout", {
|
|
262
|
+
description: "Sign out of your Privateer account on this terminal",
|
|
263
|
+
handler: (_args: string, ctx: any) => doSignOut(ctx),
|
|
264
|
+
});
|
|
265
|
+
pi.registerCommand?.("privateer", {
|
|
266
|
+
description: "Privateer account: /privateer [status | signin | signout]",
|
|
267
|
+
handler: (args: string, ctx: any) => {
|
|
268
|
+
const sub = String(args ?? "").trim().toLowerCase().split(/\s+/)[0];
|
|
269
|
+
if (sub === "signin" || sub === "login") return doSignIn(ctx);
|
|
270
|
+
if (sub === "signout" || sub === "logout") return doSignOut(ctx);
|
|
271
|
+
return showStatus(ctx);
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// The Privateer control extension for Pi's TUI (Phase 6): the permission gate PLUS
|
|
2
|
+
// remote access, so the app can drive the real TUI (not just the lean REPL).
|
|
3
|
+
//
|
|
4
|
+
// - gate: tool_call → block/allow via Pi's native approval UI (ctx.ui); /mode command.
|
|
5
|
+
// - remote access: /remote-access on connects the relay; the bridge routes the
|
|
6
|
+
// gate's approvals to the phone (getRemote/remoteAsk) and drives turns from app
|
|
7
|
+
// prompts (pi.sendUserMessage). Turn events forward to the app via the adapter —
|
|
8
|
+
// the same pi.on(...) stream the adapter already speaks.
|
|
9
|
+
//
|
|
10
|
+
// The gate + bridge share one extension so the gate's remote branch and the relay
|
|
11
|
+
// are wired to the same state.
|
|
12
|
+
|
|
13
|
+
import { makePermissionGate, defaultLocalAsk } from "../src/ext/permissionGate.ts";
|
|
14
|
+
import { createEngineEventAdapter } from "../src/bridge/engineAdapter.ts";
|
|
15
|
+
import { RemoteBridge } from "../src/remote/remoteBridge.ts";
|
|
16
|
+
import { RelayClient } from "../src/remote/relayClient.ts";
|
|
17
|
+
import { makeSendFileTool } from "../src/tools/sendFile.ts";
|
|
18
|
+
import { makeSaveAttachmentTool } from "../src/tools/saveAttachment.ts";
|
|
19
|
+
import { AttachmentStore, type StoredAttachment } from "../src/util/attachmentStore.ts";
|
|
20
|
+
import * as priv from "../src/auth/privateer.ts";
|
|
21
|
+
import type { PermissionMode } from "../src/config/permissionMode.ts";
|
|
22
|
+
|
|
23
|
+
const MODES: PermissionMode[] = ["default", "acceptEdits", "bypass", "plan"];
|
|
24
|
+
let mode: PermissionMode = MODES.includes(process.env.PRIVATEER_MODE as PermissionMode)
|
|
25
|
+
? (process.env.PRIVATEER_MODE as PermissionMode)
|
|
26
|
+
: "default";
|
|
27
|
+
const allowlist: string[] = [];
|
|
28
|
+
const allowedOutsideRoots: string[] = [];
|
|
29
|
+
|
|
30
|
+
let piRef: any = null;
|
|
31
|
+
let relay: any = null;
|
|
32
|
+
|
|
33
|
+
// Inbound app→CLI files land here (keyed by "#n"); save_attachment persists them.
|
|
34
|
+
const attachments = new AttachmentStore();
|
|
35
|
+
let sinceLastPrompt: StoredAttachment[] = [];
|
|
36
|
+
|
|
37
|
+
const bridge = new RemoteBridge({
|
|
38
|
+
onPrompt: (text) => {
|
|
39
|
+
// Fold any files the app sent since the last prompt into a reference note so the
|
|
40
|
+
// model knows they exist and can save_attachment them.
|
|
41
|
+
const atts = sinceLastPrompt;
|
|
42
|
+
sinceLastPrompt = [];
|
|
43
|
+
const note = atts.length
|
|
44
|
+
? `\n\n[Files attached from the app: ${atts.map((a) => `#${a.n} ${a.name} (${a.mediaType})`).join(", ")}. ` +
|
|
45
|
+
`Use the save_attachment tool with the ref number to write one to disk.]`
|
|
46
|
+
: "";
|
|
47
|
+
piRef?.sendUserMessage?.(text + note); // drive a turn in Pi's TUI
|
|
48
|
+
},
|
|
49
|
+
onInterrupt: () => {}, // Pi owns interrupt; best-effort no-op
|
|
50
|
+
onControllerAttached: () => relay?.sendSnapshot([{ kind: "notice", text: "Privateer terminal connected." }]),
|
|
51
|
+
onAttachment: (file) => sinceLastPrompt.push(attachments.register(file)),
|
|
52
|
+
onStatus: () => {},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const gate = makePermissionGate({
|
|
56
|
+
getMode: () => mode,
|
|
57
|
+
setMode: (m) => (mode = m),
|
|
58
|
+
allowlist,
|
|
59
|
+
allowedOutsideRoots,
|
|
60
|
+
cwd: process.cwd(),
|
|
61
|
+
localAsk: defaultLocalAsk,
|
|
62
|
+
getRemote: bridge.getRemote,
|
|
63
|
+
getNoQuarter: bridge.getNoQuarter,
|
|
64
|
+
remoteAsk: bridge.remoteAsk,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export default function privateerControl(pi: any): void {
|
|
68
|
+
piRef = pi;
|
|
69
|
+
gate(pi); // tool_call (block/allow) + tool_result (redact)
|
|
70
|
+
|
|
71
|
+
// File transfer both ways: send_file_to_client (CLI→app, via the bridge's relay) and
|
|
72
|
+
// save_attachment (app→CLI, from the AttachmentStore inbound files land in). Both
|
|
73
|
+
// live here because they share the RemoteBridge / its attachment stream.
|
|
74
|
+
pi.registerTool?.(makeSendFileTool(bridge));
|
|
75
|
+
pi.registerTool?.(makeSaveAttachmentTool(attachments));
|
|
76
|
+
|
|
77
|
+
// Subagents (and print/rpc) run as headless child `pi` processes with no UI. There
|
|
78
|
+
// no one can approve, so a "default" gate would fail-closed on every tool and the
|
|
79
|
+
// subagent couldn't work. Instead switch to bypass: auto-approve within the agent's
|
|
80
|
+
// OWN restricted tool set (pi-subagents' per-role `tools:` allowlist), while
|
|
81
|
+
// decideAuto STILL forces dangerous shell / secret-exfil / destructive actions to
|
|
82
|
+
// "ask" → headless → denied. So delegation is gated in the parent (the spawn tool
|
|
83
|
+
// call), and the subagent is bounded by its role + danger detection. The TUI
|
|
84
|
+
// (mode "tui") keeps the interactive default gate.
|
|
85
|
+
// Explicit ALLOWLIST of headless run modes — never an "anything but tui" denylist,
|
|
86
|
+
// so an unexpected/undefined mode in the interactive TUI can't silently drop us into
|
|
87
|
+
// bypass (which would run tools ungated). Only switch when we're *sure* it's headless
|
|
88
|
+
// and the user hasn't pinned a mode via PRIVATEER_MODE.
|
|
89
|
+
const HEADLESS = new Set(["json", "print", "rpc"]);
|
|
90
|
+
pi.on("session_start", (_e: any, ctx: any) => {
|
|
91
|
+
if (ctx?.mode && HEADLESS.has(ctx.mode) && (process.env.PRIVATEER_MODE ?? "") === "") {
|
|
92
|
+
mode = "bypass";
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Forward turn events to the app. The relay only sends when a controller is
|
|
97
|
+
// attached, so this is safe on every turn (local or remote).
|
|
98
|
+
const adapter = createEngineEventAdapter();
|
|
99
|
+
const fwd = (ev: any) => {
|
|
100
|
+
for (const ee of adapter.toEngineEvents(ev)) bridge.forwardEvent(ee);
|
|
101
|
+
};
|
|
102
|
+
pi.on("message_update", (ev: any) => fwd(ev));
|
|
103
|
+
pi.on("tool_execution_start", (ev: any) => fwd(ev));
|
|
104
|
+
pi.on("tool_execution_end", (ev: any) => fwd(ev));
|
|
105
|
+
pi.on("turn_end", (ev: any) => fwd(ev));
|
|
106
|
+
// A remote-initiated agent run ends here → clear the remote flag so a later
|
|
107
|
+
// locally-typed turn isn't treated as remote.
|
|
108
|
+
pi.on("agent_end", (ev: any) => {
|
|
109
|
+
fwd(ev);
|
|
110
|
+
bridge.settleTurn();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
pi.registerCommand?.("mode", {
|
|
114
|
+
description: "Show or set the permission mode: default | acceptEdits | bypass | plan",
|
|
115
|
+
handler: (args: string, ctx: any) => {
|
|
116
|
+
const m = String(args ?? "").trim() as PermissionMode;
|
|
117
|
+
if (m && MODES.includes(m)) mode = m;
|
|
118
|
+
else if (m) return ctx.ui?.notify?.(`unknown mode "${m}" — use ${MODES.join(" | ")}`, "warning");
|
|
119
|
+
ctx.ui?.notify?.(`permission mode: ${mode}`, "info");
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
pi.registerCommand?.("remote-access", {
|
|
124
|
+
description: "Drive this terminal from the Privateer app: /remote-access on | off",
|
|
125
|
+
handler: async (args: string, ctx: any) => {
|
|
126
|
+
const off = String(args ?? "").trim().toLowerCase() === "off";
|
|
127
|
+
if (off) {
|
|
128
|
+
relay?.stop();
|
|
129
|
+
relay = null;
|
|
130
|
+
return ctx.ui?.notify?.("remote access off", "info");
|
|
131
|
+
}
|
|
132
|
+
if (relay) return ctx.ui?.notify?.("remote access already on", "info");
|
|
133
|
+
if (!priv.hasCredentials()) return ctx.ui?.notify?.("Not signed in to Privateer.", "warning");
|
|
134
|
+
relay = new RelayClient(bridge.callbacks, { label: "privateer-cli" });
|
|
135
|
+
bridge.attachRelay(relay);
|
|
136
|
+
await relay.start();
|
|
137
|
+
ctx.ui?.notify?.("Remote access on — approve this terminal in the Privateer app, then drive it from there.", "info");
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The privacy-posture badge in Pi's status bar (Phase 6 polish). On model select
|
|
2
|
+
// (and at session start) it computes the current model's posture and pins it to the
|
|
3
|
+
// footer via ctx.ui.setStatus — so the moat is *visible*: a green "Verified TEE"
|
|
4
|
+
// for an attested enclave, a distinct label for a mere ZDR claim.
|
|
5
|
+
//
|
|
6
|
+
// Handles both surfaces: the account channel (privateer/*, via server-proxy
|
|
7
|
+
// attestation) which pi-privacy doesn't know, and everything else via pi-privacy.
|
|
8
|
+
|
|
9
|
+
import { verifyModelPosture, TIERS, type PrivacyTier } from "pi-privacy";
|
|
10
|
+
import { accountPosture } from "../src/providers/account.ts";
|
|
11
|
+
|
|
12
|
+
const DOT: Record<string, string> = { green: "🟢", yellow: "🟡", red: "🔴", neutral: "⚪" };
|
|
13
|
+
|
|
14
|
+
async function badgeFor(provider: string, modelId: string): Promise<string> {
|
|
15
|
+
const res =
|
|
16
|
+
provider === "privateer"
|
|
17
|
+
? await accountPosture(modelId)
|
|
18
|
+
: await verifyModelPosture(provider, modelId, {
|
|
19
|
+
apiKey: provider === "nearai" ? process.env.NEARAI_API_KEY ?? process.env.NEAR_AI_API_KEY : undefined,
|
|
20
|
+
});
|
|
21
|
+
const info = TIERS[res.tier as PrivacyTier];
|
|
22
|
+
return `${DOT[info.posture] ?? "⚪"} ${info.label}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function privateerPosture(pi: any): void {
|
|
26
|
+
// "latest wins" so rapid model cycling (Ctrl+P) doesn't leave a stale badge.
|
|
27
|
+
let seq = 0;
|
|
28
|
+
const update = async (provider?: string, modelId?: string, ctx?: any) => {
|
|
29
|
+
if (!provider || !modelId || !ctx?.ui?.setStatus) return;
|
|
30
|
+
const mine = ++seq;
|
|
31
|
+
try {
|
|
32
|
+
ctx.ui.setStatus("privacy", "⛉ …"); // immediate placeholder while attesting
|
|
33
|
+
const badge = await badgeFor(provider, modelId);
|
|
34
|
+
if (mine === seq) ctx.ui.setStatus("privacy", badge);
|
|
35
|
+
} catch {
|
|
36
|
+
if (mine === seq) ctx.ui.setStatus("privacy", undefined);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
pi.on("model_select", (event: any, ctx: any) => update(event?.model?.provider, event?.model?.id, ctx));
|
|
41
|
+
pi.on("session_start", (_event: any, ctx: any) => update(ctx?.model?.provider, ctx?.model?.id, ctx));
|
|
42
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// pi-privacy for privateer-agent: the standard pi-privacy extension (providers +
|
|
2
|
+
// attestation + posture badge feed + PII gate) PLUS a tier resolver that teaches it
|
|
3
|
+
// about the private ACCOUNT channel it doesn't ship — so a privateer/near… model
|
|
4
|
+
// (actually confidential-compute TEE) is treated as verified-private (no PII
|
|
5
|
+
// over-warning), and a zdr account model as zdr-policy. Replaces loading pi-privacy's
|
|
6
|
+
// default entry directly.
|
|
7
|
+
import { makePiPrivacyExtension } from "pi-privacy";
|
|
8
|
+
import { accountPosture } from "../src/providers/account.ts";
|
|
9
|
+
|
|
10
|
+
export default makePiPrivacyExtension({
|
|
11
|
+
resolveTier: async (provider, modelId) => {
|
|
12
|
+
if (provider !== "privateer") return undefined; // pi-privacy handles its own providers
|
|
13
|
+
return (await accountPosture(modelId)).tier;
|
|
14
|
+
},
|
|
15
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Privateer-specific custom tools for Pi's TUI (Phase 5). Today: create_routine
|
|
2
|
+
// (schedule unattended tasks → the daemon runs them). The generic tools (read/edit/
|
|
3
|
+
// bash/grep, web, subagents, todo) come from Pi builtins + adopted packages, so only
|
|
4
|
+
// the privateer-only tools live here. Gated by our permission-gate extension.
|
|
5
|
+
import { routineToolDefinition } from "../src/tools/routine.ts";
|
|
6
|
+
|
|
7
|
+
export default function privateerTools(pi: any): void {
|
|
8
|
+
pi.registerTool?.(routineToolDefinition);
|
|
9
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"venice"
|
|
29
29
|
],
|
|
30
30
|
"bin": {
|
|
31
|
-
"privateer": "./bin/privateer
|
|
31
|
+
"privateer": "./bin/privateer-tui"
|
|
32
32
|
},
|
|
33
33
|
"publishConfig": {
|
|
34
34
|
"access": "public"
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"files": [
|
|
37
37
|
"bin",
|
|
38
38
|
"src",
|
|
39
|
+
"extensions",
|
|
39
40
|
"README.md",
|
|
40
41
|
"LICENSE"
|
|
41
42
|
],
|