privateer-agent 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -11
- package/bin/privateer-launch.mjs +1 -0
- package/extensions/privateer-brand.ts +24 -11
- package/extensions/privateer-connect.ts +135 -10
- package/extensions/privateer-gate.ts +97 -5
- package/extensions/privateer-privacy.ts +21 -0
- package/package.json +2 -2
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +20 -1
- package/src/auth/privateer.ts +24 -5
- package/src/channels/run.ts +18 -1
- package/src/cli/chat.ts +66 -5
- package/src/config/hosted.ts +21 -0
- package/src/harbor/index.ts +260 -75
- package/src/mcp/catalog.ts +88 -1
- package/src/mcp/toolNames.ts +177 -0
- package/src/permissions/noQuarter.ts +42 -0
- package/src/providers/account.ts +321 -11
- package/src/remote/liveTaskSession.ts +20 -3
- package/src/remote/mcpControl.ts +224 -28
- package/src/remote/relayClient.ts +103 -4
- package/src/remote/routinesControl.ts +1 -1
- package/src/routines/schema.ts +2 -0
- package/src/routines/store.ts +1 -1
- package/src/routines/toolSelect.ts +13 -19
- package/src/tools/web.ts +236 -0
- package/src/ui/palette.ts +3 -0
package/src/auth/privateer.ts
CHANGED
|
@@ -149,10 +149,12 @@ let _refreshInFlight: Promise<ChildSession> | null = null;
|
|
|
149
149
|
// copy in place would let the next run send a token that still looks valid but is dead
|
|
150
150
|
// server-side → inference fails with a dead-end `401 {code: SESSION_REVOKED}`.
|
|
151
151
|
// The fix is to revoke it AND drop the persisted credential together: the caller must
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
152
|
+
// drop the "privateer" entry from Pi's authStorage — via
|
|
153
|
+
// providers/account.ts dropPersistedAccountCredential(), NOT a bare
|
|
154
|
+
// authStorage.remove(), because auth.json is machine-global and the entry may belong to
|
|
155
|
+
// another running terminal — right after revokeLocalSessions(), so the next launch
|
|
156
|
+
// spawns a fresh session instead of reusing the revoked one. Doing both is safe; doing
|
|
157
|
+
// only one is not. See revokeLocalSessions and its callers (cli/chat.ts, harbor/index.ts).
|
|
156
158
|
//
|
|
157
159
|
// That pairing only covers a CLEAN exit, though. A terminal killed without running its
|
|
158
160
|
// shutdown hook leaves its row alive server-side for the full TTL, and the next launch
|
|
@@ -195,6 +197,21 @@ export function saveCredentials(creds: Credentials): void {
|
|
|
195
197
|
shared().cache = creds;
|
|
196
198
|
}
|
|
197
199
|
|
|
200
|
+
// The account-provider credential this process armed is memoized on a registered
|
|
201
|
+
// symbol by providers/account.ts (one slot across jiti's per-extension module copies).
|
|
202
|
+
// Clearing it from here — rather than importing account.ts, which would make a cycle —
|
|
203
|
+
// keeps a single rule: local credentials gone ⇒ armed credential gone.
|
|
204
|
+
//
|
|
205
|
+
// Without this, /logout followed by signing in as a DIFFERENT account reused the old
|
|
206
|
+
// account's memoized session: logout() revokes that whole token family, so the new
|
|
207
|
+
// sign-in armed Pi with a token already dead server-side and the first prompt 401'd.
|
|
208
|
+
const ARMED_SLOT = Symbol.for("privateer.accountCredential");
|
|
209
|
+
|
|
210
|
+
function forgetArmedAccountCredential(): void {
|
|
211
|
+
const slot = (globalThis as { [ARMED_SLOT]?: { cred?: unknown } })[ARMED_SLOT];
|
|
212
|
+
if (slot) slot.cred = undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
198
215
|
export function clearCredentials(): void {
|
|
199
216
|
try {
|
|
200
217
|
rmSync(credentialsPath(), { force: true });
|
|
@@ -207,6 +224,7 @@ export function clearCredentials(): void {
|
|
|
207
224
|
shared().cache = null;
|
|
208
225
|
_child = null;
|
|
209
226
|
_account = null;
|
|
227
|
+
forgetArmedAccountCredential();
|
|
210
228
|
}
|
|
211
229
|
|
|
212
230
|
export function hasCredentials(): boolean {
|
|
@@ -623,7 +641,8 @@ export async function revokeAccountSession(timeoutMs = 1500): Promise<void> {
|
|
|
623
641
|
*
|
|
624
642
|
* IMPORTANT: the account session is persisted by Pi (auth.json) and reused on the next
|
|
625
643
|
* launch without a reactive-on-401 refresh, so the caller MUST also drop the persisted
|
|
626
|
-
* copy right after this resolves — `
|
|
644
|
+
* copy right after this resolves — `dropPersistedAccountCredential(ctx)` from
|
|
645
|
+
* providers/account.ts, which drops it only if THIS process minted it — or the next run
|
|
627
646
|
* will reuse the token we just revoked and dead-end on a 401 (see the _account note).
|
|
628
647
|
* Callers: cli/chat.ts cleanup() and harbor/index.ts shutdown().
|
|
629
648
|
*/
|
package/src/channels/run.ts
CHANGED
|
@@ -46,6 +46,9 @@
|
|
|
46
46
|
|
|
47
47
|
import "../boot.ts"; // env + attestation dispatcher, before any Pi import
|
|
48
48
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
49
|
+
// Names only — the factory itself is imported lazily in main() like every other
|
|
50
|
+
// module here. Safe statically: this evaluates after boot.ts and pulls in no Pi.
|
|
51
|
+
import { WEB_TOOL_NAMES } from "../tools/web.ts";
|
|
49
52
|
|
|
50
53
|
// Read-only default toolset — same rationale as the routines harbor's SAFE_TOOLS:
|
|
51
54
|
// a turn nobody is watching can't mutate the filesystem or shell out. Now that the
|
|
@@ -54,6 +57,11 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
54
57
|
// prompts in-chat for a yes/no.
|
|
55
58
|
const SAFE_TOOLS = ["read", "grep", "find", "ls"];
|
|
56
59
|
|
|
60
|
+
// Web tools join the default set when the agent has web access — see
|
|
61
|
+
// config/hosted.ts. Kept out of SAFE_TOOLS proper because they're the one "read-only"
|
|
62
|
+
// capability that still sends a query off the machine.
|
|
63
|
+
const WEB_TOOLS: string[] = [...WEB_TOOL_NAMES];
|
|
64
|
+
|
|
57
65
|
// A channel's posture governs how an ADMIN's risky actions are handled (members are
|
|
58
66
|
// always capped to read-only — see effectivePosture). Config + restart only; there
|
|
59
67
|
// is deliberately no in-chat toggle.
|
|
@@ -111,6 +119,8 @@ async function main() {
|
|
|
111
119
|
const { makePiPrivacyExtension } = await import("pi-privacy");
|
|
112
120
|
const { makeAccountProvider, privateerChannel } = await import("../providers/account.ts");
|
|
113
121
|
const { hasCredentials } = await import("../auth/privateer.ts");
|
|
122
|
+
const { makeWebTools } = await import("../tools/web.ts");
|
|
123
|
+
const { webEnabled } = await import("../config/hosted.ts");
|
|
114
124
|
const { resolveDefaultModel } = await import("../providers/defaultModel.ts");
|
|
115
125
|
const { agentDir, configPath, globalDir } = await import("../config/paths.ts");
|
|
116
126
|
const { redactText, collectSecrets } = await import("../util/redact.ts");
|
|
@@ -133,7 +143,10 @@ async function main() {
|
|
|
133
143
|
}
|
|
134
144
|
const ch = cfg.channels ?? {};
|
|
135
145
|
const defaultModel: string = resolveDefaultModel({ explicit: ch.model ?? cfg.defaultModel });
|
|
136
|
-
const
|
|
146
|
+
const web = webEnabled();
|
|
147
|
+
const defaultTools: string[] = Array.isArray(ch.tools) && ch.tools.length
|
|
148
|
+
? (web ? ch.tools : ch.tools.filter((t: string) => !WEB_TOOLS.includes(t)))
|
|
149
|
+
: (web ? [...SAFE_TOOLS, ...WEB_TOOLS] : [...SAFE_TOOLS]);
|
|
137
150
|
const defaultPosture: Posture = normalizePosture(ch.posture) ?? "approve";
|
|
138
151
|
const cwd: string = ch.cwd ?? process.cwd();
|
|
139
152
|
const secrets = collectSecrets(cfg.providers);
|
|
@@ -190,6 +203,10 @@ async function main() {
|
|
|
190
203
|
privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
191
204
|
}),
|
|
192
205
|
makeAccountProvider(),
|
|
206
|
+
// Web access (src/tools/web.ts) — same wiring as the harbor: registered here
|
|
207
|
+
// because this path builds its session explicitly, and omitted entirely when
|
|
208
|
+
// the agent isn't allowed the web.
|
|
209
|
+
...(webEnabled() ? [makeWebTools()] : []),
|
|
193
210
|
] as any,
|
|
194
211
|
},
|
|
195
212
|
});
|
package/src/cli/chat.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import "../boot.ts"; // env + attestation dispatcher, before any Pi import
|
|
12
12
|
import { fileURLToPath } from "node:url"; // builtin, safe pre-boot
|
|
13
13
|
import { cliPalette } from "../ui/palette.ts"; // no Pi deps → safe pre-boot
|
|
14
|
+
import { noQuarterActive, setNoQuarter } from "../permissions/noQuarter.ts"; // no Pi deps → safe pre-boot
|
|
14
15
|
import type { GateController } from "../ext/permissionGate.ts"; // type-only → erased, safe pre-boot
|
|
15
16
|
|
|
16
17
|
// This lean REPL has no Pi TUI (and so no Theme), so it detects the terminal background
|
|
@@ -38,7 +39,13 @@ async function main() {
|
|
|
38
39
|
const { authorizeControl } = await import("../remote/controlAuth.ts");
|
|
39
40
|
const { resolveMentions, completeMention, searchFiles } = await import("../util/fileMentions.ts");
|
|
40
41
|
const priv = await import("../auth/privateer.ts");
|
|
41
|
-
const {
|
|
42
|
+
const {
|
|
43
|
+
makeAccountProvider,
|
|
44
|
+
accountPosture,
|
|
45
|
+
privateerChannel,
|
|
46
|
+
rememberAccountCredential,
|
|
47
|
+
dropPersistedAccountCredential,
|
|
48
|
+
} = await import("../providers/account.ts");
|
|
42
49
|
const { agentVersion } = await import("../config/version.ts");
|
|
43
50
|
const { resolveDefaultModel, resolveSignedInModel } = await import("../providers/defaultModel.ts");
|
|
44
51
|
|
|
@@ -307,8 +314,9 @@ async function main() {
|
|
|
307
314
|
},
|
|
308
315
|
getRemote: bridge.getRemote,
|
|
309
316
|
getNoQuarter: bridge.getNoQuarter,
|
|
310
|
-
// `--no-quarter` at launch
|
|
311
|
-
|
|
317
|
+
// Total gate bypass, no prompts — from `--no-quarter` at launch or shift+tab /
|
|
318
|
+
// `/no-quarter` mid-session. One shared state (../permissions/noQuarter.ts).
|
|
319
|
+
getSkipAllPermissions: noQuarterActive,
|
|
312
320
|
remoteAsk: bridge.remoteAsk,
|
|
313
321
|
// Subagents (and their child-only intercom tools) can't be driven from the app
|
|
314
322
|
// yet — pi-subagents runs each in a child session whose gate/UI bypass the relay,
|
|
@@ -322,7 +330,37 @@ async function main() {
|
|
|
322
330
|
},
|
|
323
331
|
};
|
|
324
332
|
|
|
333
|
+
// ── no quarter (shift+tab) ──────────────────────────────────────────────────
|
|
334
|
+
// The "step away from the keyboard" switch: lower the moat for the rest of the
|
|
335
|
+
// session so a long task runs to completion instead of stalling on the next
|
|
336
|
+
// approval prompt. Same state the `--no-quarter` launch flag sets, and the same
|
|
337
|
+
// chord the full TUI uses (extensions/privateer-gate.ts). Reversible with the
|
|
338
|
+
// same key; takes effect from the next gated action, so an approval already on
|
|
339
|
+
// screen still needs an answer.
|
|
340
|
+
function applyNoQuarter(on: boolean): void {
|
|
341
|
+
setNoQuarter(on);
|
|
342
|
+
flushOut(); // land streamed output above the notice
|
|
343
|
+
console.log(
|
|
344
|
+
on
|
|
345
|
+
? `\n${RED}⚑ No quarter — the permission gate is OFF for this session.${RESET}\n` +
|
|
346
|
+
`${DIM} Every action (shell, edits, destructive tools, out-of-cwd, protected files) runs without asking.\n` +
|
|
347
|
+
` shift+tab (or /no-quarter off) raises the moat again.${RESET}`
|
|
348
|
+
: `\n${GREEN}⚓ Moat raised — the permission gate is back on.${RESET}`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// readline already emits keypress events for a TTY input, so we just listen. Node
|
|
353
|
+
// decodes shift+tab (CSI Z) as {name: "tab", shift: true}. We don't swallow it —
|
|
354
|
+
// readline still runs its own tab handler, which for a non-`@mention` line
|
|
355
|
+
// completes to nothing.
|
|
356
|
+
if (process.stdin.isTTY) {
|
|
357
|
+
process.stdin.on("keypress", (_s: string, key: any) => {
|
|
358
|
+
if (key?.name === "tab" && key.shift && !key.ctrl && !key.meta) applyNoQuarter(!noQuarterActive());
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
325
362
|
console.log(`${DIM}privateer-agent — lean REPL. Loading ${provider}/${modelId}…${RESET}`);
|
|
363
|
+
if (noQuarterActive()) applyNoQuarter(true); // launched with --no-quarter: say so up front
|
|
326
364
|
|
|
327
365
|
const services = await createAgentSessionServices({
|
|
328
366
|
cwd,
|
|
@@ -365,7 +403,9 @@ async function main() {
|
|
|
365
403
|
cleanedUp = true;
|
|
366
404
|
try { relay?.stop(); } catch { /* already stopped */ }
|
|
367
405
|
try { await priv.revokeLocalSessions(); } catch { /* best effort — server TTL is the fallback */ }
|
|
368
|
-
|
|
406
|
+
// Ownership-checked: auth.json is machine-global, so removing an entry another
|
|
407
|
+
// running terminal minted would strand it (see providers/account.ts).
|
|
408
|
+
try { dropPersistedAccountCredential({ modelRegistry: { authStorage: services.authStorage } }); } catch { /* nothing persisted */ }
|
|
369
409
|
}
|
|
370
410
|
const onSignal = (): void => { void cleanup().finally(() => process.exit(0)); };
|
|
371
411
|
process.once("SIGINT", onSignal);
|
|
@@ -377,6 +417,7 @@ async function main() {
|
|
|
377
417
|
try {
|
|
378
418
|
const creds = await priv.acquireAccountCredential();
|
|
379
419
|
(services.authStorage as any).set("privateer", { type: "oauth", ...creds });
|
|
420
|
+
rememberAccountCredential(creds); // claim it, so cleanup drops OUR entry and only ours
|
|
380
421
|
} catch (e) {
|
|
381
422
|
console.log(`${RED}Account channel unavailable: ${(e as Error).message}${RESET}`);
|
|
382
423
|
}
|
|
@@ -533,8 +574,9 @@ async function main() {
|
|
|
533
574
|
: `${DIM}Not signed in. /login to enable remote access & the account provider.${RESET}`,
|
|
534
575
|
);
|
|
535
576
|
|
|
536
|
-
const HELP = "Commands: /remote-access <on|off> /login /model <provider/id> /models [filter] /verify /mode <…> /quit";
|
|
577
|
+
const HELP = "Commands: /remote-access <on|off> /login /model <provider/id> /models [filter] /verify /mode <…> /no-quarter [on|off] /quit";
|
|
537
578
|
console.log(`${DIM}Ready. Type a prompt — reference a file with @path (Tab completes). ${HELP}${RESET}`);
|
|
579
|
+
console.log(`${DIM}shift+tab toggles no quarter — unattended mode, no approval prompts.${RESET}`);
|
|
538
580
|
await showPosture();
|
|
539
581
|
|
|
540
582
|
// The available model catalog as sorted "provider/id" specs. Same source the
|
|
@@ -620,6 +662,24 @@ async function main() {
|
|
|
620
662
|
console.log(items.length ? items.map((s) => ` ${s.name}${s.disabled ? ` ${DIM}(disabled)${RESET}` : ""}${s.editable ? "" : ` ${DIM}(read-only)${RESET}`} — ${s.description}`).join("\n") : `${DIM}No skills yet. Create them from the Privateer app.${RESET}`);
|
|
621
663
|
return true;
|
|
622
664
|
}
|
|
665
|
+
// The typed equivalent of shift+tab. A PHYSICAL-terminal action, like
|
|
666
|
+
// /remote-access: it's stronger than any mode (it also switches off the
|
|
667
|
+
// dangerous-command denylist and the protected-file guard), so a remote
|
|
668
|
+
// controller must not be able to reach it. The app's own no-quarter toggle
|
|
669
|
+
// covers driven turns and is `bypass` exactly — never weaker than /mode bypass.
|
|
670
|
+
if (line === "/no-quarter" || line.startsWith("/no-quarter ")) {
|
|
671
|
+
if (remote) {
|
|
672
|
+
relay?.sendNotice("/no-quarter is terminal-only — run it at the machine, or use the app's own no-quarter toggle.");
|
|
673
|
+
return true;
|
|
674
|
+
}
|
|
675
|
+
const arg = line.slice(11).trim().toLowerCase();
|
|
676
|
+
if (arg && arg !== "on" && arg !== "off") {
|
|
677
|
+
console.log(`${YELLOW}usage: /no-quarter [on|off] (currently ${noQuarterActive() ? "on" : "off"})${RESET}`);
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
applyNoQuarter(arg ? arg === "on" : !noQuarterActive());
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
623
683
|
if (line.startsWith("/mode ")) { mode = line.slice(6).trim() as typeof mode; const m = `mode → ${mode}`; console.log(`${DIM}${m}${RESET}`); if (remote) relay?.sendNotice(m); return true; }
|
|
624
684
|
// Bare /mode → the picker (remote) or a hint (local).
|
|
625
685
|
if (line === "/mode") {
|
|
@@ -648,6 +708,7 @@ async function main() {
|
|
|
648
708
|
const builtins = [
|
|
649
709
|
{ name: "/model", description: "Switch the model" },
|
|
650
710
|
{ name: "/mode", description: "Change the approval mode (default/acceptEdits/plan/bypass)" },
|
|
711
|
+
// /no-quarter is deliberately absent — terminal-only, see runCommand.
|
|
651
712
|
{ name: "/models", description: "List available models" },
|
|
652
713
|
{ name: "/extensions", description: "Manage installed Pi extensions" },
|
|
653
714
|
{ name: "/skills", description: "Manage the terminal's skills" },
|
package/src/config/hosted.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { writeFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { globalDir } from "./paths.ts";
|
|
4
4
|
import { terminalPublicKeyBase64 } from "../crypto/terminalKey.ts";
|
|
5
|
+
import { hasCredentials } from "../auth/privateer.ts";
|
|
5
6
|
|
|
6
7
|
// Harbor hosted mode.
|
|
7
8
|
//
|
|
@@ -17,6 +18,26 @@ export function isHosted(): boolean {
|
|
|
17
18
|
return process.env.HARBOR_HOSTED === "1";
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Is this agent allowed to reach the live web (web_search / web_fetch)?
|
|
23
|
+
*
|
|
24
|
+
* Both tools are served by the account API, so credentials are a hard prerequisite —
|
|
25
|
+
* without them there is nothing to authenticate with and every call would 401.
|
|
26
|
+
*
|
|
27
|
+
* `HARBOR_WEB` is authoritative when set. Hosted agents always set it explicitly, from
|
|
28
|
+
* the per-agent switch in the app (harborOrchestrator/tenants.js → tenantEnv), because
|
|
29
|
+
* a search sends the derived query out of the enclave to our servers and that has to be
|
|
30
|
+
* the user's call. Unset — a daemon on someone's own laptop — defaults to on once
|
|
31
|
+
* signed in: the same account, the same billing, and nothing to disclose beyond what
|
|
32
|
+
* the tool description already says.
|
|
33
|
+
*/
|
|
34
|
+
export function webEnabled(): boolean {
|
|
35
|
+
const flag = process.env.HARBOR_WEB;
|
|
36
|
+
if (flag === "1") return hasCredentials();
|
|
37
|
+
if (flag === "0") return false;
|
|
38
|
+
return hasCredentials();
|
|
39
|
+
}
|
|
40
|
+
|
|
20
41
|
/**
|
|
21
42
|
* Publish this harbor's relay identity key so the Harbor host can attest it.
|
|
22
43
|
*
|