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.
- package/extensions/privateer-brand.ts +24 -11
- package/extensions/privateer-connect.ts +135 -10
- package/package.json +2 -2
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +20 -1
- package/src/auth/privateer.ts +24 -5
- package/src/channels/run.ts +18 -1
- package/src/cli/chat.ts +11 -2
- package/src/config/hosted.ts +21 -0
- package/src/harbor/index.ts +255 -74
- package/src/mcp/catalog.ts +32 -1
- package/src/mcp/toolNames.ts +177 -0
- package/src/providers/account.ts +318 -9
- package/src/remote/liveTaskSession.ts +11 -3
- package/src/remote/mcpControl.ts +224 -28
- package/src/remote/relayClient.ts +43 -6
- package/src/remote/routinesControl.ts +1 -1
- package/src/routines/schema.ts +2 -0
- package/src/routines/store.ts +1 -1
- package/src/routines/toolSelect.ts +13 -19
- package/src/tools/web.ts +236 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translating a routine's MCP selectors into the tool names Pi will actually honour.
|
|
3
|
+
*
|
|
4
|
+
* There are TWO vocabularies here and conflating them is why per-routine connector
|
|
5
|
+
* allow-lists silently granted nothing:
|
|
6
|
+
*
|
|
7
|
+
* • The SELECTOR vocabulary — "<server>__<tool>" exact, or "<server>__*" for a whole
|
|
8
|
+
* server. This is what a routine stores, what the app writes, and what the AI
|
|
9
|
+
* drafts. The double underscore is the point: no Pi builtin contains "__", so
|
|
10
|
+
* splitRoutineTools can tell a connector selector from a builtin name without a
|
|
11
|
+
* lookup table. This vocabulary is STABLE — routines on disk depend on it.
|
|
12
|
+
*
|
|
13
|
+
* • The REGISTERED vocabulary — what pi-mcp-adapter actually names a tool when it
|
|
14
|
+
* registers it with Pi: `formatToolName()` → "<serverPrefix>_<tool>", ONE
|
|
15
|
+
* underscore, with dashes in the server name folded to underscores. And Pi's
|
|
16
|
+
* `tools:` option is an exact-match Set (`allowedToolNames`), so a literal
|
|
17
|
+
* "github__*" or even "github__create_issue" handed to it matches nothing at all.
|
|
18
|
+
*
|
|
19
|
+
* This module is the translation layer, mirroring pi-mcp-adapter's `getServerPrefix`
|
|
20
|
+
* / `formatToolName` rather than importing them — the adapter ships as .ts in
|
|
21
|
+
* node_modules and pulling it into our typecheck is the thing `harbor/index.ts`
|
|
22
|
+
* already dodges with a variable import specifier. The mirror is four lines and is
|
|
23
|
+
* pinned by tests/toolSelect.test.ts.
|
|
24
|
+
*
|
|
25
|
+
* The other half of the problem: per-tool names only EXIST when direct tools are
|
|
26
|
+
* enabled. Otherwise the adapter exposes MCP through a single proxy tool named "mcp"
|
|
27
|
+
* — all servers, all tools, one grant, which is precisely what a per-routine
|
|
28
|
+
* allow-list is meant to avoid. So callers pair `names` with `directToolsEnv`, which
|
|
29
|
+
* scopes MCP_DIRECT_TOOLS to exactly the selected server/tool pairs for that one run.
|
|
30
|
+
*/
|
|
31
|
+
import { readFileSync } from "node:fs";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { agentDir } from "../config/paths.ts";
|
|
34
|
+
|
|
35
|
+
export type PrefixMode = "server" | "none" | "short";
|
|
36
|
+
|
|
37
|
+
/** Mirrors pi-mcp-adapter's getServerPrefix. */
|
|
38
|
+
export function serverPrefix(serverName: string, mode: PrefixMode): string {
|
|
39
|
+
if (mode === "none") return "";
|
|
40
|
+
if (mode === "short") {
|
|
41
|
+
const short = serverName.replace(/-?mcp$/i, "").replace(/-/g, "_");
|
|
42
|
+
return short || "mcp";
|
|
43
|
+
}
|
|
44
|
+
return serverName.replace(/-/g, "_");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Mirrors pi-mcp-adapter's formatToolName. */
|
|
48
|
+
export function formatToolName(toolName: string, serverName: string, mode: PrefixMode): string {
|
|
49
|
+
const p = serverPrefix(serverName, mode);
|
|
50
|
+
return p ? `${p}_${toolName}` : toolName;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The cached tool inventory the adapter builds after connecting: server → tool names. */
|
|
54
|
+
export type McpInventory = Record<string, string[]>;
|
|
55
|
+
|
|
56
|
+
export interface ResolveInput {
|
|
57
|
+
/** "<server>__<tool>" / "<server>__*" selectors, as stored on the routine. */
|
|
58
|
+
selectors: string[];
|
|
59
|
+
/** Server → the tool names it exposes, from the adapter's metadata cache. */
|
|
60
|
+
inventory: McpInventory;
|
|
61
|
+
/** The adapter's tool-prefix mode (mcp.json → settings.toolPrefix). */
|
|
62
|
+
prefix: PrefixMode;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ResolvedMcpTools {
|
|
66
|
+
/** Exact registered tool names to hand to Pi's `tools:` allow-list. */
|
|
67
|
+
names: string[];
|
|
68
|
+
/** Servers touched by the selectors — the set that must be reachable this run. */
|
|
69
|
+
servers: string[];
|
|
70
|
+
/** MCP_DIRECT_TOOLS entries ("server/tool", or bare "server" for a wildcard). */
|
|
71
|
+
directToolsEnv: string[];
|
|
72
|
+
/**
|
|
73
|
+
* Servers a selector named that the metadata cache knows nothing about. A wildcard
|
|
74
|
+
* over one of these expands to NOTHING, so the caller must warm the cache before
|
|
75
|
+
* building the session — see harbor/index.ts. Never silently ignore this.
|
|
76
|
+
*/
|
|
77
|
+
coldServers: string[];
|
|
78
|
+
/**
|
|
79
|
+
* Exact selectors whose server DID report an inventory that doesn't contain that
|
|
80
|
+
* tool — a typo, or a tool the connector dropped. The name is still granted (it
|
|
81
|
+
* simply never registers), but the caller should say so rather than let the run
|
|
82
|
+
* quietly come back thinner than asked for.
|
|
83
|
+
*/
|
|
84
|
+
unknownTools: string[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Expand selectors against a known inventory. Pure — the file reads live in
|
|
89
|
+
* `resolveMcpSelection` below so this stays trivially testable.
|
|
90
|
+
*
|
|
91
|
+
* An EXACT selector resolves without the inventory (we can compute the registered
|
|
92
|
+
* name from the server + tool alone), so a connector whose cache entry is stale still
|
|
93
|
+
* works. A WILDCARD needs the inventory to enumerate, which is why `coldServers`
|
|
94
|
+
* exists.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveMcpTools({ selectors, inventory, prefix }: ResolveInput): ResolvedMcpTools {
|
|
97
|
+
const names: string[] = [];
|
|
98
|
+
const servers: string[] = [];
|
|
99
|
+
const directToolsEnv: string[] = [];
|
|
100
|
+
const coldServers: string[] = [];
|
|
101
|
+
const unknownTools: string[] = [];
|
|
102
|
+
|
|
103
|
+
const push = <T>(arr: T[], v: T) => {
|
|
104
|
+
if (!arr.includes(v)) arr.push(v);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
for (const selector of selectors) {
|
|
108
|
+
const sep = selector.indexOf("__");
|
|
109
|
+
if (sep <= 0) continue; // not a selector; splitRoutineTools already routed it
|
|
110
|
+
const server = selector.slice(0, sep);
|
|
111
|
+
const tool = selector.slice(sep + 2);
|
|
112
|
+
if (!tool) continue;
|
|
113
|
+
push(servers, server);
|
|
114
|
+
|
|
115
|
+
if (tool === "*") {
|
|
116
|
+
const known = inventory[server];
|
|
117
|
+
if (!known || known.length === 0) {
|
|
118
|
+
push(coldServers, server);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// Bare server name = "every tool on this server" to MCP_DIRECT_TOOLS.
|
|
122
|
+
push(directToolsEnv, server);
|
|
123
|
+
for (const t of known) push(names, formatToolName(t, server, prefix));
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
push(names, formatToolName(tool, server, prefix));
|
|
128
|
+
// MCP_DIRECT_TOOLS matches the ORIGINAL (unprefixed) tool name.
|
|
129
|
+
push(directToolsEnv, `${server}/${tool}`);
|
|
130
|
+
const known = inventory[server];
|
|
131
|
+
if (!known || known.length === 0) push(coldServers, server);
|
|
132
|
+
else if (!known.includes(tool)) push(unknownTools, selector);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { names, servers, directToolsEnv, coldServers, unknownTools };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The adapter's metadata cache, as it lands on disk (agent/mcp-cache.json). */
|
|
139
|
+
export function readMcpInventory(dir: string = agentDir()): McpInventory {
|
|
140
|
+
const out: McpInventory = {};
|
|
141
|
+
try {
|
|
142
|
+
const raw = JSON.parse(readFileSync(join(dir, "mcp-cache.json"), "utf8"));
|
|
143
|
+
for (const [server, entry] of Object.entries<any>(raw?.servers ?? {})) {
|
|
144
|
+
const tools = Array.isArray(entry?.tools)
|
|
145
|
+
? entry.tools.map((t: any) => String(t?.name ?? "")).filter(Boolean)
|
|
146
|
+
: [];
|
|
147
|
+
out[server] = tools;
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
/* no cache yet — every selected server is cold */
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The adapter's prefix mode. `mcpControl.project()` pins "server" into the file it
|
|
157
|
+
* writes, so this is really a guard for a hand-written mcp.json.
|
|
158
|
+
*/
|
|
159
|
+
export function readPrefixMode(dir: string = agentDir()): PrefixMode {
|
|
160
|
+
try {
|
|
161
|
+
const raw = JSON.parse(readFileSync(join(dir, "mcp.json"), "utf8"));
|
|
162
|
+
const mode = raw?.settings?.toolPrefix;
|
|
163
|
+
if (mode === "server" || mode === "none" || mode === "short") return mode;
|
|
164
|
+
} catch {
|
|
165
|
+
/* no config — the adapter's own default */
|
|
166
|
+
}
|
|
167
|
+
return "server";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Read-from-disk wrapper around resolveMcpTools. */
|
|
171
|
+
export function resolveMcpSelection(selectors: string[], dir: string = agentDir()): ResolvedMcpTools {
|
|
172
|
+
return resolveMcpTools({
|
|
173
|
+
selectors,
|
|
174
|
+
inventory: readMcpInventory(dir),
|
|
175
|
+
prefix: readPrefixMode(dir),
|
|
176
|
+
});
|
|
177
|
+
}
|
package/src/providers/account.ts
CHANGED
|
@@ -14,12 +14,17 @@ import {
|
|
|
14
14
|
type AccountCredential,
|
|
15
15
|
serverBaseUrl,
|
|
16
16
|
hasCredentials,
|
|
17
|
+
currentUser,
|
|
18
|
+
logout,
|
|
17
19
|
runDeviceLogin,
|
|
18
20
|
authedFetch,
|
|
19
21
|
acquireAccountCredential,
|
|
20
22
|
refreshAccountCredentials,
|
|
21
23
|
notifySignedIn,
|
|
22
24
|
} from "../auth/privateer.ts";
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { globalDir } from "../config/paths.ts";
|
|
23
28
|
import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
|
|
24
29
|
import { ACCOUNT_DEFAULT_MODEL_ID, ACCOUNT_NEAR_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
|
|
25
30
|
import {
|
|
@@ -36,7 +41,8 @@ import {
|
|
|
36
41
|
// found" warning, which matters more than ever now that a signed-OUT terminal also
|
|
37
42
|
// launches on it. The first two entries are the TEE tiers (Tinfoil, then NEAR); the
|
|
38
43
|
// rest are the familiar names. Also the fallback list if the live listing is
|
|
39
|
-
// unreachable.
|
|
44
|
+
// unreachable. It is the FLOOR of the synchronous seed, not the whole of it — a launch
|
|
45
|
+
// that has seen the live catalog before seeds from the cache too (see seedCatalogIds).
|
|
40
46
|
const DEFAULT_MODELS = [
|
|
41
47
|
ACCOUNT_DEFAULT_MODEL_ID,
|
|
42
48
|
ACCOUNT_NEAR_MODEL_ID,
|
|
@@ -58,6 +64,72 @@ function seedModel(id: string) {
|
|
|
58
64
|
};
|
|
59
65
|
}
|
|
60
66
|
|
|
67
|
+
// ── Catalog cache ────────────────────────────────────────────────────────────
|
|
68
|
+
//
|
|
69
|
+
// The live catalog (241 models and counting) can only be registered once the network
|
|
70
|
+
// fetch resolves, and a registration made after extension load does NOT reach the model
|
|
71
|
+
// registry immediately: pi queues it (extensions/loader.js pendingProviderRegistrations)
|
|
72
|
+
// and flushes it when the session BINDS. Everything that resolves a model at LAUNCH runs
|
|
73
|
+
// before that — Pi's findInitialModel (saved settings default) and its session-model
|
|
74
|
+
// restore both call modelRegistry.find() while only the synchronous seed exists. So a
|
|
75
|
+
// model outside DEFAULT_MODELS was un-resolvable at launch and Pi fell through to "first
|
|
76
|
+
// model with configured auth", i.e. a BYO provider — measurably `openrouter/*` on a
|
|
77
|
+
// machine with an OpenRouter key. That is the dead end defaultModel.ts exists to prevent,
|
|
78
|
+
// re-entered through the back door.
|
|
79
|
+
//
|
|
80
|
+
// So: remember the ids the live catalog last returned, and seed from them SYNCHRONOUSLY
|
|
81
|
+
// on the next launch. The live fetch still re-registers the authoritative list moments
|
|
82
|
+
// later, so a model the server drops disappears on the next launch rather than lingering.
|
|
83
|
+
//
|
|
84
|
+
// Deliberately ids ONLY. Privacy tiers are never cached: the tier drives the shield in
|
|
85
|
+
// /models, and a stale privacy claim is exactly the thing not to render from disk. The
|
|
86
|
+
// picker keeps fetching them live (accountCatalogLoaded stays false until it does).
|
|
87
|
+
const CATALOG_CACHE_MAX = 2000; // bound what a corrupted/hostile file can register
|
|
88
|
+
|
|
89
|
+
function catalogCachePath(): string {
|
|
90
|
+
return join(globalDir(), "account-models.json");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Best effort in both directions: this cache is an optimization, and a launch must never
|
|
94
|
+
// fail because it couldn't be read or written.
|
|
95
|
+
function saveCachedCatalogIds(ids: string[]): void {
|
|
96
|
+
try {
|
|
97
|
+
mkdirSync(globalDir(), { recursive: true });
|
|
98
|
+
const payload = { v: 1, fetchedAt: new Date().toISOString(), ids: ids.slice(0, CATALOG_CACHE_MAX) };
|
|
99
|
+
writeFileSync(catalogCachePath(), JSON.stringify(payload) + "\n", "utf8");
|
|
100
|
+
} catch {
|
|
101
|
+
/* unwritable home — we just seed from DEFAULT_MODELS next launch */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function loadCachedCatalogIds(): string[] {
|
|
106
|
+
try {
|
|
107
|
+
const path = catalogCachePath();
|
|
108
|
+
if (!existsSync(path)) return [];
|
|
109
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as { ids?: unknown };
|
|
110
|
+
if (!Array.isArray(parsed.ids)) return [];
|
|
111
|
+
return parsed.ids.filter((id): id is string => typeof id === "string" && id.length > 0).slice(0, CATALOG_CACHE_MAX);
|
|
112
|
+
} catch {
|
|
113
|
+
return []; // absent, unreadable, or garbage — the seed list still works
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The ids to register synchronously at load. DEFAULT_MODELS FIRST and always: the account
|
|
118
|
+
// default has to be index 0 both because it must always resolve and because Pi clones the
|
|
119
|
+
// provider's first/default model when it synthesizes a custom model id
|
|
120
|
+
// (model-resolver.js buildFallbackModel).
|
|
121
|
+
export function seedCatalogIds(): string[] {
|
|
122
|
+
const ids = [...DEFAULT_MODELS];
|
|
123
|
+
const seen = new Set(ids);
|
|
124
|
+
for (const id of loadCachedCatalogIds()) {
|
|
125
|
+
if (!seen.has(id)) {
|
|
126
|
+
seen.add(id);
|
|
127
|
+
ids.push(id);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return ids;
|
|
131
|
+
}
|
|
132
|
+
|
|
61
133
|
// One catalog entry with its server-asserted baseline privacy tier.
|
|
62
134
|
export interface AccountModelInfo {
|
|
63
135
|
id: string;
|
|
@@ -129,6 +201,9 @@ export async function fetchAccountCatalog(): Promise<AccountModelInfo[]> {
|
|
|
129
201
|
const parsed = (data.models ?? [])
|
|
130
202
|
.map((m) => (m.modelId ? { id: m.modelId, tier: normalizeTier(m.privacy?.tier, m.modelId) } : null))
|
|
131
203
|
.filter((x): x is AccountModelInfo => !!x);
|
|
204
|
+
// Cache only a real LIVE listing — never the fallback, which would freeze the six
|
|
205
|
+
// seed ids on disk and read back as though it were the catalog.
|
|
206
|
+
if (parsed.length) saveCachedCatalogIds(parsed.map((p) => p.id));
|
|
132
207
|
infos = parsed.length ? parsed : fallback();
|
|
133
208
|
}
|
|
134
209
|
} catch {
|
|
@@ -144,6 +219,23 @@ export async function fetchAccountModels(): Promise<string[]> {
|
|
|
144
219
|
return (await fetchAccountCatalog()).map((m) => m.id);
|
|
145
220
|
}
|
|
146
221
|
|
|
222
|
+
// The device-code verification link, as an ABSOLUTE url.
|
|
223
|
+
//
|
|
224
|
+
// The server sends it scheme-less ("www.privateer.pro/settings/link-terminal?code=…"),
|
|
225
|
+
// and both surfaces that show it treat it as a URL: Pi's login dialog wraps it in an
|
|
226
|
+
// OSC-8 terminal hyperlink, and our own /login widget prints it for the user to open.
|
|
227
|
+
// Without a scheme an OSC-8 target isn't a valid URI, so terminals decline to linkify
|
|
228
|
+
// it — the one link in the sign-in flow becomes unclickable text. Prefix https:// when
|
|
229
|
+
// the value has no scheme of its own, and leave a well-formed (or empty) value alone.
|
|
230
|
+
// Only http/https are honoured: a scheme-looking prefix we don't expect is treated as a
|
|
231
|
+
// hostname rather than passed through to a terminal as a clickable link.
|
|
232
|
+
export function verificationLink(raw: string | undefined): string {
|
|
233
|
+
const uri = (raw ?? "").trim();
|
|
234
|
+
if (!uri) return "";
|
|
235
|
+
if (/^https?:\/\//i.test(uri)) return uri;
|
|
236
|
+
return `https://${uri.replace(/^\/+/, "")}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
147
239
|
// The Pi OAuth provider (Omit<OAuthProviderInterface, "id"> — Pi supplies the id from
|
|
148
240
|
// the provider name). login/refreshToken/getApiKey are the whole contract.
|
|
149
241
|
export const privateerOAuthProvider = {
|
|
@@ -155,12 +247,40 @@ export const privateerOAuthProvider = {
|
|
|
155
247
|
// promise never settles, and Pi never restores the editor: the "Waiting for
|
|
156
248
|
// authentication…" screen hangs with no way out. See auth/privateer.ts
|
|
157
249
|
// pollForToken, which checks the signal and rejects with "Login cancelled.".
|
|
158
|
-
async login(cb: {
|
|
250
|
+
async login(cb: {
|
|
251
|
+
onDeviceCode?: (info: unknown) => void;
|
|
252
|
+
onSelect?: (prompt: { message: string; options: { id: string; label: string }[] }) => Promise<string | undefined>;
|
|
253
|
+
signal?: AbortSignal;
|
|
254
|
+
}) {
|
|
159
255
|
// Fresh machine? The device-code flow below fires notifySignedIn itself (via
|
|
160
256
|
// pollForToken). Already linked? No device code runs — so we announce the
|
|
161
257
|
// completed subscription login ourselves at the end, or the header/badge would
|
|
162
258
|
// keep showing "not signed in" until the next launch.
|
|
163
|
-
|
|
259
|
+
//
|
|
260
|
+
// Already linked AND the user wants a DIFFERENT account is the third case, and it
|
|
261
|
+
// used to be unreachable: login() short-circuited on hasCredentials(), so choosing
|
|
262
|
+
// "Privateer account" while linked silently re-armed the account already signed in
|
|
263
|
+
// and reported success — there was no way to switch accounts from here at all. Pi
|
|
264
|
+
// hands us an `onSelect` callback for exactly this, so ask. Switching means signing
|
|
265
|
+
// this machine out first (logout revokes the machine's whole token family), which
|
|
266
|
+
// the prompt says plainly; the device flow then runs as if fresh.
|
|
267
|
+
let wasLinked = hasCredentials();
|
|
268
|
+
if (wasLinked && cb.onSelect) {
|
|
269
|
+
const user = currentUser();
|
|
270
|
+
const who = user?.email ?? user?.id ?? "this account";
|
|
271
|
+
const choice = await cb.onSelect({
|
|
272
|
+
message: `Already signed in as ${who}.`,
|
|
273
|
+
options: [
|
|
274
|
+
{ id: "keep", label: `Stay signed in as ${who}` },
|
|
275
|
+
{ id: "switch", label: "Sign in as a different account (signs this machine out first)" },
|
|
276
|
+
],
|
|
277
|
+
});
|
|
278
|
+
if (choice === undefined) throw new Error("Login cancelled"); // selector dismissed
|
|
279
|
+
if (choice === "switch") {
|
|
280
|
+
await logout(); // revokes this machine's sessions and wipes local auth state
|
|
281
|
+
wasLinked = false; // fall through to the device flow as a fresh machine
|
|
282
|
+
}
|
|
283
|
+
}
|
|
164
284
|
if (!wasLinked) {
|
|
165
285
|
try {
|
|
166
286
|
await runDeviceLogin({
|
|
@@ -168,7 +288,9 @@ export const privateerOAuthProvider = {
|
|
|
168
288
|
onCode: (code) =>
|
|
169
289
|
cb.onDeviceCode?.({
|
|
170
290
|
userCode: code.user_code,
|
|
171
|
-
|
|
291
|
+
// Absolute url — the server's value is scheme-less and Pi renders this as
|
|
292
|
+
// a terminal hyperlink. See verificationLink.
|
|
293
|
+
verificationUri: verificationLink(code.verification_uri_complete ?? code.verification_uri),
|
|
172
294
|
intervalSeconds: code.interval,
|
|
173
295
|
expiresInSeconds: code.expires_in,
|
|
174
296
|
}),
|
|
@@ -207,9 +329,19 @@ export const privateerOAuthProvider = {
|
|
|
207
329
|
return creds;
|
|
208
330
|
},
|
|
209
331
|
async refreshToken(creds: { refresh: string }) {
|
|
332
|
+
// Rotate THIS process's own session, never another terminal's. Pi keeps one
|
|
333
|
+
// credential per provider in auth.json and that file is machine-global, so the
|
|
334
|
+
// credential handed to us here can belong to a different, still-running terminal
|
|
335
|
+
// (see the ownership note above rememberAccountCredential). Rotating that one
|
|
336
|
+
// would take over its session and invalidate the copy it still holds — the exact
|
|
337
|
+
// reuse hazard the child-session split exists to avoid (auth/privateer.ts). When
|
|
338
|
+
// the incoming token isn't ours, rotate ours instead: Pi gets a valid credential
|
|
339
|
+
// either way, and the other terminal keeps its own.
|
|
340
|
+
const mine = armSlot().cred?.refresh;
|
|
341
|
+
const refresh = mine && creds.refresh !== mine ? mine : creds.refresh;
|
|
210
342
|
let next: AccountCredential;
|
|
211
343
|
try {
|
|
212
|
-
next = await refreshAccountCredentials(
|
|
344
|
+
next = await refreshAccountCredentials(refresh);
|
|
213
345
|
} catch {
|
|
214
346
|
// Child token expired/reused → get another. acquire (not spawn) so a terminal
|
|
215
347
|
// that already holds the device's last session slot can reclaim an orphan
|
|
@@ -334,7 +466,10 @@ export function makeAccountProvider() {
|
|
|
334
466
|
const shim = sealedShimBase();
|
|
335
467
|
return provider && shim ? { ...base, baseUrl: `${shim}/${provider}/v1` } : base;
|
|
336
468
|
};
|
|
337
|
-
|
|
469
|
+
// Seed with the last live catalog when we have one (see seedCatalogIds): this is the
|
|
470
|
+
// list Pi resolves a saved default / a restored session model against at launch,
|
|
471
|
+
// before the live re-registration can reach the registry.
|
|
472
|
+
let lastIds: string[] = seedCatalogIds();
|
|
338
473
|
const register = (ids: string[]): void => {
|
|
339
474
|
lastIds = ids;
|
|
340
475
|
pi.registerProvider!("privateer", {
|
|
@@ -345,7 +480,7 @@ export function makeAccountProvider() {
|
|
|
345
480
|
models: ids.map(modelEntry),
|
|
346
481
|
});
|
|
347
482
|
};
|
|
348
|
-
register(
|
|
483
|
+
register(lastIds); // immediate: provider exists this tick, with a resolvable catalog
|
|
349
484
|
// Bring up the sealed shim, then re-register so sealed models pick up their shim
|
|
350
485
|
// baseUrl. Registration re-runs anyway after the catalog fetch; this just makes
|
|
351
486
|
// sure the switch lands even if the fetch is slow or fails.
|
|
@@ -373,6 +508,32 @@ export function makeAccountProvider() {
|
|
|
373
508
|
// though the banner says "connected". The REPL (cli/chat.ts) and the harbor
|
|
374
509
|
// already spawn one at startup; this gives the TUI the same seed.
|
|
375
510
|
pi.on?.("session_start", (_e, ctx) => void armAccountCredential(ctx));
|
|
511
|
+
|
|
512
|
+
// Two safety nets around the account channel, both OUTSIDE the request path —
|
|
513
|
+
// they read Pi's in-memory auth map and its finished messages, and never touch the
|
|
514
|
+
// inference request itself, so a healthy prompt behaves exactly as before.
|
|
515
|
+
//
|
|
516
|
+
// before_agent_start: re-arm if Pi's persisted credential vanished mid-session.
|
|
517
|
+
// Awaited (Pi awaits extension handlers), so the turn starts with a key rather than
|
|
518
|
+
// failing and asking the user to resend. See ensureAccountArmed.
|
|
519
|
+
pi.on?.("before_agent_start", async (_e, ctx) => {
|
|
520
|
+
try {
|
|
521
|
+
await ensureAccountArmed(ctx);
|
|
522
|
+
} catch {
|
|
523
|
+
/* the turn's own error path reports better than a diagnostic here */
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// message_end: an assistant turn that ended in an auth error on the account channel
|
|
528
|
+
// means our session token is dead server-side. Pi has no reactive-401 refresh, so
|
|
529
|
+
// replace the session now instead of failing every prompt until `expires`.
|
|
530
|
+
// See recoverAccountSession.
|
|
531
|
+
pi.on?.("message_end", (e, ctx) => {
|
|
532
|
+
const msg = (e as { message?: { role?: string; stopReason?: string; errorMessage?: string } })?.message;
|
|
533
|
+
if (msg?.role !== "assistant" || msg.stopReason !== "error" || !msg.errorMessage) return;
|
|
534
|
+
if ((ctx as SeedContext)?.model?.provider !== "privateer") return;
|
|
535
|
+
void recoverAccountSession(ctx, msg.errorMessage);
|
|
536
|
+
});
|
|
376
537
|
};
|
|
377
538
|
}
|
|
378
539
|
|
|
@@ -394,7 +555,12 @@ export function makeAccountProvider() {
|
|
|
394
555
|
// which Pi would happily reuse and 401 on.
|
|
395
556
|
const ARMED = Symbol.for("privateer.accountCredential");
|
|
396
557
|
type ArmedSlot = {
|
|
397
|
-
[ARMED]?: {
|
|
558
|
+
[ARMED]?: {
|
|
559
|
+
cred?: AccountCredential;
|
|
560
|
+
inFlight?: Promise<AccountCredential>;
|
|
561
|
+
// When we last replaced a failed session (recoverAccountSession's cooldown).
|
|
562
|
+
recoveredAt?: number;
|
|
563
|
+
};
|
|
398
564
|
};
|
|
399
565
|
|
|
400
566
|
function armSlot(): NonNullable<ArmedSlot[typeof ARMED]> {
|
|
@@ -405,10 +571,29 @@ function armSlot(): NonNullable<ArmedSlot[typeof ARMED]> {
|
|
|
405
571
|
// Record a credential this process minted so nothing mints a second one. Exported for
|
|
406
572
|
// the OAuth login path, which acquires its credential for Pi to own and would
|
|
407
573
|
// otherwise leave the next arm() with nothing to reuse.
|
|
574
|
+
//
|
|
575
|
+
// This memo is also the OWNERSHIP record for Pi's persisted credential, which matters
|
|
576
|
+
// because auth.json holds exactly one `privateer` entry and is shared by every
|
|
577
|
+
// Privateer terminal on the machine. Each terminal mints its own session at launch, so
|
|
578
|
+
// the last one to start wins on disk and the entry any terminal reads back may belong
|
|
579
|
+
// to a different, still-running terminal. Two rules follow, both keyed off this memo:
|
|
580
|
+
//
|
|
581
|
+
// 1. Only DROP the persisted entry when it's the one we minted
|
|
582
|
+
// (dropPersistedAccountCredential). The exit hooks used to remove it
|
|
583
|
+
// unconditionally, so quitting one terminal deleted a live terminal's credential.
|
|
584
|
+
// That terminal kept working from Pi's in-memory copy until `expires`, then Pi
|
|
585
|
+
// reloaded auth.json, found nothing, and every prompt dead-ended on "No API key
|
|
586
|
+
// found for privateer" — with nothing to re-arm it before the next session_start.
|
|
587
|
+
// 2. Only ROTATE our own refresh token (privateerOAuthProvider.refreshToken).
|
|
408
588
|
export function rememberAccountCredential(cred: AccountCredential): void {
|
|
409
589
|
armSlot().cred = cred;
|
|
410
590
|
}
|
|
411
591
|
|
|
592
|
+
// The credential this process minted, if any. Ownership probe for the two rules above.
|
|
593
|
+
export function ownedAccountCredential(): AccountCredential | undefined {
|
|
594
|
+
return armSlot().cred;
|
|
595
|
+
}
|
|
596
|
+
|
|
412
597
|
// The remembered credential, if it's still usable. A minute of headroom: handing back
|
|
413
598
|
// one that expires mid-request just trades a spawn for a 401.
|
|
414
599
|
function liveAccountCredential(): AccountCredential | undefined {
|
|
@@ -437,7 +622,14 @@ async function accountCredential(): Promise<AccountCredential> {
|
|
|
437
622
|
// `ctx` is Pi's ExtensionContext; the auth store hangs off its model registry (the same
|
|
438
623
|
// path privateer-brand uses to DROP the credential on sign-out).
|
|
439
624
|
type SeedContext = {
|
|
440
|
-
modelRegistry?: {
|
|
625
|
+
modelRegistry?: {
|
|
626
|
+
authStorage?: {
|
|
627
|
+
set?: (provider: string, cred: unknown) => void;
|
|
628
|
+
get?: (provider: string) => unknown;
|
|
629
|
+
remove?: (provider: string) => void;
|
|
630
|
+
};
|
|
631
|
+
};
|
|
632
|
+
model?: { provider?: string; id?: string };
|
|
441
633
|
hasUI?: boolean;
|
|
442
634
|
ui?: { notify?: (message: string, level: string) => void };
|
|
443
635
|
};
|
|
@@ -474,3 +666,120 @@ export async function armAccountCredential(
|
|
|
474
666
|
return false;
|
|
475
667
|
}
|
|
476
668
|
}
|
|
669
|
+
|
|
670
|
+
// Drop Pi's PERSISTED account credential (the `privateer` entry in auth.json) — but
|
|
671
|
+
// ONLY when it's the one this process minted. Every exit path pairs a session revoke
|
|
672
|
+
// with this drop (the contract in auth/privateer.ts): Pi reuses the persisted
|
|
673
|
+
// credential on the next launch and refreshes it only on expiry, never reactively on a
|
|
674
|
+
// 401, so leaving a revoked token behind dead-ends the next run.
|
|
675
|
+
//
|
|
676
|
+
// The ownership check is what keeps that teardown from harming a CONCURRENT terminal.
|
|
677
|
+
// auth.json is machine-global with one entry per provider, so the entry on disk is
|
|
678
|
+
// whichever terminal armed last; an unconditional remove here deleted a live
|
|
679
|
+
// terminal's key and stranded it (see the note above rememberAccountCredential).
|
|
680
|
+
// Returns true only if the entry was actually removed.
|
|
681
|
+
//
|
|
682
|
+
// `force` is for the teardowns where ownership is irrelevant because NOTHING on this
|
|
683
|
+
// machine can use the entry any more: an explicit sign-out and a detected
|
|
684
|
+
// expiry/revocation both go through logout()/clearCredentials(), which revoke the
|
|
685
|
+
// machine's whole token family — every terminal's session included. Those callers have
|
|
686
|
+
// also already wiped the local credentials, so the ownership memo is gone by then and an
|
|
687
|
+
// ownership-checked drop would refuse and strand a dead entry on disk.
|
|
688
|
+
export function dropPersistedAccountCredential(ctx: unknown, opts: { force?: boolean } = {}): boolean {
|
|
689
|
+
const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
|
|
690
|
+
const mine = ownedAccountCredential()?.access;
|
|
691
|
+
// Every caller revokes this process's session immediately before calling us, so drop
|
|
692
|
+
// the memo whatever happens to the entry on disk — otherwise a later arm() in this
|
|
693
|
+
// process (a harbor run's session_start, say) could re-persist the revoked token.
|
|
694
|
+
armSlot().cred = undefined;
|
|
695
|
+
if (typeof store?.remove !== "function") return false;
|
|
696
|
+
try {
|
|
697
|
+
if (opts.force) {
|
|
698
|
+
/* whole family revoked — the entry is dead for every terminal, drop it */
|
|
699
|
+
} else if (typeof store.get === "function") {
|
|
700
|
+
const persisted = store.get("privateer") as { access?: unknown } | undefined;
|
|
701
|
+
if (!persisted) return false; // nothing persisted — nothing to drop
|
|
702
|
+
// Some other terminal's session. Leave it: it's the key that terminal is using.
|
|
703
|
+
if (typeof persisted.access === "string" && persisted.access !== mine) return false;
|
|
704
|
+
} else if (!mine) {
|
|
705
|
+
// Older Pi with no readable auth store AND we never minted anything — we have no
|
|
706
|
+
// basis to claim the entry, so leave it alone rather than break another terminal.
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
store.remove("privateer");
|
|
710
|
+
return true;
|
|
711
|
+
} catch {
|
|
712
|
+
return false; // older Pi shape / unwritable store — the server TTL is the fallback
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Make sure Pi still HAS an account credential before a turn goes out. Normally
|
|
717
|
+
// session_start's arm is enough and this is a free in-memory lookup. It exists for the
|
|
718
|
+
// case session_start can't cover: the persisted entry disappearing mid-session, which
|
|
719
|
+
// another terminal's exit could do (and still can, for a terminal running an older
|
|
720
|
+
// build without the ownership check above). Once the entry is gone Pi has no path back
|
|
721
|
+
// — getApiKey returns undefined and nothing re-arms until the next session_start — so
|
|
722
|
+
// every prompt fails on "No API key found for privateer".
|
|
723
|
+
//
|
|
724
|
+
// An entry that exists but has EXPIRED is deliberately left alone: that's Pi's own
|
|
725
|
+
// refresh path (refreshToken), and pre-empting it would mint a second session row.
|
|
726
|
+
export async function ensureAccountArmed(ctx: unknown): Promise<void> {
|
|
727
|
+
if (!hasCredentials()) return;
|
|
728
|
+
const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
|
|
729
|
+
if (typeof store?.get !== "function") return; // can't tell — leave it to session_start
|
|
730
|
+
try {
|
|
731
|
+
if (store.get("privateer")) return;
|
|
732
|
+
} catch {
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
await armAccountCredential(ctx, { notify: false });
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Errors that mean "the token we're presenting is no longer accepted", as opposed to a
|
|
739
|
+
// cap, a network blip, or a model error. Matched against the provider error text Pi
|
|
740
|
+
// surfaces (the OpenAI SDK prefixes the status, and our backend sends `code` +
|
|
741
|
+
// `message`), so a revoked or dead session is recognizable without parsing internals.
|
|
742
|
+
// "No API key ... privateer" covers both wordings that mean the channel wasn't armed:
|
|
743
|
+
// pi-ai's "No API key for provider: privateer" (thrown when getApiKey resolves to
|
|
744
|
+
// undefined, which is also what a swallowed refresh failure looks like — pi-ai flattens
|
|
745
|
+
// our reason into "Failed to refresh OAuth token") and Pi's own "No API key found for
|
|
746
|
+
// privateer." guidance text.
|
|
747
|
+
const ACCOUNT_AUTH_FAILURE =
|
|
748
|
+
/\b401\b|SESSION_REVOKED|Authentication required|Invalid token|Failed to refresh OAuth token|No API key\b[^\n]*privateer/i;
|
|
749
|
+
|
|
750
|
+
// Don't spin: if the account itself is gone, one replacement attempt per window is
|
|
751
|
+
// plenty, and the user gets a clear message instead of a retry loop.
|
|
752
|
+
const RECOVERY_COOLDOWN_MS = 30_000;
|
|
753
|
+
|
|
754
|
+
// Replace a dead account session after an auth failure, so the NEXT prompt works.
|
|
755
|
+
//
|
|
756
|
+
// This is the client-side answer to a gap in Pi's OAuth contract: inference goes out
|
|
757
|
+
// over Pi's own HTTP path (provider baseUrl + the bearer from getApiKey), which has no
|
|
758
|
+
// reactive-401 hook — unlike authedFetch, which refreshes and retries in place. Pi
|
|
759
|
+
// refreshes on `expires` alone, so a session that dies server-side early (revoked from
|
|
760
|
+
// the app's Linked Devices, or cascaded by another device's logout) stays dead for the
|
|
761
|
+
// remaining lifetime of the access token — up to ~24h — with every prompt failing.
|
|
762
|
+
// Detecting the failure after the fact and re-arming turns that into a single failed
|
|
763
|
+
// turn the user can resend.
|
|
764
|
+
export async function recoverAccountSession(ctx: unknown, errorMessage: string): Promise<boolean> {
|
|
765
|
+
if (!hasCredentials()) return false; // signed out: onSessionExpired owns that message
|
|
766
|
+
if (!ACCOUNT_AUTH_FAILURE.test(errorMessage)) return false;
|
|
767
|
+
const slot = armSlot();
|
|
768
|
+
const now = Date.now();
|
|
769
|
+
if (slot.recoveredAt !== undefined && now - slot.recoveredAt < RECOVERY_COOLDOWN_MS) return false;
|
|
770
|
+
slot.recoveredAt = now;
|
|
771
|
+
// The credential we hold is the one that just failed — forget it so the arm below
|
|
772
|
+
// reclaims or spawns a live session instead of handing back the dead token.
|
|
773
|
+
slot.cred = undefined;
|
|
774
|
+
const armed = await armAccountCredential(ctx, { notify: false });
|
|
775
|
+
const c = ctx as SeedContext;
|
|
776
|
+
if (c?.hasUI) {
|
|
777
|
+
c.ui?.notify?.(
|
|
778
|
+
armed
|
|
779
|
+
? "Your Privateer account session had expired — opened a new one. Send that message again."
|
|
780
|
+
: "Your Privateer account session isn't valid any more and couldn't be renewed. Run /login to sign back in.",
|
|
781
|
+
armed ? "warning" : "error",
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
return armed;
|
|
785
|
+
}
|
|
@@ -20,7 +20,12 @@ import { agentVersion } from "../config/version.ts";
|
|
|
20
20
|
import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
|
|
21
21
|
import { makePermissionGate, isRemoteUnsafeTool, type GateController } from "../ext/permissionGate.ts";
|
|
22
22
|
import { makePiPrivacyExtension } from "pi-privacy";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
makeAccountProvider,
|
|
25
|
+
privateerChannel,
|
|
26
|
+
rememberAccountCredential,
|
|
27
|
+
dropPersistedAccountCredential,
|
|
28
|
+
} from "../providers/account.ts";
|
|
24
29
|
import { RelayClient, type TaskSpec } from "./relayClient.ts";
|
|
25
30
|
import { RemoteBridge } from "./remoteBridge.ts";
|
|
26
31
|
import { spawnAccountCredentials, revokeAccountSession, hasCredentials } from "../auth/privateer.ts";
|
|
@@ -62,7 +67,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
62
67
|
let initialPromptSent = false;
|
|
63
68
|
let stopped = false;
|
|
64
69
|
let spawnedAccount = false;
|
|
65
|
-
let servicesRef: { authStorage?: { remove?: (p: string) => void } } | null = null;
|
|
70
|
+
let servicesRef: { authStorage?: { remove?: (p: string) => void; get?: (p: string) => unknown } } | null = null;
|
|
66
71
|
|
|
67
72
|
let attachTimer: ReturnType<typeof setTimeout> | undefined;
|
|
68
73
|
let lifeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -77,7 +82,9 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
77
82
|
// app's Linked Devices; the harbor's own child session stays alive. Best-effort.
|
|
78
83
|
if (spawnedAccount) {
|
|
79
84
|
try { await revokeAccountSession(); } catch { /* server TTL is the fallback */ }
|
|
80
|
-
|
|
85
|
+
// Ownership-checked: auth.json is shared machine-wide, so a live task's teardown
|
|
86
|
+
// must not delete an interactive terminal's entry (see providers/account.ts).
|
|
87
|
+
try { dropPersistedAccountCredential({ modelRegistry: { authStorage: servicesRef?.authStorage } }); } catch { /* nothing persisted */ }
|
|
81
88
|
}
|
|
82
89
|
deps.onClosed(termId);
|
|
83
90
|
deps.log(`live task ${termId} closed`);
|
|
@@ -160,6 +167,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
160
167
|
try {
|
|
161
168
|
const creds = await spawnAccountCredentials();
|
|
162
169
|
(services.authStorage as any).set("privateer", { type: "oauth", ...creds });
|
|
170
|
+
rememberAccountCredential(creds); // claim it, so stop() drops OUR entry only
|
|
163
171
|
spawnedAccount = true;
|
|
164
172
|
} catch (e) {
|
|
165
173
|
deps.log(`live task ${termId} account channel unavailable: ${(e as Error).message}`);
|