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 +11 -3
- package/extensions/privateer-web.ts +100 -0
- package/package.json +1 -1
- package/src/config/moatManifest.json +2 -2
- package/src/tools/web.ts +52 -13
- package/src/tools/webMode.ts +198 -0
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
|
-
| `
|
|
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** (`
|
|
616
|
-
|
|
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.
|
|
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": "
|
|
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.
|
|
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
|
-
//
|
|
29
|
-
// session
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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)
|
|
233
|
-
*
|
|
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
|
+
}
|