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
|
@@ -1,5 +1,28 @@
|
|
|
1
|
+
diff --git a/node_modules/pi-background-tasks/src/core/anthropic-attribution.ts b/node_modules/pi-background-tasks/src/core/anthropic-attribution.ts
|
|
2
|
+
index 2a4d905..3a689f5 100644
|
|
3
|
+
--- a/node_modules/pi-background-tasks/src/core/anthropic-attribution.ts
|
|
4
|
+
+++ b/node_modules/pi-background-tasks/src/core/anthropic-attribution.ts
|
|
5
|
+
@@ -7,6 +7,7 @@ import type {
|
|
6
|
+
Context as HostContext,
|
|
7
|
+
Model as HostModel,
|
|
8
|
+
SimpleStreamOptions as HostSimpleStreamOptions,
|
|
9
|
+
+ TranscriptContext as HostTranscriptContext,
|
|
10
|
+
} from '@earendil-works/pi-ai';
|
|
11
|
+
|
|
12
|
+
export const CLAUDE_CODE_SESSION_HEADER = 'X-Claude-Code-Session-Id';
|
|
13
|
+
@@ -3024,7 +3025,9 @@ async function fetchAnthropicResponse(
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type HostForwardingModel = PiModelLike & HostModel<'anthropic-messages'>;
|
|
17
|
+
-type HostForwardingContext = PiStreamContext & HostContext;
|
|
18
|
+
+// Privateer: pi-ai 1.x brands the host's context type (TranscriptContext). The value
|
|
19
|
+
+// passed through is the host's own context object, so only the type needs the brand.
|
|
20
|
+
+type HostForwardingContext = PiStreamContext & HostContext & HostTranscriptContext;
|
|
21
|
+
type HostForwardingOptions = PiSimpleStreamOptions & HostSimpleStreamOptions;
|
|
22
|
+
type HostForwardingStream = AssistantMessageEventStreamLike & HostAssistantMessageEventStream;
|
|
23
|
+
|
|
1
24
|
diff --git a/node_modules/pi-background-tasks/src/core/registry.ts b/node_modules/pi-background-tasks/src/core/registry.ts
|
|
2
|
-
index
|
|
25
|
+
index df6e6f4..cbab9f2 100644
|
|
3
26
|
--- a/node_modules/pi-background-tasks/src/core/registry.ts
|
|
4
27
|
+++ b/node_modules/pi-background-tasks/src/core/registry.ts
|
|
5
28
|
@@ -1,11 +1,11 @@
|
|
@@ -7,7 +30,7 @@ index cfbf133..c632390 100644
|
|
|
7
30
|
-import { randomBytes } from 'node:crypto';
|
|
8
31
|
+import { createHash, randomBytes } from 'node:crypto';
|
|
9
32
|
import { createWriteStream, existsSync } from 'node:fs';
|
|
10
|
-
import { mkdir, realpath, writeFile } from 'node:fs/promises';
|
|
33
|
+
import { mkdir, realpath, rm, writeFile } from 'node:fs/promises';
|
|
11
34
|
-import { join } from 'node:path';
|
|
12
35
|
+import { join, resolve } from 'node:path';
|
|
13
36
|
import type { Api, Model } from '@earendil-works/pi-ai';
|
|
@@ -17,7 +40,7 @@ index cfbf133..c632390 100644
|
|
|
17
40
|
import {
|
|
18
41
|
boundedRead,
|
|
19
42
|
deriveTaskNameFromCommand,
|
|
20
|
-
@@ -
|
|
43
|
+
@@ -1263,9 +1263,29 @@ export class BackgroundTaskRegistry {
|
|
21
44
|
if (this.runtimeDir) return this.runtimeDir;
|
|
22
45
|
const sessionId = sanitizePathSegment(ctx.sessionId ?? `session-${String(process.pid)}`);
|
|
23
46
|
const runId = `${sessionId}-${String(process.pid)}`;
|
|
@@ -51,29 +74,21 @@ index cfbf133..c632390 100644
|
|
|
51
74
|
return this.runtimeDir;
|
|
52
75
|
}
|
|
53
76
|
diff --git a/node_modules/pi-background-tasks/src/extension.ts b/node_modules/pi-background-tasks/src/extension.ts
|
|
54
|
-
index
|
|
77
|
+
index c966df5..b073a15 100644
|
|
55
78
|
--- a/node_modules/pi-background-tasks/src/extension.ts
|
|
56
79
|
+++ b/node_modules/pi-background-tasks/src/extension.ts
|
|
57
|
-
@@ -
|
|
58
|
-
pi.on('session_start', async (_event, ctx) => {
|
|
59
|
-
registry.setShuttingDown(false);
|
|
80
|
+
@@ -710,8 +710,8 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
|
|
60
81
|
currentCtx = ctx;
|
|
61
|
-
|
|
82
|
+
const nextRegistryCtx = currentRegistryCtx ?? registryContext(ctx);
|
|
83
|
+
currentRegistryCtx = nextRegistryCtx;
|
|
84
|
+
- await registry.ensureRuntimeDir(nextRegistryCtx);
|
|
85
|
+
- if (disposed) return;
|
|
62
86
|
+ // Privateer: no project writes just to open a session. Each task-start path
|
|
63
87
|
+ // creates its runtime directory on demand, with a per-user fallback.
|
|
64
88
|
updateUi(ctx);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}, STATUS_INTERVAL_MS);
|
|
69
|
-
- // One-shot, non-blocking: never awaited on the session-start path or the status tick.
|
|
70
|
-
- void scheduleUpdateCheck(ctx);
|
|
71
|
-
+ // Privateer: update check disabled. Privateer manages updates at the distribution
|
|
72
|
-
+ // level via `privateer update`; individual tool packs shouldn't check npm or prompt /bg-update.
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
pi.on('session_shutdown', async (_event, ctx) => {
|
|
76
|
-
@@ -565,22 +566,11 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
89
|
+
hostService.markReady();
|
|
90
|
+
if (disposed) return;
|
|
91
|
+
@@ -830,22 +830,11 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
|
|
77
92
|
});
|
|
78
93
|
|
|
79
94
|
pi.registerCommand('bg-update', {
|
|
@@ -99,7 +114,7 @@ index 3afbd8e..d003a57 100644
|
|
|
99
114
|
];
|
|
100
115
|
ctx.ui.notify(lines.join('\n'), 'info');
|
|
101
116
|
return Promise.resolve();
|
|
102
|
-
@@ -
|
|
117
|
+
@@ -958,7 +947,7 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
|
|
103
118
|
pi.registerTool<typeof BgRunParams, BgRunDetails>({
|
|
104
119
|
name: 'bg_run',
|
|
105
120
|
label: 'Background Run',
|
|
@@ -80,6 +80,12 @@ export function createEngineEventAdapter() {
|
|
|
80
80
|
let inRun = false;
|
|
81
81
|
|
|
82
82
|
function toEngineEvents(ev: PiSessionEvent): EngineEvent[] {
|
|
83
|
+
// A tool call a codemode script made (Pi sets parentToolCallId; the id is
|
|
84
|
+
// "<parent>/<n>"). Not surfaced: the EngineEvent vocabulary has no nesting, so it
|
|
85
|
+
// would land as a top-level tool card for a call that isn't in the transcript. The
|
|
86
|
+
// script's own result reports what it did, and each nested call still went through
|
|
87
|
+
// the permission gate — any approval it needed was asked for.
|
|
88
|
+
if (ev.type.startsWith("tool_execution_") && ev.parentToolCallId) return [];
|
|
83
89
|
switch (ev.type) {
|
|
84
90
|
case "agent_start":
|
|
85
91
|
terminalSent = false;
|
package/src/config/moat.ts
CHANGED
|
@@ -39,8 +39,10 @@
|
|
|
39
39
|
* loads everything Pi-touching after ../boot.ts, and this must not break that).
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
|
+
import { readFileSync } from "node:fs";
|
|
42
43
|
import { basename } from "node:path";
|
|
43
44
|
import { managedNames } from "./moatManifest.ts";
|
|
45
|
+
import { migrateMcpProjection } from "../remote/mcpControl.ts";
|
|
44
46
|
import type { GateController } from "../ext/permissionGate.ts";
|
|
45
47
|
import type { SendFileBridge } from "../tools/sendFile.ts";
|
|
46
48
|
import type { CargoSaveBridge } from "../tools/cargo.ts";
|
|
@@ -94,7 +96,7 @@ export interface MoatOptions {
|
|
|
94
96
|
*/
|
|
95
97
|
webHint?: string;
|
|
96
98
|
/**
|
|
97
|
-
* How to import a
|
|
99
|
+
* How to import a shared dependency (today: pi-coding-agent, for its MCP extensions). Defaults to a plain
|
|
98
100
|
* dynamic import, which is right for every process that runs from a normal Node
|
|
99
101
|
* resolution root.
|
|
100
102
|
*
|
|
@@ -107,6 +109,13 @@ export interface MoatOptions {
|
|
|
107
109
|
* teaching this module about Electron.
|
|
108
110
|
*/
|
|
109
111
|
hostImport?: (spec: string) => Promise<any>;
|
|
112
|
+
/**
|
|
113
|
+
* A window-scoped mcp.json (the desktop: each window activates its own subset of the
|
|
114
|
+
* catalog, projected by its connector manager). Set → this session's MCP support
|
|
115
|
+
* reads ONLY that file, so concurrent windows run different connector sets in one
|
|
116
|
+
* process. Unset → the shared agent/mcp.json plus a trusted project's, as the CLI.
|
|
117
|
+
*/
|
|
118
|
+
mcpConfigPath?: string;
|
|
110
119
|
}
|
|
111
120
|
|
|
112
121
|
/**
|
|
@@ -260,6 +269,25 @@ export async function moatResourceOptions(
|
|
|
260
269
|
return { extensionFactories: await buildMoat(opts), extensionsOverride: excludeDiscoveredMoat() };
|
|
261
270
|
}
|
|
262
271
|
|
|
272
|
+
// A window's mcp.json, in the shape Pi's MCP extension takes from `loadConfig`. The
|
|
273
|
+
// file is our own projection (mcpService / mcpControl.toPiServerEntry), already in
|
|
274
|
+
// Pi's format; an unreadable one is an empty config with the reason, not a crash.
|
|
275
|
+
// `/mcp` changes save back to this same file (the entries' `source`).
|
|
276
|
+
export function loadWindowMcpConfig(path: string): { servers: unknown[]; errors: string[] } {
|
|
277
|
+
try {
|
|
278
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
279
|
+
const servers = Object.entries<Record<string, unknown>>(raw?.mcpServers ?? {}).map(([name, config]) => ({
|
|
280
|
+
name,
|
|
281
|
+
config,
|
|
282
|
+
source: path,
|
|
283
|
+
scope: "global",
|
|
284
|
+
}));
|
|
285
|
+
return { servers, errors: [] };
|
|
286
|
+
} catch (e) {
|
|
287
|
+
return { servers: [], errors: [`${path}: ${e instanceof Error ? e.message : String(e)}`] };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
263
291
|
/** Build the ordered extension factory list for one session. */
|
|
264
292
|
export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]> {
|
|
265
293
|
const caps = CAPABILITIES[opts.kind];
|
|
@@ -282,7 +310,8 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
|
|
|
282
310
|
const { privacyExtension } = await import("./privacyPolicy.ts");
|
|
283
311
|
factories.push(privacyExtension());
|
|
284
312
|
}
|
|
285
|
-
|
|
313
|
+
// must follow pi-privacy — see header. The harbor holds its own account lease.
|
|
314
|
+
factories.push(makeAccountProvider({ armOnStart: opts.kind !== "harbor-session" }));
|
|
286
315
|
|
|
287
316
|
// Every kind: a text-only model gets image descriptions from a vision model on the
|
|
288
317
|
// same key instead of silently losing the image. See providers/visionDelegate.ts.
|
|
@@ -375,18 +404,28 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
|
|
|
375
404
|
}
|
|
376
405
|
|
|
377
406
|
if (caps.mcp) {
|
|
378
|
-
//
|
|
379
|
-
// MCP manager writes over the relay
|
|
380
|
-
//
|
|
381
|
-
//
|
|
407
|
+
// Pi's built-in MCP support: servers from the shared agent/mcp.json — the same
|
|
408
|
+
// projection the app's MCP manager writes over the relay — plus codemode, the
|
|
409
|
+
// default way MCP tools are reached, and tool_search for servers set to `deferred`.
|
|
410
|
+
// The CLI loads these as built-ins; SDK sessions don't, so they're added here. No
|
|
411
|
+
// servers configured → no tools. Imported through hostImport so the desktop gets the
|
|
412
|
+
// ONE Pi instance it already runs (see hostImport above).
|
|
413
|
+
//
|
|
414
|
+
// codemode is created with `models: false`. Its `models` global runs classifier and
|
|
415
|
+
// image models with the session's credentials, straight from a script — calls our
|
|
416
|
+
// permission gate never sees and that need not go to the provider this session was
|
|
417
|
+
// chosen for. Scripts keep tool calls, each of which is gated like a direct one.
|
|
382
418
|
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
const mcpAdapterSpec = "pi-mcp-adapter";
|
|
419
|
+
// A file an older release wrote in pi-mcp-adapter's format is rewritten first, or
|
|
420
|
+
// every HTTP connector in it would be rejected as invalid.
|
|
421
|
+
migrateMcpProjection();
|
|
387
422
|
const hostImport = opts.hostImport ?? ((spec: string) => import(spec));
|
|
388
|
-
const
|
|
389
|
-
factories.push(
|
|
423
|
+
const pi = await hostImport("@earendil-works/pi-coding-agent");
|
|
424
|
+
factories.push(
|
|
425
|
+
pi.createMcpExtension(opts.mcpConfigPath ? { loadConfig: () => loadWindowMcpConfig(opts.mcpConfigPath!) } : {}),
|
|
426
|
+
pi.createCodemodeExtension({ models: false }),
|
|
427
|
+
pi.createToolSearchExtension(),
|
|
428
|
+
);
|
|
390
429
|
}
|
|
391
430
|
|
|
392
431
|
return factories;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"@juicesharp/rpiv-ask-user-question",
|
|
6
6
|
"background-tasks"
|
|
7
7
|
],
|
|
8
|
-
"retired": ["pi-privacy", "pi-web-access", "pi-hypa", "rpiv-web-tools"],
|
|
8
|
+
"retired": ["pi-privacy", "pi-web-access", "pi-hypa", "rpiv-web-tools", "pi-mcp-adapter"],
|
|
9
9
|
"shims": [
|
|
10
10
|
{ "entry": "extensions/privateer-brand.ts", "name": "privateer-brand", "note": "banner, ⚓ badge, /signin /signout" },
|
|
11
11
|
{ "entry": "extensions/privateer-context.ts", "name": "privateer-context", "note": "PRIVATEER.md context + /init" },
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
{ "entry": "extensions/privateer-tools.ts", "name": "privateer-tools", "note": "Privateer tool pack" },
|
|
17
17
|
{ "entry": "extensions/privateer-privacy.ts", "name": "privateer-privacy", "note": "pi-privacy + account tier resolver" },
|
|
18
18
|
{ "entry": "extensions/privateer-connect.ts", "name": "privateer-connect", "note": "/connect — MCP connector manager" },
|
|
19
|
+
{ "entry": "extensions/privateer-codemode.ts", "name": "privateer-codemode", "note": "codemode — scripts that call tools; how MCP tools are reached (no `models` global)" },
|
|
19
20
|
{ "entry": "extensions/privateer-vision.ts", "name": "privateer-vision", "note": "a vision model on the same key describes images for a text-only model" },
|
|
20
21
|
{ "entry": "extensions/privateer-media.ts", "name": "privateer-media", "note": "image/video/speech/music + ffmpeg compose" },
|
|
21
22
|
{ "entry": "extensions/privateer-computer.ts", "name": "privateer-computer", "note": "screen control — screenshots, mouse and keyboard (off unless armed with --allow-computer-control)" },
|
|
@@ -28,8 +29,7 @@
|
|
|
28
29
|
{ "entry": "extensions/privateer-speak.ts", "name": "privateer-speak", "note": "spoken responses (/speak) + voice input (/talk) — pi-speak + confidential account TTS/STT" },
|
|
29
30
|
{ "entry": "extensions/privateer-web.ts", "name": "privateer-web", "note": "web_search/web_fetch — account search when signed in, else the user's own provider (rpiv-web-tools); crypto_lookup — account market data, in both cases" },
|
|
30
31
|
{ "dep": ["@juicesharp/rpiv-ask-user-question", "index.ts"], "name": "rpiv-ask-user-question", "note": "ask_user_question" },
|
|
31
|
-
{ "dep": ["pi-
|
|
32
|
-
{ "dep": ["pi-subagents", "src", "extension", "index.ts"], "name": "pi-subagents", "note": "bounded parallel sub-agents" },
|
|
32
|
+
{ "dep": ["pi-subagents", "index.js"], "name": "pi-subagents", "note": "bounded parallel sub-agents" },
|
|
33
33
|
{ "dep": ["pi-background-tasks", "extensions", "anthropic-attribution.ts"], "name": "anthropic-attribution", "note": "Anthropic OAuth attribution and prompt sanitization" },
|
|
34
34
|
{ "dep": ["pi-background-tasks", "extensions", "background-tasks.ts"], "name": "pi-background-tasks", "note": "durable background tasks, delegated agents, and fusion" }
|
|
35
35
|
]
|
package/src/config/whatsNew.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "The banner's What's New list, newest first: the two to four things a returning user should notice, not a changelog. `reviewedFor` is the release this list was last looked at for. A release can't ship until it equals package.json's version (scripts/check-whats-new.mjs, run by `npm test` and by release.yml before anything builds), so every release, patches included, is a deliberate look at this list. If nothing new is worth a line, bumping `reviewedFor` alone is the answer. `since` is the release an entry arrived in. `cmd` is shown in the accent colour.",
|
|
3
|
-
"reviewedFor": "0.
|
|
3
|
+
"reviewedFor": "0.15.0",
|
|
4
4
|
"items": [
|
|
5
|
+
{ "text": "MCP on Pi's built-in support — sign OAuth connectors back in with", "cmd": "/mcp", "since": "0.15.0" },
|
|
6
|
+
{ "text": "Sub-agents are back: hand work to bounded parallel agents", "since": "0.15.0" },
|
|
5
7
|
{ "text": "API keys can expire and carry their own credit limit — set them in the app", "since": "0.14.6" },
|
|
6
|
-
{ "text": "Save to Cargo with no app attached — it lands on your next app open", "since": "0.14.4" }
|
|
7
|
-
{ "text": "Several privateer -p runs at once no longer sign each other out", "since": "0.14.3" },
|
|
8
|
-
{ "text": "A reply cut off at the output limit picks back up on its own", "since": "0.14.2" }
|
|
8
|
+
{ "text": "Save to Cargo with no app attached — it lands on your next app open", "since": "0.14.4" }
|
|
9
9
|
]
|
|
10
10
|
}
|
|
@@ -284,11 +284,12 @@ export function makePermissionGate(ctrl: GateController) {
|
|
|
284
284
|
toolName: string;
|
|
285
285
|
toolCallId: string;
|
|
286
286
|
content: Array<{ type: string; text?: string; [k: string]: unknown }>;
|
|
287
|
+
structuredContent?: unknown;
|
|
287
288
|
details?: unknown;
|
|
288
289
|
isError?: boolean;
|
|
289
290
|
},
|
|
290
291
|
ctx: unknown,
|
|
291
|
-
) => { content?: unknown[] } | undefined,
|
|
292
|
+
) => { content?: unknown[]; structuredContent?: unknown } | undefined,
|
|
292
293
|
): void;
|
|
293
294
|
}
|
|
294
295
|
|
|
@@ -304,6 +305,12 @@ export function makePermissionGate(ctrl: GateController) {
|
|
|
304
305
|
// tool_result delivers content PARTS (per the Pi extension contract), not a
|
|
305
306
|
// bare string — patch text parts and return a partial { content }; omitted
|
|
306
307
|
// fields keep their current values.
|
|
308
|
+
//
|
|
309
|
+
// structuredContent too. A codemode script receives a nested call's
|
|
310
|
+
// structuredContent INSTEAD of its text (bash's full output, an MCP tool's JSON),
|
|
311
|
+
// and whatever the script prints goes to the model — so redacting only `content`
|
|
312
|
+
// would leave the script a clean path around it. Pi also drops structuredContent
|
|
313
|
+
// when a handler replaces `content` without it, so it is always returned alongside.
|
|
307
314
|
pi.on("tool_result", (event) => {
|
|
308
315
|
let touched = false;
|
|
309
316
|
const content = event.content.map((part) => {
|
|
@@ -314,11 +321,41 @@ export function makePermissionGate(ctrl: GateController) {
|
|
|
314
321
|
}
|
|
315
322
|
return part;
|
|
316
323
|
});
|
|
317
|
-
return touched ? { content } : undefined;
|
|
324
|
+
if (event.structuredContent === undefined) return touched ? { content } : undefined;
|
|
325
|
+
const structured = redactJson(event.structuredContent, redact);
|
|
326
|
+
if (structured !== event.structuredContent) touched = true;
|
|
327
|
+
return touched ? { content, structuredContent: structured } : undefined;
|
|
318
328
|
});
|
|
319
329
|
};
|
|
320
330
|
}
|
|
321
331
|
|
|
332
|
+
// Redact every string in a JSON value, keys included. Returns the SAME value when
|
|
333
|
+
// nothing changed, so the caller can tell a no-op from a rewrite without a deep compare.
|
|
334
|
+
export function redactJson(value: unknown, redact: (s: string) => string): unknown {
|
|
335
|
+
if (typeof value === "string") return redact(value);
|
|
336
|
+
if (Array.isArray(value)) {
|
|
337
|
+
let changed = false;
|
|
338
|
+
const out = value.map((v) => {
|
|
339
|
+
const r = redactJson(v, redact);
|
|
340
|
+
if (r !== v) changed = true;
|
|
341
|
+
return r;
|
|
342
|
+
});
|
|
343
|
+
return changed ? out : value;
|
|
344
|
+
}
|
|
345
|
+
if (value && typeof value === "object") {
|
|
346
|
+
let changed = false;
|
|
347
|
+
const out: Record<string, unknown> = {};
|
|
348
|
+
for (const [k, v] of Object.entries(value)) {
|
|
349
|
+
const rk = redact(k);
|
|
350
|
+
const rv = redactJson(v, redact);
|
|
351
|
+
if (rk !== k || rv !== v) changed = true;
|
|
352
|
+
out[rk] = rv;
|
|
353
|
+
}
|
|
354
|
+
return changed ? out : value;
|
|
355
|
+
}
|
|
356
|
+
return value;
|
|
357
|
+
}
|
|
358
|
+
|
|
322
359
|
function withTimeout<T>(p: Promise<T>, ms: number | undefined, signal?: AbortSignal): Promise<T> {
|
|
323
360
|
if (!ms && !signal) return p;
|
|
324
361
|
return new Promise<T>((resolve, reject) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Tear a headless session all the way down. dispose() alone stops the agent but never
|
|
2
2
|
// emits session_shutdown — that is AgentSessionRuntime's job, and we build sessions
|
|
3
|
-
// without one — so every extension that cleans up on it (
|
|
3
|
+
// without one — so every extension that cleans up on it (Pi's MCP server connections,
|
|
4
4
|
// OAuth runtime and lifecycle state among them) leaked once per run, for the life of a
|
|
5
5
|
// resident process. Reason "new", not "quit": to our own extensions "quit" means the
|
|
6
6
|
// PROCESS is exiting (privateer-brand revokes the machine's sessions on it), and the
|
package/src/harbor/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Server } from "node:net";
|
|
2
|
-
import { readFileSync
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
@@ -55,7 +55,7 @@ import {
|
|
|
55
55
|
import type { Routine } from "../routines/schema.ts";
|
|
56
56
|
import { triggerError, computeNextRun, advanceAfterRun } from "../routines/trigger.ts";
|
|
57
57
|
import { splitRoutineTools } from "../routines/toolSelect.ts";
|
|
58
|
-
import {
|
|
58
|
+
import { resolveMcpTools, isToolOfServer } from "../mcp/toolNames.ts";
|
|
59
59
|
import { deliver, type RelayPusher, type CloudPusher } from "../routines/delivery.ts";
|
|
60
60
|
import { ResultMedia, type StagedMedia } from "../routines/resultMedia.ts";
|
|
61
61
|
import { withBrief } from "../routines/resultBrief.ts";
|
|
@@ -63,7 +63,6 @@ import { ATTACH_RESULT_TOOL } from "../tools/attachResult.ts";
|
|
|
63
63
|
import { postOutbox as sealToOutbox, type OutboxSource } from "../outbox/cloudOutbox.ts";
|
|
64
64
|
import { redactText, collectSecrets } from "../util/redact.ts";
|
|
65
65
|
import { startIpcServer, sendToHarbor, describeRelay, formatDuration, HarborAlreadyRunningError, type IpcRequest, type IpcResponse, type RelayStatus } from "./ipc.ts";
|
|
66
|
-
import { serializeBuild } from "./buildLock.ts";
|
|
67
66
|
import { createAccountLease } from "./accountLease.ts";
|
|
68
67
|
import { closeSession } from "./closeSession.ts";
|
|
69
68
|
import { rotateLogIfLarge } from "./logRotate.ts";
|
|
@@ -133,11 +132,9 @@ const HOSTED_SUSPEND_MIN_LEAD_MS = Number(process.env.HARBOR_SUSPEND_MIN_LEAD_MS
|
|
|
133
132
|
// answer before it fail-closes to "no response" (the runner then defers the run). Bounds
|
|
134
133
|
// a stuck graph from pinning a `running` slot forever when the controller wanders off.
|
|
135
134
|
const GATE_TIMEOUT_MS = 5 * 60_000;
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
const WARM_TIMEOUT_MS = Number(process.env.PRIVATEER_MCP_WARM_MS) || 30_000;
|
|
140
|
-
const WARM_POLL_MS = 250;
|
|
135
|
+
// Every tool Pi's MCP support registers: server tools, and the resource tools that
|
|
136
|
+
// reach every server at once.
|
|
137
|
+
const MCP_TOOL_PATTERNS = ["mcp__*", "list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"];
|
|
141
138
|
// Hard ceiling on one unattended run (a routine, a submitted task, a workflow agent
|
|
142
139
|
// step). Nothing else bounds a provider stream that stops sending without closing, and a
|
|
143
140
|
// run stuck there holds its routine's `running` slot until the harbor restarts — the
|
|
@@ -208,9 +205,6 @@ function formatResult(routine: Routine, body: string, status: "ok" | "error", er
|
|
|
208
205
|
return `${head}${body.trim() || "(no output)"}\n${formatNotes(notes)}`;
|
|
209
206
|
}
|
|
210
207
|
|
|
211
|
-
// Session construction is serialized process-wide — see harbor/buildLock.ts for why.
|
|
212
|
-
// Imported rather than defined here so the channels runtime can share the same lock.
|
|
213
|
-
|
|
214
208
|
// A short human title for an ad-hoc task: the app's explicit title, else the first
|
|
215
209
|
// non-empty line of the prompt, clipped. Exported for the signed-args round-trip test.
|
|
216
210
|
export function deriveTaskTitle(spec: TaskSpec): string {
|
|
@@ -281,8 +275,6 @@ export class Harbor {
|
|
|
281
275
|
// + no live work rather than on controllerAttached, which never resets while the
|
|
282
276
|
// socket stays open).
|
|
283
277
|
private lastActivityAt = Date.now();
|
|
284
|
-
// In-flight MCP tool-inventory rebuild, shared by every run that needs one.
|
|
285
|
-
private warmInFlight?: Promise<void>;
|
|
286
278
|
// Live, app-drivable sessions spawned on demand (task_spawn). Each has its OWN relay
|
|
287
279
|
// terminal (task-<uuid>); the harbor just keeps handles so it can reap them on shutdown.
|
|
288
280
|
private readonly liveTasks = new Map<string, LiveTaskHandle>();
|
|
@@ -878,22 +870,15 @@ export class Harbor {
|
|
|
878
870
|
}
|
|
879
871
|
|
|
880
872
|
// Build the Pi services for one unattended run: the auto-approve gate, the account
|
|
881
|
-
// provider, web tools when the agent has them, and
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
|
|
885
|
-
// one grant, all servers, all tools — which is exactly what a per-routine allow-list
|
|
886
|
-
// is supposed to prevent. The adapter reads it from process.env at extension
|
|
887
|
-
// activation, so the build is serialized (serializeBuild) and the previous value is
|
|
888
|
-
// restored. "__none__" is its sentinel for "register none": an unattended run with
|
|
889
|
-
// no connector selectors gets no direct tools, whatever a shared mcp.json says.
|
|
890
|
-
private buildSessionServices(
|
|
873
|
+
// provider, web tools when the agent has them, and Pi's MCP support. Which MCP tools
|
|
874
|
+
// the run may reach is decided by the session's `tools` allow-list (resolveRunTools),
|
|
875
|
+
// not here, so concurrent builds share nothing.
|
|
876
|
+
private async buildSessionServices(
|
|
891
877
|
cwd: string,
|
|
892
|
-
directTools: string[],
|
|
893
878
|
media?: ResultMedia,
|
|
894
879
|
spendGrant: readonly string[] = [],
|
|
895
880
|
): Promise<any> {
|
|
896
|
-
|
|
881
|
+
{
|
|
897
882
|
const granted = new Set(spendGrant);
|
|
898
883
|
const gate: GateController = {
|
|
899
884
|
getMode: () => "bypass",
|
|
@@ -919,122 +904,39 @@ export class Harbor {
|
|
|
919
904
|
// grant cover a call that leaves cwd or reads a protected file.
|
|
920
905
|
isSpendPreauthorized: (req) => granted.has(req.tool),
|
|
921
906
|
};
|
|
922
|
-
// Every extension this session gets, in the one canonical order — including
|
|
923
|
-
//
|
|
907
|
+
// Every extension this session gets, in the one canonical order — including MCP
|
|
908
|
+
// and codemode, the web/media capability shaping, and the filter that ignores
|
|
924
909
|
// any moat shim an older release left in the shared agent dir (releases up to 0.11
|
|
925
910
|
// installed one per extension; the launcher sweeps them, but a daemon can outlive
|
|
926
911
|
// the launch that would have). See config/moat.ts.
|
|
927
912
|
const moat = await moatResourceOptions({ kind: "harbor-session", gate, resultMedia: media });
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
return await createAgentSessionServices({
|
|
935
|
-
cwd,
|
|
936
|
-
agentDir: agentDir(),
|
|
937
|
-
resourceLoaderOptions: moat as any,
|
|
938
|
-
});
|
|
939
|
-
} finally {
|
|
940
|
-
if (prevDirect === undefined) delete process.env.MCP_DIRECT_TOOLS;
|
|
941
|
-
else process.env.MCP_DIRECT_TOOLS = prevDirect;
|
|
942
|
-
}
|
|
943
|
-
});
|
|
913
|
+
return await createAgentSessionServices({
|
|
914
|
+
cwd,
|
|
915
|
+
agentDir: agentDir(),
|
|
916
|
+
resourceLoaderOptions: moat as any,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
944
919
|
}
|
|
945
920
|
|
|
946
921
|
// Turn a routine's stored tool list into what Pi will actually accept.
|
|
947
922
|
//
|
|
948
923
|
// Builtins pass through builtinToolsFor (explicit list, or the safe read/web set).
|
|
949
|
-
// MCP entries are SELECTORS — "<server>__<tool>" / "<server>__*" —
|
|
950
|
-
//
|
|
951
|
-
//
|
|
952
|
-
//
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
)
|
|
924
|
+
// MCP entries are SELECTORS — "<server>__<tool>" / "<server>__*" — translated into
|
|
925
|
+
// Pi's "mcp__<server>__<tool>" names and patterns. Once any `tools` entry starts with
|
|
926
|
+
// "mcp__", Pi registers ONLY the MCP tools that match, so the run can reach exactly
|
|
927
|
+
// the connectors it named. codemode rides along because it is how MCP tools are
|
|
928
|
+
// called by default; it can only call tools the session registered.
|
|
929
|
+
//
|
|
930
|
+
// With no selectors, `excludeTools` keeps every MCP tool unregistered. A builtin-only
|
|
931
|
+
// list would leave them registered (Pi only filters MCP tools once an entry names
|
|
932
|
+
// one) and merely unreachable — a run nobody watches gets the stronger guarantee.
|
|
933
|
+
private resolveRunTools(raw: string[]): { tools: string[]; excludeTools: string[]; servers: string[] } {
|
|
958
934
|
const split = splitRoutineTools(raw);
|
|
959
935
|
const builtin = builtinToolsFor(split.builtin);
|
|
960
|
-
if (split.mcp.length === 0) return { tools: builtin,
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
await this.warmMcpCache(cwd, modelSpec, resolved.coldServers);
|
|
965
|
-
resolved = resolveMcpSelection(split.mcp);
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
const notes: string[] = [];
|
|
969
|
-
for (const server of resolved.coldServers) {
|
|
970
|
-
notes.push(
|
|
971
|
-
`Connector "${server}" didn't provide a tool list, so none of its tools were available to this run. ` +
|
|
972
|
-
`Check it is configured and, if it uses OAuth, that it is still authorized.`,
|
|
973
|
-
);
|
|
974
|
-
}
|
|
975
|
-
for (const selector of resolved.unknownTools) {
|
|
976
|
-
notes.push(`Connector tool "${selector}" doesn't exist on that connector, so it was not available to this run.`);
|
|
977
|
-
}
|
|
978
|
-
if (resolved.names.length > 0) {
|
|
979
|
-
log(` connectors: ${resolved.servers.join(", ")} → ${resolved.names.length} tools`);
|
|
980
|
-
}
|
|
981
|
-
for (const note of notes) log(` ${note}`);
|
|
982
|
-
return { tools: [...builtin, ...resolved.names], directToolsEnv: resolved.directToolsEnv, notes };
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
// Make pi-mcp-adapter build a fresh tool inventory for every configured connector.
|
|
986
|
-
//
|
|
987
|
-
// The adapter only connects to ALL of them when agent/mcp-cache.json is absent
|
|
988
|
-
// (init.ts → bootstrapAll); with the file present it connects lazily, so a connector
|
|
989
|
-
// added since the last session never gets an inventory and a "<server>__*" selector
|
|
990
|
-
// over it expands to nothing, for ever. Dropping the cache is safe — it is a cache —
|
|
991
|
-
// and makes this one throwaway session rebuild it. The session is never prompted
|
|
992
|
-
// (empty allow-list) and its MCP connections close on the adapter's idle timeout.
|
|
993
|
-
//
|
|
994
|
-
// The adapter does NOT await its own initialization from session_start — it stashes
|
|
995
|
-
// the promise and returns — so session creation resolving means nothing here. We
|
|
996
|
-
// watch the cache file instead, and give up after WARM_TIMEOUT_MS: a connector that
|
|
997
|
-
// never answers (dead endpoint, expired OAuth) must not hold a scheduled run open.
|
|
998
|
-
//
|
|
999
|
-
// A warm rebuilds the inventory for EVERY configured connector, so two runs that
|
|
1000
|
-
// both hit a cold selector share one pass rather than racing to wipe the file out
|
|
1001
|
-
// from under each other.
|
|
1002
|
-
private warmMcpCache(cwd: string, modelSpec: string, servers: string[]): Promise<void> {
|
|
1003
|
-
if (this.warmInFlight) return this.warmInFlight;
|
|
1004
|
-
const p = this.doWarmMcpCache(cwd, modelSpec, servers).finally(() => {
|
|
1005
|
-
if (this.warmInFlight === p) this.warmInFlight = undefined;
|
|
1006
|
-
});
|
|
1007
|
-
this.warmInFlight = p;
|
|
1008
|
-
return p;
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
private async doWarmMcpCache(cwd: string, modelSpec: string, servers: string[]): Promise<void> {
|
|
1012
|
-
log(` warming connector tool lists: ${servers.join(", ")}`);
|
|
1013
|
-
try {
|
|
1014
|
-
rmSync(join(agentDir(), "mcp-cache.json"), { force: true });
|
|
1015
|
-
const services = await this.buildSessionServices(cwd, []);
|
|
1016
|
-
const { provider, modelId } = parseSpec(modelSpec);
|
|
1017
|
-
const model = (modelRegistryOf(services) as any).find(provider, modelId);
|
|
1018
|
-
if (!model) return;
|
|
1019
|
-
const { session } = await createAgentSessionFromServices({
|
|
1020
|
-
services,
|
|
1021
|
-
sessionManager: SessionManager.inMemory(cwd),
|
|
1022
|
-
model,
|
|
1023
|
-
tools: [],
|
|
1024
|
-
} as any);
|
|
1025
|
-
try {
|
|
1026
|
-
const deadline = Date.now() + WARM_TIMEOUT_MS;
|
|
1027
|
-
while (Date.now() < deadline) {
|
|
1028
|
-
const inventory = readMcpInventory(agentDir());
|
|
1029
|
-
if (servers.every((s) => (inventory[s]?.length ?? 0) > 0)) break;
|
|
1030
|
-
await new Promise((r) => setTimeout(r, WARM_POLL_MS));
|
|
1031
|
-
}
|
|
1032
|
-
} finally {
|
|
1033
|
-
await closeSession(session);
|
|
1034
|
-
}
|
|
1035
|
-
} catch (e) {
|
|
1036
|
-
log(` connector warm-up failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1037
|
-
}
|
|
936
|
+
if (split.mcp.length === 0) return { tools: builtin, excludeTools: [...MCP_TOOL_PATTERNS], servers: [] };
|
|
937
|
+
const { patterns, servers } = resolveMcpTools(split.mcp);
|
|
938
|
+
log(` connectors: ${servers.join(", ")}`);
|
|
939
|
+
return { tools: [...builtin, "codemode", ...patterns], excludeTools: [], servers };
|
|
1038
940
|
}
|
|
1039
941
|
|
|
1040
942
|
// Drive one headless Pi turn to completion and return its collected text + status.
|
|
@@ -1055,14 +957,14 @@ export class Harbor {
|
|
|
1055
957
|
let error: string | undefined;
|
|
1056
958
|
let leasedAccount = false;
|
|
1057
959
|
let session: any;
|
|
1058
|
-
const resolved =
|
|
960
|
+
const resolved = this.resolveRunTools(spec.tools);
|
|
1059
961
|
// attach_to_result rides with the staging area rather than the allow-list: it is
|
|
1060
962
|
// how the answer is DELIVERED, not a capability the run gains. A routine that
|
|
1061
963
|
// named an explicit tool list still gets it, because the alternative is a run
|
|
1062
964
|
// that can generate a still and then has no way to hand it over — and the file
|
|
1063
965
|
// is going to the user's own mailbox, which `delivery: cloud` already chose.
|
|
1064
966
|
const allowedTools = spec.media ? [...resolved.tools, ATTACH_RESULT_TOOL] : resolved.tools;
|
|
1065
|
-
const
|
|
967
|
+
const notes: string[] = [];
|
|
1066
968
|
// The billing tools this run may use: the ones its own allow-list names, and no
|
|
1067
969
|
// others. Authorizes the run's own calls (the gate, below) and its subagents' —
|
|
1068
970
|
// children are a separate process and read the grant from the environment, released
|
|
@@ -1070,7 +972,7 @@ export class Harbor {
|
|
|
1070
972
|
const spendGrant = allowedTools.filter((t) => BILLED_MEDIA_TOOLS.has(t));
|
|
1071
973
|
const releaseChildSpend = grantChildSpend(`run:${++runSeq}`, spendGrant);
|
|
1072
974
|
try {
|
|
1073
|
-
const services = await this.buildSessionServices(spec.cwd,
|
|
975
|
+
const services = await this.buildSessionServices(spec.cwd, spec.media, spendGrant);
|
|
1074
976
|
|
|
1075
977
|
const { provider, modelId } = parseSpec(spec.model);
|
|
1076
978
|
if (provider === "privateer") {
|
|
@@ -1090,7 +992,16 @@ export class Harbor {
|
|
|
1090
992
|
sessionManager: SessionManager.inMemory(spec.cwd),
|
|
1091
993
|
model,
|
|
1092
994
|
tools: allowedTools,
|
|
995
|
+
excludeTools: resolved.excludeTools,
|
|
1093
996
|
} as any));
|
|
997
|
+
// session_start is what connects the MCP servers (Pi's MCP support). Bound with
|
|
998
|
+
// NO uiContext on purpose: Pi then keeps its own no-op UI and reports hasUI
|
|
999
|
+
// false, which is the truth for a run nobody watches. Any context object we
|
|
1000
|
+
// passed — even an empty one — reads as a UI to extensions, and pi-privacy
|
|
1001
|
+
// would then "ask" about personal data in a tool call, get no answer, and
|
|
1002
|
+
// block it, where with no UI it allows the call with a notice. The gate's own
|
|
1003
|
+
// localAsk (deny) answers anything that would have prompted.
|
|
1004
|
+
await session.bindExtensions({});
|
|
1094
1005
|
const adapter = createEngineEventAdapter();
|
|
1095
1006
|
session.subscribe((ev: any) => {
|
|
1096
1007
|
for (const ee of adapter.toEngineEvents(ev)) {
|
|
@@ -1115,6 +1026,17 @@ export class Harbor {
|
|
|
1115
1026
|
status = "error";
|
|
1116
1027
|
error = `run stopped after ${Math.round(RUN_TIMEOUT_MS / 60_000)} minutes (PRIVATEER_RUN_TIMEOUT_MS)`;
|
|
1117
1028
|
}
|
|
1029
|
+
// A connector that never connected registers no tools, and the run quietly comes
|
|
1030
|
+
// back thinner than asked for. Say which, so the result explains itself.
|
|
1031
|
+
const registered: string[] = (session.getAllTools?.() ?? []).map((t: { name: string }) => t.name);
|
|
1032
|
+
for (const server of resolved.servers) {
|
|
1033
|
+
if (registered.some((name) => isToolOfServer(name, server))) continue;
|
|
1034
|
+
const note =
|
|
1035
|
+
`Connector "${server}" didn't provide any tools, so none were available to this run. ` +
|
|
1036
|
+
`Check it is configured and, if it uses OAuth, that it is still signed in (/mcp login ${server}).`;
|
|
1037
|
+
notes.push(note);
|
|
1038
|
+
log(` ${note}`);
|
|
1039
|
+
}
|
|
1118
1040
|
}
|
|
1119
1041
|
} catch (err) {
|
|
1120
1042
|
status = "error";
|