privateer-agent 0.3.4 → 0.3.6
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 +19 -0
- package/bin/privateer-tui +16 -5
- package/extensions/privateer-brand.ts +160 -22
- package/extensions/privateer-context.ts +59 -0
- package/extensions/privateer-gate.ts +62 -4
- package/extensions/privateer-posture.ts +18 -2
- package/extensions/privateer-privacy.ts +50 -1
- package/package.json +1 -1
- package/src/auth/privateer.ts +106 -13
- package/src/cli/chat.ts +29 -2
- package/src/config/version.ts +16 -0
- package/src/context.ts +171 -0
- package/src/daemon/index.ts +25 -2
- package/src/providers/account.ts +46 -22
- package/src/remote/relayClient.ts +13 -0
package/README.md
CHANGED
|
@@ -63,6 +63,7 @@ What Privateer adds is a *moat* of Pi extensions layered on top:
|
|
|
63
63
|
| Extension | What it adds |
|
|
64
64
|
|---|---|
|
|
65
65
|
| `privateer-gate` | safe-by-default permission gate + destructive-command danger filter |
|
|
66
|
+
| `privateer-context` | loads `PRIVATEER.md` project context (like `AGENTS.md`/`CLAUDE.md`) + the `/init` command |
|
|
66
67
|
| `privateer-privacy` | `pi-privacy` — TEE attestation, ZDR routing, on-device PII gate — bound to the account tier resolver |
|
|
67
68
|
| `privateer-account` | `/signin` billed inference against a Privateer account (device flow) |
|
|
68
69
|
| `privateer-posture`, `privateer-tools` | live attestation shield + Privateer tool pack |
|
|
@@ -168,6 +169,24 @@ vLLM, llama.cpp) works as a custom provider — just give it a base URL.
|
|
|
168
169
|
|
|
169
170
|
Override the config location with `PRIVATEER_HOME`.
|
|
170
171
|
|
|
172
|
+
## Context files — `PRIVATEER.md`
|
|
173
|
+
|
|
174
|
+
Give the agent standing knowledge about your project — conventions, common commands,
|
|
175
|
+
domain notes — by dropping a **`PRIVATEER.md`** in the directory. Privateer loads it
|
|
176
|
+
automatically at the start of every turn and prepends it to the model's system prompt,
|
|
177
|
+
exactly the way Pi loads `AGENTS.md` / `CLAUDE.md` (all three are recognized, and all
|
|
178
|
+
matching files are concatenated).
|
|
179
|
+
|
|
180
|
+
Run **`/init`** to scaffold a starter `PRIVATEER.md` in the current directory, then edit
|
|
181
|
+
it. The startup banner shows a **⚓** line with the loaded file's path (and a `+N` count
|
|
182
|
+
when ancestor files also apply), or a `/init` hint when none is found.
|
|
183
|
+
|
|
184
|
+
Discovery mirrors Pi's context-file lookup: the global agent dir
|
|
185
|
+
(`~/.privateer/agent/PRIVATEER.md`) first, then every directory from the filesystem root
|
|
186
|
+
down to the current one — so a repo-root `PRIVATEER.md` applies to every subdirectory, and
|
|
187
|
+
a deeper file can refine it. `AGENTS.md` and `CLAUDE.md` continue to work unchanged; use
|
|
188
|
+
`--no-context-files` (`-nc`) to disable context-file loading entirely.
|
|
189
|
+
|
|
171
190
|
## Private & verifiable inference
|
|
172
191
|
|
|
173
192
|
**NEAR AI Cloud** and **Tinfoil** run every model inside a **Trusted Execution Environment** —
|
package/bin/privateer-tui
CHANGED
|
@@ -46,11 +46,13 @@ mkdir -p "$EXT_DIR"
|
|
|
46
46
|
# ABSOLUTE path so the target's own relative imports resolve from the repo (a plain
|
|
47
47
|
# symlink would resolve them relative to the shim's location and break). We remove
|
|
48
48
|
# any shim we previously managed first, so a dropped package can't linger and reload.
|
|
49
|
-
MANAGED="privateer-brand privateer-gate privateer-account privateer-posture privateer-tools privateer-privacy pi-privacy pi-web-access rpiv-web-tools pi-mcp-adapter pi-hypa pi-subagents"
|
|
49
|
+
MANAGED="privateer-brand privateer-context privateer-gate privateer-account privateer-posture privateer-tools privateer-privacy pi-privacy pi-web-access rpiv-web-tools pi-mcp-adapter pi-hypa pi-subagents"
|
|
50
50
|
for name in $MANAGED; do rm -f "$EXT_DIR/$name.ts"; done
|
|
51
51
|
shim() { printf 'export { default } from "%s";\n' "$2" > "$EXT_DIR/$1.ts"; }
|
|
52
52
|
# Branding + the account sign-in surface (banner, ⚓ badge, /signin /signout).
|
|
53
53
|
shim privateer-brand "$REPO/extensions/privateer-brand.ts"
|
|
54
|
+
# PRIVATEER.md project-context loading (like AGENTS.md/CLAUDE.md) + the /init command.
|
|
55
|
+
shim privateer-context "$REPO/extensions/privateer-context.ts"
|
|
54
56
|
shim privateer-gate "$REPO/extensions/privateer-gate.ts"
|
|
55
57
|
shim privateer-account "$REPO/extensions/privateer-account.ts"
|
|
56
58
|
shim privateer-posture "$REPO/extensions/privateer-posture.ts"
|
|
@@ -108,13 +110,22 @@ if [ -z "$(find "$UPDATE_CACHE" -mtime -1 2>/dev/null)" ]; then
|
|
|
108
110
|
) </dev/null >/dev/null 2>&1 &
|
|
109
111
|
fi
|
|
110
112
|
|
|
111
|
-
# Default model
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
113
|
+
# Default model. An explicit PRIVATEER_MODEL always wins. Otherwise prefer Tinfoil's
|
|
114
|
+
# GLM 5.2 when a Tinfoil key is available: verifiable TEE inference with CLIENT-side
|
|
115
|
+
# attestation (the live TLS key is bound to the enclave's quote), the strongest privacy
|
|
116
|
+
# tier we offer — stronger than the account's server-proxied NEAR channel. Failing that,
|
|
117
|
+
# use the signed-in Privateer account's NEAR confidential-compute channel; with neither,
|
|
118
|
+
# fall back to a cheap OpenRouter model. The Tinfoil key may sit in the ambient env or in
|
|
119
|
+
# the dev .env the launcher loads below, so check both.
|
|
115
120
|
CRED="${PRIVATEER_HOME:-$HOME/.privateer}/credentials.json"
|
|
121
|
+
have_tinfoil_key() {
|
|
122
|
+
[ -n "${TINFOIL_API_KEY:-}" ] && return 0
|
|
123
|
+
[ -f "$REPO/.env" ] && grep -qE '^TINFOIL_API_KEY=.+' "$REPO/.env"
|
|
124
|
+
}
|
|
116
125
|
if [ -n "${PRIVATEER_MODEL:-}" ]; then
|
|
117
126
|
MODEL="$PRIVATEER_MODEL"
|
|
127
|
+
elif have_tinfoil_key; then
|
|
128
|
+
MODEL="tinfoil/glm-5-2"
|
|
118
129
|
elif [ -f "$CRED" ]; then
|
|
119
130
|
MODEL="privateer/near/zai-org/GLM-5.1-FP8"
|
|
120
131
|
else
|
|
@@ -19,11 +19,12 @@
|
|
|
19
19
|
// appear immediately (the account catalog refreshes to the live listing without a
|
|
20
20
|
// restart).
|
|
21
21
|
|
|
22
|
-
import { readFileSync } from "node:fs";
|
|
22
|
+
import { readFileSync, appendFileSync } from "node:fs";
|
|
23
23
|
import { homedir } from "node:os";
|
|
24
24
|
import { join } from "node:path";
|
|
25
25
|
import * as priv from "../src/auth/privateer.ts";
|
|
26
26
|
import { makeAccountProvider } from "../src/providers/account.ts";
|
|
27
|
+
import { discoverContextFiles, onContextChanged } from "../src/context.ts";
|
|
27
28
|
|
|
28
29
|
const VERSION: string = (() => {
|
|
29
30
|
try {
|
|
@@ -51,14 +52,14 @@ const YELLOW = `${ESC}33m`;
|
|
|
51
52
|
|
|
52
53
|
// The Privateer mark in ASCII: a padlock (with keyhole) atop an anchor — "bring your
|
|
53
54
|
// own model" meets lock-and-key privacy, echoing the app's anchor+padlock logo. Every
|
|
54
|
-
// line is the
|
|
55
|
+
// line is the SAME visible width (MARK_W) and centers on column 5 (the shank/keyhole),
|
|
56
|
+
// so the text column beside it stays aligned. Keep them equal-width if you edit the art.
|
|
57
|
+
const MARK_W = 11;
|
|
55
58
|
const ANCHOR = [
|
|
56
59
|
" .-. ", // shackle arch
|
|
57
|
-
" |
|
|
58
|
-
"
|
|
59
|
-
" |
|
|
60
|
-
" '--+--' ", // lock body base, shank exits
|
|
61
|
-
" /\\ | /\\ ", // stock — arms flare from the shank (each \\ is one backslash)
|
|
60
|
+
" |___| ", // lock body top (the shackle's base)
|
|
61
|
+
" |_t_| ", // lock body + keyhole
|
|
62
|
+
" /\\ | /\\ ", // stock — arms flare from the shank (each \\ is one backslash)
|
|
62
63
|
" \\ | / ", // arms
|
|
63
64
|
" \\_|_/ ", // flukes
|
|
64
65
|
];
|
|
@@ -82,11 +83,16 @@ function clean(s: unknown): string {
|
|
|
82
83
|
return String(s ?? "").replace(CONTROL_RE, "");
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
// Collapse $HOME to ~ in an absolute path, and strip control bytes (paths come off the
|
|
87
|
+
// filesystem). Shared by the cwd line and the PRIVATEER.md line.
|
|
88
|
+
function shortPath(p: string): string {
|
|
87
89
|
const home = homedir();
|
|
88
|
-
const
|
|
89
|
-
return clean(
|
|
90
|
+
const short = p === home || p.startsWith(home + "/") ? "~" + p.slice(home.length) : p;
|
|
91
|
+
return clean(short);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function shortCwd(): string {
|
|
95
|
+
return shortPath(process.cwd());
|
|
90
96
|
}
|
|
91
97
|
|
|
92
98
|
// The account line under the tagline — three states, ported from tree-cli's Banner:
|
|
@@ -134,25 +140,73 @@ function updateNotice(): string {
|
|
|
134
140
|
return "";
|
|
135
141
|
}
|
|
136
142
|
|
|
137
|
-
//
|
|
143
|
+
// The PRIVATEER.md line under the block: green anchor when a project-context file is
|
|
144
|
+
// loaded (so the moat's "the agent knows this project" state is visible), otherwise a
|
|
145
|
+
// quiet tease that /init scaffolds one. Reads the filesystem at render time, so it
|
|
146
|
+
// reflects the current cwd and updates after /init (via onContextChanged → refresh).
|
|
147
|
+
function contextLine(): string {
|
|
148
|
+
const files = discoverContextFiles();
|
|
149
|
+
if (files.length === 0) {
|
|
150
|
+
return `${DIM}no PRIVATEER.md · ${OCEAN_LIGHT}/init${DIM} to add project context${RESET}`;
|
|
151
|
+
}
|
|
152
|
+
// Show the nearest (deepest, wins-last) file's path; note any additional ancestors
|
|
153
|
+
// with a "+N" so the header stays one line but the count isn't hidden.
|
|
154
|
+
const nearest = shortPath(files[files.length - 1].path);
|
|
155
|
+
const more = files.length > 1 ? `${DIM} +${files.length - 1}${RESET}` : "";
|
|
156
|
+
return `${GREEN}⚓${DIM} ${RESET}${OCEAN_LIGHT}${nearest}${RESET}${more}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── "What's New" — a tiny in-banner changelog ────────────────────────────────
|
|
160
|
+
// A hand-curated highlights list (newest first). Not the full changelog — just the two
|
|
161
|
+
// or three things a returning user should notice. `cmd`, when present, is rendered in the
|
|
162
|
+
// accent color so the actionable bit stands out from the prose. Trim this as it ages.
|
|
163
|
+
const WHATS_NEW: Array<{ text: string; cmd?: string }> = [
|
|
164
|
+
{ text: "Privateer agent CLI is live —", cmd: "npm i -g privateer-agent" },
|
|
165
|
+
{ text: "PRIVATEER.md project context —", cmd: "/init" },
|
|
166
|
+
{ text: "Self-update built in —", cmd: "privateer update" },
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
function whatsNewRows(): string[] {
|
|
170
|
+
const head = `${BOLD}${OCEAN_LIGHT}✦ What's new${RESET}`;
|
|
171
|
+
const items = WHATS_NEW.map(
|
|
172
|
+
({ text, cmd }) =>
|
|
173
|
+
`${OCEAN}·${RESET} ${DIM}${text}${RESET}${cmd ? ` ${OCEAN_LIGHT}${cmd}${RESET}` : ""}`,
|
|
174
|
+
);
|
|
175
|
+
return [head, ...items];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Compose the framed banner: the mark on the left, an independent text column on the
|
|
179
|
+
// right. The two columns have DIFFERENT heights (the text runs longer than the 6-line
|
|
180
|
+
// mark), so we zip by row index and pad the short side — every text-only row lands in
|
|
181
|
+
// the same column as the rows beside the mark. One place owns the left gutter, so
|
|
182
|
+
// spacing can't drift between the mark rows and the trailing rows.
|
|
138
183
|
function renderBanner(width: number, modelProvider?: string): string[] {
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
const
|
|
142
|
-
"",
|
|
184
|
+
// Right column, top to bottom. The two leading blanks drop the wordmark down so it
|
|
185
|
+
// sits beside the lock body (not the shackle); the rest follows in reading order.
|
|
186
|
+
const text: string[] = [
|
|
143
187
|
"",
|
|
144
|
-
`${BOLD}${OCEAN_LIGHT}✻ ${OCEAN}P${OCEAN_LIGHT}RIVATEER${RESET}`,
|
|
188
|
+
`${BOLD}${OCEAN_LIGHT}✻ ${OCEAN}P${OCEAN_LIGHT}RIVATEER${RESET}${DIM} privateer-agent ${OCEAN_LIGHT}v${VERSION}${RESET}`,
|
|
145
189
|
`${DIM}Chart your own course privately.${RESET}`,
|
|
146
190
|
"",
|
|
147
191
|
accountLine(modelProvider),
|
|
148
|
-
`${DIM}privateer-agent ${OCEAN_LIGHT}v${VERSION}${RESET}`,
|
|
149
192
|
`${OCEAN_LIGHT}${shortCwd()}${RESET}`,
|
|
193
|
+
contextLine(),
|
|
150
194
|
];
|
|
151
|
-
// Build the body rows (anchor + gutter + text). A pending-update notice, if any, gets
|
|
152
|
-
// its own row under the block, indented to sit beneath the text column.
|
|
153
|
-
const rows = ANCHOR.map((a, i) => `${OCEAN}${a}${RESET} ${right[i] ?? ""}`);
|
|
154
195
|
const notice = updateNotice();
|
|
155
|
-
if (notice)
|
|
196
|
+
if (notice) text.push(notice);
|
|
197
|
+
// A blank spacer, then the What's New block — set off below the identity lines.
|
|
198
|
+
text.push("", ...whatsNewRows());
|
|
199
|
+
|
|
200
|
+
// Zip the mark and the text column by row. Rows past the mark's height get a blank
|
|
201
|
+
// gutter of the mark's width, so the text stays in one column throughout.
|
|
202
|
+
const gap = " ";
|
|
203
|
+
const height = Math.max(ANCHOR.length, text.length);
|
|
204
|
+
const rows: string[] = [];
|
|
205
|
+
for (let i = 0; i < height; i++) {
|
|
206
|
+
const left = i < ANCHOR.length ? `${OCEAN}${ANCHOR[i]}${RESET}` : " ".repeat(MARK_W);
|
|
207
|
+
rows.push(`${left}${gap}${text[i] ?? ""}`.trimEnd());
|
|
208
|
+
}
|
|
209
|
+
|
|
156
210
|
const cap = Math.max(20, width - 4); // 2 border cells + 2 padding
|
|
157
211
|
const inner = Math.min(cap, Math.max(...rows.map(vlen)));
|
|
158
212
|
const bar = "─".repeat(inner + 2);
|
|
@@ -186,15 +240,45 @@ export default function privateerBrand(pi: any): void {
|
|
|
186
240
|
let currentModelProvider: string | undefined;
|
|
187
241
|
let ctxRef: any = null;
|
|
188
242
|
|
|
243
|
+
// TEMP debug trace (PRIVATEER_DEBUG=1): append a line to ~/.privateer/brand-debug.log
|
|
244
|
+
// so we can see, from a real sign-in, whether the refresh path fires and with what state.
|
|
245
|
+
const dbg = (msg: string): void => {
|
|
246
|
+
if (!process.env.PRIVATEER_DEBUG) return;
|
|
247
|
+
try {
|
|
248
|
+
const home = process.env.PRIVATEER_HOME || join(homedir(), ".privateer");
|
|
249
|
+
appendFileSync(join(home, "brand-debug.log"), `${new Date().toISOString()} ${msg}\n`);
|
|
250
|
+
} catch {
|
|
251
|
+
/* best effort */
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
189
255
|
const setHeader = (ctx: any) =>
|
|
190
256
|
ctx?.ui?.setHeader?.(() => headerComponent(currentModelProvider));
|
|
191
257
|
|
|
192
258
|
const refresh = (ctx: any) => {
|
|
259
|
+
dbg(`refresh: hasUI=${!!ctx?.hasUI} hasSetHeader=${typeof ctx?.ui?.setHeader} user=${priv.currentUser()?.email ?? null}`);
|
|
193
260
|
if (!ctx?.hasUI) return;
|
|
194
261
|
setHeader(ctx);
|
|
195
262
|
ctx?.ui?.setStatus?.("account", accountBadge());
|
|
196
263
|
};
|
|
197
264
|
|
|
265
|
+
// Drop Pi's PERSISTED account credential (the "privateer" entry in auth.json).
|
|
266
|
+
// Pi reuses this credential on the next launch and refreshes it only when it
|
|
267
|
+
// EXPIRES — never reactively on a 401 (see the LIFECYCLE HAZARD note in
|
|
268
|
+
// src/auth/privateer.ts). So whenever the machine login goes away — an explicit
|
|
269
|
+
// /signout, or a revocation/expiry we detect server-side — we MUST also drop this
|
|
270
|
+
// persisted copy, or the next run reuses a token that's already dead server-side
|
|
271
|
+
// and dead-ends on the first inference. Removing it makes the next /signin spawn a
|
|
272
|
+
// fresh session. Reached via the model registry (constructed with the auth
|
|
273
|
+
// storage; see session.ts). Best-effort: nothing persisted → nothing to do.
|
|
274
|
+
const dropPersistedAccount = (ctx: any): void => {
|
|
275
|
+
try {
|
|
276
|
+
ctx?.modelRegistry?.authStorage?.remove?.("privateer");
|
|
277
|
+
} catch {
|
|
278
|
+
/* no persisted credential / older Pi without this shape — nothing to do */
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
198
282
|
// /update — run the global npm install in a child process and report the outcome via
|
|
199
283
|
// notify (the TUI keeps running the OLD code; npm swaps the global bin's inode in
|
|
200
284
|
// place, so replacing it under us is safe and the new version loads on next launch).
|
|
@@ -266,6 +350,7 @@ export default function privateerBrand(pi: any): void {
|
|
|
266
350
|
if (!priv.hasCredentials()) return ctx?.ui?.notify?.("Not signed in.", "info");
|
|
267
351
|
const u = priv.currentUser();
|
|
268
352
|
await priv.logout();
|
|
353
|
+
dropPersistedAccount(ctx);
|
|
269
354
|
refresh(ctx);
|
|
270
355
|
ctx?.ui?.notify?.(`Signed out${u?.email ? ` (${u.email})` : ""}. Drop anchor for now.`, "info");
|
|
271
356
|
}
|
|
@@ -280,9 +365,26 @@ export default function privateerBrand(pi: any): void {
|
|
|
280
365
|
);
|
|
281
366
|
}
|
|
282
367
|
|
|
368
|
+
dbg("extension loaded, onSignedIn listener registering");
|
|
369
|
+
|
|
283
370
|
pi.on("session_start", (_e: any, ctx: any) => {
|
|
371
|
+
dbg("session_start");
|
|
284
372
|
ctxRef = ctx;
|
|
285
373
|
currentModelProvider = ctx?.model?.provider ?? currentModelProvider;
|
|
374
|
+
|
|
375
|
+
// Validate the machine login against the server at launch. The banner/badge
|
|
376
|
+
// otherwise reflect ONLY local credentials.json, so a terminal that was signed
|
|
377
|
+
// out from the app (or whose login expired) keeps showing "connected as …"
|
|
378
|
+
// indefinitely — nothing else spawns a session at startup (Pi reuses its
|
|
379
|
+
// persisted account credential and refreshes it only on expiry, not on a 401).
|
|
380
|
+
// warmSession spawns this terminal's child session from the parent refresh
|
|
381
|
+
// token; if that token was revoked/expired the server 401s, which clears the
|
|
382
|
+
// local credentials and fires onSessionExpired — flipping the banner to "not
|
|
383
|
+
// signed in" right here at launch instead of dead-ending on the first prompt.
|
|
384
|
+
// Fire-and-forget: warmSession swallows transient errors, and the
|
|
385
|
+
// onSessionExpired handler below owns the UI update.
|
|
386
|
+
void priv.warmSession();
|
|
387
|
+
|
|
286
388
|
if (!ctx?.hasUI) return; // headless (print/json): no banner or prompts
|
|
287
389
|
ctx?.ui?.setTitle?.("Privateer");
|
|
288
390
|
refresh(ctx);
|
|
@@ -301,9 +403,45 @@ export default function privateerBrand(pi: any): void {
|
|
|
301
403
|
}
|
|
302
404
|
});
|
|
303
405
|
|
|
406
|
+
// A machine login was newly established. This fires for BOTH sign-in paths — our
|
|
407
|
+
// /signin command AND Pi's /login → "Use a subscription" OAuth flow. doSignIn
|
|
408
|
+
// already refreshes itself, but a /login sign-in has no other hook back to us, so
|
|
409
|
+
// without this the header/badge would keep showing "not signed in" until relaunch.
|
|
410
|
+
priv.onSignedIn(() => {
|
|
411
|
+
dbg(`onSignedIn fired; ctxRef=${ctxRef ? "set" : "null"}`);
|
|
412
|
+
refresh(ctxRef);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// /init (in privateer-context) just created or changed a PRIVATEER.md — re-render the
|
|
416
|
+
// banner so its context line flips from the "/init" hint to "PRIVATEER.md loaded".
|
|
417
|
+
onContextChanged(() => refresh(ctxRef));
|
|
418
|
+
|
|
419
|
+
// The terminal is quitting (Ctrl+C, Ctrl+D, /quit, SIGTERM …). Pi awaits this
|
|
420
|
+
// handler inside runtimeHost.dispose() BEFORE process.exit, so it's our one
|
|
421
|
+
// reliable window to revoke the server-side sessions this run created — the
|
|
422
|
+
// account channel Pi drives AND any child session — so the terminal drops off the
|
|
423
|
+
// app's Linked Devices list immediately instead of lingering until the rows expire.
|
|
424
|
+
// Only on "quit": the other reasons (new/resume/fork/reload) keep this process
|
|
425
|
+
// alive and reuse the same account credential, so revoking would kill a live session.
|
|
426
|
+
// Best-effort and time-bounded (see revokeLocalSessions); exit must never hang.
|
|
427
|
+
pi.on("session_shutdown", async (e: any) => {
|
|
428
|
+
if (e?.reason && e.reason !== "quit") return;
|
|
429
|
+
await priv.revokeLocalSessions();
|
|
430
|
+
// Pair the revoke with dropping Pi's persisted account credential (the contract
|
|
431
|
+
// in src/auth/privateer.ts): revokeLocalSessions kills the account session
|
|
432
|
+
// server-side, so leaving the persisted copy behind would make the NEXT launch
|
|
433
|
+
// reuse a token that's already dead and dead-end on its first prompt (Pi doesn't
|
|
434
|
+
// refresh on a 401). Mirrors the daemon's shutdown (daemon/index.ts).
|
|
435
|
+
dropPersistedAccount(ctxRef);
|
|
436
|
+
});
|
|
437
|
+
|
|
304
438
|
// The machine login was invalidated server-side (TTL lapsed or revoked in the app):
|
|
305
439
|
// announce it and reflect it in the badge/header immediately.
|
|
306
440
|
priv.onSessionExpired(() => {
|
|
441
|
+
// clearCredentials() has already wiped the local machine login; also drop Pi's
|
|
442
|
+
// persisted account credential so the next prompt/launch doesn't reuse a token
|
|
443
|
+
// that's now dead server-side (see dropPersistedAccount).
|
|
444
|
+
dropPersistedAccount(ctxRef);
|
|
307
445
|
refresh(ctxRef);
|
|
308
446
|
ctxRef?.ui?.notify?.("Your Privateer session expired. Run /signin to sign back in.", "warning");
|
|
309
447
|
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// PRIVATEER.md context loading + the /init command.
|
|
2
|
+
//
|
|
3
|
+
// Pi natively loads AGENTS.md / CLAUDE.md but its candidate list is hardcoded upstream,
|
|
4
|
+
// so PRIVATEER.md would otherwise be ignored. This extension makes PRIVATEER.md a
|
|
5
|
+
// first-class context file without patching node_modules:
|
|
6
|
+
//
|
|
7
|
+
// 1. before_agent_start — discover PRIVATEER.md (global agent dir + cwd ancestors) and
|
|
8
|
+
// append its contents to the turn's system prompt, framed exactly like Pi frames
|
|
9
|
+
// AGENTS.md, so the model treats them identically.
|
|
10
|
+
// 2. /init — write a starter PRIVATEER.md into the current directory.
|
|
11
|
+
//
|
|
12
|
+
// The banner (privateer-brand) shows whether a PRIVATEER.md is loaded and, when none is,
|
|
13
|
+
// advertises /init. After /init we emit the shared context-changed signal so that line
|
|
14
|
+
// refreshes at once. See src/context.ts for the discovery/formatting details.
|
|
15
|
+
|
|
16
|
+
import { contextBlock, writeTemplate, emitContextChanged, CONTEXT_BLOCK_MARKER } from "../src/context.ts";
|
|
17
|
+
|
|
18
|
+
// Honor Pi's own "disable context files" switch, so --no-context-files / -nc silences
|
|
19
|
+
// PRIVATEER.md too (not just AGENTS.md/CLAUDE.md) — otherwise the flag would half-work.
|
|
20
|
+
const CONTEXT_FILES_DISABLED =
|
|
21
|
+
process.argv.includes("--no-context-files") || process.argv.includes("-nc");
|
|
22
|
+
|
|
23
|
+
export default function privateerContext(pi: any): void {
|
|
24
|
+
// Inject PRIVATEER.md into every turn's system prompt. The prompt is rebuilt per turn
|
|
25
|
+
// and chained across before_agent_start handlers, so appending here is idempotent for
|
|
26
|
+
// the turn; the marker guard makes it a no-op if an earlier handler already added it.
|
|
27
|
+
pi.on("before_agent_start", (event: any) => {
|
|
28
|
+
if (CONTEXT_FILES_DISABLED) return;
|
|
29
|
+
const cwd = event?.systemPromptOptions?.cwd ?? process.cwd();
|
|
30
|
+
const base: string = event?.systemPrompt ?? "";
|
|
31
|
+
if (base.includes(CONTEXT_BLOCK_MARKER)) return; // already injected this chain
|
|
32
|
+
const block = contextBlock(cwd);
|
|
33
|
+
if (!block) return; // no PRIVATEER.md anywhere — leave the prompt untouched
|
|
34
|
+
return { systemPrompt: base + block };
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// /init — scaffold a PRIVATEER.md in the working directory. Never clobbers an existing
|
|
38
|
+
// one; on success we signal the banner so its "PRIVATEER.md loaded" line updates now
|
|
39
|
+
// (the file is picked up automatically on the next turn — no reload needed).
|
|
40
|
+
pi.registerCommand?.("init", {
|
|
41
|
+
description: "Create a starter PRIVATEER.md project-context file in this directory",
|
|
42
|
+
handler: (_args: string, ctx: any) => {
|
|
43
|
+
try {
|
|
44
|
+
const { path, created } = writeTemplate(process.cwd());
|
|
45
|
+
if (!created) {
|
|
46
|
+
ctx?.ui?.notify?.(`PRIVATEER.md already exists at ${path} — left untouched.`, "info");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
emitContextChanged();
|
|
50
|
+
ctx?.ui?.notify?.(
|
|
51
|
+
`Created ${path}. Edit it with your project's context — it loads automatically each turn.`,
|
|
52
|
+
"info",
|
|
53
|
+
);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
ctx?.ui?.notify?.(`Could not create PRIVATEER.md: ${(e as Error).message || e}`, "error");
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -30,6 +30,45 @@ const allowedOutsideRoots: string[] = [];
|
|
|
30
30
|
let piRef: any = null;
|
|
31
31
|
let relay: any = null;
|
|
32
32
|
|
|
33
|
+
// Persistent footer indicator for remote access. When the relay is up, the footer
|
|
34
|
+
// shows a GREEN "⟿ remote access" line so it's always obvious this terminal can be
|
|
35
|
+
// driven from the phone — with a reminder that `/remote-access off` stops it. We
|
|
36
|
+
// keep a UI handle (captured from session_start / the command ctx) so the relay's
|
|
37
|
+
// own connect/disconnect callbacks can refresh the indicator, not just the command.
|
|
38
|
+
const GREEN = "\x1b[32m", YELLOW = "\x1b[33m", DIM = "\x1b[2m", RESET = "\x1b[0m";
|
|
39
|
+
const REMOTE_STATUS_KEY = "privateer:remote-access";
|
|
40
|
+
let uiRef: any = null;
|
|
41
|
+
// "off" → no indicator; "connecting" → relay starting or reconnecting (yellow);
|
|
42
|
+
// "connected" → socket open, controller reachable (green).
|
|
43
|
+
let remoteState: "off" | "connecting" | "connected" = "off";
|
|
44
|
+
|
|
45
|
+
function refreshRemoteStatus(): void {
|
|
46
|
+
const ui = uiRef;
|
|
47
|
+
if (!ui?.setStatus) return;
|
|
48
|
+
if (remoteState === "off") {
|
|
49
|
+
ui.setStatus(REMOTE_STATUS_KEY, undefined);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const text =
|
|
53
|
+
remoteState === "connected"
|
|
54
|
+
? `${GREEN}⟿ remote access${RESET} ${DIM}· /remote-access off to stop${RESET}`
|
|
55
|
+
: `${YELLOW}⟿ remote access · connecting…${RESET} ${DIM}· /remote-access off to stop${RESET}`;
|
|
56
|
+
ui.setStatus(REMOTE_STATUS_KEY, text);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function setRemoteState(s: typeof remoteState): void {
|
|
60
|
+
remoteState = s;
|
|
61
|
+
refreshRemoteStatus();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Tear down the relay and clear the indicator. Used by `/remote-access off` AND by
|
|
65
|
+
// the app's own "End remote access" action (onTerminate), so both paths converge.
|
|
66
|
+
function disableRemote(): void {
|
|
67
|
+
relay?.stop();
|
|
68
|
+
relay = null;
|
|
69
|
+
setRemoteState("off");
|
|
70
|
+
}
|
|
71
|
+
|
|
33
72
|
// Inbound app→CLI files land here (keyed by "#n"); save_attachment persists them.
|
|
34
73
|
const attachments = new AttachmentStore();
|
|
35
74
|
let sinceLastPrompt: StoredAttachment[] = [];
|
|
@@ -47,9 +86,22 @@ const bridge = new RemoteBridge({
|
|
|
47
86
|
piRef?.sendUserMessage?.(text + note); // drive a turn in Pi's TUI
|
|
48
87
|
},
|
|
49
88
|
onInterrupt: () => {}, // Pi owns interrupt; best-effort no-op
|
|
50
|
-
|
|
89
|
+
// The app asked to end remote access from its side — stop the relay locally too so
|
|
90
|
+
// the terminal doesn't keep reconnecting, and clear the green indicator.
|
|
91
|
+
onTerminate: () => disableRemote(),
|
|
92
|
+
onControllerAttached: () => {
|
|
93
|
+
// A controller reached us → the socket is up and driving: go green.
|
|
94
|
+
setRemoteState("connected");
|
|
95
|
+
relay?.sendSnapshot([{ kind: "notice", text: "Privateer terminal connected." }]);
|
|
96
|
+
},
|
|
51
97
|
onAttachment: (file) => sinceLastPrompt.push(attachments.register(file)),
|
|
52
|
-
|
|
98
|
+
// Drive the indicator from the relay's own status stream: "connected" → green;
|
|
99
|
+
// its reconnect/retry notices → yellow "connecting…". Ignored once we're off.
|
|
100
|
+
onStatus: (text) => {
|
|
101
|
+
if (!relay) return;
|
|
102
|
+
if (/disconnect|reconnect|retry|couldn't|could not/i.test(text)) setRemoteState("connecting");
|
|
103
|
+
else if (/connected/i.test(text)) setRemoteState("connected");
|
|
104
|
+
},
|
|
53
105
|
});
|
|
54
106
|
|
|
55
107
|
const gate = makePermissionGate({
|
|
@@ -88,6 +140,11 @@ export default function privateerControl(pi: any): void {
|
|
|
88
140
|
// and the user hasn't pinned a mode via PRIVATEER_MODE.
|
|
89
141
|
const HEADLESS = new Set(["json", "print", "rpc"]);
|
|
90
142
|
pi.on("session_start", (_e: any, ctx: any) => {
|
|
143
|
+
// Capture the UI handle so the relay's connect/disconnect callbacks can refresh
|
|
144
|
+
// the footer indicator (they fire outside any command's ctx). Re-render in case
|
|
145
|
+
// remote access was already on when the session (re)started.
|
|
146
|
+
if (ctx?.ui) uiRef = ctx.ui;
|
|
147
|
+
refreshRemoteStatus();
|
|
91
148
|
if (ctx?.mode && HEADLESS.has(ctx.mode) && (process.env.PRIVATEER_MODE ?? "") === "") {
|
|
92
149
|
mode = "bypass";
|
|
93
150
|
}
|
|
@@ -123,16 +180,17 @@ export default function privateerControl(pi: any): void {
|
|
|
123
180
|
pi.registerCommand?.("remote-access", {
|
|
124
181
|
description: "Drive this terminal from the Privateer app: /remote-access on | off",
|
|
125
182
|
handler: async (args: string, ctx: any) => {
|
|
183
|
+
if (ctx?.ui) uiRef = ctx.ui; // keep the handle fresh for relay-driven refreshes
|
|
126
184
|
const off = String(args ?? "").trim().toLowerCase() === "off";
|
|
127
185
|
if (off) {
|
|
128
|
-
|
|
129
|
-
relay = null;
|
|
186
|
+
disableRemote();
|
|
130
187
|
return ctx.ui?.notify?.("remote access off", "info");
|
|
131
188
|
}
|
|
132
189
|
if (relay) return ctx.ui?.notify?.("remote access already on", "info");
|
|
133
190
|
if (!priv.hasCredentials()) return ctx.ui?.notify?.("Not signed in to Privateer.", "warning");
|
|
134
191
|
relay = new RelayClient(bridge.callbacks, { label: "privateer-cli" });
|
|
135
192
|
bridge.attachRelay(relay);
|
|
193
|
+
setRemoteState("connecting"); // yellow until the relay reports connected
|
|
136
194
|
await relay.start();
|
|
137
195
|
ctx.ui?.notify?.("Remote access on — approve this terminal in the Privateer app, then drive it from there.", "info");
|
|
138
196
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The privacy-posture badge in Pi's status bar (Phase 6 polish). On model select
|
|
2
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
|
|
4
|
-
// for an attested enclave, a distinct label for a mere ZDR claim.
|
|
3
|
+
// footer via ctx.ui.setStatus — so the moat is *visible*: a green shield "Trusted
|
|
4
|
+
// Execution" for an attested enclave, a distinct label for a mere ZDR claim.
|
|
5
5
|
//
|
|
6
6
|
// Handles both surfaces: the account channel (privateer/*, via server-proxy
|
|
7
7
|
// attestation) which pi-privacy doesn't know, and everything else via pi-privacy.
|
|
@@ -11,6 +11,20 @@ import { accountPosture } from "../src/providers/account.ts";
|
|
|
11
11
|
|
|
12
12
|
const DOT: Record<string, string> = { green: "🟢", yellow: "🟡", red: "🔴", neutral: "⚪" };
|
|
13
13
|
|
|
14
|
+
// ANSI so the shield "references the previous color": the TEE tiers used to show a
|
|
15
|
+
// green/yellow traffic-light dot — now they show a shield tinted the same color
|
|
16
|
+
// (green = verified, yellow = unconfirmed). The status bar renders these escapes.
|
|
17
|
+
const GREEN = "\x1b[32m", YELLOW = "\x1b[33m", RESET = "\x1b[0m";
|
|
18
|
+
|
|
19
|
+
// The TEE tiers render as a colored shield + "Trusted Execution" (pi-privacy labels
|
|
20
|
+
// these "Verified TEE" / "TEE (unconfirmed)"; we rename to Trusted Execution for the
|
|
21
|
+
// privateer badge and swap the dot for a shield). Everything else keeps the dot.
|
|
22
|
+
function badgeLabel(tier: PrivacyTier): string | null {
|
|
23
|
+
if (tier === "tee-verified") return `${GREEN}⛉ Trusted Execution${RESET}`;
|
|
24
|
+
if (tier === "tee-unverified") return `${YELLOW}⛉ Trusted Execution (unconfirmed)${RESET}`;
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
async function badgeFor(provider: string, modelId: string): Promise<string> {
|
|
15
29
|
const res =
|
|
16
30
|
provider === "privateer"
|
|
@@ -18,6 +32,8 @@ async function badgeFor(provider: string, modelId: string): Promise<string> {
|
|
|
18
32
|
: await verifyModelPosture(provider, modelId, {
|
|
19
33
|
apiKey: provider === "nearai" ? process.env.NEARAI_API_KEY ?? process.env.NEAR_AI_API_KEY : undefined,
|
|
20
34
|
});
|
|
35
|
+
const shield = badgeLabel(res.tier as PrivacyTier);
|
|
36
|
+
if (shield) return shield;
|
|
21
37
|
const info = TIERS[res.tier as PrivacyTier];
|
|
22
38
|
return `${DOT[info.posture] ?? "⚪"} ${info.label}`;
|
|
23
39
|
}
|
|
@@ -4,12 +4,61 @@
|
|
|
4
4
|
// (actually confidential-compute TEE) is treated as verified-private (no PII
|
|
5
5
|
// over-warning), and a zdr account model as zdr-policy. Replaces loading pi-privacy's
|
|
6
6
|
// default entry directly.
|
|
7
|
+
//
|
|
8
|
+
// It also WIDENS the tinfoil provider's model list. pi-privacy registers `tinfoil` with
|
|
9
|
+
// a single seed model, so any other Tinfoil model — notably our default `tinfoil/glm-5-2`
|
|
10
|
+
// — resolves as a "custom model id" with a startup warning and never shows in the picker.
|
|
11
|
+
// We re-register tinfoil with its current chat catalog AFTER pi-privacy runs (a second
|
|
12
|
+
// registerProvider call replaces the provider's model list; pi-privacy registers
|
|
13
|
+
// synchronously, so ours lands second and wins). This is purely a display/resolution
|
|
14
|
+
// list — posture and attestation are dispatcher-bound and unaffected by the model set.
|
|
7
15
|
import { makePiPrivacyExtension } from "pi-privacy";
|
|
8
16
|
import { accountPosture } from "../src/providers/account.ts";
|
|
9
17
|
|
|
10
|
-
|
|
18
|
+
// Tinfoil's live chat models (inference.tinfoil.sh/v1/models), glm-5-2 first — the
|
|
19
|
+
// launcher's default. Non-chat endpoints (embeddings, tts, whisper, websearch,
|
|
20
|
+
// doc-upload) are intentionally omitted. Refresh from the live catalog if Tinfoil adds
|
|
21
|
+
// models; this static list just needs to cover what we default to and commonly pick.
|
|
22
|
+
const TINFOIL_MODELS = [
|
|
23
|
+
"glm-5-2",
|
|
24
|
+
"kimi-k2-6",
|
|
25
|
+
"deepseek-v4-pro",
|
|
26
|
+
"gpt-oss-120b",
|
|
27
|
+
"gpt-oss-safeguard-120b",
|
|
28
|
+
"gemma4-31b",
|
|
29
|
+
"llama3-3-70b",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function tinfoilModel(id: string) {
|
|
33
|
+
return {
|
|
34
|
+
id,
|
|
35
|
+
name: id,
|
|
36
|
+
reasoning: false,
|
|
37
|
+
input: ["text"] as ("text" | "image")[],
|
|
38
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
39
|
+
contextWindow: 128000,
|
|
40
|
+
maxTokens: 4096,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const privacy = makePiPrivacyExtension({
|
|
11
45
|
resolveTier: async (provider, modelId) => {
|
|
12
46
|
if (provider !== "privateer") return undefined; // pi-privacy handles its own providers
|
|
13
47
|
return (await accountPosture(modelId)).tier;
|
|
14
48
|
},
|
|
15
49
|
});
|
|
50
|
+
|
|
51
|
+
export default function privateerPrivacy(pi: any): void {
|
|
52
|
+
privacy(pi);
|
|
53
|
+
// Re-register tinfoil with the fuller catalog. Mirrors pi-privacy's provider config
|
|
54
|
+
// (baseUrl/api + ${TINFOIL_API_KEY} template with authHeader); only the model list is
|
|
55
|
+
// widened so `tinfoil/glm-5-2` and friends resolve without the "custom model id" warning.
|
|
56
|
+
pi.registerProvider?.("tinfoil", {
|
|
57
|
+
name: "Tinfoil (private TEE inference)",
|
|
58
|
+
baseUrl: "https://inference.tinfoil.sh/v1",
|
|
59
|
+
api: "openai-completions",
|
|
60
|
+
apiKey: "${TINFOIL_API_KEY}",
|
|
61
|
+
authHeader: true,
|
|
62
|
+
models: TINFOIL_MODELS.map(tinfoilModel),
|
|
63
|
+
});
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
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",
|
package/src/auth/privateer.ts
CHANGED
|
@@ -97,6 +97,26 @@ let _child: ChildSession | null = null;
|
|
|
97
97
|
let _spawnInFlight: Promise<ChildSession> | null = null;
|
|
98
98
|
let _refreshInFlight: Promise<ChildSession> | null = null;
|
|
99
99
|
|
|
100
|
+
// The most recent ACCOUNT-provider session (spawnAccountCredentials /
|
|
101
|
+
// refreshAccountCredentials). Pi owns this credential's lifecycle — it drives the
|
|
102
|
+
// account inference channel in the TUI — so it's a distinct server-side session
|
|
103
|
+
// (device row) from _child. We record only its latest access token here so exit
|
|
104
|
+
// cleanup / an explicit sign-out (revokeAccountSession) can kill it. Rotations
|
|
105
|
+
// overwrite it; the previous token is already dead server-side, so tracking only
|
|
106
|
+
// the latest is right.
|
|
107
|
+
//
|
|
108
|
+
// LIFECYCLE HAZARD: Pi PERSISTS this session to auth.json (with a ~24h `expires`) and
|
|
109
|
+
// reuses it on the next launch, refreshing only when `Date.now() >= expires` — it does
|
|
110
|
+
// NOT refresh reactively on a 401. So revoking it at exit while leaving the persisted
|
|
111
|
+
// copy in place would let the next run send a token that still looks valid but is dead
|
|
112
|
+
// server-side → inference fails with a dead-end `401 {code: SESSION_REVOKED}`.
|
|
113
|
+
// The fix is to revoke it AND drop the persisted credential together: the caller must
|
|
114
|
+
// remove the "privateer" entry from Pi's authStorage (authStorage.remove("privateer"))
|
|
115
|
+
// right after revokeLocalSessions() so the next launch spawns a fresh session instead
|
|
116
|
+
// of reusing the revoked one. Doing both is safe; doing only one is not. See
|
|
117
|
+
// revokeLocalSessions and its callers (cli/chat.ts, daemon/index.ts).
|
|
118
|
+
let _account: { accessToken: string } | null = null;
|
|
119
|
+
|
|
100
120
|
export function loadCredentials(): Credentials | null {
|
|
101
121
|
if (_cache !== undefined) return _cache;
|
|
102
122
|
const path = credentialsPath();
|
|
@@ -127,6 +147,7 @@ export function clearCredentials(): void {
|
|
|
127
147
|
}
|
|
128
148
|
_cache = null;
|
|
129
149
|
_child = null;
|
|
150
|
+
_account = null;
|
|
130
151
|
}
|
|
131
152
|
|
|
132
153
|
export function hasCredentials(): boolean {
|
|
@@ -171,6 +192,39 @@ function notifySessionExpired(): void {
|
|
|
171
192
|
}
|
|
172
193
|
}
|
|
173
194
|
|
|
195
|
+
// ── Sign-in notification ─────────────────────────────────────────────────────
|
|
196
|
+
// Fired when a Privateer login completes on this terminal — the cue for the UI to
|
|
197
|
+
// re-render its header/badge to the signed-in state. It reaches every sign-in entry
|
|
198
|
+
// point:
|
|
199
|
+
// - our dedicated /signin command and a FRESH /login → "Use a subscription" OAuth
|
|
200
|
+
// login both run the device-code flow, which fires this from pollForToken once
|
|
201
|
+
// credentials are written; and
|
|
202
|
+
// - an ALREADY-LINKED machine selecting the subscription runs no device code (it
|
|
203
|
+
// just spawns an account session), so privateerOAuthProvider.login() fires this
|
|
204
|
+
// itself — otherwise that path had no hook back to the header and it kept showing
|
|
205
|
+
// the stale "not signed in" banner until the next launch.
|
|
206
|
+
// A listener here refreshes the UI regardless of which path the user took.
|
|
207
|
+
|
|
208
|
+
type SignedInListener = () => void;
|
|
209
|
+
const _signedInListeners = new Set<SignedInListener>();
|
|
210
|
+
|
|
211
|
+
export function onSignedIn(listener: SignedInListener): () => void {
|
|
212
|
+
_signedInListeners.add(listener);
|
|
213
|
+
return () => _signedInListeners.delete(listener);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Emit the sign-in signal. Exported so the account OAuth provider can announce a
|
|
217
|
+
// completed subscription login on the already-linked path (see the note above).
|
|
218
|
+
export function notifySignedIn(): void {
|
|
219
|
+
for (const listener of _signedInListeners) {
|
|
220
|
+
try {
|
|
221
|
+
listener();
|
|
222
|
+
} catch {
|
|
223
|
+
/* a failing listener must not break the auth path */
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
174
228
|
// ── Device authorization flow ────────────────────────────────────────────────
|
|
175
229
|
|
|
176
230
|
export interface DeviceCode {
|
|
@@ -229,6 +283,7 @@ export async function pollForToken(
|
|
|
229
283
|
const data = (await res.json()) as Omit<Credentials, "serverBaseUrl">;
|
|
230
284
|
const creds: Credentials = { ...data, serverBaseUrl: base };
|
|
231
285
|
saveCredentials(creds);
|
|
286
|
+
notifySignedIn();
|
|
232
287
|
return creds;
|
|
233
288
|
}
|
|
234
289
|
|
|
@@ -409,23 +464,17 @@ export async function apiRequest(path: string, init: RequestInit = {}): Promise<
|
|
|
409
464
|
}
|
|
410
465
|
|
|
411
466
|
/**
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
* it. If no child was ever spawned (e.g. BYO-key run), there's nothing to do.
|
|
418
|
-
* Bounded by a short timeout — exit must never hang on a slow network — and all
|
|
419
|
-
* failures are swallowed; the server's TTL remains the fallback.
|
|
467
|
+
* DELETE the server-side session identified by `accessToken` (RFC-style bearer
|
|
468
|
+
* possession proof). Deliberately a raw fetch, NOT authedFetch — authedFetch would
|
|
469
|
+
* spawn/refresh a brand-new session just to kill this one. Bounded by a short
|
|
470
|
+
* timeout so exit never hangs on a slow network, and all failures are swallowed;
|
|
471
|
+
* the server's TTL is the fallback.
|
|
420
472
|
*/
|
|
421
|
-
|
|
422
|
-
const child = _child;
|
|
423
|
-
if (!child) return;
|
|
424
|
-
_child = null; // never reuse a session we've asked the server to revoke
|
|
473
|
+
async function deleteSession(accessToken: string, timeoutMs: number): Promise<void> {
|
|
425
474
|
try {
|
|
426
475
|
await fetch(`${serverBaseUrl()}/auth/session/current`, {
|
|
427
476
|
method: "DELETE",
|
|
428
|
-
headers: { Authorization: `Bearer ${
|
|
477
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
429
478
|
signal: AbortSignal.timeout(timeoutMs),
|
|
430
479
|
});
|
|
431
480
|
} catch {
|
|
@@ -433,6 +482,48 @@ export async function revokeChildSession(timeoutMs = 1500): Promise<void> {
|
|
|
433
482
|
}
|
|
434
483
|
}
|
|
435
484
|
|
|
485
|
+
/**
|
|
486
|
+
* Best-effort revoke of THIS terminal's child session (from authedFetch/apiRequest).
|
|
487
|
+
* If no child was ever spawned (e.g. BYO-key run), there's nothing to do.
|
|
488
|
+
*/
|
|
489
|
+
export async function revokeChildSession(timeoutMs = 1500): Promise<void> {
|
|
490
|
+
const child = _child;
|
|
491
|
+
if (!child) return;
|
|
492
|
+
_child = null; // never reuse a session we've asked the server to revoke
|
|
493
|
+
await deleteSession(child.accessToken, timeoutMs);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Best-effort revoke of the account-provider session (the one Pi drives for account
|
|
498
|
+
* inference). Called both on EXPLICIT sign-out AND as part of exit cleanup (via
|
|
499
|
+
* revokeLocalSessions) — safe in the exit path ONLY because the caller also drops Pi's
|
|
500
|
+
* persisted copy (authStorage.remove("privateer")) so the next launch spawns fresh
|
|
501
|
+
* rather than reusing this now-dead token. See the _account note and revokeLocalSessions.
|
|
502
|
+
*/
|
|
503
|
+
export async function revokeAccountSession(timeoutMs = 1500): Promise<void> {
|
|
504
|
+
const account = _account;
|
|
505
|
+
if (!account) return;
|
|
506
|
+
_account = null;
|
|
507
|
+
await deleteSession(account.accessToken, timeoutMs);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Revoke ALL server-side sessions this terminal created — the in-memory child session
|
|
512
|
+
* (authedFetch/apiRequest) AND the account-provider inference session — so the terminal
|
|
513
|
+
* drops off the app's Linked Devices list the moment it exits (Ctrl+C, /quit, SIGTERM …)
|
|
514
|
+
* instead of lingering until its token TTL (~24h). Best-effort, time-bounded, and the
|
|
515
|
+
* two revokes run in parallel so a slow network can't double the exit delay.
|
|
516
|
+
*
|
|
517
|
+
* IMPORTANT: the account session is persisted by Pi (auth.json) and reused on the next
|
|
518
|
+
* launch without a reactive-on-401 refresh, so the caller MUST also drop the persisted
|
|
519
|
+
* copy right after this resolves — `authStorage.remove("privateer")` — or the next run
|
|
520
|
+
* will reuse the token we just revoked and dead-end on a 401 (see the _account note).
|
|
521
|
+
* Callers: cli/chat.ts cleanup() and daemon/index.ts shutdown().
|
|
522
|
+
*/
|
|
523
|
+
export async function revokeLocalSessions(timeoutMs = 1500): Promise<void> {
|
|
524
|
+
await Promise.all([revokeChildSession(timeoutMs), revokeAccountSession(timeoutMs)]);
|
|
525
|
+
}
|
|
526
|
+
|
|
436
527
|
// ── Logout ───────────────────────────────────────────────────────────────────
|
|
437
528
|
|
|
438
529
|
/**
|
|
@@ -493,6 +584,7 @@ export async function spawnAccountCredentials(): Promise<AccountCredential> {
|
|
|
493
584
|
throw new Error("Your Privateer session expired. Run /login to sign in again.");
|
|
494
585
|
}
|
|
495
586
|
const { accessToken, refreshToken } = (await res.json()) as { accessToken: string; refreshToken: string };
|
|
587
|
+
_account = { accessToken }; // track for explicit sign-out revoke (revokeAccountSession)
|
|
496
588
|
return { access: accessToken, refresh: refreshToken, expires: jwtExpMs(accessToken) };
|
|
497
589
|
}
|
|
498
590
|
|
|
@@ -502,6 +594,7 @@ export async function refreshAccountCredentials(refresh: string): Promise<Accoun
|
|
|
502
594
|
const res = await postJson(serverBaseUrl(), "/auth/refresh", { refreshToken: refresh });
|
|
503
595
|
if (!res.ok) throw new Error(`account refresh failed (${res.status})`);
|
|
504
596
|
const { accessToken, refreshToken } = (await res.json()) as { accessToken: string; refreshToken: string };
|
|
597
|
+
_account = { accessToken }; // the rotated session is the one an explicit sign-out revokes
|
|
505
598
|
return { access: accessToken, refresh: refreshToken, expires: jwtExpMs(accessToken) };
|
|
506
599
|
}
|
|
507
600
|
|
package/src/cli/chat.ts
CHANGED
|
@@ -27,6 +27,7 @@ async function main() {
|
|
|
27
27
|
const { RelayClient } = await import("../remote/relayClient.ts");
|
|
28
28
|
const priv = await import("../auth/privateer.ts");
|
|
29
29
|
const { makeAccountProvider, accountPosture } = await import("../providers/account.ts");
|
|
30
|
+
const { agentVersion } = await import("../config/version.ts");
|
|
30
31
|
|
|
31
32
|
const spec = process.env.PRIVATEER_MODEL ?? "openrouter/openai/gpt-4o-mini";
|
|
32
33
|
const slash = spec.indexOf("/");
|
|
@@ -62,7 +63,13 @@ async function main() {
|
|
|
62
63
|
const bridge = new RemoteBridge({
|
|
63
64
|
onPrompt: (text) => void runTurn(text, true),
|
|
64
65
|
onInterrupt: () => void session?.abort?.(),
|
|
65
|
-
|
|
66
|
+
// On (re)attach, resync the transcript AND push live context (model +
|
|
67
|
+
// version) so the app's session banner shows what this terminal is really
|
|
68
|
+
// running. NON-PII: no cwd — see RelayClient.sendContext.
|
|
69
|
+
onControllerAttached: () => {
|
|
70
|
+
relay?.sendSnapshot([]);
|
|
71
|
+
relay?.sendContext({ model: spec, version: agentVersion() });
|
|
72
|
+
},
|
|
66
73
|
onStatus: (t) => console.log(`\n${DIM}⟿ ${t}${RESET}`),
|
|
67
74
|
});
|
|
68
75
|
|
|
@@ -113,6 +120,26 @@ async function main() {
|
|
|
113
120
|
});
|
|
114
121
|
for (const d of services.diagnostics) if (d.type === "error") console.log(`${RED}! ${d.message}${RESET}`);
|
|
115
122
|
|
|
123
|
+
// Exit cleanup: revoke the server-side sessions THIS run created (the child API
|
|
124
|
+
// session AND the account inference session) so the terminal drops off the app's
|
|
125
|
+
// Linked Devices list the instant it closes — instead of lingering ~24h until its
|
|
126
|
+
// token TTL. We also drop Pi's persisted account credential (auth.json) so the next
|
|
127
|
+
// launch spawns a fresh session rather than reusing the one we just revoked (which
|
|
128
|
+
// Pi wouldn't reactively refresh on the resulting 401). Idempotent + time-bounded so
|
|
129
|
+
// a Ctrl+C during a slow network never hangs the exit. Registered BEFORE the account
|
|
130
|
+
// spawn below so an early Ctrl+C still tears down whatever was created.
|
|
131
|
+
let cleanedUp = false;
|
|
132
|
+
async function cleanup(): Promise<void> {
|
|
133
|
+
if (cleanedUp) return;
|
|
134
|
+
cleanedUp = true;
|
|
135
|
+
try { relay?.stop(); } catch { /* already stopped */ }
|
|
136
|
+
try { await priv.revokeLocalSessions(); } catch { /* best effort — server TTL is the fallback */ }
|
|
137
|
+
try { (services.authStorage as any).remove?.("privateer"); } catch { /* nothing persisted */ }
|
|
138
|
+
}
|
|
139
|
+
const onSignal = (): void => { void cleanup().finally(() => process.exit(0)); };
|
|
140
|
+
process.once("SIGINT", onSignal);
|
|
141
|
+
process.once("SIGTERM", onSignal);
|
|
142
|
+
|
|
116
143
|
// Account channel: seed the OAuth credential (a fresh child session) so getApiKey
|
|
117
144
|
// resolves it; Pi then manages refresh on expiry via the registered oauth provider.
|
|
118
145
|
if (provider === "privateer") {
|
|
@@ -231,7 +258,7 @@ async function main() {
|
|
|
231
258
|
if (!line) continue;
|
|
232
259
|
await runTurn(line, false);
|
|
233
260
|
}
|
|
234
|
-
|
|
261
|
+
await cleanup();
|
|
235
262
|
rl.close();
|
|
236
263
|
console.log(`${DIM}bye.${RESET}`);
|
|
237
264
|
process.exit(0);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
|
|
3
|
+
// The privateer-agent package version, read once from package.json. Used for the
|
|
4
|
+
// relay `context` frame so the app's session banner can show the real agent
|
|
5
|
+
// version. Returns "" if unreadable (never throws) — the app just omits the row.
|
|
6
|
+
let cached: string | null = null;
|
|
7
|
+
export function agentVersion(): string {
|
|
8
|
+
if (cached === null) {
|
|
9
|
+
try {
|
|
10
|
+
cached = String(createRequire(import.meta.url)("../../package.json").version ?? "");
|
|
11
|
+
} catch {
|
|
12
|
+
cached = "";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return cached;
|
|
16
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// PRIVATEER.md — Privateer's own project-context file, loaded like AGENTS.md / CLAUDE.md.
|
|
2
|
+
//
|
|
3
|
+
// Pi's built-in context loader only recognizes AGENTS.md and CLAUDE.md (the candidate
|
|
4
|
+
// list is hardcoded in the upstream resource-loader and isn't extensible via a hook).
|
|
5
|
+
// Rather than patch node_modules, we discover PRIVATEER.md ourselves and inject its
|
|
6
|
+
// contents into the system prompt from the privateer-context extension — using the
|
|
7
|
+
// exact <project_context>/<project_instructions> framing Pi uses for AGENTS.md, so the
|
|
8
|
+
// model treats a PRIVATEER.md indistinguishably from a native context file.
|
|
9
|
+
//
|
|
10
|
+
// Discovery mirrors Pi's loadProjectContextFiles: the global agent dir first, then every
|
|
11
|
+
// ancestor directory from the filesystem root down to cwd (nearest-wins ordering, deeper
|
|
12
|
+
// files last so they can refine broader ones). All matches are concatenated.
|
|
13
|
+
//
|
|
14
|
+
// This module is pure (no Pi imports) so both the injection extension and the brand
|
|
15
|
+
// banner can share it. The onContextChanged / emitContextChanged pair lets /init poke the
|
|
16
|
+
// banner to re-render its "PRIVATEER.md loaded" line without either extension reaching
|
|
17
|
+
// into the other — the same listener idiom as priv.onSignedIn in the auth module.
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join, resolve } from "node:path";
|
|
22
|
+
|
|
23
|
+
export const PRIVATEER_MD = "PRIVATEER.md";
|
|
24
|
+
|
|
25
|
+
// Case variants we accept on disk (matches Pi's AGENTS.md / AGENTS.MD tolerance).
|
|
26
|
+
const CANDIDATES = ["PRIVATEER.md", "PRIVATEER.MD"];
|
|
27
|
+
|
|
28
|
+
export interface ContextFile {
|
|
29
|
+
path: string;
|
|
30
|
+
content: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The global agent dir the launcher points Pi at (PRIVATEER_HOME/agent). We read the
|
|
34
|
+
// same PI_CODING_AGENT_DIR env the launcher exports so a global ~/.privateer/agent/
|
|
35
|
+
// PRIVATEER.md is honored just like a global AGENTS.md; fall back for `npm start`/dev
|
|
36
|
+
// runs that don't go through bin/privateer-tui.
|
|
37
|
+
function globalAgentDir(): string {
|
|
38
|
+
const fromEnv = process.env.PI_CODING_AGENT_DIR;
|
|
39
|
+
if (fromEnv) return resolve(fromEnv);
|
|
40
|
+
const home = process.env.PRIVATEER_HOME || join(homedir(), ".privateer");
|
|
41
|
+
return join(home, "agent");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readCandidate(dir: string): ContextFile | null {
|
|
45
|
+
for (const name of CANDIDATES) {
|
|
46
|
+
const path = join(dir, name);
|
|
47
|
+
if (existsSync(path)) {
|
|
48
|
+
try {
|
|
49
|
+
return { path, content: readFileSync(path, "utf-8") };
|
|
50
|
+
} catch {
|
|
51
|
+
// unreadable (perms, races) — skip silently; the model just won't see it.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// All PRIVATEER.md files that apply to `cwd`, in prompt order: global agent dir first,
|
|
59
|
+
// then root→cwd so the nearest (deepest) file lands last. De-duplicated by absolute path
|
|
60
|
+
// (the global dir can coincide with an ancestor).
|
|
61
|
+
export function discoverContextFiles(cwd: string = process.cwd()): ContextFile[] {
|
|
62
|
+
const files: ContextFile[] = [];
|
|
63
|
+
const seen = new Set<string>();
|
|
64
|
+
const push = (f: ContextFile | null) => {
|
|
65
|
+
if (f && !seen.has(f.path)) {
|
|
66
|
+
files.push(f);
|
|
67
|
+
seen.add(f.path);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
push(readCandidate(globalAgentDir()));
|
|
72
|
+
|
|
73
|
+
// Walk cwd → root collecting matches, then reverse so root comes first (matching Pi's
|
|
74
|
+
// ancestorContextFiles.unshift ordering).
|
|
75
|
+
const ancestors: ContextFile[] = [];
|
|
76
|
+
let dir = resolve(cwd);
|
|
77
|
+
const root = resolve("/");
|
|
78
|
+
while (true) {
|
|
79
|
+
const f = readCandidate(dir);
|
|
80
|
+
if (f && !seen.has(f.path)) {
|
|
81
|
+
ancestors.unshift(f);
|
|
82
|
+
seen.add(f.path);
|
|
83
|
+
}
|
|
84
|
+
if (dir === root) break;
|
|
85
|
+
const parent = resolve(dir, "..");
|
|
86
|
+
if (parent === dir) break;
|
|
87
|
+
dir = parent;
|
|
88
|
+
}
|
|
89
|
+
files.push(...ancestors);
|
|
90
|
+
return files;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// A unique sentinel opening the injected block, so before_agent_start can no-op if the
|
|
94
|
+
// block is already present in the chained system prompt (defensive against re-entrancy).
|
|
95
|
+
export const CONTEXT_BLOCK_MARKER = "<!-- privateer:PRIVATEER.md -->";
|
|
96
|
+
|
|
97
|
+
// Format the discovered files into a system-prompt fragment using the same framing Pi
|
|
98
|
+
// applies to AGENTS.md (see core/system-prompt.js), so the model can't tell the two
|
|
99
|
+
// apart. Returns "" when there's nothing to inject.
|
|
100
|
+
export function contextBlock(cwd: string = process.cwd()): string {
|
|
101
|
+
const files = discoverContextFiles(cwd);
|
|
102
|
+
if (files.length === 0) return "";
|
|
103
|
+
let out = `\n\n${CONTEXT_BLOCK_MARKER}\n<project_context>\n\nProject-specific instructions and guidelines:\n\n`;
|
|
104
|
+
for (const { path, content } of files) {
|
|
105
|
+
out += `<project_instructions path="${path}">\n${content}\n</project_instructions>\n\n`;
|
|
106
|
+
}
|
|
107
|
+
out += "</project_context>\n";
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The starter template `/init` writes. Kept deliberately short and self-explaining — the
|
|
112
|
+
// first line tells a reader (and the model) exactly what the file is and how it's used.
|
|
113
|
+
export const PRIVATEER_TEMPLATE = `# PRIVATEER.md
|
|
114
|
+
|
|
115
|
+
Project context for the Privateer agent. Privateer loads this file automatically at
|
|
116
|
+
startup (the same way it loads AGENTS.md / CLAUDE.md) and prepends it to the model's
|
|
117
|
+
system prompt — so put anything the agent should always know about THIS project here.
|
|
118
|
+
|
|
119
|
+
## Project
|
|
120
|
+
|
|
121
|
+
<One or two lines: what this project is and what it does.>
|
|
122
|
+
|
|
123
|
+
## Conventions
|
|
124
|
+
|
|
125
|
+
- <Coding style, patterns, and idioms to follow.>
|
|
126
|
+
- <Things to avoid.>
|
|
127
|
+
|
|
128
|
+
## Commands
|
|
129
|
+
|
|
130
|
+
- build: <command>
|
|
131
|
+
- test: <command>
|
|
132
|
+
- run: <command>
|
|
133
|
+
|
|
134
|
+
## Notes for the agent
|
|
135
|
+
|
|
136
|
+
- <Domain context, gotchas, or constraints worth stating once.>
|
|
137
|
+
`;
|
|
138
|
+
|
|
139
|
+
export interface WriteResult {
|
|
140
|
+
path: string;
|
|
141
|
+
created: boolean; // false when a file was already there and we left it untouched
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Write a starter PRIVATEER.md into `dir`, never clobbering an existing one.
|
|
145
|
+
export function writeTemplate(dir: string = process.cwd()): WriteResult {
|
|
146
|
+
const path = join(dir, PRIVATEER_MD);
|
|
147
|
+
if (existsSync(path)) return { path, created: false };
|
|
148
|
+
writeFileSync(path, PRIVATEER_TEMPLATE, "utf-8");
|
|
149
|
+
return { path, created: true };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── change notification ──────────────────────────────────────────────────────
|
|
153
|
+
// Lets /init (in the context extension) tell the banner (in the brand extension) that
|
|
154
|
+
// PRIVATEER.md state changed, so the header re-renders its loaded/hint line immediately —
|
|
155
|
+
// without either extension importing the other. Mirrors priv.onSignedIn.
|
|
156
|
+
type Listener = () => void;
|
|
157
|
+
const listeners = new Set<Listener>();
|
|
158
|
+
|
|
159
|
+
export function onContextChanged(fn: Listener): void {
|
|
160
|
+
listeners.add(fn);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function emitContextChanged(): void {
|
|
164
|
+
for (const fn of listeners) {
|
|
165
|
+
try {
|
|
166
|
+
fn();
|
|
167
|
+
} catch {
|
|
168
|
+
// a stale/broken listener must not break /init.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
package/src/daemon/index.ts
CHANGED
|
@@ -8,12 +8,13 @@ import {
|
|
|
8
8
|
SessionManager,
|
|
9
9
|
} from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { agentDir, configPath } from "../config/paths.ts";
|
|
11
|
+
import { agentVersion } from "../config/version.ts";
|
|
11
12
|
import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
|
|
12
13
|
import { makePermissionGate, type GateController } from "../ext/permissionGate.ts";
|
|
13
14
|
import { makePiPrivacyExtension } from "pi-privacy";
|
|
14
15
|
import { makeAccountProvider } from "../providers/account.ts";
|
|
15
16
|
import { RelayClient } from "../remote/relayClient.ts";
|
|
16
|
-
import { hasCredentials,
|
|
17
|
+
import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest, spawnAccountCredentials } from "../auth/privateer.ts";
|
|
17
18
|
import {
|
|
18
19
|
loadRoutines,
|
|
19
20
|
upsertRoutine,
|
|
@@ -161,6 +162,9 @@ export class Daemon {
|
|
|
161
162
|
private onControllerAttached(): void {
|
|
162
163
|
this.controllerAttached = true;
|
|
163
164
|
this.relay?.sendSnapshot([{ kind: "notice", text: "Privateer routines — results will appear here as they run." }]);
|
|
165
|
+
// Version only — the routines terminal isn't a single-model session, so no
|
|
166
|
+
// model field (and no cwd, per RelayClient.sendContext's non-PII stance).
|
|
167
|
+
this.relay?.sendContext({ version: agentVersion() });
|
|
164
168
|
const pending = drainPendingRelay();
|
|
165
169
|
if (pending.length === 0) return;
|
|
166
170
|
log(`controller attached — flushing ${pending.length} pending routine result(s)`);
|
|
@@ -260,6 +264,12 @@ export class Daemon {
|
|
|
260
264
|
let out = "";
|
|
261
265
|
let status: "ok" | "error" = "ok";
|
|
262
266
|
let error: string | undefined;
|
|
267
|
+
// Track this run's account inference session so it can be torn down when the
|
|
268
|
+
// routine finishes — each run force-spawns a fresh one (below), so without this
|
|
269
|
+
// a long-lived daemon would leave one orphaned account "device" per run lingering
|
|
270
|
+
// in the app's Linked Devices until its token TTL.
|
|
271
|
+
let servicesRef: { authStorage?: { remove?: (p: string) => void } } | null = null;
|
|
272
|
+
let spawnedAccount = false;
|
|
263
273
|
try {
|
|
264
274
|
// Auto-approve (bypass) gate — safety is `tools: allowedTools`; a dangerous
|
|
265
275
|
// shell command still fail-closes headlessly (localAsk denies).
|
|
@@ -281,12 +291,14 @@ export class Daemon {
|
|
|
281
291
|
extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider()] as any,
|
|
282
292
|
},
|
|
283
293
|
});
|
|
294
|
+
servicesRef = services as any;
|
|
284
295
|
|
|
285
296
|
const { provider, modelId } = parseSpec(modelSpec);
|
|
286
297
|
if (provider === "privateer") {
|
|
287
298
|
try {
|
|
288
299
|
const creds = await spawnAccountCredentials();
|
|
289
300
|
(services.authStorage as any).set("privateer", { type: "oauth", ...creds });
|
|
301
|
+
spawnedAccount = true;
|
|
290
302
|
} catch (e) {
|
|
291
303
|
log(` account channel unavailable: ${(e as Error).message}`);
|
|
292
304
|
}
|
|
@@ -318,6 +330,17 @@ export class Daemon {
|
|
|
318
330
|
} catch (err) {
|
|
319
331
|
status = "error";
|
|
320
332
|
error = err instanceof Error ? err.message : String(err);
|
|
333
|
+
} finally {
|
|
334
|
+
// Tear down THIS run's account inference session so it doesn't linger in the
|
|
335
|
+
// app's Linked Devices after the routine finishes. Revoke only the account
|
|
336
|
+
// session — the daemon's child API session (relay/outbox) must stay alive for
|
|
337
|
+
// the daemon's lifetime and is revoked on shutdown. Also drop Pi's persisted
|
|
338
|
+
// copy so a later run's fallback never reuses this revoked token. Best-effort;
|
|
339
|
+
// the next run force-spawns a fresh account session.
|
|
340
|
+
if (spawnedAccount) {
|
|
341
|
+
try { await revokeAccountSession(); } catch { /* best effort — server TTL is the fallback */ }
|
|
342
|
+
try { servicesRef?.authStorage?.remove?.("privateer"); } catch { /* nothing persisted */ }
|
|
343
|
+
}
|
|
321
344
|
}
|
|
322
345
|
|
|
323
346
|
const content = formatResult(routine, out, status, error);
|
|
@@ -398,7 +421,7 @@ export function runDaemon(): void {
|
|
|
398
421
|
const shutdown = () => {
|
|
399
422
|
log("shutting down");
|
|
400
423
|
daemon.stop();
|
|
401
|
-
void
|
|
424
|
+
void revokeLocalSessions().finally(() => process.exit(0));
|
|
402
425
|
};
|
|
403
426
|
process.on("SIGINT", shutdown);
|
|
404
427
|
process.on("SIGTERM", shutdown);
|
package/src/providers/account.ts
CHANGED
|
@@ -17,8 +17,9 @@ import {
|
|
|
17
17
|
authedFetch,
|
|
18
18
|
spawnAccountCredentials,
|
|
19
19
|
refreshAccountCredentials,
|
|
20
|
+
notifySignedIn,
|
|
20
21
|
} from "../auth/privateer.ts";
|
|
21
|
-
import { interpretReport, teePosture, type PrivacyTier } from "pi-privacy";
|
|
22
|
+
import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
|
|
22
23
|
|
|
23
24
|
// Seed/fallback catalog: registered synchronously so the account provider has real
|
|
24
25
|
// models the instant it loads (before the live /api/models fetch resolves) — in
|
|
@@ -66,19 +67,44 @@ export async function fetchAccountModels(): Promise<string[]> {
|
|
|
66
67
|
export const privateerOAuthProvider = {
|
|
67
68
|
name: "Privateer account",
|
|
68
69
|
usesCallbackServer: false,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
70
|
+
// Pi's login dialog passes `signal` (its cancel AbortController) alongside the
|
|
71
|
+
// callbacks. We MUST thread it into runDeviceLogin — otherwise escape/ctrl+c
|
|
72
|
+
// aborts the dialog's signal but our poll loop never sees it, the login()
|
|
73
|
+
// promise never settles, and Pi never restores the editor: the "Waiting for
|
|
74
|
+
// authentication…" screen hangs with no way out. See auth/privateer.ts
|
|
75
|
+
// pollForToken, which checks the signal and rejects with "Login cancelled.".
|
|
76
|
+
async login(cb: { onDeviceCode?: (info: unknown) => void; signal?: AbortSignal }) {
|
|
77
|
+
// Fresh machine? The device-code flow below fires notifySignedIn itself (via
|
|
78
|
+
// pollForToken). Already linked? No device code runs — so we announce the
|
|
79
|
+
// completed subscription login ourselves at the end, or the header/badge would
|
|
80
|
+
// keep showing "not signed in" until the next launch.
|
|
81
|
+
const wasLinked = hasCredentials();
|
|
82
|
+
if (!wasLinked) {
|
|
83
|
+
try {
|
|
84
|
+
await runDeviceLogin({
|
|
85
|
+
signal: cb.signal,
|
|
86
|
+
onCode: (code) =>
|
|
87
|
+
cb.onDeviceCode?.({
|
|
88
|
+
userCode: code.user_code,
|
|
89
|
+
verificationUri: code.verification_uri_complete ?? code.verification_uri ?? "",
|
|
90
|
+
intervalSeconds: code.interval,
|
|
91
|
+
expiresInSeconds: code.expires_in,
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
} catch (e) {
|
|
95
|
+
// Normalize the cancel message to exactly "Login cancelled" (no period):
|
|
96
|
+
// Pi's login dialog only suppresses its "Failed to login…" error toast for
|
|
97
|
+
// that exact string, so a cancel should exit quietly, not flash an error.
|
|
98
|
+
if (cb.signal?.aborted) throw new Error("Login cancelled");
|
|
99
|
+
throw e;
|
|
100
|
+
}
|
|
80
101
|
}
|
|
81
|
-
|
|
102
|
+
if (cb.signal?.aborted) throw new Error("Login cancelled");
|
|
103
|
+
const creds = await spawnAccountCredentials();
|
|
104
|
+
// The fresh path already fired notifySignedIn (pollForToken); fire here for the
|
|
105
|
+
// already-linked path so the header re-renders to "connected" on this terminal too.
|
|
106
|
+
if (wasLinked) notifySignedIn();
|
|
107
|
+
return creds;
|
|
82
108
|
},
|
|
83
109
|
async refreshToken(creds: { refresh: string }) {
|
|
84
110
|
try {
|
|
@@ -108,13 +134,11 @@ export interface AccountPosture {
|
|
|
108
134
|
|
|
109
135
|
// Posture for an account-channel model. For NEAR models the attestation is fetched
|
|
110
136
|
// through the SERVER proxy (the account's NEAR key stays server-side): the server
|
|
111
|
-
// mints the nonce
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
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
|
|
137
|
+
// mints the nonce and returns the report. A green attestation is trusted as a genuine
|
|
138
|
+
// TEE — promoted to `tee-verified` (green shield "Trusted Execution" in the badge) —
|
|
139
|
+
// so an attested confidential-compute model reads as verified-private. A yellow report
|
|
140
|
+
// stays `tee-unverified` (unconfirmed) and red falls back to `standard`; the raw
|
|
141
|
+
// teePosture is still surfaced for display. ZDR-channel models route to
|
|
118
142
|
// zero-retention endpoints server-side, which we can't observe here — a policy claim.
|
|
119
143
|
export async function accountPosture(modelId: string): Promise<AccountPosture> {
|
|
120
144
|
if (privateerChannel(modelId) === "zdr") {
|
|
@@ -128,8 +152,8 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
|
|
|
128
152
|
const data = (await res.json()) as { nonce?: string; report?: unknown };
|
|
129
153
|
const att = interpretReport(modelId, data.nonce ?? "", data.report ?? {});
|
|
130
154
|
const tp = teePosture(att);
|
|
131
|
-
//
|
|
132
|
-
const tier: PrivacyTier = tp
|
|
155
|
+
// green → tee-verified, yellow → tee-unverified, red → standard.
|
|
156
|
+
const tier: PrivacyTier = tierFromTeePosture(tp);
|
|
133
157
|
return { tier, teePosture: tp };
|
|
134
158
|
} catch (e) {
|
|
135
159
|
return { tier: "tee-unverified", error: (e as Error).message };
|
|
@@ -422,6 +422,19 @@ export class RelayClient {
|
|
|
422
422
|
this.rawSend({ type: "no_quarter", on });
|
|
423
423
|
}
|
|
424
424
|
|
|
425
|
+
// Push this terminal's live context (selected model, agent version) to a
|
|
426
|
+
// controller so the app's session banner reflects reality instead of a stub.
|
|
427
|
+
// Sent on controller attach — like the snapshot/no_quarter resync. NON-PII ONLY
|
|
428
|
+
// by design: deliberately NO cwd / hostname / username, matching terminalLabel's
|
|
429
|
+
// stance (the server/controller learns as little as possible about the machine).
|
|
430
|
+
// Empty/absent fields are omitted so the app renders less rather than blank.
|
|
431
|
+
sendContext(ctx: { model?: string; version?: string }): void {
|
|
432
|
+
const frame: Record<string, unknown> = { type: "context" };
|
|
433
|
+
if (typeof ctx.model === "string" && ctx.model) frame.model = ctx.model;
|
|
434
|
+
if (typeof ctx.version === "string" && ctx.version) frame.version = ctx.version;
|
|
435
|
+
this.rawSend(frame);
|
|
436
|
+
}
|
|
437
|
+
|
|
425
438
|
requestApproval(id: string, req: PermissionRequest): void {
|
|
426
439
|
this.rawSend({
|
|
427
440
|
type: "approval_request",
|