privateer-agent 0.8.2 → 0.9.1

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/src/harbor/ipc.ts CHANGED
@@ -22,6 +22,28 @@ export type IpcRequest =
22
22
  | { cmd: "run-now"; idOrName: string }
23
23
  | { cmd: "reload" };
24
24
 
25
+ /**
26
+ * The harbor's view of its own relay connection, reported by `status`.
27
+ *
28
+ * A harbor answering on this socket is running; that is NOT the same as being
29
+ * reachable from the app, which needs the relay socket up (the server drops a
30
+ * terminal from its presence registry ~60s after it stops hearing from it). The two
31
+ * used to be conflated — "running (answering IPC)" while the app showed the same
32
+ * harbor as offline — so every liveness report carries both now.
33
+ */
34
+ export interface RelayStatus {
35
+ /** The relay terminal id the app looks for ("routines-…"). */
36
+ termId: string;
37
+ /** Socket open right now, i.e. the app can see and drive this harbor. */
38
+ connected: boolean;
39
+ /** Seconds the current connection has been up. */
40
+ upSec?: number;
41
+ /** Seconds since the server last sent anything (frame, ping or pong). */
42
+ quietSec?: number;
43
+ /** Why it isn't connected, when we know: signed out, turned off from the app, … */
44
+ detail?: string;
45
+ }
46
+
25
47
  export interface IpcResponse {
26
48
  ok: boolean;
27
49
  message?: string;
@@ -29,6 +51,8 @@ export interface IpcResponse {
29
51
  // Harbor liveness/uptime for `status`.
30
52
  pid?: number;
31
53
  uptimeSec?: number;
54
+ // Relay reachability for `status` — see RelayStatus.
55
+ relay?: RelayStatus;
32
56
  }
33
57
 
34
58
  export type IpcHandler = (req: IpcRequest) => Promise<IpcResponse> | IpcResponse;
@@ -164,6 +188,32 @@ export class HarborAlreadyRunningError extends Error {
164
188
  }
165
189
  }
166
190
 
191
+ // One-line, human rendering of a status reply's relay block — shared by
192
+ // `privateer harbor status` and the second-instance notice so both tell the same
193
+ // story. `undefined` means the harbor answering us predates this field.
194
+ export function describeRelay(relay?: RelayStatus): string {
195
+ if (!relay) return "unknown (this harbor is an older build)";
196
+ if (!relay.connected) {
197
+ return `NOT connected — the app shows this Harbor as inactive${relay.detail ? ` (${relay.detail})` : ""}`;
198
+ }
199
+ const up = typeof relay.upSec === "number" ? `, up ${formatDuration(relay.upSec)}` : "";
200
+ // A connected socket the server hasn't spoken on in a while is the half-open shape;
201
+ // the watchdog drops it within ~75s, so say so rather than reporting a flat "connected".
202
+ const quiet = typeof relay.quietSec === "number" && relay.quietSec > 40 ? `, quiet for ${relay.quietSec}s — checking` : "";
203
+ return `connected — drivable from the Privateer app (${relay.termId}${up}${quiet})`;
204
+ }
205
+
206
+ export function formatDuration(totalSec: number): string {
207
+ const s = Math.max(0, Math.round(totalSec));
208
+ const d = Math.floor(s / 86400);
209
+ const h = Math.floor((s % 86400) / 3600);
210
+ const m = Math.floor((s % 3600) / 60);
211
+ if (d) return `${d}d ${h}h`;
212
+ if (h) return `${h}h ${m}m`;
213
+ if (m) return `${m}m`;
214
+ return `${s}s`;
215
+ }
216
+
167
217
  // Convenience: is the harbor reachable right now?
168
218
  export async function harborIsRunning(): Promise<boolean> {
169
219
  try {
@@ -12,7 +12,7 @@ import { homedir } from "node:os";
12
12
  import { join, dirname, resolve } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { globalDir } from "../config/paths.ts";
15
- import { harborIsRunning } from "./ipc.ts";
15
+ import { harborIsRunning, sendToHarbor, describeRelay, formatDuration, type IpcResponse } from "./ipc.ts";
16
16
 
17
17
  const LABEL = "pro.privateer.harbor"; // launchd label / reverse-dns id
18
18
  const UNIT = "privateer-harbor.service"; // systemd --user unit name
@@ -62,7 +62,13 @@ function xmlEscape(s: string): string {
62
62
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
63
63
  }
64
64
 
65
- function launchdPlist(): string {
65
+ // KeepAlive is `{ SuccessfulExit: false }`, NOT plain `true` — restart on a crash,
66
+ // but leave a CLEAN exit alone. A bare `true` restarts unconditionally, which turns
67
+ // the two clean-exit paths into loops: the harbor that finds another one already
68
+ // holding the machine lock (exit 0 every ~10s, appending the same line to harbor.log
69
+ // forever — this is what produced a 7 MB log of "already running"), and a deliberate
70
+ // shutdown, which launchd would undo. Matches the systemd unit's Restart=on-failure.
71
+ export function launchdPlist(): string {
66
72
  const args = [nodeBinaryPath(), harborLauncherPath(), "run"];
67
73
  const envVars = forwardedEnv();
68
74
  const argXml = args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
@@ -83,7 +89,10 @@ ${argXml}
83
89
  ${envVars.PRIVATEER_HOME || envVars.PRIVATEER_SERVER_URL ? ` <key>EnvironmentVariables</key>\n <dict>\n${envXml}\n </dict>\n` : ""} <key>RunAtLoad</key>
84
90
  <true/>
85
91
  <key>KeepAlive</key>
86
- <true/>
92
+ <dict>
93
+ <key>SuccessfulExit</key>
94
+ <false/>
95
+ </dict>
87
96
  <key>StandardOutPath</key>
88
97
  <string>${log}</string>
89
98
  <key>StandardErrorPath</key>
@@ -199,6 +208,24 @@ export interface ServiceInfo {
199
208
  installed: boolean;
200
209
  unitPath: string;
201
210
  logPath: string;
211
+ /** Installed unit predates a fix and should be rewritten — see needsRefresh(). */
212
+ needsRefresh: boolean;
213
+ }
214
+
215
+ // Does the INSTALLED unit need rewriting? Deliberately narrow: a full text compare
216
+ // against what we'd generate today would flag every service installed by a different
217
+ // copy of the CLI (a dev checkout resolves a different launcher path), which is not a
218
+ // problem and not something the user should be nagged about. The one thing worth
219
+ // flagging is the pre-fix launchd `KeepAlive: true`, which restarts the harbor even
220
+ // after a clean exit — the log-spam loop described above launchdPlist().
221
+ export function unitNeedsRefresh(platform: NodeJS.Platform, unitPath: string): boolean {
222
+ if (platform !== "darwin" || !existsSync(unitPath)) return false;
223
+ try {
224
+ const plist = readFileSync(unitPath, "utf8");
225
+ return /<key>KeepAlive<\/key>\s*<true\s*\/>/.test(plist);
226
+ } catch {
227
+ return false;
228
+ }
202
229
  }
203
230
 
204
231
  function unitPathFor(platform: NodeJS.Platform): string {
@@ -216,6 +243,7 @@ export function serviceInfo(): ServiceInfo {
216
243
  installed: !!unitPath && existsSync(unitPath),
217
244
  unitPath,
218
245
  logPath: harborLogPath(),
246
+ needsRefresh: unitNeedsRefresh(platform, unitPath),
219
247
  };
220
248
  }
221
249
 
@@ -236,20 +264,40 @@ export function uninstallService(): ServiceInfo {
236
264
  return serviceInfo();
237
265
  }
238
266
 
239
- // Human-readable status line for `privateer harbor status`: whether the service is
240
- // installed AND whether a harbor is actually answering on the IPC socket right now.
267
+ // Human-readable status for `privateer harbor status`: whether the service is
268
+ // installed, whether a harbor is answering on the IPC socket, and — the part that
269
+ // actually answers "why does the app say inactive?" — whether that harbor is
270
+ // connected to the relay. Answering IPC only proves a local process is alive; the
271
+ // app lists a harbor from the server's presence registry, which a dead relay socket
272
+ // drops within ~60s. Reporting the first as if it implied the second is what made a
273
+ // stale harbor look healthy from the terminal and offline from the phone.
241
274
  export async function statusReport(): Promise<string> {
242
275
  const info = serviceInfo();
243
- const live = await harborIsRunning();
276
+ let status: IpcResponse | null = null;
277
+ try {
278
+ status = await sendToHarbor({ cmd: "status" }, 3_000);
279
+ } catch {
280
+ status = null; // not running, or wedged — harborIsRunning() below tells them apart
281
+ }
282
+ const live = status ? status.ok : await harborIsRunning();
283
+ const up = status && typeof status.uptimeSec === "number" ? `, up ${formatDuration(status.uptimeSec)}` : "";
284
+ const pid = status?.pid ? `pid ${status.pid}${up}` : "answering IPC";
244
285
  const lines = [
245
286
  `platform: ${info.platform}${info.supported ? "" : " (auto-start unsupported — run `privateer harbor` manually)"}`,
246
287
  `service: ${info.installed ? `installed (${info.unitPath})` : "not installed"}`,
247
- `harbor: ${live ? "running (answering IPC)" : "not reachable"}`,
248
- `logs: ${info.logPath}`,
288
+ `harbor: ${live ? `running (${pid})` : "not reachable"}`,
249
289
  ];
290
+ if (live) lines.push(`relay: ${describeRelay(status?.relay)}`);
291
+ lines.push(`logs: ${info.logPath}`);
250
292
  // Surface a stale-unit hint: file present but nothing answering usually means it
251
293
  // failed to boot — the log path above is where to look.
252
294
  if (info.installed && !live) lines.push("hint: service is installed but not answering — check the log for a boot error.");
295
+ if (live && status?.relay && !status.relay.connected) {
296
+ lines.push("hint: Harbor is running but not reachable from the app. Restart it (`privateer harbor uninstall && privateer harbor install`) once the cause above is resolved.");
297
+ }
298
+ if (info.needsRefresh) {
299
+ lines.push("hint: the installed login service restarts Harbor even after a clean exit (older install) — run `privateer harbor install` to refresh it.");
300
+ }
253
301
  return lines.join("\n");
254
302
  }
255
303
 
@@ -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 ?? {});
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Translating a routine's MCP selectors into the tool names Pi will actually honour.
3
+ *
4
+ * There are TWO vocabularies here and conflating them is why per-routine connector
5
+ * allow-lists silently granted nothing:
6
+ *
7
+ * • The SELECTOR vocabulary — "<server>__<tool>" exact, or "<server>__*" for a whole
8
+ * server. This is what a routine stores, what the app writes, and what the AI
9
+ * drafts. The double underscore is the point: no Pi builtin contains "__", so
10
+ * splitRoutineTools can tell a connector selector from a builtin name without a
11
+ * lookup table. This vocabulary is STABLE — routines on disk depend on it.
12
+ *
13
+ * • The REGISTERED vocabulary — what pi-mcp-adapter actually names a tool when it
14
+ * registers it with Pi: `formatToolName()` → "<serverPrefix>_<tool>", ONE
15
+ * underscore, with dashes in the server name folded to underscores. And Pi's
16
+ * `tools:` option is an exact-match Set (`allowedToolNames`), so a literal
17
+ * "github__*" or even "github__create_issue" handed to it matches nothing at all.
18
+ *
19
+ * This module is the translation layer, mirroring pi-mcp-adapter's `getServerPrefix`
20
+ * / `formatToolName` rather than importing them — the adapter ships as .ts in
21
+ * node_modules and pulling it into our typecheck is the thing `harbor/index.ts`
22
+ * already dodges with a variable import specifier. The mirror is four lines and is
23
+ * pinned by tests/toolSelect.test.ts.
24
+ *
25
+ * The other half of the problem: per-tool names only EXIST when direct tools are
26
+ * enabled. Otherwise the adapter exposes MCP through a single proxy tool named "mcp"
27
+ * — all servers, all tools, one grant, which is precisely what a per-routine
28
+ * allow-list is meant to avoid. So callers pair `names` with `directToolsEnv`, which
29
+ * scopes MCP_DIRECT_TOOLS to exactly the selected server/tool pairs for that one run.
30
+ */
31
+ import { readFileSync } from "node:fs";
32
+ import { join } from "node:path";
33
+ import { agentDir } from "../config/paths.ts";
34
+
35
+ export type PrefixMode = "server" | "none" | "short";
36
+
37
+ /** Mirrors pi-mcp-adapter's getServerPrefix. */
38
+ export function serverPrefix(serverName: string, mode: PrefixMode): string {
39
+ if (mode === "none") return "";
40
+ if (mode === "short") {
41
+ const short = serverName.replace(/-?mcp$/i, "").replace(/-/g, "_");
42
+ return short || "mcp";
43
+ }
44
+ return serverName.replace(/-/g, "_");
45
+ }
46
+
47
+ /** Mirrors pi-mcp-adapter's formatToolName. */
48
+ export function formatToolName(toolName: string, serverName: string, mode: PrefixMode): string {
49
+ const p = serverPrefix(serverName, mode);
50
+ return p ? `${p}_${toolName}` : toolName;
51
+ }
52
+
53
+ /** The cached tool inventory the adapter builds after connecting: server → tool names. */
54
+ export type McpInventory = Record<string, string[]>;
55
+
56
+ export interface ResolveInput {
57
+ /** "<server>__<tool>" / "<server>__*" selectors, as stored on the routine. */
58
+ selectors: string[];
59
+ /** Server → the tool names it exposes, from the adapter's metadata cache. */
60
+ inventory: McpInventory;
61
+ /** The adapter's tool-prefix mode (mcp.json → settings.toolPrefix). */
62
+ prefix: PrefixMode;
63
+ }
64
+
65
+ export interface ResolvedMcpTools {
66
+ /** Exact registered tool names to hand to Pi's `tools:` allow-list. */
67
+ names: string[];
68
+ /** Servers touched by the selectors — the set that must be reachable this run. */
69
+ servers: string[];
70
+ /** MCP_DIRECT_TOOLS entries ("server/tool", or bare "server" for a wildcard). */
71
+ directToolsEnv: string[];
72
+ /**
73
+ * Servers a selector named that the metadata cache knows nothing about. A wildcard
74
+ * over one of these expands to NOTHING, so the caller must warm the cache before
75
+ * building the session — see harbor/index.ts. Never silently ignore this.
76
+ */
77
+ coldServers: string[];
78
+ /**
79
+ * Exact selectors whose server DID report an inventory that doesn't contain that
80
+ * tool — a typo, or a tool the connector dropped. The name is still granted (it
81
+ * simply never registers), but the caller should say so rather than let the run
82
+ * quietly come back thinner than asked for.
83
+ */
84
+ unknownTools: string[];
85
+ }
86
+
87
+ /**
88
+ * Expand selectors against a known inventory. Pure — the file reads live in
89
+ * `resolveMcpSelection` below so this stays trivially testable.
90
+ *
91
+ * An EXACT selector resolves without the inventory (we can compute the registered
92
+ * name from the server + tool alone), so a connector whose cache entry is stale still
93
+ * works. A WILDCARD needs the inventory to enumerate, which is why `coldServers`
94
+ * exists.
95
+ */
96
+ export function resolveMcpTools({ selectors, inventory, prefix }: ResolveInput): ResolvedMcpTools {
97
+ const names: string[] = [];
98
+ const servers: string[] = [];
99
+ const directToolsEnv: string[] = [];
100
+ const coldServers: string[] = [];
101
+ const unknownTools: string[] = [];
102
+
103
+ const push = <T>(arr: T[], v: T) => {
104
+ if (!arr.includes(v)) arr.push(v);
105
+ };
106
+
107
+ for (const selector of selectors) {
108
+ const sep = selector.indexOf("__");
109
+ if (sep <= 0) continue; // not a selector; splitRoutineTools already routed it
110
+ const server = selector.slice(0, sep);
111
+ const tool = selector.slice(sep + 2);
112
+ if (!tool) continue;
113
+ push(servers, server);
114
+
115
+ if (tool === "*") {
116
+ const known = inventory[server];
117
+ if (!known || known.length === 0) {
118
+ push(coldServers, server);
119
+ continue;
120
+ }
121
+ // Bare server name = "every tool on this server" to MCP_DIRECT_TOOLS.
122
+ push(directToolsEnv, server);
123
+ for (const t of known) push(names, formatToolName(t, server, prefix));
124
+ continue;
125
+ }
126
+
127
+ push(names, formatToolName(tool, server, prefix));
128
+ // MCP_DIRECT_TOOLS matches the ORIGINAL (unprefixed) tool name.
129
+ push(directToolsEnv, `${server}/${tool}`);
130
+ const known = inventory[server];
131
+ if (!known || known.length === 0) push(coldServers, server);
132
+ else if (!known.includes(tool)) push(unknownTools, selector);
133
+ }
134
+
135
+ return { names, servers, directToolsEnv, coldServers, unknownTools };
136
+ }
137
+
138
+ /** The adapter's metadata cache, as it lands on disk (agent/mcp-cache.json). */
139
+ export function readMcpInventory(dir: string = agentDir()): McpInventory {
140
+ const out: McpInventory = {};
141
+ try {
142
+ const raw = JSON.parse(readFileSync(join(dir, "mcp-cache.json"), "utf8"));
143
+ for (const [server, entry] of Object.entries<any>(raw?.servers ?? {})) {
144
+ const tools = Array.isArray(entry?.tools)
145
+ ? entry.tools.map((t: any) => String(t?.name ?? "")).filter(Boolean)
146
+ : [];
147
+ out[server] = tools;
148
+ }
149
+ } catch {
150
+ /* no cache yet — every selected server is cold */
151
+ }
152
+ return out;
153
+ }
154
+
155
+ /**
156
+ * The adapter's prefix mode. `mcpControl.project()` pins "server" into the file it
157
+ * writes, so this is really a guard for a hand-written mcp.json.
158
+ */
159
+ export function readPrefixMode(dir: string = agentDir()): PrefixMode {
160
+ try {
161
+ const raw = JSON.parse(readFileSync(join(dir, "mcp.json"), "utf8"));
162
+ const mode = raw?.settings?.toolPrefix;
163
+ if (mode === "server" || mode === "none" || mode === "short") return mode;
164
+ } catch {
165
+ /* no config — the adapter's own default */
166
+ }
167
+ return "server";
168
+ }
169
+
170
+ /** Read-from-disk wrapper around resolveMcpTools. */
171
+ export function resolveMcpSelection(selectors: string[], dir: string = agentDir()): ResolvedMcpTools {
172
+ return resolveMcpTools({
173
+ selectors,
174
+ inventory: readMcpInventory(dir),
175
+ prefix: readPrefixMode(dir),
176
+ });
177
+ }