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.
@@ -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 { makeAccountProvider, privateerChannel } from "../providers/account.ts";
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. (0.2's web_fetch/web_search return once the web tools
59
- // land in Phase 5.) Safety is the tool restriction; the gate auto-approves what's
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
- function formatResult(routine: Routine, body: string, status: "ok" | "error", error?: string): string {
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 Routines" terminal is always reachable
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
- { termId: routineRelayId(), label: "Privateer Routines" },
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 } — a token the relay never sees in the clear.
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 withEnv = draft;
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.termId !== routineRelayId()) {
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
- return this.mcp.save(withEnv as any).message;
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: allowedTools,
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
- // Drive one headless Pi turn to completion and return its collected text + status.
712
- // The shared core of BOTH a scheduled routine and an app-submitted ad-hoc task: an
713
- // auto-approve (bypass) gate whose safety is the restricted `tools` list — a dangerous
714
- // shell command still fail-closes headlessly (localAsk denies) plus per-run account
715
- // credentials that are revoked in the finally so they never linger as an orphaned
716
- // "device" in the app's Linked Devices.
717
- private async runSession(spec: { prompt: string; cwd: string; model: string; tools: string[] }): Promise<{ out: string; status: "ok" | "error"; error?: string }> {
718
- let out = "";
719
- let status: "ok" | "error" = "ok";
720
- let error: string | undefined;
721
- let servicesRef: { authStorage?: { remove?: (p: string) => void } } | null = null;
722
- let spawnedAccount = false;
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: spec.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 services = await createAgentSessionServices({
744
- cwd: spec.cwd,
745
- agentDir: agentDir(),
746
- resourceLoaderOptions: {
747
- extensionFactories: [
748
- makePermissionGate(gate),
749
- // Per-model verified-TEE capability for pi-privacy's /models picker: show
750
- // Privateer's TEE-channel models (near/tinfoil/phala) as "◆ Verifiable TEE"
751
- // when logged in; ZDR-channel models stay at their honest floor. The live
752
- // verdict still comes from accountPosture on select — this only lifts the label.
753
- makePiPrivacyExtension({
754
- privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
755
- }),
756
- makeAccountProvider(),
757
- mcpAdapter,
758
- ] as any,
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: spec.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
- try { servicesRef?.authStorage?.remove?.("privateer"); } catch { /* nothing persisted */ }
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: allowedTools });
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 split = spec.tools && spec.tools.length ? splitRoutineTools(spec.tools) : undefined;
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
- return { text: out, output, status, error };
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 {
@@ -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
- draft.oauth = e.oauth ?? true;
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 ?? {});