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
package/src/harbor/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Server } from "node:net";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
3
4
|
import { spawn } from "node:child_process";
|
|
4
5
|
import { randomUUID } from "node:crypto";
|
|
5
6
|
// Pi session stack. The harbor MUST be launched after ./boot.ts (env +
|
|
@@ -14,13 +15,18 @@ import { agentVersion } from "../config/version.ts";
|
|
|
14
15
|
import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
|
|
15
16
|
import { makePermissionGate, type GateController } from "../ext/permissionGate.ts";
|
|
16
17
|
import { makePiPrivacyExtension } from "pi-privacy";
|
|
17
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
makeAccountProvider,
|
|
20
|
+
privateerChannel,
|
|
21
|
+
rememberAccountCredential,
|
|
22
|
+
dropPersistedAccountCredential,
|
|
23
|
+
} from "../providers/account.ts";
|
|
18
24
|
import { resolveDefaultModel } from "../providers/defaultModel.ts";
|
|
19
25
|
import { RelayClient, type TaskSpec } from "../remote/relayClient.ts";
|
|
20
26
|
import { createLiveTaskSession, type LiveTaskHandle } from "../remote/liveTaskSession.ts";
|
|
21
27
|
import { makeRoutinesControl } from "../remote/routinesControl.ts";
|
|
22
28
|
import { makeChannelsControl } from "../remote/channelsControl.ts";
|
|
23
|
-
import { makeMcpControl } from "../remote/mcpControl.ts";
|
|
29
|
+
import { makeMcpControl, mergeSealedMcpSecrets } from "../remote/mcpControl.ts";
|
|
24
30
|
import { makeWorkflowsControl } from "../remote/workflowsControl.ts";
|
|
25
31
|
import { runWorkflow as executeWorkflow, type RunnerDeps, type AgentRunSpec, type AgentRunResult, type ScriptRunResult } from "../workflows/runner.ts";
|
|
26
32
|
import type { Workflow, Step } from "../workflows/schema.ts";
|
|
@@ -47,19 +53,39 @@ import {
|
|
|
47
53
|
import type { Routine } from "../routines/schema.ts";
|
|
48
54
|
import { triggerError, computeNextRun, advanceAfterRun } from "../routines/trigger.ts";
|
|
49
55
|
import { splitRoutineTools } from "../routines/toolSelect.ts";
|
|
56
|
+
import { resolveMcpSelection, readMcpInventory, type ResolvedMcpTools } from "../mcp/toolNames.ts";
|
|
50
57
|
import { deliver, type RelayPusher, type CloudPusher } from "../routines/delivery.ts";
|
|
51
58
|
import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
|
|
52
59
|
import { redactText, collectSecrets } from "../util/redact.ts";
|
|
53
60
|
import { startIpcServer, HarborAlreadyRunningError, type IpcRequest, type IpcResponse } from "./ipc.ts";
|
|
54
|
-
import { isHosted, publishRelayPub } from "../config/hosted.ts";
|
|
61
|
+
import { isHosted, publishRelayPub, webEnabled } from "../config/hosted.ts";
|
|
62
|
+
import { makeWebTools, WEB_TOOL_NAMES } from "../tools/web.ts";
|
|
55
63
|
|
|
56
64
|
// The safe, read-only toolset for unattended runs — Pi builtins with no
|
|
57
65
|
// write/edit/bash, so a routine firing with nobody watching can't mutate the
|
|
58
|
-
// filesystem or shell out.
|
|
59
|
-
//
|
|
60
|
-
// allowed but still fail-closes a dangerous shell command headlessly.
|
|
66
|
+
// filesystem or shell out. Safety is the tool restriction; the gate auto-approves
|
|
67
|
+
// what's allowed but still fail-closes a dangerous shell command headlessly.
|
|
61
68
|
const SAFE_TOOLS = ["read", "grep", "find", "ls"];
|
|
62
69
|
|
|
70
|
+
// Read-only in the same sense — they can't touch the filesystem or the shell — but
|
|
71
|
+
// they do send a derived query out to Privateer's servers, so they are a switch
|
|
72
|
+
// (webEnabled) rather than part of the unconditional safe set. When the switch is on
|
|
73
|
+
// they join the DEFAULT allow-list: the overwhelmingly common unattended request
|
|
74
|
+
// ("summarize today's news at 7pm") needs the live web and nothing else, and making
|
|
75
|
+
// the user hand-write an allow-list for it was the whole friction.
|
|
76
|
+
const WEB_TOOLS: string[] = [...WEB_TOOL_NAMES];
|
|
77
|
+
|
|
78
|
+
// Resolve a run's builtin allow-list. An explicit list wins, minus any web tools when
|
|
79
|
+
// web access is off — a routine saved while it was on must not silently reference a
|
|
80
|
+
// tool that no longer registers.
|
|
81
|
+
function builtinToolsFor(explicit: string[]): string[] {
|
|
82
|
+
const web = webEnabled();
|
|
83
|
+
if (explicit.length > 0) {
|
|
84
|
+
return web ? explicit : explicit.filter((t) => !WEB_TOOLS.includes(t));
|
|
85
|
+
}
|
|
86
|
+
return web ? [...SAFE_TOOLS, ...WEB_TOOLS] : [...SAFE_TOOLS];
|
|
87
|
+
}
|
|
88
|
+
|
|
63
89
|
const TICK_MS = 60_000; // scan for due routines once a minute
|
|
64
90
|
// Harbor hosted mode (isHosted): suspend after this much idle time with no work,
|
|
65
91
|
// and stay up if a routine is due within the lead window (avoids suspend→wake churn).
|
|
@@ -70,6 +96,11 @@ const MAX_CLOUD_PLAINTEXT = 45_000;
|
|
|
70
96
|
// answer before it fail-closes to "no response" (the runner then defers the run). Bounds
|
|
71
97
|
// a stuck graph from pinning a `running` slot forever when the controller wanders off.
|
|
72
98
|
const GATE_TIMEOUT_MS = 5 * 60_000;
|
|
99
|
+
// How long a run waits for a newly-selected connector to hand over its tool list
|
|
100
|
+
// before giving up and saying so in the result (see warmMcpCache). Bounds a dead
|
|
101
|
+
// endpoint or an expired OAuth token from stalling a scheduled run.
|
|
102
|
+
const WARM_TIMEOUT_MS = Number(process.env.PRIVATEER_MCP_WARM_MS) || 30_000;
|
|
103
|
+
const WARM_POLL_MS = 250;
|
|
73
104
|
|
|
74
105
|
interface HarborConfig {
|
|
75
106
|
defaultModel: string;
|
|
@@ -108,11 +139,33 @@ function log(msg: string): void {
|
|
|
108
139
|
process.stdout.write(`[${new Date().toISOString()}] ${msg}\n`);
|
|
109
140
|
}
|
|
110
141
|
|
|
111
|
-
|
|
142
|
+
// Connector notes are things the RUN could not do — a selected connector that isn't
|
|
143
|
+
// configured, or one that wouldn't hand over a tool list. They ride the result
|
|
144
|
+
// because the alternative is the failure mode RoutineIssues exists to prevent: an
|
|
145
|
+
// empty answer three mornings running with no clue why.
|
|
146
|
+
function formatNotes(notes: string[]): string {
|
|
147
|
+
if (notes.length === 0) return "";
|
|
148
|
+
return `\n---\n\n${notes.map((n) => `> ⚠︎ ${n}`).join("\n>\n")}\n`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function formatResult(routine: Routine, body: string, status: "ok" | "error", error?: string, notes: string[] = []): string {
|
|
112
152
|
const when = new Date().toISOString();
|
|
113
153
|
const head = `# ${routine.name}\n\n_${when} · ${status}${routine.model ? ` · ${routine.model}` : ""}_\n\n`;
|
|
114
|
-
if (status === "error") return `${head}**Run failed:** ${error ?? "unknown error"}\n\n${body}`.trimEnd() + "\n";
|
|
115
|
-
return `${head}${body.trim() || "(no output)"}\n`;
|
|
154
|
+
if (status === "error") return `${head}**Run failed:** ${error ?? "unknown error"}\n\n${body}${formatNotes(notes)}`.trimEnd() + "\n";
|
|
155
|
+
return `${head}${body.trim() || "(no output)"}\n${formatNotes(notes)}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Session CONSTRUCTION is serialized across the harbor. pi-mcp-adapter decides which
|
|
159
|
+
// MCP tools to register directly at extension-activation time, and the only knob for
|
|
160
|
+
// that is the MCP_DIRECT_TOOLS env var — process-global state we set per run to keep
|
|
161
|
+
// each unattended session to its own connector allow-list. Serializing the (short)
|
|
162
|
+
// build window is what stops two concurrent routines from reading each other's value.
|
|
163
|
+
// Prompting is NOT serialized — only the build.
|
|
164
|
+
let buildLock: Promise<unknown> = Promise.resolve();
|
|
165
|
+
function serializeBuild<T>(fn: () => Promise<T>): Promise<T> {
|
|
166
|
+
const run = buildLock.then(fn, fn);
|
|
167
|
+
buildLock = run.catch(() => {});
|
|
168
|
+
return run;
|
|
116
169
|
}
|
|
117
170
|
|
|
118
171
|
// A short human title for an ad-hoc task: the app's explicit title, else the first
|
|
@@ -124,11 +177,11 @@ export function deriveTaskTitle(spec: TaskSpec): string {
|
|
|
124
177
|
return firstLine.slice(0, 80);
|
|
125
178
|
}
|
|
126
179
|
|
|
127
|
-
function formatTaskResult(title: string, body: string, status: "ok" | "error", error?: string, model?: string): string {
|
|
180
|
+
function formatTaskResult(title: string, body: string, status: "ok" | "error", error?: string, model?: string, notes: string[] = []): string {
|
|
128
181
|
const when = new Date().toISOString();
|
|
129
182
|
const head = `# ${title}\n\n_${when} · ${status}${model ? ` · ${model}` : ""}_\n\n`;
|
|
130
|
-
if (status === "error") return `${head}**Task failed:** ${error ?? "unknown error"}\n\n${body}`.trimEnd() + "\n";
|
|
131
|
-
return `${head}${body.trim() || "(no output)"}\n`;
|
|
183
|
+
if (status === "error") return `${head}**Task failed:** ${error ?? "unknown error"}\n\n${body}${formatNotes(notes)}`.trimEnd() + "\n";
|
|
184
|
+
return `${head}${body.trim() || "(no output)"}\n${formatNotes(notes)}`;
|
|
132
185
|
}
|
|
133
186
|
|
|
134
187
|
// Render a finished workflow run into the delivery/outbox markdown: the terminal status,
|
|
@@ -169,6 +222,8 @@ export class Harbor {
|
|
|
169
222
|
// + no live work rather than on controllerAttached, which never resets while the
|
|
170
223
|
// socket stays open).
|
|
171
224
|
private lastActivityAt = Date.now();
|
|
225
|
+
// In-flight MCP tool-inventory rebuild, shared by every run that needs one.
|
|
226
|
+
private warmInFlight?: Promise<void>;
|
|
172
227
|
// Live, app-drivable sessions spawned on demand (task_spawn). Each has its OWN relay
|
|
173
228
|
// terminal (task-<uuid>); the harbor just keeps handles so it can reap them on shutdown.
|
|
174
229
|
private readonly liveTasks = new Map<string, LiveTaskHandle>();
|
|
@@ -258,7 +313,7 @@ export class Harbor {
|
|
|
258
313
|
private syncRelay(): void {
|
|
259
314
|
if (this.relay || this.relayTerminated) return;
|
|
260
315
|
// Connect whenever the account is signed in — not only when a routine wants
|
|
261
|
-
// `relay` delivery — so the "Privateer
|
|
316
|
+
// `relay` delivery — so the "Privateer Local Harbor" terminal is always reachable
|
|
262
317
|
// from the app for management (including creating the very first routine).
|
|
263
318
|
if (!hasCredentials()) return;
|
|
264
319
|
this.relay = new RelayClient(
|
|
@@ -347,7 +402,10 @@ export class Harbor {
|
|
|
347
402
|
this.controllerAttached = false;
|
|
348
403
|
},
|
|
349
404
|
},
|
|
350
|
-
|
|
405
|
+
// The name the app shows for this terminal when it has nothing better (no
|
|
406
|
+
// machine label yet). "Local Harbor" is the product name for the always-on
|
|
407
|
+
// agent on the user's own machine — as opposed to hosted Harbor.
|
|
408
|
+
{ termId: routineRelayId(), label: "Privateer Local Harbor" },
|
|
351
409
|
);
|
|
352
410
|
void this.relay.start();
|
|
353
411
|
log("relay connection starting (account signed in — routines terminal reachable from the app)");
|
|
@@ -374,7 +432,9 @@ export class Harbor {
|
|
|
374
432
|
// envelope (action "mcp_save", args {draft, sealedSecrets}) — the action tag stops a
|
|
375
433
|
// signature made for any other frame from being replayed as an MCP save. Fail-closed:
|
|
376
434
|
// an unsigned/forged/stale frame returns the refusal message and NOTHING is written.
|
|
377
|
-
// The sealed box opens to { termId, env } —
|
|
435
|
+
// The sealed box opens to { termId, env?, headers?, bearerToken? } — credentials the
|
|
436
|
+
// relay never sees in the clear. mergeSealedMcpSecrets enforces that those three may
|
|
437
|
+
// arrive NO OTHER WAY: a signature proves authorship, not confidentiality.
|
|
378
438
|
private applyMcpSave(draft: Record<string, unknown>, sealedSecrets?: string, sig?: string, ts?: number): string | undefined {
|
|
379
439
|
const auth = authorizeControl(
|
|
380
440
|
routineRelayId(),
|
|
@@ -385,20 +445,22 @@ export class Harbor {
|
|
|
385
445
|
);
|
|
386
446
|
if (!auth.ok) return auth.message;
|
|
387
447
|
|
|
388
|
-
let
|
|
448
|
+
let opened:
|
|
449
|
+
| { termId?: string; env?: Record<string, string>; headers?: Record<string, string>; bearerToken?: string }
|
|
450
|
+
| undefined;
|
|
389
451
|
if (sealedSecrets) {
|
|
390
|
-
let opened: { termId?: string; env?: Record<string, string> };
|
|
391
452
|
try {
|
|
392
453
|
opened = openJsonFromApp(sealedSecrets);
|
|
393
454
|
} catch {
|
|
394
455
|
return "Couldn't decrypt the connector credentials — they may have been sealed to a different terminal.";
|
|
395
456
|
}
|
|
396
|
-
if (opened
|
|
457
|
+
if (opened?.termId !== routineRelayId()) {
|
|
397
458
|
return "These credentials were addressed to a different terminal.";
|
|
398
459
|
}
|
|
399
|
-
withEnv = { ...draft, env: opened.env ?? {} };
|
|
400
460
|
}
|
|
401
|
-
|
|
461
|
+
const prepared = mergeSealedMcpSecrets(draft, opened);
|
|
462
|
+
if (!prepared.ok) return prepared.message;
|
|
463
|
+
return this.mcp.save(prepared.draft as any).message;
|
|
402
464
|
}
|
|
403
465
|
|
|
404
466
|
// Push the current workflow summaries to an attached controller (its workflows
|
|
@@ -671,25 +733,18 @@ export class Harbor {
|
|
|
671
733
|
|
|
672
734
|
const config = loadHarborConfig();
|
|
673
735
|
const modelSpec = routine.model ?? config.defaultModel;
|
|
674
|
-
const split = splitRoutineTools(routine.tools);
|
|
675
|
-
// MCP tools (server__tool) join the allow-list: the mcpAdapter loaded in runSession
|
|
676
|
-
// registers them from the shared mcp.json, and the routine's SIGNED tool list is the
|
|
677
|
-
// authorization boundary under the bypass gate (same as builtin tools). An http/OAuth
|
|
678
|
-
// connector that never completed its browser flow simply errors at call time.
|
|
679
|
-
const builtinAllow = split.builtin.length > 0 ? split.builtin : SAFE_TOOLS;
|
|
680
|
-
const allowedTools = [...builtinAllow, ...split.mcp];
|
|
681
736
|
if (routine.delivery.includes("email")) {
|
|
682
737
|
log(" note: email delivery is not wired yet (Phase 5) — skipping it");
|
|
683
738
|
}
|
|
684
739
|
|
|
685
|
-
const { out, status, error } = await this.runSession({
|
|
740
|
+
const { out, status, error, notes } = await this.runSession({
|
|
686
741
|
prompt: routine.prompt,
|
|
687
742
|
cwd: routine.cwd,
|
|
688
743
|
model: modelSpec,
|
|
689
|
-
tools:
|
|
744
|
+
tools: routine.tools ?? [],
|
|
690
745
|
});
|
|
691
746
|
|
|
692
|
-
const content = formatResult(routine, out, status, error);
|
|
747
|
+
const content = formatResult(routine, out, status, error, notes);
|
|
693
748
|
const report = await deliver(routine, content, status, {
|
|
694
749
|
pushRelay: this.pushRelay,
|
|
695
750
|
pushCloud: this.pushCloud,
|
|
@@ -708,25 +763,24 @@ export class Harbor {
|
|
|
708
763
|
return { ok: status === "ok", message: report.delivered.join(", ") || undefined };
|
|
709
764
|
}
|
|
710
765
|
|
|
711
|
-
//
|
|
712
|
-
//
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
try {
|
|
766
|
+
// Build the Pi services for one unattended run: the auto-approve gate, the account
|
|
767
|
+
// provider, web tools when the agent has them, and the MCP adapter.
|
|
768
|
+
//
|
|
769
|
+
// `directTools` is the ONLY way to make per-tool MCP names exist at all. Without it
|
|
770
|
+
// pi-mcp-adapter exposes every connector through a single proxy tool named "mcp" —
|
|
771
|
+
// one grant, all servers, all tools — which is exactly what a per-routine allow-list
|
|
772
|
+
// is supposed to prevent. The adapter reads it from process.env at extension
|
|
773
|
+
// activation, so the build is serialized (serializeBuild) and the previous value is
|
|
774
|
+
// restored. "__none__" is its sentinel for "register none": an unattended run with
|
|
775
|
+
// no connector selectors gets no direct tools, whatever a shared mcp.json says.
|
|
776
|
+
private buildSessionServices(cwd: string, directTools: string[]): Promise<any> {
|
|
777
|
+
return serializeBuild(async () => {
|
|
724
778
|
const gate: GateController = {
|
|
725
779
|
getMode: () => "bypass",
|
|
726
780
|
setMode: () => {},
|
|
727
781
|
allowlist: [],
|
|
728
782
|
allowedOutsideRoots: [],
|
|
729
|
-
cwd
|
|
783
|
+
cwd,
|
|
730
784
|
confineToCwd: true,
|
|
731
785
|
async localAsk() {
|
|
732
786
|
return "deny";
|
|
@@ -740,24 +794,149 @@ export class Harbor {
|
|
|
740
794
|
// adapter's own .ts into our typecheck — same intent as the desktop's agentImport.
|
|
741
795
|
const mcpAdapterSpec = "pi-mcp-adapter";
|
|
742
796
|
const { default: mcpAdapter } = await import(mcpAdapterSpec);
|
|
743
|
-
const
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
797
|
+
const prevDirect = process.env.MCP_DIRECT_TOOLS;
|
|
798
|
+
process.env.MCP_DIRECT_TOOLS = directTools.length > 0 ? directTools.join(",") : "__none__";
|
|
799
|
+
try {
|
|
800
|
+
return await createAgentSessionServices({
|
|
801
|
+
cwd,
|
|
802
|
+
agentDir: agentDir(),
|
|
803
|
+
resourceLoaderOptions: {
|
|
804
|
+
extensionFactories: [
|
|
805
|
+
makePermissionGate(gate),
|
|
806
|
+
// Per-model verified-TEE capability for pi-privacy's /models picker: show
|
|
807
|
+
// Privateer's TEE-channel models (near/tinfoil/phala) as "◆ Verifiable TEE"
|
|
808
|
+
// when logged in; ZDR-channel models stay at their honest floor. The live
|
|
809
|
+
// verdict still comes from accountPosture on select — this only lifts the label.
|
|
810
|
+
makePiPrivacyExtension({
|
|
811
|
+
privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
812
|
+
}),
|
|
813
|
+
makeAccountProvider(),
|
|
814
|
+
// Web access (src/tools/web.ts), when the agent is allowed it. Registered
|
|
815
|
+
// here rather than picked up from extensions/ because the harbor never
|
|
816
|
+
// installs the launcher's shims. Omitting the factory — not just dropping
|
|
817
|
+
// the names from the allow-list — is what makes "web off" mean the tools
|
|
818
|
+
// don't exist for this run at all.
|
|
819
|
+
...(webEnabled() ? [makeWebTools()] : []),
|
|
820
|
+
mcpAdapter,
|
|
821
|
+
] as any,
|
|
822
|
+
},
|
|
823
|
+
});
|
|
824
|
+
} finally {
|
|
825
|
+
if (prevDirect === undefined) delete process.env.MCP_DIRECT_TOOLS;
|
|
826
|
+
else process.env.MCP_DIRECT_TOOLS = prevDirect;
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Turn a routine's stored tool list into what Pi will actually accept.
|
|
832
|
+
//
|
|
833
|
+
// Builtins pass through builtinToolsFor (explicit list, or the safe read/web set).
|
|
834
|
+
// MCP entries are SELECTORS — "<server>__<tool>" / "<server>__*" — and have to be
|
|
835
|
+
// translated into the adapter's registered names before Pi's exact-match `tools:`
|
|
836
|
+
// Set can grant anything at all. A "<server>__*" needs the adapter's metadata cache
|
|
837
|
+
// to enumerate; when that is cold we warm it (below) and re-resolve once.
|
|
838
|
+
private async resolveRunTools(
|
|
839
|
+
raw: string[],
|
|
840
|
+
cwd: string,
|
|
841
|
+
modelSpec: string,
|
|
842
|
+
): Promise<{ tools: string[]; directToolsEnv: string[]; notes: string[] }> {
|
|
843
|
+
const split = splitRoutineTools(raw);
|
|
844
|
+
const builtin = builtinToolsFor(split.builtin);
|
|
845
|
+
if (split.mcp.length === 0) return { tools: builtin, directToolsEnv: [], notes: [] };
|
|
846
|
+
|
|
847
|
+
let resolved: ResolvedMcpTools = resolveMcpSelection(split.mcp);
|
|
848
|
+
if (resolved.coldServers.length > 0) {
|
|
849
|
+
await this.warmMcpCache(cwd, modelSpec, resolved.coldServers);
|
|
850
|
+
resolved = resolveMcpSelection(split.mcp);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const notes: string[] = [];
|
|
854
|
+
for (const server of resolved.coldServers) {
|
|
855
|
+
notes.push(
|
|
856
|
+
`Connector "${server}" didn't provide a tool list, so none of its tools were available to this run. ` +
|
|
857
|
+
`Check it is configured and, if it uses OAuth, that it is still authorized.`,
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
for (const selector of resolved.unknownTools) {
|
|
861
|
+
notes.push(`Connector tool "${selector}" doesn't exist on that connector, so it was not available to this run.`);
|
|
862
|
+
}
|
|
863
|
+
if (resolved.names.length > 0) {
|
|
864
|
+
log(` connectors: ${resolved.servers.join(", ")} → ${resolved.names.length} tools`);
|
|
865
|
+
}
|
|
866
|
+
for (const note of notes) log(` ${note}`);
|
|
867
|
+
return { tools: [...builtin, ...resolved.names], directToolsEnv: resolved.directToolsEnv, notes };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Make pi-mcp-adapter build a fresh tool inventory for every configured connector.
|
|
871
|
+
//
|
|
872
|
+
// The adapter only connects to ALL of them when agent/mcp-cache.json is absent
|
|
873
|
+
// (init.ts → bootstrapAll); with the file present it connects lazily, so a connector
|
|
874
|
+
// added since the last session never gets an inventory and a "<server>__*" selector
|
|
875
|
+
// over it expands to nothing, for ever. Dropping the cache is safe — it is a cache —
|
|
876
|
+
// and makes this one throwaway session rebuild it. The session is never prompted
|
|
877
|
+
// (empty allow-list) and its MCP connections close on the adapter's idle timeout.
|
|
878
|
+
//
|
|
879
|
+
// The adapter does NOT await its own initialization from session_start — it stashes
|
|
880
|
+
// the promise and returns — so session creation resolving means nothing here. We
|
|
881
|
+
// watch the cache file instead, and give up after WARM_TIMEOUT_MS: a connector that
|
|
882
|
+
// never answers (dead endpoint, expired OAuth) must not hold a scheduled run open.
|
|
883
|
+
//
|
|
884
|
+
// A warm rebuilds the inventory for EVERY configured connector, so two runs that
|
|
885
|
+
// both hit a cold selector share one pass rather than racing to wipe the file out
|
|
886
|
+
// from under each other.
|
|
887
|
+
private warmMcpCache(cwd: string, modelSpec: string, servers: string[]): Promise<void> {
|
|
888
|
+
if (this.warmInFlight) return this.warmInFlight;
|
|
889
|
+
const p = this.doWarmMcpCache(cwd, modelSpec, servers).finally(() => {
|
|
890
|
+
if (this.warmInFlight === p) this.warmInFlight = undefined;
|
|
891
|
+
});
|
|
892
|
+
this.warmInFlight = p;
|
|
893
|
+
return p;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
private async doWarmMcpCache(cwd: string, modelSpec: string, servers: string[]): Promise<void> {
|
|
897
|
+
log(` warming connector tool lists: ${servers.join(", ")}`);
|
|
898
|
+
try {
|
|
899
|
+
rmSync(join(agentDir(), "mcp-cache.json"), { force: true });
|
|
900
|
+
const services = await this.buildSessionServices(cwd, []);
|
|
901
|
+
const { provider, modelId } = parseSpec(modelSpec);
|
|
902
|
+
const model = (services.modelRegistry as any).find(provider, modelId);
|
|
903
|
+
if (!model) return;
|
|
904
|
+
const { session } = await createAgentSessionFromServices({
|
|
905
|
+
services,
|
|
906
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
907
|
+
model,
|
|
908
|
+
tools: [],
|
|
909
|
+
} as any);
|
|
910
|
+
try {
|
|
911
|
+
const deadline = Date.now() + WARM_TIMEOUT_MS;
|
|
912
|
+
while (Date.now() < deadline) {
|
|
913
|
+
const inventory = readMcpInventory(agentDir());
|
|
914
|
+
if (servers.every((s) => (inventory[s]?.length ?? 0) > 0)) break;
|
|
915
|
+
await new Promise((r) => setTimeout(r, WARM_POLL_MS));
|
|
916
|
+
}
|
|
917
|
+
} finally {
|
|
918
|
+
session.dispose?.();
|
|
919
|
+
}
|
|
920
|
+
} catch (e) {
|
|
921
|
+
log(` connector warm-up failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Drive one headless Pi turn to completion and return its collected text + status.
|
|
926
|
+
// The shared core of BOTH a scheduled routine and an app-submitted ad-hoc task: an
|
|
927
|
+
// auto-approve (bypass) gate whose safety is the restricted `tools` list — a dangerous
|
|
928
|
+
// shell command still fail-closes headlessly (localAsk denies) — plus per-run account
|
|
929
|
+
// credentials that are revoked in the finally so they never linger as an orphaned
|
|
930
|
+
// "device" in the app's Linked Devices.
|
|
931
|
+
private async runSession(spec: { prompt: string; cwd: string; model: string; tools: string[] }): Promise<{ out: string; status: "ok" | "error"; error?: string; notes: string[] }> {
|
|
932
|
+
let out = "";
|
|
933
|
+
let status: "ok" | "error" = "ok";
|
|
934
|
+
let error: string | undefined;
|
|
935
|
+
let servicesRef: { authStorage?: { remove?: (p: string) => void; get?: (p: string) => unknown } } | null = null;
|
|
936
|
+
let spawnedAccount = false;
|
|
937
|
+
const { tools: allowedTools, directToolsEnv, notes } = await this.resolveRunTools(spec.tools, spec.cwd, spec.model);
|
|
938
|
+
try {
|
|
939
|
+
const services = await this.buildSessionServices(spec.cwd, directToolsEnv);
|
|
761
940
|
servicesRef = services as any;
|
|
762
941
|
|
|
763
942
|
const { provider, modelId } = parseSpec(spec.model);
|
|
@@ -765,6 +944,7 @@ export class Harbor {
|
|
|
765
944
|
try {
|
|
766
945
|
const creds = await acquireAccountCredential();
|
|
767
946
|
(services.authStorage as any).set("privateer", { type: "oauth", ...creds });
|
|
947
|
+
rememberAccountCredential(creds); // claim it, so the teardown drops OUR entry only
|
|
768
948
|
spawnedAccount = true;
|
|
769
949
|
} catch (e) {
|
|
770
950
|
log(` account channel unavailable: ${(e as Error).message}`);
|
|
@@ -780,7 +960,7 @@ export class Harbor {
|
|
|
780
960
|
services,
|
|
781
961
|
sessionManager: SessionManager.inMemory(spec.cwd),
|
|
782
962
|
model,
|
|
783
|
-
tools:
|
|
963
|
+
tools: allowedTools,
|
|
784
964
|
} as any);
|
|
785
965
|
const adapter = createEngineEventAdapter();
|
|
786
966
|
session.subscribe((ev: any) => {
|
|
@@ -803,10 +983,12 @@ export class Harbor {
|
|
|
803
983
|
// too so a later run's fallback never reuses a revoked token. Best-effort.
|
|
804
984
|
if (spawnedAccount) {
|
|
805
985
|
try { await revokeAccountSession(); } catch { /* best effort — server TTL is the fallback */ }
|
|
806
|
-
|
|
986
|
+
// Ownership-checked: an interactive terminal on this machine shares auth.json,
|
|
987
|
+
// and its entry must survive a harbor run's teardown (see providers/account.ts).
|
|
988
|
+
try { dropPersistedAccountCredential({ modelRegistry: { authStorage: servicesRef?.authStorage } }); } catch { /* nothing persisted */ }
|
|
807
989
|
}
|
|
808
990
|
}
|
|
809
|
-
return { out, status, error };
|
|
991
|
+
return { out, status, error, notes };
|
|
810
992
|
}
|
|
811
993
|
|
|
812
994
|
// Run an app-submitted AD-HOC task (task_submit): one restricted headless turn whose
|
|
@@ -819,8 +1001,6 @@ export class Harbor {
|
|
|
819
1001
|
const config = loadHarborConfig();
|
|
820
1002
|
const cwd = spec.cwd && spec.cwd.trim() ? spec.cwd : process.cwd();
|
|
821
1003
|
const modelSpec = spec.model && spec.model.trim() ? spec.model : config.defaultModel;
|
|
822
|
-
const split = spec.tools && spec.tools.length ? splitRoutineTools(spec.tools) : undefined;
|
|
823
|
-
const allowedTools = [...(split && split.builtin.length > 0 ? split.builtin : SAFE_TOOLS), ...(split?.mcp ?? [])];
|
|
824
1004
|
const title = deriveTaskTitle(spec);
|
|
825
1005
|
const key = `task:${title}`;
|
|
826
1006
|
if (this.running.has(key)) {
|
|
@@ -830,8 +1010,8 @@ export class Harbor {
|
|
|
830
1010
|
this.running.add(key);
|
|
831
1011
|
log(`running task "${title}"`);
|
|
832
1012
|
try {
|
|
833
|
-
const { out, status, error } = await this.runSession({ prompt: spec.prompt, cwd, model: modelSpec, tools:
|
|
834
|
-
const content = redactText(formatTaskResult(title, out, status, error, modelSpec), collectSecrets(config.providers));
|
|
1013
|
+
const { out, status, error, notes } = await this.runSession({ prompt: spec.prompt, cwd, model: modelSpec, tools: spec.tools ?? [] });
|
|
1014
|
+
const content = redactText(formatTaskResult(title, out, status, error, modelSpec, notes), collectSecrets(config.providers));
|
|
835
1015
|
const at = new Date().toISOString();
|
|
836
1016
|
// Durable delivery: seal to the outbox. If we can't seal yet (no verified pubkey /
|
|
837
1017
|
// offline), queue it with kind:"task" so the flush re-seals it correctly later.
|
|
@@ -1003,12 +1183,13 @@ export class Harbor {
|
|
|
1003
1183
|
private async runWorkflowAgent(spec: AgentRunSpec): Promise<AgentRunResult> {
|
|
1004
1184
|
const config = loadHarborConfig();
|
|
1005
1185
|
const model = spec.model && spec.model.trim() ? spec.model : config.defaultModel;
|
|
1006
|
-
const
|
|
1007
|
-
const tools = [...(split && split.builtin.length > 0 ? split.builtin : SAFE_TOOLS), ...(split?.mcp ?? [])];
|
|
1008
|
-
const { out, status, error } = await this.runSession({ prompt: spec.prompt, cwd: spec.cwd, model, tools });
|
|
1186
|
+
const { out, status, error, notes } = await this.runSession({ prompt: spec.prompt, cwd: spec.cwd, model, tools: spec.tools ?? [] });
|
|
1009
1187
|
let output: Record<string, unknown> = {};
|
|
1010
1188
|
try { const p = JSON.parse(out.trim()); if (p && typeof p === "object" && !Array.isArray(p)) output = p as Record<string, unknown>; } catch { /* non-JSON → raw text only */ }
|
|
1011
|
-
|
|
1189
|
+
// A step's `text` is what a later step interpolates and what the run report shows,
|
|
1190
|
+
// so an unreachable connector has to be visible there too — silently returning a
|
|
1191
|
+
// thinner answer is how a broken graph looks like a working one.
|
|
1192
|
+
return { text: notes.length > 0 ? `${out}${formatNotes(notes)}` : out, output, status, error };
|
|
1012
1193
|
}
|
|
1013
1194
|
|
|
1014
1195
|
private persistRun(id: string, patch: Partial<Routine>): void {
|
package/src/mcp/catalog.ts
CHANGED
|
@@ -36,6 +36,34 @@ export interface CatalogEntry {
|
|
|
36
36
|
fill?: string;
|
|
37
37
|
// Where to get the credential, shown as a hint in the form.
|
|
38
38
|
credUrl?: string;
|
|
39
|
+
// Can this connector run on a HOSTED (Harbor) agent? Leave unset to take the derived
|
|
40
|
+
// answer from hostedCapable() below; set it explicitly only to say "no" to something
|
|
41
|
+
// that would otherwise qualify.
|
|
42
|
+
hosted?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether an entry can run on a hosted agent, as opposed to a local daemon/desktop.
|
|
47
|
+
*
|
|
48
|
+
* The rule is not a preference, it is the runtime: a Harbor tenant is `--read-only`,
|
|
49
|
+
* `--cap-drop ALL`, has no `uv`/`uvx`/Python/browser, and its home is tmpfs wiped on
|
|
50
|
+
* every suspend. A stdio entry would have to download and execute unmeasured
|
|
51
|
+
* third-party code inside an attested enclave at runtime, which defeats the point of
|
|
52
|
+
* the measurement; and a token-bearing entry would need a durable secret at rest,
|
|
53
|
+
* which we decided against (Option B — see treeview/docs/HARBOR_CONNECTORS_PLAN.md §2).
|
|
54
|
+
* What is left is remote HTTP + OAuth.
|
|
55
|
+
*
|
|
56
|
+
* The `/connect` picker filters on this (extensions/privateer-connect.ts →
|
|
57
|
+
* catalogRows) rather than showing 21 options of which 16 cannot work. It shapes the
|
|
58
|
+
* custom-connector form there too: no local command, no stored token.
|
|
59
|
+
*
|
|
60
|
+
* It does NOT gate mcpControl.save() — the app-over-relay path can still write a
|
|
61
|
+
* connector this returns false for. Fixing that means teaching mcpControl about
|
|
62
|
+
* hosted mode, which is a bigger change than a picker filter.
|
|
63
|
+
*/
|
|
64
|
+
export function hostedCapable(e: CatalogEntry): boolean {
|
|
65
|
+
if (e.hosted !== undefined) return e.hosted;
|
|
66
|
+
return e.transport === "http" && e.oauth === true;
|
|
39
67
|
}
|
|
40
68
|
|
|
41
69
|
export const MCP_CATALOG: CatalogEntry[] = [
|
|
@@ -327,7 +355,10 @@ export function draftFromCatalog(
|
|
|
327
355
|
draft.args = (e.args ?? []).map((a) => (e.fill && a === e.fill && filled ? filled : a));
|
|
328
356
|
} else {
|
|
329
357
|
draft.url = e.url;
|
|
330
|
-
|
|
358
|
+
// Every http entry in this catalog is an OAuth connector. Emit the adapter's own
|
|
359
|
+
// vocabulary (`auth`) rather than the legacy boolean, so the projection carries
|
|
360
|
+
// `auth: "oauth"` and not a bogus boolean in the adapter's OAuthConfig slot.
|
|
361
|
+
draft.auth = (e.oauth ?? true) ? "oauth" : "none";
|
|
331
362
|
}
|
|
332
363
|
|
|
333
364
|
const keys = Object.keys(e.env ?? {});
|