privateer-agent 0.14.6 → 0.15.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/README.md +5 -5
- package/bin/headless-flags.mjs +1 -1
- package/bin/privateer-launch.mjs +33 -0
- package/bin/privateer-subagent.mjs +13 -0
- package/extensions/privateer-codemode.ts +15 -0
- package/extensions/privateer-connect.ts +21 -18
- package/extensions/privateer-web.ts +1 -1
- package/package.json +8 -9
- package/patches/{@earendil-works+pi-ai+0.86.0.patch → @earendil-works+pi-ai+1.3.0.patch} +6 -7
- package/patches/{@earendil-works+pi-coding-agent+0.86.0.patch → @earendil-works+pi-coding-agent+1.3.0.patch} +275 -184
- package/patches/{pi-background-tasks+2.5.0.patch → pi-background-tasks+2.6.10.patch} +36 -21
- package/src/bridge/engineAdapter.ts +6 -0
- package/src/config/moat.ts +51 -12
- package/src/config/moatManifest.json +3 -3
- package/src/config/whatsNew.json +4 -4
- package/src/ext/permissionGate.ts +39 -2
- package/src/harbor/closeSession.ts +1 -1
- package/src/harbor/index.ts +55 -133
- package/src/mcp/catalog.ts +10 -9
- package/src/mcp/piMcp.ts +139 -0
- package/src/mcp/toolNames.ts +39 -140
- package/src/permissions/classify.ts +42 -4
- package/src/permissions/protected.ts +7 -0
- package/src/providers/account.ts +7 -2
- package/src/remote/mcpControl.ts +189 -66
- package/src/remote/relayClient.ts +1 -1
- package/src/routines/toolSelect.ts +4 -4
- package/src/harbor/buildLock.ts +0 -19
package/src/mcp/catalog.ts
CHANGED
|
@@ -44,8 +44,8 @@ export interface CatalogEntry {
|
|
|
44
44
|
* flag rather than falling out of the URL: every other http entry here is a remote
|
|
45
45
|
* service the user authorizes, so "must authenticate" is derived from
|
|
46
46
|
* `transport === "http"` alone. That is false here — there is nothing to authorize
|
|
47
|
-
* — so the flag is what makes draftFromCatalog emit `auth: "none"
|
|
48
|
-
*
|
|
47
|
+
* — so the flag is what makes draftFromCatalog emit `auth: "none"`, and the app
|
|
48
|
+
* doesn't tell the user to sign in to something with no sign-in.
|
|
49
49
|
*
|
|
50
50
|
* Always pair with `hosted: false`: a hosted enclave has no route to the user's
|
|
51
51
|
* loopback, and hostedCapable()'s derived rule keys on `oauth`, not on this.
|
|
@@ -199,7 +199,8 @@ export const MCP_CATALOG: CatalogEntry[] = [
|
|
|
199
199
|
label: "Jira & Confluence",
|
|
200
200
|
blurb: "Atlassian issues and pages. Sign in via browser.",
|
|
201
201
|
transport: "http",
|
|
202
|
-
|
|
202
|
+
// Streamable HTTP; /v1/sse is the legacy SSE endpoint, which Pi does not speak.
|
|
203
|
+
url: "https://mcp.atlassian.com/v1/mcp",
|
|
203
204
|
oauth: true,
|
|
204
205
|
needs: "oauth",
|
|
205
206
|
},
|
|
@@ -219,7 +220,9 @@ export const MCP_CATALOG: CatalogEntry[] = [
|
|
|
219
220
|
label: "Asana",
|
|
220
221
|
blurb: "Tasks and projects. Sign in via browser.",
|
|
221
222
|
transport: "http",
|
|
222
|
-
|
|
223
|
+
// Streamable HTTP on the same OAuth server as the legacy /sse endpoint, which Pi does
|
|
224
|
+
// not speak. (/v2/mcp is a separate app-registration flow.)
|
|
225
|
+
url: "https://mcp.asana.com/mcp",
|
|
223
226
|
oauth: true,
|
|
224
227
|
needs: "oauth",
|
|
225
228
|
},
|
|
@@ -453,11 +456,9 @@ export function draftFromCatalog(
|
|
|
453
456
|
draft.args = (e.args ?? []).map((a) => (e.fill && a === e.fill && filled ? filled : a));
|
|
454
457
|
} else {
|
|
455
458
|
draft.url = input.url?.trim() || e.url;
|
|
456
|
-
// Emit
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
// there would send the adapter looking for an authorization server that does not
|
|
460
|
-
// exist, which fails at connect time rather than at save time.
|
|
459
|
+
// Emit our `auth` vocabulary rather than the legacy boolean. A localHttp entry
|
|
460
|
+
// authenticates to nothing — saying "oauth" there would put a sign-in prompt in
|
|
461
|
+
// front of a server that has no sign-in.
|
|
461
462
|
draft.auth = e.localHttp ? "none" : (e.oauth ?? true) ? "oauth" : "none";
|
|
462
463
|
}
|
|
463
464
|
|
package/src/mcp/piMcp.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi's MCP machinery for callers OUTSIDE a session: sign in to a connector, sign out,
|
|
3
|
+
* ask whether it is signed in, and test that it answers.
|
|
4
|
+
*
|
|
5
|
+
* The desktop's connector manager is the caller (treeview desktop/src/main/
|
|
6
|
+
* mcpService.ts). It used to reach into pi-mcp-adapter's OAuth flow for this; with
|
|
7
|
+
* Pi's built-in MCP support, the tokens a session uses live in Pi's own store
|
|
8
|
+
* (`agent/mcp-auth.json`, keyed by server name and URL), so anything that signs in has
|
|
9
|
+
* to go through Pi or the session will not find the result.
|
|
10
|
+
*
|
|
11
|
+
* Sign-in and sign-out run Pi's own `mcp login` / `mcp logout` (runMcpCommand) — the
|
|
12
|
+
* whole flow: connect, read the server's challenge, browser authorization against a
|
|
13
|
+
* loopback callback, store, reconnect. The test builds the same McpServerConnection a
|
|
14
|
+
* session builds, so "the test passed" means what a session will see, stored OAuth
|
|
15
|
+
* tokens included.
|
|
16
|
+
*
|
|
17
|
+
* These are not in pi-coding-agent's package exports, so they are imported by file,
|
|
18
|
+
* resolved from the package's own entry (which follows a hoisted install). Every deep
|
|
19
|
+
* import of Pi's MCP internals lives HERE, behind small functions, and
|
|
20
|
+
* tests/piMcp.test.ts pins each one against the installed Pi.
|
|
21
|
+
*/
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { agentDir } from "../config/paths.ts";
|
|
25
|
+
|
|
26
|
+
// Pi finds mcp-auth.json, mcp.log and its refresh locks through getAgentDir(), which
|
|
27
|
+
// reads this variable. Every Privateer entrypoint sets it in boot.ts; the desktop's
|
|
28
|
+
// main process, which calls these without running a session, may not have.
|
|
29
|
+
process.env.PI_CODING_AGENT_DIR ??= agentDir();
|
|
30
|
+
|
|
31
|
+
const PI_ENTRY = import.meta.resolve("@earendil-works/pi-coding-agent");
|
|
32
|
+
const piFile = (rel: string) => import(new URL(rel, PI_ENTRY).href);
|
|
33
|
+
|
|
34
|
+
export type McpAuthStatus = "not_supported" | "not_authenticated" | "authenticated";
|
|
35
|
+
|
|
36
|
+
/** Run one `pi mcp …` command against our agent dir; collect what it printed. */
|
|
37
|
+
async function mcpCommand(
|
|
38
|
+
args: string[],
|
|
39
|
+
opts: { openUrl?: (url: string) => void } = {},
|
|
40
|
+
): Promise<{ code: number; out: string[]; err: string[] }> {
|
|
41
|
+
const { runMcpCommand } = await piFile("./extensions/mcp/cli.js");
|
|
42
|
+
const out: string[] = [];
|
|
43
|
+
const err: string[] = [];
|
|
44
|
+
const code = await runMcpCommand(args, {
|
|
45
|
+
// The user-level config only: these commands manage the connectors the user added,
|
|
46
|
+
// never a project's.
|
|
47
|
+
cwd: homedir(),
|
|
48
|
+
agentDir: agentDir(),
|
|
49
|
+
openUrl: opts.openUrl,
|
|
50
|
+
log: (line: string) => out.push(line),
|
|
51
|
+
error: (line: string) => err.push(line),
|
|
52
|
+
});
|
|
53
|
+
return { code, out, err };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Sign in to an OAuth connector from mcp.json. `onUrl` gets the authorization URL —
|
|
58
|
+
* the caller opens the browser and shows it. Already signed in → ok at once.
|
|
59
|
+
*/
|
|
60
|
+
export async function mcpSignIn(
|
|
61
|
+
name: string,
|
|
62
|
+
onUrl: (url: string) => void,
|
|
63
|
+
timeoutMs = 5 * 60_000,
|
|
64
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
65
|
+
const r = await mcpCommand(["login", name, "--timeout", String(Math.round(timeoutMs / 1000))], { openUrl: onUrl });
|
|
66
|
+
return r.code === 0 ? { ok: true } : { ok: false, error: r.err.join("\n") || r.out.join("\n") || "sign-in failed" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Delete the stored credentials of an OAuth connector. */
|
|
70
|
+
export async function mcpSignOut(name: string): Promise<void> {
|
|
71
|
+
await mcpCommand(["logout", name]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether a connector has stored OAuth credentials. "not_supported" for a server Pi
|
|
76
|
+
* would never start OAuth for: stdio, or HTTP carrying its own Authorization header.
|
|
77
|
+
* A stored but expired access token still counts — Pi refreshes it on the next call.
|
|
78
|
+
*/
|
|
79
|
+
export async function mcpAuthStatus(server: {
|
|
80
|
+
name: string;
|
|
81
|
+
url?: string;
|
|
82
|
+
headers?: Record<string, string>;
|
|
83
|
+
}): Promise<McpAuthStatus> {
|
|
84
|
+
if (!server.url) return "not_supported";
|
|
85
|
+
if (Object.keys(server.headers ?? {}).some((h) => h.toLowerCase() === "authorization")) return "not_supported";
|
|
86
|
+
const { McpOAuthCredentialStore } = await piFile("./extensions/mcp/oauth.js");
|
|
87
|
+
const tokens = new McpOAuthCredentialStore(undefined, undefined).tokens(server.name, server.url);
|
|
88
|
+
return tokens ? "authenticated" : "not_authenticated";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface McpHandshake {
|
|
92
|
+
ok: boolean;
|
|
93
|
+
tools?: Array<{ name: string; description?: string }>;
|
|
94
|
+
/** True when the server answered but wants a sign-in first. */
|
|
95
|
+
needsAuth?: boolean;
|
|
96
|
+
error?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Connect to one server exactly as a session would, list its tools, hang up. `config`
|
|
101
|
+
* is a Pi `mcpServers` entry — what mcpControl / mcpService project into mcp.json.
|
|
102
|
+
*/
|
|
103
|
+
export async function mcpHandshake(name: string, config: Record<string, unknown>, timeoutMs = 20_000): Promise<McpHandshake> {
|
|
104
|
+
const { McpServerConnection, createDefaultTransport, McpOAuthCredentialStore, McpServerLog } = await piFile(
|
|
105
|
+
"./extensions/mcp/runtime.js",
|
|
106
|
+
);
|
|
107
|
+
const connection = new McpServerConnection({
|
|
108
|
+
entry: { name, config, source: join(agentDir(), "mcp.json"), scope: "global" },
|
|
109
|
+
cwd: homedir(),
|
|
110
|
+
createTransport: createDefaultTransport,
|
|
111
|
+
credentials: new McpOAuthCredentialStore(),
|
|
112
|
+
log: new McpServerLog(join(agentDir(), "mcp.log")),
|
|
113
|
+
onTools: () => {},
|
|
114
|
+
});
|
|
115
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
116
|
+
try {
|
|
117
|
+
await Promise.race([
|
|
118
|
+
connection.getClient(),
|
|
119
|
+
new Promise((_, reject) => {
|
|
120
|
+
timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
121
|
+
}),
|
|
122
|
+
]);
|
|
123
|
+
const tools = (connection.tools as Array<{ name: string; description?: string }>).map((t) => ({
|
|
124
|
+
name: t.name,
|
|
125
|
+
description: t.description,
|
|
126
|
+
}));
|
|
127
|
+
return { ok: true, tools };
|
|
128
|
+
} catch (e) {
|
|
129
|
+
if (connection.state === "needs-auth") return { ok: false, needsAuth: true, error: "sign-in required" };
|
|
130
|
+
return { ok: false, error: connection.error ?? (e instanceof Error ? e.message : String(e)) };
|
|
131
|
+
} finally {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
try {
|
|
134
|
+
await connection.close();
|
|
135
|
+
} catch {
|
|
136
|
+
/* already gone */
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
package/src/mcp/toolNames.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Translating a routine's MCP selectors into the tool names Pi
|
|
2
|
+
* Translating a routine's MCP selectors into the tool names Pi registers.
|
|
3
3
|
*
|
|
4
|
-
* There are TWO vocabularies here
|
|
5
|
-
* allow-lists silently granted nothing:
|
|
4
|
+
* There are TWO vocabularies here:
|
|
6
5
|
*
|
|
7
6
|
* • The SELECTOR vocabulary — "<server>__<tool>" exact, or "<server>__*" for a whole
|
|
8
7
|
* server. This is what a routine stores, what the app writes, and what the AI
|
|
@@ -10,100 +9,50 @@
|
|
|
10
9
|
* splitRoutineTools can tell a connector selector from a builtin name without a
|
|
11
10
|
* lookup table. This vocabulary is STABLE — routines on disk depend on it.
|
|
12
11
|
*
|
|
13
|
-
* • The REGISTERED vocabulary — what
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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.
|
|
12
|
+
* • The REGISTERED vocabulary — what Pi's built-in MCP support names a tool:
|
|
13
|
+
* "mcp__<server>__<tool>", with every character other than letters, digits and "_"
|
|
14
|
+
* replaced by "_" (pi-coding-agent docs/mcp.md, "Configuration rules").
|
|
18
15
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
16
|
+
* Pi's `tools:` option takes `*` patterns, and once any entry starts with "mcp__" it
|
|
17
|
+
* keeps ONLY the MCP tools that match — so a selector translates to one allow-list
|
|
18
|
+
* entry, wildcard included, with no tool inventory to consult and nothing to warm.
|
|
19
|
+
* Mirrors Pi's naming rather than importing it so this stays Pi-free and testable;
|
|
23
20
|
* 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
21
|
*/
|
|
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
22
|
|
|
47
|
-
|
|
48
|
-
export function formatToolName(toolName: string, serverName: string, mode: PrefixMode): string {
|
|
49
|
-
const p = serverPrefix(serverName, mode);
|
|
50
|
-
return p ? `${p}_${toolName}` : toolName;
|
|
51
|
-
}
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
52
24
|
|
|
53
|
-
|
|
54
|
-
|
|
25
|
+
const NON_IDENT = /[^A-Za-z0-9_]/g;
|
|
26
|
+
// Pi's cap (extensions/mcp/tools.js MAX_TOOL_NAME_LENGTH); longer names get a hash.
|
|
27
|
+
const MAX_TOOL_NAME_LENGTH = 64;
|
|
55
28
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Pi's registered name for one MCP tool — createMcpToolName, including its shortening
|
|
31
|
+
* of a name over 64 characters to a prefix plus a hash of server and tool. (Pi also
|
|
32
|
+
* hashes two tools whose sanitized names collide; that depends on the live tool list
|
|
33
|
+
* and can't be predicted from a selector.)
|
|
34
|
+
*/
|
|
35
|
+
export function mcpToolName(server: string, tool: string): string {
|
|
36
|
+
const name = `mcp__${server}__${tool}`.replace(NON_IDENT, "_");
|
|
37
|
+
if (name.length <= MAX_TOOL_NAME_LENGTH) return name;
|
|
38
|
+
const hash = createHash("sha256").update(`${server}\0${tool}`).digest("hex").slice(0, 8);
|
|
39
|
+
return `${name.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;
|
|
63
40
|
}
|
|
64
41
|
|
|
65
42
|
export interface ResolvedMcpTools {
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
/** Servers touched by the selectors
|
|
43
|
+
/** Entries for Pi's `tools:` allow-list — exact names, or "mcp__<server>__*". */
|
|
44
|
+
patterns: string[];
|
|
45
|
+
/** Servers touched by the selectors, in selector order. */
|
|
69
46
|
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
47
|
}
|
|
86
48
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
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[] = [];
|
|
49
|
+
/** Translate stored selectors into Pi allow-list patterns. Pure. */
|
|
50
|
+
export function resolveMcpTools(selectors: string[]): ResolvedMcpTools {
|
|
51
|
+
const patterns: string[] = [];
|
|
98
52
|
const servers: string[] = [];
|
|
99
|
-
const
|
|
100
|
-
const coldServers: string[] = [];
|
|
101
|
-
const unknownTools: string[] = [];
|
|
102
|
-
|
|
103
|
-
const push = <T>(arr: T[], v: T) => {
|
|
53
|
+
const push = (arr: string[], v: string) => {
|
|
104
54
|
if (!arr.includes(v)) arr.push(v);
|
|
105
55
|
};
|
|
106
|
-
|
|
107
56
|
for (const selector of selectors) {
|
|
108
57
|
const sep = selector.indexOf("__");
|
|
109
58
|
if (sep <= 0) continue; // not a selector; splitRoutineTools already routed it
|
|
@@ -111,67 +60,17 @@ export function resolveMcpTools({ selectors, inventory, prefix }: ResolveInput):
|
|
|
111
60
|
const tool = selector.slice(sep + 2);
|
|
112
61
|
if (!tool) continue;
|
|
113
62
|
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);
|
|
63
|
+
push(patterns, tool === "*" ? `${serverPrefix(server)}*` : mcpToolName(server, tool));
|
|
133
64
|
}
|
|
134
|
-
|
|
135
|
-
return { names, servers, directToolsEnv, coldServers, unknownTools };
|
|
65
|
+
return { patterns, servers };
|
|
136
66
|
}
|
|
137
67
|
|
|
138
|
-
/**
|
|
139
|
-
|
|
140
|
-
|
|
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";
|
|
68
|
+
/** "mcp__<server>__", the start of every tool name of `server`. */
|
|
69
|
+
function serverPrefix(server: string): string {
|
|
70
|
+
return `mcp__${server.replace(NON_IDENT, "_")}__`;
|
|
168
71
|
}
|
|
169
72
|
|
|
170
|
-
/**
|
|
171
|
-
export function
|
|
172
|
-
return
|
|
173
|
-
selectors,
|
|
174
|
-
inventory: readMcpInventory(dir),
|
|
175
|
-
prefix: readPrefixMode(dir),
|
|
176
|
-
});
|
|
73
|
+
/** Does a registered tool name belong to `server`? */
|
|
74
|
+
export function isToolOfServer(registered: string, server: string): boolean {
|
|
75
|
+
return registered.startsWith(serverPrefix(server));
|
|
177
76
|
}
|
|
@@ -235,6 +235,10 @@ const MEDIA_TITLES: Record<string, string> = {
|
|
|
235
235
|
const RESUME_VIDEO_TITLE = "Save a video already generated (nothing further is billed)";
|
|
236
236
|
const RESUME_SPRITE_TITLE = "Save a sprite animation already generated (nothing further is billed)";
|
|
237
237
|
|
|
238
|
+
// Pi's MCP resource tools (core/mcp-servers.js). They list and read resources from
|
|
239
|
+
// every connected server — network reads from connectors the user configured.
|
|
240
|
+
const MCP_RESOURCE_TOOLS = new Set(["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"]);
|
|
241
|
+
|
|
238
242
|
export function classifyToolCall(
|
|
239
243
|
toolName: string,
|
|
240
244
|
input: unknown,
|
|
@@ -246,6 +250,41 @@ export function classifyToolCall(
|
|
|
246
250
|
|
|
247
251
|
if (NON_GATED.has(name)) return null;
|
|
248
252
|
|
|
253
|
+
// codemode runs a model-written script in a QuickJS sandbox with no file system, no
|
|
254
|
+
// network and no timers: everything it does to the world is a tool call, and Pi
|
|
255
|
+
// sends each one through tool_call like a direct call (parentToolCallId set). Those
|
|
256
|
+
// nested calls ARE the actions, and each is classified on its own merits right here.
|
|
257
|
+
// Gating the script as well would put two prompts in front of every action, the
|
|
258
|
+
// outer one showing only source code. (Its `models` global is turned off where we
|
|
259
|
+
// create it — config/moat.ts and extensions/privateer-codemode.ts.)
|
|
260
|
+
//
|
|
261
|
+
// tool_search only changes which tools are declared to the model; it calls nothing.
|
|
262
|
+
if (name === "codemode" || name === "tool_search") return null;
|
|
263
|
+
|
|
264
|
+
if (MCP_RESOURCE_TOOLS.has(name)) {
|
|
265
|
+
return {
|
|
266
|
+
tool: toolName,
|
|
267
|
+
kind: "fetch",
|
|
268
|
+
title: name === "read_mcp_resource" ? `Read a resource from ${str(obj.server) || "a connector"}` : "List connector resources",
|
|
269
|
+
detail: str(obj.uri) || str(obj.server) || "all connectors",
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// An MCP server tool, "mcp__<server>__<tool>". Matched before the name heuristics
|
|
274
|
+
// below, which would otherwise read a tool called fetch_url or web_search on some
|
|
275
|
+
// server as a plain network fetch. Bash-kind, exactly as every MCP call was before
|
|
276
|
+
// (asks in default, denies in plan, allows under bypass). Not reclassified from the
|
|
277
|
+
// server's own readOnlyHint: that hint is self-reported, and a fetch-kind "always"
|
|
278
|
+
// relaxes the whole session to acceptEdits (ModeGate) rather than remembering the
|
|
279
|
+
// one call.
|
|
280
|
+
if (name.startsWith("mcp__")) {
|
|
281
|
+
const rest = toolName.slice("mcp__".length);
|
|
282
|
+
const sep = rest.indexOf("__");
|
|
283
|
+
const server = sep > 0 ? rest.slice(0, sep) : "a connector";
|
|
284
|
+
const tool = sep > 0 ? rest.slice(sep + 2) : rest;
|
|
285
|
+
return { tool: toolName, kind: "bash", title: `Run ${tool} on ${server}`, detail: safeJson(obj) };
|
|
286
|
+
}
|
|
287
|
+
|
|
249
288
|
// Creating a routine is a persistent mutation; surface the trigger + delivery, and
|
|
250
289
|
// force a human decision (alwaysAsk) when it grants off-machine egress.
|
|
251
290
|
if (name === "create_routine" || name === "routine") {
|
|
@@ -688,10 +727,9 @@ export function classifyToolCall(
|
|
|
688
727
|
};
|
|
689
728
|
}
|
|
690
729
|
|
|
691
|
-
// Unknown / custom
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
// this with MCP destructiveHint → alwaysAsk and a per-tool policy map.
|
|
730
|
+
// Unknown / custom tool: we can't prove it's side-effect-free, so safe-by-default
|
|
731
|
+
// is to prompt. Classified as a bash-kind action (asks in default/acceptEdits,
|
|
732
|
+
// denies in plan, allows only under bypass). MCP tools are handled above.
|
|
695
733
|
return {
|
|
696
734
|
tool: toolName,
|
|
697
735
|
kind: "bash",
|
|
@@ -26,8 +26,15 @@ function looksSensitive(name: string): boolean {
|
|
|
26
26
|
return /^\.env(\..+)?$/.test(name) || /secret|credential/i.test(name);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// A project's own MCP config: Pi reads `.pi/mcp.json` once the project is trusted and
|
|
30
|
+
// launches every stdio `command` in it, and expands `!command` values in env and
|
|
31
|
+
// headers — so a write there is code that runs at the next session start, not a
|
|
32
|
+
// settings tweak.
|
|
33
|
+
const PROJECT_CONFIG_DIRS = new Set([".pi", ".privateer"]);
|
|
34
|
+
|
|
29
35
|
export function isProtectedPath(p: string): boolean {
|
|
30
36
|
const name = basename(p);
|
|
37
|
+
if (name === "mcp.json" && PROJECT_CONFIG_DIRS.has(basename(dirname(p)))) return true;
|
|
31
38
|
return PROTECTED_BASENAMES.has(name) || looksSensitive(name);
|
|
32
39
|
}
|
|
33
40
|
|
package/src/providers/account.ts
CHANGED
|
@@ -872,7 +872,12 @@ export function registerAccountModels(pi: {
|
|
|
872
872
|
// after the picker built its list, and privateer models would be missing until reopen.
|
|
873
873
|
// registerProvider replaces the provider's models on the second call, and the picker's
|
|
874
874
|
// refresh() re-applies registered providers, so the full list appears once fetched.
|
|
875
|
-
|
|
875
|
+
// `armOnStart: false` is for a host that owns the credential itself. The harbor leases
|
|
876
|
+
// ONE account credential across its concurrent runs (harbor/accountLease.ts) and now
|
|
877
|
+
// binds every run's extensions — Pi's MCP support connects servers on session_start —
|
|
878
|
+
// so the launch seed below would mint a second credential per daemon for runs that
|
|
879
|
+
// never use the account channel, a device nothing revokes.
|
|
880
|
+
export function makeAccountProvider(opts: { armOnStart?: boolean } = {}) {
|
|
876
881
|
return (pi: {
|
|
877
882
|
registerProvider?: (name: string, config: unknown) => void;
|
|
878
883
|
on?: (event: string, handler: (e: unknown, ctx: unknown) => unknown) => void;
|
|
@@ -918,7 +923,7 @@ export function makeAccountProvider() {
|
|
|
918
923
|
// all, and the first prompt dead-ends on "No API key found for privateer." — even
|
|
919
924
|
// though the banner says "connected". The REPL (cli/chat.ts) and the harbor
|
|
920
925
|
// already spawn one at startup; this gives the TUI the same seed.
|
|
921
|
-
pi.on?.("session_start", (_e, ctx) => void armAccountCredential(ctx));
|
|
926
|
+
if (opts.armOnStart !== false) pi.on?.("session_start", (_e, ctx) => void armAccountCredential(ctx));
|
|
922
927
|
|
|
923
928
|
// Two safety nets around the account channel, both OUTSIDE the request path —
|
|
924
929
|
// they read Pi's in-memory auth map and its finished messages, and never touch the
|