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.
@@ -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
- // remove the "privateer" entry from Pi's authStorage (authStorage.remove("privateer"))
153
- // right after revokeLocalSessions() so the next launch spawns a fresh session instead
154
- // of reusing the revoked one. Doing both is safe; doing only one is not. See
155
- // revokeLocalSessions and its callers (cli/chat.ts, harbor/index.ts).
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 — `authStorage.remove("privateer")` — or the next run
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
  */
@@ -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 defaultTools: string[] = Array.isArray(ch.tools) && ch.tools.length ? ch.tools : SAFE_TOOLS;
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 { makeAccountProvider, accountPosture, privateerChannel } = await import("../providers/account.ts");
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 (env PRIVATEER_NO_QUARTER) → total gate bypass, no prompts.
311
- getSkipAllPermissions: () => process.env.PRIVATEER_NO_QUARTER === "1",
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
- try { (services.authStorage as any).remove?.("privateer"); } catch { /* nothing persisted */ }
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" },
@@ -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
  *