privateer-agent 0.12.12 → 0.12.13

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 CHANGED
@@ -72,7 +72,7 @@ What Privateer adds is a *moat* of Pi extensions layered on top:
72
72
  | `privateer-privacy` | `pi-privacy` — TEE attestation, ZDR routing, on-device PII gate — bound to the account tier resolver |
73
73
  | `privateer-account` | `/signin` billed inference against a Privateer account (device flow) |
74
74
  | `privateer-posture`, `privateer-tools` | live attestation shield + Privateer tool pack |
75
- | `rpiv-web-tools` | private-by-default web search (self-hosted SearXNG, no WebView) |
75
+ | `privateer-web` | `web_search`/`web_fetch` your account's search once signed in, or your own provider (`rpiv-web-tools`: self-hosted SearXNG, Brave, Tavily…) |
76
76
  | `rpiv-ask-user-question` | `ask_user_question` — a structured questionnaire the agent puts to you instead of guessing |
77
77
  | `pi-mcp-adapter`, `pi-subagents` | MCP servers · bounded parallel sub-agents |
78
78
 
@@ -612,8 +612,15 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
612
612
  approve it once.
613
613
  - **Workflows** — declarative multi-step pipelines the harbor executes; see
614
614
  [Workflows](#workflows).
615
- - **Web tools** (`rpiv-web-tools`) — private-by-default web search/fetch with pluggable backends
616
- (self-hosted SearXNG for fully private search).
615
+ - **Web tools** (`privateer-web`) — `web_search` and `web_fetch`, by one of two routes. Sign in
616
+ and they run on your Privateer account: no API key to obtain or keep on the machine, metered
617
+ against the account's daily web allowance, with the search itself made server-side (the derived
618
+ query is visible to Privateer — the page you fetch and the conversation around it are not).
619
+ Configure a provider of your own with `/web-tools` and that wins instead — `rpiv-web-tools` with
620
+ pluggable backends, including self-hosted SearXNG for fully private search, which is why a
621
+ provider you chose is never overridden. `PRIVATEER_WEB_SEARCH=privateer` picks the account route
622
+ even when you hold a key. Unattended runs (harbor, channels, ACP) always take the account route:
623
+ a routine must not hold a provider key it could be prompt-injected into leaking.
617
624
  - **Ask user question** (`rpiv-ask-user-question`) — when a request is underspecified the agent
618
625
  raises a structured questionnaire (typed options, multi-select, markdown previews, or type your
619
626
  own answer) instead of guessing. Ungated by design — it only asks you something.
@@ -631,6 +638,7 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
631
638
  | `/connect` · `/mcp` | add, enable, or remove MCP connectors / see what actually connected |
632
639
  | `/speak` · `/talk` | read answers aloud / voice input (**alt+t** is push-to-talk) |
633
640
  | `/extensions` | list loaded Pi extensions |
641
+ | `/web-tools` | point `web_search`/`web_fetch` at a search provider of your own (signed in, they already work on your account) |
634
642
  | `/init` | scaffold a starter `PRIVATEER.md` in this directory |
635
643
  | `/update` · `/privateer` | update to the latest release / Privateer status and posture |
636
644
 
@@ -0,0 +1,100 @@
1
+ // `web_search` / `web_fetch` for the terminal — one route, chosen at load.
2
+ //
3
+ // WHAT CHANGED. The TUI used to load @juicesharp/rpiv-web-tools and nothing else, so web
4
+ // access at a prompt meant going and getting a provider key first. Being signed in to a
5
+ // Privateer account bought you nothing here, even though the same account's unattended
6
+ // runs (harbor, channels, ACP) have searched through /api/rag/* all along. Now signing in
7
+ // is enough: with no provider of your own configured, the account's search is what the
8
+ // tools call — Brave server-side, no key on the machine, the account's daily allowance
9
+ // and metering, and the query visible to Privateer's servers (never say a session that
10
+ // searches this way is fully private).
11
+ //
12
+ // PRECEDENCE, AND WHY THIS FILE EXISTS. Both packs register the SAME two tool names, and
13
+ // Pi resolves duplicates first-registration-wins — a session holding both would silently
14
+ // get whichever loaded first. So exactly one of them may register, and something has to
15
+ // decide which: this extension, once per process, via tools/webMode.ts. A configured
16
+ // provider always wins. Someone who set up a self-hosted SearXNG picked the more private
17
+ // route deliberately, and re-pointing their searches at our servers (billing their account
18
+ // for them) would quietly undo that. `PRIVATEER_WEB_SEARCH=privateer` or `"provider":
19
+ // "privateer"` in the rpiv config is the way back for someone who holds a key and wants
20
+ // the account path anyway.
21
+ //
22
+ // WHAT IS STILL LIVE, AND WHAT NEEDS A RELAUNCH. `/signin` mid-session works: the account
23
+ // tools are registered up front and check for credentials per call, so a signed-out
24
+ // terminal says how to fix it rather than silently lacking a web tool. Configuring a
25
+ // provider mid-session does NOT switch routes — the registration already happened — so it
26
+ // takes effect at the next launch. `/web-tools` is registered either way, so that
27
+ // configuration is always reachable.
28
+ //
29
+ // THE PACK IS OPTIONAL. rpiv-web-tools is a dependency the launcher drops if it didn't
30
+ // resolve (a partial or hoisted install). Every import of it here is dynamic and
31
+ // failure-tolerant: without it there is no user provider to choose, so the account route
32
+ // is the only one left — which is the honest answer, not a fallback.
33
+ //
34
+ // The specifiers are built rather than written as literals on purpose: it keeps tsc from
35
+ // pulling that package's own .ts sources into our typecheck, the same trick config/moat.ts
36
+ // uses for pi-mcp-adapter.
37
+ import { guardedWebToolDefinitions } from "../src/tools/web.ts";
38
+ import { readWebToolsConfig, resolveWebMode, type ProviderMetaLike } from "../src/tools/webMode.ts";
39
+
40
+ const RPIV_WEB_TOOLS = "@juicesharp/rpiv-web-tools";
41
+
42
+ // What a signed-out terminal hears when the model reaches for the web. Both exits are
43
+ // named because both are real: sign in and it works on the account's allowance, or bring
44
+ // a provider of your own and the query never touches our servers.
45
+ const SIGNED_OUT_HINT =
46
+ "Web access needs a Privateer account: run /signin to search on your account's " +
47
+ "allowance, or /web-tools to use your own search provider (a Brave/Tavily key, or a " +
48
+ "self-hosted SearXNG) instead.";
49
+
50
+ interface RpivWebTools {
51
+ registerWebSearchTool(pi: unknown): void;
52
+ registerWebFetchTool(pi: unknown): void;
53
+ registerWebSearchConfigCommand(pi: unknown): void;
54
+ providers: readonly ProviderMetaLike[];
55
+ }
56
+
57
+ async function loadRpivWebTools(): Promise<RpivWebTools | null> {
58
+ try {
59
+ // Two entry points: the package root registers the tools and the `/web-tools`
60
+ // command, and providers/ carries the metadata (which env var holds which provider's
61
+ // key, which providers are self-hosted) that the precedence rules read. Taking that
62
+ // metadata from the package rather than restating it here is what keeps our notion of
63
+ // "configured" from drifting from theirs when it gains a provider.
64
+ const [tools, providers] = await Promise.all([
65
+ import(`${RPIV_WEB_TOOLS}/index.ts`),
66
+ import(`${RPIV_WEB_TOOLS}/providers/index.ts`),
67
+ ]);
68
+ if (typeof tools?.registerWebSearchTool !== "function") return null;
69
+ return {
70
+ registerWebSearchTool: tools.registerWebSearchTool,
71
+ registerWebFetchTool: tools.registerWebFetchTool,
72
+ registerWebSearchConfigCommand: tools.registerWebSearchConfigCommand,
73
+ providers: Array.isArray(providers?.PROVIDERS) ? providers.PROVIDERS : [],
74
+ };
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ export default async function privateerWeb(pi: any): Promise<void> {
81
+ const rpiv = await loadRpivWebTools();
82
+ const decision = resolveWebMode({ providers: rpiv?.providers ?? [], config: readWebToolsConfig() });
83
+
84
+ if (rpiv && decision.mode === "own") {
85
+ // The user's own provider, run by the package that owns it — its interceptors, its
86
+ // guidance overrides, its truncation. We are choosing between packs, not wrapping one.
87
+ rpiv.registerWebSearchTool(pi);
88
+ rpiv.registerWebFetchTool(pi);
89
+ } else {
90
+ for (const def of guardedWebToolDefinitions(SIGNED_OUT_HINT)) pi.registerTool?.(def);
91
+ }
92
+
93
+ // Always available, whichever pack won: it is how a user on the account route configures
94
+ // a provider of their own, and the only place to see which keys are resolving.
95
+ try {
96
+ rpiv?.registerWebSearchConfigCommand(pi);
97
+ } catch {
98
+ /* the command is a convenience — never let it cost the session its web tools */
99
+ }
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.12",
3
+ "version": "0.12.13",
4
4
  "description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -14,12 +14,12 @@
14
14
  { "name": "privateer-hints", "entry": "extensions/privateer-hints.ts", "note": "rotating tips in the working line + /hints" },
15
15
  { "name": "privateer-update", "entry": "extensions/privateer-update.ts", "note": "tool pack updates in place — banner flag + /update" },
16
16
  { "name": "privateer-speak", "entry": "extensions/privateer-speak.ts", "note": "spoken responses (/speak) + voice input (/talk) — pi-speak + confidential account TTS/STT" },
17
- { "name": "rpiv-web-tools", "dep": ["@juicesharp/rpiv-web-tools", "index.ts"], "note": "private web tools (user's own provider key)" },
17
+ { "name": "privateer-web", "entry": "extensions/privateer-web.ts", "note": "web_search/web_fetch account search when signed in, else the user's own provider (rpiv-web-tools)" },
18
18
  { "name": "rpiv-ask-user-question", "dep": ["@juicesharp/rpiv-ask-user-question", "index.ts"], "note": "ask_user_question" },
19
19
  { "name": "pi-mcp-adapter", "dep": ["pi-mcp-adapter", "index.ts"], "note": "MCP servers as first-class tools" },
20
20
  { "name": "pi-subagents", "dep": ["pi-subagents", "src", "extension", "index.ts"], "note": "bounded parallel sub-agents" }
21
21
  ],
22
- "retired": ["pi-privacy", "pi-web-access", "pi-hypa"],
22
+ "retired": ["pi-privacy", "pi-web-access", "pi-hypa", "rpiv-web-tools"],
23
23
  "reservedAliases": [
24
24
  "@juicesharp/rpiv-web-tools",
25
25
  "@juicesharp/rpiv-ask-user-question"
package/src/tools/web.ts CHANGED
@@ -18,20 +18,29 @@
18
18
  // access is a switch on the agent rather than an unconditional capability. Never
19
19
  // describe a routine that searches as fully private.
20
20
  //
21
- // SCOPE. These are the UNATTENDED paths' web tools: the harbor (routines, tasks,
22
- // workflow agent steps) and channels, both of which build a session from an explicit
23
- // extensionFactories list. The interactive TUI is deliberately untouched — the launcher
24
- // loads @juicesharp/rpiv-web-tools for it instead, which registers tools by these same
25
- // two names against a provider key the user configures themselves. A person at a terminal
26
- // choosing their own search provider is fine; an unattended run holding that key is not.
21
+ // SCOPE. Two kinds of session reach these tools, by two different routes.
27
22
  //
28
- // The two must never meet: Pi resolves duplicate tool names first-registration-wins, so a
29
- // session that loaded both would silently get whichever came first. That is why the moat's
30
- // packs reach the TUI as `-e` args and the unattended paths as factories one route each,
31
- // chosen per process, and config/moat.ts's per-kind table is where the choice is recorded.
23
+ // Unattended the harbor (routines, tasks, workflow agent steps), channels and ACP
24
+ // build their session from an explicit extensionFactories list, and makeWebTools() is
25
+ // in it whenever webEnabled(). These paths get the account API and nothing else, for
26
+ // the reason above: an unattended run must not hold a provider key.
27
+ //
28
+ // Interactive — the TUI takes ONE of these tools and @juicesharp/rpiv-web-tools, which
29
+ // registers the same two names against a provider the user configured themselves.
30
+ // extensions/privateer-web.ts makes that choice once per process: a configured provider
31
+ // wins (it is the user's own decision, and a self-hosted SearXNG is more private than
32
+ // this path), otherwise these tools serve a signed-in account. See tools/webMode.ts for
33
+ // the precedence and the escape hatches. Signing in mid-session is enough to start
34
+ // searching — guardedWebToolDefinitions() checks credentials per call, not at load.
35
+ //
36
+ // The two implementations must never meet: Pi resolves duplicate tool names
37
+ // first-registration-wins, so a session that loaded both would silently get whichever came
38
+ // first. That is why the moat's packs reach the TUI as `-e` args and the unattended paths
39
+ // as factories — one route each, chosen per process, and config/moat.ts's per-kind table
40
+ // (plus privateer-web.ts, for the TUI) is where the choice is recorded.
32
41
 
33
42
  import { Type } from "typebox";
34
- import { apiRequest } from "../auth/privateer.ts";
43
+ import { apiRequest, hasCredentials } from "../auth/privateer.ts";
35
44
 
36
45
  /** Tool names these definitions register, for allow-list construction. */
37
46
  export const WEB_TOOL_NAMES = ["web_search", "web_fetch"] as const;
@@ -229,8 +238,8 @@ export const webFetchToolDefinition = {
229
238
 
230
239
  /**
231
240
  * Extension factory registering both tools. Used by the unattended paths, which build
232
- * their session from an explicit `extensionFactories` list (see config/moat.ts); the
233
- * interactive TUI gets rpiv-web-tools instead, per the SCOPE note above.
241
+ * their session from an explicit `extensionFactories` list (see config/moat.ts) only once
242
+ * webEnabled() has confirmed there are credentials to search with.
234
243
  */
235
244
  export function makeWebTools() {
236
245
  return (pi: { registerTool?: (def: unknown) => void }): void => {
@@ -238,3 +247,33 @@ export function makeWebTools() {
238
247
  pi.registerTool?.(webFetchToolDefinition);
239
248
  };
240
249
  }
250
+
251
+ interface WebToolDefinition {
252
+ name: string;
253
+ execute(toolCallId: string, params: any): Promise<unknown>;
254
+ }
255
+
256
+ /**
257
+ * The same two tools, with a sign-in check in front of each call — the INTERACTIVE form.
258
+ *
259
+ * The unattended paths decide once, at session build, whether web access exists at all
260
+ * (webEnabled() is credentials-gated, and a run with no account simply has no such tool).
261
+ * A terminal can't work that way: the session outlives the decision, `/signin` happens in
262
+ * the middle of it, and each extension gets its own module instance under jiti — so the
263
+ * question has to be asked when the tool RUNS. loadCredentials() deliberately doesn't
264
+ * memoize a negative result, which is what makes that cheap and correct.
265
+ *
266
+ * `hint` is what a signed-out session hears. It belongs to the caller because only the
267
+ * caller knows what the user can do about it — the terminal can offer `/signin` and
268
+ * `/web-tools`, a headless host has neither.
269
+ */
270
+ export function guardedWebToolDefinitions(hint: string): unknown[] {
271
+ const guard = <T extends WebToolDefinition>(def: T): T => ({
272
+ ...def,
273
+ async execute(toolCallId: string, params: any) {
274
+ if (!hasCredentials()) return text(hint);
275
+ return def.execute(toolCallId, params);
276
+ },
277
+ });
278
+ return [guard(webSearchToolDefinition), guard(webFetchToolDefinition)];
279
+ }
@@ -0,0 +1,198 @@
1
+ // Which web route an INTERACTIVE session takes — the user's own search provider, or
2
+ // Privateer's account-backed search.
3
+ //
4
+ // WHY THERE IS A CHOICE AT ALL. Two implementations of `web_search`/`web_fetch` ship
5
+ // with this agent and they buy different things:
6
+ //
7
+ // own @juicesharp/rpiv-web-tools against a provider the user configured
8
+ // themselves (a Brave/Tavily/… key, or a self-hosted SearXNG). The query
9
+ // reaches that provider and nobody else — a self-hosted SearXNG is the most
10
+ // private web access we can offer — but it only exists once the user has
11
+ // gone and got a key.
12
+ // privateer tools/web.ts against the account API (/api/rag/*, Brave server-side). No
13
+ // key to obtain, no key on the machine, per-account daily caps and metering —
14
+ // but the derived query is visible to Privateer's servers.
15
+ //
16
+ // Before this, the terminal only ever got the first one, so a signed-in user at a prompt
17
+ // had no web access until they went and configured a provider, while the same account's
18
+ // unattended runs (harbor, channels, ACP) searched fine. Signing in is now enough.
19
+ //
20
+ // PRECEDENCE: A CONFIGURED PROVIDER ALWAYS WINS. Account search fills a gap; it never
21
+ // overrides a decision the user already made. Someone who stood up a SearXNG chose the
22
+ // more private route on purpose, and quietly re-pointing their searches at our servers
23
+ // (and billing their account for them) would undo that choice without telling them.
24
+ // So: configured ⇒ own, otherwise ⇒ privateer, with `PRIVATEER_WEB_SEARCH` or a
25
+ // `"provider": "privateer"` in the rpiv config as the escape hatch for someone who
26
+ // holds a key but wants the account path anyway.
27
+ //
28
+ // ONE ROUTE PER PROCESS. Pi resolves duplicate tool names first-registration-wins, so a
29
+ // session that loaded both packs would silently get whichever came first. The decision
30
+ // is therefore made ONCE, at extension load (extensions/privateer-web.ts), and only the
31
+ // winning pack registers its tools — see the SCOPE note in tools/web.ts. The consequence
32
+ // to know: configuring a provider mid-session takes effect at the next launch. Signing IN
33
+ // mid-session does not need one, because the account tools are already registered and
34
+ // only check credentials when called.
35
+ //
36
+ // This module is the decision, kept separate from the extension so it can be tested
37
+ // without Pi or jiti in the picture. No Pi imports, no side effects on load.
38
+
39
+ import { existsSync, readFileSync } from "node:fs";
40
+ import { homedir } from "node:os";
41
+ import { join } from "node:path";
42
+
43
+ export type WebMode = "own" | "privateer";
44
+
45
+ /** The fields we read off one of rpiv-web-tools' provider descriptors. */
46
+ export interface ProviderMetaLike {
47
+ name: string;
48
+ /** API-key env var, if the provider takes a key (BRAVE_SEARCH_API_KEY, …). */
49
+ envVar?: string;
50
+ /** Base-URL env var. Only self-hosted providers (SearXNG, Ollama) declare one. */
51
+ baseUrlEnvVar?: string;
52
+ }
53
+
54
+ /** The fields we read out of ~/.config/rpiv-web-tools/config.json. */
55
+ export interface WebToolsConfigLike {
56
+ provider?: string;
57
+ /** Legacy top-level Brave key, still honoured by rpiv-web-tools' own resolver. */
58
+ apiKey?: string;
59
+ apiKeys?: Record<string, string>;
60
+ baseUrls?: Record<string, string>;
61
+ }
62
+
63
+ /** Config-file / env value that pins the account path even when a provider is configured. */
64
+ export const PRIVATEER_PROVIDER_NAME = "privateer";
65
+
66
+ /** Force one route or the other: PRIVATEER_WEB_SEARCH=privateer | own. */
67
+ export const WEB_MODE_ENV = "PRIVATEER_WEB_SEARCH";
68
+
69
+ // Mirrors rpiv-web-tools' own DEFAULT_PROVIDER_NAME. It matters because the config file
70
+ // may name no provider at all, in which case that package still resolves a Brave key —
71
+ // so an unset `provider` with BRAVE_SEARCH_API_KEY in the environment is a configured
72
+ // user, and we must not take their searches off it.
73
+ const DEFAULT_PROVIDER_NAME = "brave";
74
+
75
+ // The one provider whose key was historically stored at the top level, again mirroring
76
+ // rpiv-web-tools: `config.apiKey` is still a working Brave key there until the next save
77
+ // migrates it into `apiKeys.brave`.
78
+ const LEGACY_TOP_LEVEL_KEY_PROVIDER = "brave";
79
+
80
+ /** Where rpiv-web-tools keeps its config (@juicesharp/rpiv-config's `configPath`). */
81
+ export function webToolsConfigPath(): string {
82
+ return join(homedir(), ".config", "rpiv-web-tools", "config.json");
83
+ }
84
+
85
+ /**
86
+ * Read that config, tolerantly. Every failure — absent, unreadable, malformed, not an
87
+ * object — degrades to `{}`, i.e. "nothing configured", which is the same fail-soft
88
+ * policy rpiv-web-tools applies to its own file. A broken config must not decide the
89
+ * route by throwing at extension load.
90
+ */
91
+ export function readWebToolsConfig(path: string = webToolsConfigPath()): WebToolsConfigLike {
92
+ try {
93
+ if (!existsSync(path)) return {};
94
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
95
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
96
+ return parsed as WebToolsConfigLike;
97
+ } catch {
98
+ return {};
99
+ }
100
+ }
101
+
102
+ const trimmed = (v: unknown): string | undefined => {
103
+ const s = typeof v === "string" ? v.trim() : "";
104
+ return s ? s : undefined;
105
+ };
106
+
107
+ /**
108
+ * Does the user have a working credential for `provider`? Deliberately the same order
109
+ * rpiv-web-tools' resolveProviderApiKey uses — env, then the per-provider map, then the
110
+ * legacy top-level field for Brave — because agreeing with it is the whole point: we are
111
+ * answering "would that package be able to search?", not inventing our own notion of
112
+ * configured.
113
+ */
114
+ function resolveKey(
115
+ provider: string,
116
+ meta: ProviderMetaLike | undefined,
117
+ config: WebToolsConfigLike,
118
+ env: Record<string, string | undefined>,
119
+ ): string | undefined {
120
+ const envKey = meta?.envVar ? trimmed(env[meta.envVar]) : undefined;
121
+ if (envKey) return envKey;
122
+ const configKey = trimmed(config.apiKeys?.[provider]);
123
+ if (configKey) return configKey;
124
+ if (provider === LEGACY_TOP_LEVEL_KEY_PROVIDER) return trimmed(config.apiKey);
125
+ return undefined;
126
+ }
127
+
128
+ export interface WebModeDecision {
129
+ mode: WebMode;
130
+ /** The rpiv-web-tools provider the decision was made about ("brave" when unset). */
131
+ provider: string;
132
+ /** Why, in a few words — for tests and for the `/web-tools` diagnostic line. */
133
+ reason: string;
134
+ }
135
+
136
+ /**
137
+ * Decide the route for this process.
138
+ *
139
+ * `providers` is rpiv-web-tools' PROVIDERS metadata, passed in rather than imported so
140
+ * this stays testable and so a missing package (the pack is optional — the launcher drops
141
+ * a shim whose target isn't installed) degrades to "no provider is configured", which is
142
+ * the truth: without the package there is nothing to configure.
143
+ */
144
+ export function resolveWebMode(opts: {
145
+ providers: readonly ProviderMetaLike[];
146
+ config: WebToolsConfigLike;
147
+ env?: Record<string, string | undefined>;
148
+ }): WebModeDecision {
149
+ const { providers, config } = opts;
150
+ const env = opts.env ?? process.env;
151
+
152
+ const configured = trimmed(config.provider);
153
+ const provider = configured ?? DEFAULT_PROVIDER_NAME;
154
+
155
+ // 1. The explicit override, either way. Note "own" is honoured even with nothing
156
+ // configured: the user gets rpiv-web-tools' own "run /web-tools" prompt, which is
157
+ // the right answer for someone who has just told us they want their own provider.
158
+ const forced = trimmed(env[WEB_MODE_ENV])?.toLowerCase();
159
+ if (forced === PRIVATEER_PROVIDER_NAME) return { mode: "privateer", provider, reason: `${WEB_MODE_ENV} set` };
160
+ if (forced === "own") return { mode: "own", provider, reason: `${WEB_MODE_ENV} set` };
161
+
162
+ // 2. `"provider": "privateer"` in the config file — the persistent form of the same
163
+ // choice, for someone who holds a key but wants the account path anyway. It is not a
164
+ // provider rpiv-web-tools knows (its factory would throw on it), which is exactly why
165
+ // it is safe to borrow as our marker: it can only have been set to mean this.
166
+ if (configured?.toLowerCase() === PRIVATEER_PROVIDER_NAME) {
167
+ return { mode: "privateer", provider, reason: "account search pinned in config" };
168
+ }
169
+
170
+ const meta = providers.find((p) => p.name === provider);
171
+
172
+ // 3. A key for the active provider — from the environment or the config file.
173
+ if (resolveKey(provider, meta, config, env)) {
174
+ return { mode: "own", provider, reason: `${provider} key configured` };
175
+ }
176
+
177
+ // 4. Self-hosted providers (SearXNG, Ollama) need no key — SELECTING one is the whole
178
+ // configuration, since rpiv-web-tools falls back to the provider's default URL. So a
179
+ // bare `"provider": "searxng"` is a working setup and must not be overridden.
180
+ //
181
+ // Only a SELECTED provider counts, here and above. A URL env var alone is not read as
182
+ // intent, however tempting: Ollama's is OLLAMA_HOST, which is set on any machine
183
+ // running a local model for reasons that have nothing to do with search. Honouring it
184
+ // would hand that user rpiv-web-tools with its unconfigured default provider — a
185
+ // terminal whose every search fails — instead of the account search they can actually
186
+ // use. Matching what rpiv-web-tools itself would resolve is the rule; guessing at
187
+ // half-configured states is not.
188
+ if (configured && meta?.baseUrlEnvVar) {
189
+ return { mode: "own", provider, reason: `${provider} selected (self-hosted)` };
190
+ }
191
+
192
+ // 5. A provider we don't recognise was named — a newer rpiv-web-tools than the metadata
193
+ // we were handed, or a typo. Either way the user made a choice about their own
194
+ // provider; let that package own the outcome, including telling them it's unknown.
195
+ if (configured && !meta) return { mode: "own", provider, reason: `unrecognised provider "${provider}"` };
196
+
197
+ return { mode: "privateer", provider, reason: "no provider configured" };
198
+ }