privateer-agent 0.8.2 → 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.
@@ -28,7 +28,12 @@ import { readFileSync, appendFileSync } from "node:fs";
28
28
  import { homedir } from "node:os";
29
29
  import { join } from "node:path";
30
30
  import * as priv from "../src/auth/privateer.ts";
31
- import { armAccountCredential, makeAccountProvider } from "../src/providers/account.ts";
31
+ import {
32
+ armAccountCredential,
33
+ dropPersistedAccountCredential,
34
+ makeAccountProvider,
35
+ verificationLink,
36
+ } from "../src/providers/account.ts";
32
37
  import { resolveSignedInModel } from "../src/providers/defaultModel.ts";
33
38
  import { discoverContextFiles, onContextChanged } from "../src/context.ts";
34
39
  import { type Palette, paletteFor } from "../src/ui/palette.ts";
@@ -317,12 +322,15 @@ export default function privateerBrand(pi: any): void {
317
322
  // and dead-ends on the first inference. Removing it makes the next /signin spawn a
318
323
  // fresh session. Reached via the model registry (constructed with the auth
319
324
  // storage; see session.ts). Best-effort: nothing persisted → nothing to do.
320
- const dropPersistedAccount = (ctx: any): void => {
321
- try {
322
- ctx?.modelRegistry?.authStorage?.remove?.("privateer");
323
- } catch {
324
- /* no persisted credential / older Pi without this shape — nothing to do */
325
- }
325
+ //
326
+ // Two flavours, because auth.json is machine-global and shared by every Privateer
327
+ // terminal (see providers/account.ts dropPersistedAccountCredential):
328
+ // - `force` for sign-out / expiry, where the whole token family is revoked and the
329
+ // entry is dead for every terminal;
330
+ // - the default ownership-checked drop for THIS process's exit, which must not
331
+ // delete the credential another running terminal is using.
332
+ const dropPersistedAccount = (ctx: any, opts: { force?: boolean } = {}): void => {
333
+ dropPersistedAccountCredential(ctx, opts);
326
334
  };
327
335
 
328
336
  // /update — run the global npm install in a child process and report the outcome via
@@ -370,7 +378,9 @@ export default function privateerBrand(pi: any): void {
370
378
  const p = paletteFor(ctx?.ui?.theme);
371
379
  const user = await priv.runDeviceLogin({
372
380
  onCode: (code: any) => {
373
- const uri = clean(code.verification_uri_complete ?? code.verification_uri ?? "");
381
+ // Absolute url: the server sends it scheme-less, and this line is what the
382
+ // user copies into a browser. See providers/account.ts verificationLink.
383
+ const uri = clean(verificationLink(code.verification_uri_complete ?? code.verification_uri));
374
384
  const userCode = clean(code.user_code);
375
385
  ctx?.ui?.setWidget?.(
376
386
  "privateer-signin",
@@ -412,7 +422,7 @@ export default function privateerBrand(pi: any): void {
412
422
  const u = priv.currentUser();
413
423
  ctx?.ui?.notify?.("Signing out of Privateer…", "info");
414
424
  await priv.logout(); // never throws: local state is wiped whatever the network did
415
- dropPersistedAccount(ctx);
425
+ dropPersistedAccount(ctx, { force: true }); // the whole family is revoked, not just ours
416
426
  refresh(ctx);
417
427
  ctx?.ui?.notify?.(
418
428
  `Signed out${u?.email ? ` (${u.email})` : ""} — this machine and its terminals. Drop anchor for now.`,
@@ -594,6 +604,8 @@ export default function privateerBrand(pi: any): void {
594
604
  // server-side, so leaving the persisted copy behind would make the NEXT launch
595
605
  // reuse a token that's already dead and dead-end on its first prompt (Pi doesn't
596
606
  // refresh on a 401). Mirrors the harbor's shutdown (harbor/index.ts).
607
+ // Ownership-checked (no force): revokeLocalSessions killed OUR sessions only, so a
608
+ // persisted entry belonging to another running terminal must survive our exit.
597
609
  dropPersistedAccount(ctxRef);
598
610
  });
599
611
 
@@ -602,8 +614,9 @@ export default function privateerBrand(pi: any): void {
602
614
  priv.onSessionExpired(() => {
603
615
  // clearCredentials() has already wiped the local machine login; also drop Pi's
604
616
  // persisted account credential so the next prompt/launch doesn't reuse a token
605
- // that's now dead server-side (see dropPersistedAccount).
606
- dropPersistedAccount(ctxRef);
617
+ // that's now dead server-side (see dropPersistedAccount). Forced: the machine login
618
+ // is gone, so the entry can't be useful to any terminal on this machine.
619
+ dropPersistedAccount(ctxRef, { force: true });
607
620
  refresh(ctxRef);
608
621
  ctxRef?.ui?.notify?.("Your Privateer session expired. Run /login to sign back in.", "warning");
609
622
  });
@@ -37,9 +37,11 @@ import {
37
37
  import {
38
38
  MCP_CATALOG,
39
39
  draftFromCatalog,
40
+ hostedCapable,
40
41
  promptOrder,
41
42
  type CatalogEntry,
42
43
  } from "../src/mcp/catalog.ts";
44
+ import { isHosted } from "../src/config/hosted.ts";
43
45
 
44
46
  // Minimal views of Pi's theme + TUI handle, mirroring privateer-models.ts — enough to
45
47
  // color text and request a redraw without coupling this file to Pi internals.
@@ -126,6 +128,15 @@ interface Step {
126
128
  // A step that may be submitted empty. For an edit, an empty secret means "keep
127
129
  // the value already on disk" — mcpControl's env merge rule makes that free.
128
130
  optional?: boolean;
131
+ // Ask this step only when the answers so far say it's relevant — the auth questions
132
+ // are meaningless for a local command, and three dead optional prompts in a row is
133
+ // how a wizard teaches people to hammer Enter without reading. Predicates may only
134
+ // read answers from EARLIER steps.
135
+ when?: (answers: Record<string, string>) => boolean;
136
+ // Reject an answer before it becomes an answer, returning the reason to show. For
137
+ // rules mcpControl can't check because it never sees the raw form input, or that it
138
+ // would only catch after the user has typed three more fields for nothing.
139
+ validate?: (value: string) => string | undefined;
129
140
  }
130
141
 
131
142
  type View = "list" | "catalog" | "form";
@@ -147,7 +158,15 @@ interface Pending {
147
158
  // from the answer rather than asked as a separate question: an https:// answer is an
148
159
  // http server, anything else is a stdio command line. That matches mcpControl's own
149
160
  // inference (`draft.url || prev.url ? "http" : "stdio"`), so there's one rule, not two.
161
+ const isHttpTarget = (answers: Record<string, string>) => /^https?:\/\//i.test((answers.target ?? "").trim());
162
+
150
163
  function customSteps(existing?: RemoteMcpServer): Step[] {
164
+ // A hosted agent takes remote OAuth connectors and nothing else (Option B —
165
+ // treeview/docs/HARBOR_CONNECTORS_PLAN.md §2), so the custom form drops to that
166
+ // shape: no local command, and no stored token. Both are refused at the question
167
+ // rather than at first call, because a connector that saves cleanly and then never
168
+ // works is the worse of the two failures.
169
+ const hosted = isHosted();
151
170
  const target = existing
152
171
  ? existing.transport === "http"
153
172
  ? existing.url
@@ -162,15 +181,48 @@ function customSteps(existing?: RemoteMcpServer): Step[] {
162
181
  },
163
182
  {
164
183
  key: "target",
165
- prompt: "Launch command, or an https:// URL",
166
- hint: "e.g. npx -y @modelcontextprotocol/server-memory · or https://mcp.example.com/sse",
184
+ prompt: hosted ? "Server URL" : "Launch command, or an https:// URL",
185
+ hint: hosted
186
+ ? "e.g. https://mcp.example.com/sse — a hosted agent can't run a local command."
187
+ : "e.g. npx -y @modelcontextprotocol/server-memory · or https://mcp.example.com/sse",
167
188
  initial: target,
189
+ validate: hosted
190
+ ? (v) =>
191
+ /^https?:\/\//i.test(v.trim())
192
+ ? undefined
193
+ : "A hosted agent can only reach remote connectors — this needs an https:// URL."
194
+ : undefined,
168
195
  },
169
196
  {
170
197
  key: "env",
171
198
  prompt: "Environment variables (optional)",
172
199
  hint: "KEY=value, comma-separated. Leave blank for none.",
173
200
  optional: true,
201
+ // Nothing to set them ON when there is no local process to spawn, and their
202
+ // values are credentials at rest — both reasons a hosted agent skips this.
203
+ when: () => !hosted,
204
+ },
205
+ {
206
+ key: "bearerToken",
207
+ prompt: "Bearer token (optional)",
208
+ hint: existing?.bearerTokenSet
209
+ ? "Leave blank to keep the token already saved."
210
+ : "Leave blank to sign in through the browser instead (OAuth).",
211
+ secret: true,
212
+ optional: true,
213
+ // Never asked on a hosted agent: the home is tmpfs and a stored token would have
214
+ // to rest somewhere we can read. OAuth is the only credential it may hold.
215
+ when: (a) => !hosted && isHttpTarget(a),
216
+ },
217
+ {
218
+ key: "headers",
219
+ prompt: "Extra HTTP headers (optional)",
220
+ hint: "KEY=value, comma-separated — e.g. X-Api-Version=2. Leave blank for none.",
221
+ // Header values are credentials as often as not, so this is masked like a token
222
+ // — and for the same reason a hosted agent is never offered it.
223
+ secret: true,
224
+ optional: true,
225
+ when: (a) => !hosted && isHttpTarget(a),
174
226
  },
175
227
  ];
176
228
  }
@@ -196,7 +248,22 @@ function buildCustomDraft(answers: Record<string, string>): McpDraft {
196
248
  if (/^https?:\/\//i.test(target)) {
197
249
  draft.transport = "http";
198
250
  draft.url = target;
199
- draft.oauth = true;
251
+ const token = (answers.bearerToken ?? "").trim();
252
+ const headers = parseEnv(answers.headers ?? "");
253
+ const hasHeaders = Object.keys(headers).length > 0;
254
+ if (hasHeaders) draft.headers = headers;
255
+ if (token) {
256
+ // A static token: the adapter only sends the Authorization header when auth is
257
+ // explicitly "bearer", so this pairing is not optional.
258
+ draft.auth = "bearer";
259
+ draft.bearerToken = token;
260
+ } else if (hasHeaders) {
261
+ // Custom headers ARE the credential here — the adapter's supportsOAuth() refuses
262
+ // OAuth once headers are configured, so claiming "oauth" would be a lie.
263
+ draft.auth = "none";
264
+ } else {
265
+ draft.auth = "oauth";
266
+ }
200
267
  } else {
201
268
  draft.transport = "stdio";
202
269
  const parts = target.split(/\s+/).filter(Boolean);
@@ -265,8 +332,16 @@ const NEEDS_LABEL: Record<string, string> = {
265
332
  none: "no setup",
266
333
  };
267
334
 
335
+ // On a hosted (Harbor) agent the picker offers only what the runtime can actually
336
+ // hold — see hostedCapable(). Offering the other 16 would be offering a connector that
337
+ // installs nothing, keeps nothing, and fails at first call: the tenant is `--read-only`
338
+ // with no uv/uvx/Python and a tmpfs home wiped on every suspend. Filtering here rather
339
+ // than letting them fail later is the difference between "not available on a hosted
340
+ // agent" and "I set it up and it just doesn't work."
268
341
  function catalogRows(): CatalogRow[] {
269
- const rows: CatalogRow[] = MCP_CATALOG.map((e) => ({
342
+ const hosted = isHosted();
343
+ const entries = hosted ? MCP_CATALOG.filter(hostedCapable) : MCP_CATALOG;
344
+ const rows: CatalogRow[] = entries.map((e) => ({
270
345
  entry: e,
271
346
  label: e.label,
272
347
  blurb: e.blurb,
@@ -274,7 +349,9 @@ function catalogRows(): CatalogRow[] {
274
349
  }));
275
350
  rows.push({
276
351
  label: "Custom…",
277
- blurb: "Any stdio command or http URL.",
352
+ // Custom survives the filter because a custom REMOTE OAuth connector is fine here;
353
+ // it's stdio and stored tokens that aren't. customSteps enforces that.
354
+ blurb: hosted ? "Any https:// URL with browser sign-in." : "Any stdio command or http URL.",
278
355
  needsLabel: "",
279
356
  });
280
357
  return rows;
@@ -359,6 +436,36 @@ class ConnectPanel extends Container {
359
436
  return this.pending?.steps[this.stepIndex];
360
437
  }
361
438
 
439
+ // A step is asked only when its `when` predicate (if any) passes against the answers
440
+ // collected so far. Navigation walks over the hidden ones in both directions, and
441
+ // the position counter reports the VISIBLE steps so "2/3" doesn't count a question
442
+ // the user will never see.
443
+ private stepVisible(i: number): boolean {
444
+ const step = this.pending?.steps[i];
445
+ if (!step) return false;
446
+ return step.when ? step.when(this.answers) : true;
447
+ }
448
+
449
+ private seekStep(from: number, dir: 1 | -1): number | undefined {
450
+ const total = this.pending?.steps.length ?? 0;
451
+ for (let i = from; i >= 0 && i < total; i += dir) {
452
+ if (this.stepVisible(i)) return i;
453
+ }
454
+ return undefined;
455
+ }
456
+
457
+ private visiblePosition(): { at: number; of: number } {
458
+ const total = this.pending?.steps.length ?? 0;
459
+ let at = 0;
460
+ let of = 0;
461
+ for (let i = 0; i < total; i++) {
462
+ if (!this.stepVisible(i)) continue;
463
+ of++;
464
+ if (i <= this.stepIndex) at = of;
465
+ }
466
+ return { at, of };
467
+ }
468
+
362
469
  // -- rendering ------------------------------------------------------------
363
470
 
364
471
  private refresh(): void {
@@ -429,6 +536,13 @@ class ConnectPanel extends Container {
429
536
  private renderCatalog(): void {
430
537
  const t = this.theme;
431
538
  this.body.addChild(new Text(t.fg("accent", t.bold("Add a connector")), 1, 0));
539
+ // Say why the list is short before the user goes looking for GitHub. A filter with
540
+ // no explanation reads as a missing connector, which is a support question.
541
+ if (isHosted()) {
542
+ this.body.addChild(
543
+ new Text(t.fg("muted", " Hosted agent — remote connectors with browser sign-in only."), 1, 0),
544
+ );
545
+ }
432
546
  this.body.addChild(new Spacer(1));
433
547
  this.body.addChild(this.search);
434
548
  this.body.addChild(new Spacer(1));
@@ -458,7 +572,8 @@ class ConnectPanel extends Container {
458
572
  const t = this.theme;
459
573
  const p = this.pending!;
460
574
  const step = this.currentStep()!;
461
- const counter = p.steps.length > 1 ? ` ${this.stepIndex + 1}/${p.steps.length}` : "";
575
+ const pos = this.visiblePosition();
576
+ const counter = pos.of > 1 ? ` ${pos.at}/${pos.of}` : "";
462
577
  this.body.addChild(new Text(t.fg("accent", t.bold(p.title)) + t.fg("muted", counter), 1, 0));
463
578
  this.body.addChild(new Spacer(1));
464
579
  this.body.addChild(new Text(` ${t.fg("text", step.prompt)}`, 1, 0));
@@ -555,10 +670,19 @@ class ConnectPanel extends Container {
555
670
  this.refresh();
556
671
  return;
557
672
  }
673
+ // Validate only what was actually typed: a blank optional answer means "skip", and
674
+ // a rule about the shape of a value has nothing to say about its absence.
675
+ const bad = value && step.validate ? step.validate(value) : undefined;
676
+ if (bad) {
677
+ this.status = bad;
678
+ this.refresh();
679
+ return;
680
+ }
558
681
  this.answers[step.key] = value;
559
682
  this.status = "";
560
- if (this.stepIndex < this.pending!.steps.length - 1) {
561
- this.stepIndex++;
683
+ const next = this.seekStep(this.stepIndex + 1, 1);
684
+ if (next !== undefined) {
685
+ this.stepIndex = next;
562
686
  this.loadStep();
563
687
  return;
564
688
  }
@@ -701,8 +825,9 @@ class ConnectPanel extends Container {
701
825
  const step = this.currentStep()!;
702
826
  if (kb.matches(data, "tui.select.cancel")) {
703
827
  // Back a step, or out of the form entirely from the first one.
704
- if (this.stepIndex > 0) {
705
- this.stepIndex--;
828
+ const prev = this.stepIndex > 0 ? this.seekStep(this.stepIndex - 1, -1) : undefined;
829
+ if (prev !== undefined) {
830
+ this.stepIndex = prev;
706
831
  this.loadStep();
707
832
  } else {
708
833
  this.view = this.pending?.origin ?? "list";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
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",
@@ -70,7 +70,7 @@
70
70
  "@phala/dcap-qvl": "^0.5.2",
71
71
  "patch-package": "^8.0.1",
72
72
  "pi-mcp-adapter": "^2.11.0",
73
- "pi-privacy": "^0.8.0",
73
+ "pi-privacy": "^0.9.0",
74
74
  "pi-subagents": "^0.34.0",
75
75
  "picomatch": "^4.0.4",
76
76
  "privateer-workflow": "^0.1.0",
@@ -1,5 +1,5 @@
1
1
  diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
2
- index e223ce1..3615d74 100644
2
+ index e223ce1..cc853e7 100644
3
3
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
4
4
  +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
5
5
  @@ -163,6 +163,14 @@ export class AgentSession {
@@ -33,6 +33,25 @@ index e223ce1..3615d74 100644
33
33
  }
34
34
  if (await this._checkCompaction(msg)) {
35
35
  return true;
36
+ @@ -1996,6 +2013,18 @@ export class AgentSession {
37
+ // Context overflow is handled by compaction, not retry.
38
+ if (isContextOverflow(message, this.model?.contextWindow ?? 0))
39
+ return false;
40
+ + // Privateer patch: a Privateer ACCOUNT CAP is not a throttle. Daily/monthly
41
+ + // message or token limits (and an exhausted balance) come back from the account
42
+ + // channel as a 429 carrying the backend's machine `code` and a ready-to-show
43
+ + // message. pi classifies anything containing "429" as transient, so the agent
44
+ + // spent its whole retry budget — with exponential backoff — on a limit that
45
+ + // cannot clear, and only then showed the user the one message that says what to
46
+ + // do (upgrade, top up, or /login keys for a BYO key). Scoped to the `privateer`
47
+ + // provider and to the backend's own cap wording so no other provider's transient
48
+ + // rate limit is affected. Mirrors isAccountCapCode in src/engine/errors.ts.
49
+ + if (this.model?.provider === "privateer" &&
50
+ + /"code"\s*:\s*"[A-Z0-9_]*(?:CAP|QUOTA|LIMIT_REACHED|INSUFFICIENT|TOP_?UP)[A-Z0-9_]*"|limit of [^.]*reached|upgrade or top ?up|usage limit reached/i.test(message.errorMessage ?? ""))
51
+ + return false;
52
+ return isRetryableAssistantError(message);
53
+ }
54
+ /**
36
55
  diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
37
56
  index 197bccc..27f6429 100644
38
57
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
@@ -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
@@ -39,7 +39,13 @@ async function main() {
39
39
  const { authorizeControl } = await import("../remote/controlAuth.ts");
40
40
  const { resolveMentions, completeMention, searchFiles } = await import("../util/fileMentions.ts");
41
41
  const priv = await import("../auth/privateer.ts");
42
- 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");
43
49
  const { agentVersion } = await import("../config/version.ts");
44
50
  const { resolveDefaultModel, resolveSignedInModel } = await import("../providers/defaultModel.ts");
45
51
 
@@ -397,7 +403,9 @@ async function main() {
397
403
  cleanedUp = true;
398
404
  try { relay?.stop(); } catch { /* already stopped */ }
399
405
  try { await priv.revokeLocalSessions(); } catch { /* best effort — server TTL is the fallback */ }
400
- 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 */ }
401
409
  }
402
410
  const onSignal = (): void => { void cleanup().finally(() => process.exit(0)); };
403
411
  process.once("SIGINT", onSignal);
@@ -409,6 +417,7 @@ async function main() {
409
417
  try {
410
418
  const creds = await priv.acquireAccountCredential();
411
419
  (services.authStorage as any).set("privateer", { type: "oauth", ...creds });
420
+ rememberAccountCredential(creds); // claim it, so cleanup drops OUR entry and only ours
412
421
  } catch (e) {
413
422
  console.log(`${RED}Account channel unavailable: ${(e as Error).message}${RESET}`);
414
423
  }
@@ -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
  *