@peakflo/agent-harness 0.2.13 → 0.2.14-rc.2

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.
@@ -0,0 +1,230 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CREDENTIAL_PROXY_ENV_KEYS = exports.CREDENTIAL_PROXY_TOKEN_REDACTION = exports.CREDENTIAL_PROXY_HEALTH_TIMEOUT_MS = exports.CREDENTIAL_PROXY_CA_BUNDLE_FILE = void 0;
4
+ exports.probeLoopbackPort = probeLoopbackPort;
5
+ exports.githubProxyPort = githubProxyPort;
6
+ exports.credentialProxyEnvironment = credentialProxyEnvironment;
7
+ exports.redactCredentialProxyToken = redactCredentialProxyToken;
8
+ exports.redactCredentialProxyTokenDeep = redactCredentialProxyTokenDeep;
9
+ /**
10
+ * THE AGENT'S ROUTE TO THE SANDBOX CREDENTIAL PROXY.
11
+ *
12
+ * The credential proxy runs in the ROOT sandbox broker, not in this process.
13
+ * It holds every real credential value; the harness never does. That is not a
14
+ * style choice: the harness and the agent it spawns share one uid (1500), so
15
+ * anything in the harness's memory or `/proc/<pid>/environ` is readable by the
16
+ * agent. The only thing the harness ever handles is the lease's
17
+ * `credentialProxy` descriptor — a loopback port, a CA path, and a token that
18
+ * is bound to tenant/session/turn/sandbox and is NOT a credential value.
19
+ *
20
+ * What this module does with it, and nothing more:
21
+ * - decide whether the proxy is usable for THIS turn (sandbox runtime, Pi or
22
+ * OpenCode backend, port answers within 1 s, CA readable), and
23
+ * - if so, layer the proxy variables over the turn's existing environment.
24
+ *
25
+ * Any failed check returns the input environment UNCHANGED, plus one log line
26
+ * carrying the sessionId and a reason code. The token is never logged.
27
+ */
28
+ const node_crypto_1 = require("node:crypto");
29
+ const promises_1 = require("node:fs/promises");
30
+ const node_net_1 = require("node:net");
31
+ const node_path_1 = require("node:path");
32
+ const github_proxy_certificates_1 = require("../github-proxy-certificates");
33
+ const github_workspace_1 = require("../github-workspace");
34
+ const vendor_backends_1 = require("../vendor-backends");
35
+ exports.CREDENTIAL_PROXY_CA_BUNDLE_FILE = '.pf-credential-proxy-ca.pem';
36
+ exports.CREDENTIAL_PROXY_HEALTH_TIMEOUT_MS = 1_000;
37
+ exports.CREDENTIAL_PROXY_TOKEN_REDACTION = '[credential-proxy-token]';
38
+ /** Every key this module may add or replace. Nothing else is ever touched. */
39
+ exports.CREDENTIAL_PROXY_ENV_KEYS = [
40
+ 'HTTPS_PROXY',
41
+ 'https_proxy',
42
+ 'HTTP_PROXY',
43
+ 'http_proxy',
44
+ 'NO_PROXY',
45
+ 'no_proxy',
46
+ 'SSL_CERT_FILE',
47
+ 'CURL_CA_BUNDLE',
48
+ 'NODE_EXTRA_CA_CERTS',
49
+ 'GIT_SSL_CAINFO',
50
+ 'REQUESTS_CA_BUNDLE',
51
+ 'PF_CREDENTIAL_PROXY'
52
+ ];
53
+ /*
54
+ * The token lands in the userinfo of a proxy URL, so it must need no
55
+ * percent-encoding. The contract format `pfcp_<b64url>.<b64url>` satisfies
56
+ * this; anything else is refused rather than silently mangled.
57
+ */
58
+ const TOKEN_SHAPE = /^[A-Za-z0-9._~-]{1,2048}$/;
59
+ /** TCP connect to 127.0.0.1:port; true when it connects within the timeout. */
60
+ function probeLoopbackPort(port, timeoutMs = exports.CREDENTIAL_PROXY_HEALTH_TIMEOUT_MS) {
61
+ return new Promise((resolvePromise) => {
62
+ const socket = (0, node_net_1.connect)({ host: '127.0.0.1', port });
63
+ let settled = false;
64
+ const finish = (ok) => {
65
+ if (settled)
66
+ return;
67
+ settled = true;
68
+ socket.destroy();
69
+ resolvePromise(ok);
70
+ };
71
+ // Covers the connect phase too: no SYN-ACK within the window -> 'timeout'.
72
+ socket.setTimeout(timeoutMs);
73
+ socket.once('timeout', () => finish(false));
74
+ socket.once('connect', () => finish(true));
75
+ socket.once('error', () => finish(false));
76
+ });
77
+ }
78
+ function validDescriptor(descriptor) {
79
+ return (typeof descriptor === 'object' &&
80
+ descriptor !== null &&
81
+ Number.isInteger(descriptor.port) &&
82
+ descriptor.port > 0 &&
83
+ descriptor.port < 65_536 &&
84
+ typeof descriptor.token === 'string' &&
85
+ TOKEN_SHAPE.test(descriptor.token) &&
86
+ typeof descriptor.caPath === 'string' &&
87
+ descriptor.caPath.length > 0);
88
+ }
89
+ /** The GitHub proxy's loopback port, read off the env GitHub already built. */
90
+ function githubProxyPort(environment) {
91
+ const value = environment?.HTTPS_PROXY ?? environment?.https_proxy;
92
+ if (!value)
93
+ return null;
94
+ try {
95
+ const url = new URL(value);
96
+ if (url.hostname !== '127.0.0.1' || url.username)
97
+ return null;
98
+ const port = Number(url.port);
99
+ return Number.isInteger(port) && port > 0 ? port : null;
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
105
+ function withNewline(contents) {
106
+ return contents.length === 0 || contents.at(-1) === 0x0a
107
+ ? contents
108
+ : Buffer.concat([contents, Buffer.from('\n')]);
109
+ }
110
+ /**
111
+ * System trust + GitHub proxy CA (if any) + credential proxy CA.
112
+ *
113
+ * The GitHub bundle, when the turn has one, already IS "ambient roots + Node's
114
+ * public roots + GitHub CA" (see `createGitHubProxyCertificates`), so it is
115
+ * the base as-is. Without it the base is built the same way GitHub builds it.
116
+ */
117
+ async function writeMergedBundle(input, credentialCa) {
118
+ const path = (0, node_path_1.join)((0, node_path_1.resolve)(input.sessionDir), exports.CREDENTIAL_PROXY_CA_BUNDLE_FILE);
119
+ const githubBundle = input.environment?.SSL_CERT_FILE;
120
+ let base;
121
+ if (githubBundle && (0, node_path_1.resolve)(githubBundle) !== path) {
122
+ base = [withNewline(await (0, promises_1.readFile)(githubBundle))];
123
+ }
124
+ else {
125
+ base = [
126
+ ...(await (0, github_proxy_certificates_1.ambientCaCertificates)(input.ambientEnvironment ?? process.env, path)),
127
+ Buffer.from((0, github_proxy_certificates_1.publicRootCertificates)())
128
+ ];
129
+ }
130
+ const temporary = `${path}.${(0, node_crypto_1.randomUUID)()}.tmp`;
131
+ await (0, promises_1.writeFile)(temporary, Buffer.concat([...base, withNewline(credentialCa)]), {
132
+ mode: 0o644
133
+ });
134
+ await (0, promises_1.chmod)(temporary, 0o644);
135
+ await (0, promises_1.rename)(temporary, path);
136
+ return path;
137
+ }
138
+ async function credentialProxyEnvironment(input) {
139
+ const unchanged = (reason) => {
140
+ // A self-hosted/desktop turn without the field is the normal case there,
141
+ // not a degradation; it stays silent. Everything else is one line.
142
+ if (!(reason === 'runtime_not_sandbox' && !input.descriptor)) {
143
+ input.logger.warn(`[CredentialProxy] credential proxy unavailable sessionId=${input.sessionId} reason=${reason}`);
144
+ }
145
+ return { environment: input.environment, token: null, reason };
146
+ };
147
+ // Defence in depth: the API never sends the field off-sandbox, and even if
148
+ // it did, a runner on a user's machine must not route through it.
149
+ if (input.harnessRuntime !== 'sandbox' || input.sessionRuntime !== 'sandbox')
150
+ return unchanged('runtime_not_sandbox');
151
+ if (!input.descriptor)
152
+ return unchanged('not_offered');
153
+ // A vendor CLI gets only an allowlisted env (vendor-environment.ts); it is
154
+ // not a supported credential-proxy client.
155
+ if ((0, vendor_backends_1.isVendorBackend)(input.backend))
156
+ return unchanged('vendor_backend');
157
+ const descriptor = input.descriptor;
158
+ if (!validDescriptor(descriptor))
159
+ return unchanged('invalid_descriptor');
160
+ if (!(await probeLoopbackPort(descriptor.port)))
161
+ return unchanged('health_check_failed');
162
+ let credentialCa;
163
+ try {
164
+ credentialCa = await (0, promises_1.readFile)(descriptor.caPath);
165
+ }
166
+ catch {
167
+ return unchanged('ca_unreadable');
168
+ }
169
+ if (!credentialCa.includes('-----BEGIN CERTIFICATE-----'))
170
+ return unchanged('ca_unreadable');
171
+ let bundle;
172
+ try {
173
+ bundle = await writeMergedBundle(input, credentialCa);
174
+ }
175
+ catch {
176
+ return unchanged('bundle_write_failed');
177
+ }
178
+ const base = input.environment ?? {};
179
+ // The cred proxy is the FIRST hop. When the turn has a GitHub proxy, the
180
+ // broker chains GitHub traffic to it; the password slot says where.
181
+ const chainPort = githubProxyPort(base) ?? 0;
182
+ const proxy = `http://${descriptor.token}:${chainPort}@127.0.0.1:${descriptor.port}`;
183
+ const noProxy = base.NO_PROXY ??
184
+ base.no_proxy ??
185
+ [
186
+ ...new Set([
187
+ '127.0.0.1',
188
+ 'localhost',
189
+ ...(0, github_workspace_1.directHostnames)(input.directUrls)
190
+ ])
191
+ ].join(',');
192
+ return {
193
+ environment: {
194
+ ...base,
195
+ HTTPS_PROXY: proxy,
196
+ https_proxy: proxy,
197
+ HTTP_PROXY: proxy,
198
+ http_proxy: proxy,
199
+ NO_PROXY: noProxy,
200
+ no_proxy: noProxy,
201
+ SSL_CERT_FILE: bundle,
202
+ CURL_CA_BUNDLE: bundle,
203
+ NODE_EXTRA_CA_CERTS: bundle,
204
+ GIT_SSL_CAINFO: bundle,
205
+ REQUESTS_CA_BUNDLE: bundle,
206
+ PF_CREDENTIAL_PROXY: '1'
207
+ },
208
+ token: descriptor.token,
209
+ reason: null
210
+ };
211
+ }
212
+ /** Replace every occurrence of `token` in a string. No-op without a token. */
213
+ function redactCredentialProxyToken(text, token) {
214
+ return token
215
+ ? text.split(token).join(exports.CREDENTIAL_PROXY_TOKEN_REDACTION)
216
+ : text;
217
+ }
218
+ /**
219
+ * Same, for a JSON-shaped value (transcript parts, turn metadata). The token
220
+ * alphabet needs no JSON escaping, so a string split over the serialised form
221
+ * finds every occurrence in every nested string.
222
+ */
223
+ function redactCredentialProxyTokenDeep(value, token) {
224
+ if (!token || value === undefined)
225
+ return value;
226
+ const serialised = JSON.stringify(value);
227
+ if (!serialised.includes(token))
228
+ return value;
229
+ return JSON.parse(redactCredentialProxyToken(serialised, token));
230
+ }
@@ -1,3 +1,4 @@
1
+ import type { McpServerToolListing } from './mcp-toolset';
1
2
  export interface UnavailableMcpServer {
2
3
  name: string;
3
4
  reason: string;
@@ -14,8 +15,21 @@ export interface FailedMcpCheck {
14
15
  * Check leased HTTP servers and use the caller's bounded, shared cache.
15
16
  * A successful check is only a startup hint. The gateway checks live access
16
17
  * on each tool call, even when a cached success skips this probe.
18
+ *
19
+ * When `listings` is given, a server that was probed keeps its `tools/list`
20
+ * result there (all pages: the client aggregates them when no cursor is
21
+ * passed), plus `resources/list` when the server advertises resources. The
22
+ * Pi backend seeds its MCP metadata cache from it, so Pi does not repeat the
23
+ * handshake on this turn. A server skipped by the success cache, or whose
24
+ * resource listing failed, has no listing.
17
25
  */
18
26
  export declare function verifyMcpServers(servers: Record<string, {
19
27
  type: string;
20
28
  url?: string;
21
- }>, signal?: AbortSignal, successful?: Map<string, number>, failed?: Map<string, FailedMcpCheck>): Promise<UnavailableMcpServer[]>;
29
+ }>, signal?: AbortSignal, successful?: Map<string, number>, failed?: Map<string, FailedMcpCheck>, listings?: Map<string, McpServerToolListing>,
30
+ /**
31
+ * Servers to probe and list even inside the success-cache window: their
32
+ * stored listing is no longer valid (item 1(b)), so without a fresh one Pi
33
+ * would fall back to its own eager handshake.
34
+ */
35
+ relist?: ReadonlySet<string>): Promise<UnavailableMcpServer[]>;
@@ -16,12 +16,45 @@ function safeHttpStatus(cause) {
16
16
  const match = String(cause).match(/^(?:Error:\s*)?HTTP\s+([45]\d\d)\b/i);
17
17
  return match ? Number(match[1]) : null;
18
18
  }
19
+ function toolListing(url, client, result, resources, resourcesAdvertised) {
20
+ const hints = result;
21
+ const instructions = client.getInstructions?.();
22
+ return {
23
+ url,
24
+ tools: (result.tools ?? []).filter((tool) => typeof tool?.name === 'string' && tool.name.length > 0),
25
+ resources,
26
+ resourcesAdvertised,
27
+ ...(typeof instructions === 'string' ? { instructions } : {}),
28
+ ...(typeof hints.ttlMs === 'number' &&
29
+ Number.isSafeInteger(hints.ttlMs) &&
30
+ hints.ttlMs >= 0
31
+ ? { ttlMs: hints.ttlMs }
32
+ : {}),
33
+ ...(hints.cacheScope === 'public' || hints.cacheScope === 'private'
34
+ ? { cacheScope: hints.cacheScope }
35
+ : {}),
36
+ listedAt: Date.now()
37
+ };
38
+ }
19
39
  /**
20
40
  * Check leased HTTP servers and use the caller's bounded, shared cache.
21
41
  * A successful check is only a startup hint. The gateway checks live access
22
42
  * on each tool call, even when a cached success skips this probe.
43
+ *
44
+ * When `listings` is given, a server that was probed keeps its `tools/list`
45
+ * result there (all pages: the client aggregates them when no cursor is
46
+ * passed), plus `resources/list` when the server advertises resources. The
47
+ * Pi backend seeds its MCP metadata cache from it, so Pi does not repeat the
48
+ * handshake on this turn. A server skipped by the success cache, or whose
49
+ * resource listing failed, has no listing.
50
+ */
51
+ async function verifyMcpServers(servers, signal, successful, failed, listings,
52
+ /**
53
+ * Servers to probe and list even inside the success-cache window: their
54
+ * stored listing is no longer valid (item 1(b)), so without a fresh one Pi
55
+ * would fall back to its own eager handshake.
23
56
  */
24
- async function verifyMcpServers(servers, signal, successful, failed) {
57
+ relist) {
25
58
  const results = await Promise.all(Object.entries(servers).map(async ([name, server]) => {
26
59
  if (server.type !== 'http' || !server.url)
27
60
  return null;
@@ -30,6 +63,7 @@ async function verifyMcpServers(servers, signal, successful, failed) {
30
63
  const key = `${name}\n${server.url}`;
31
64
  const checkedAt = successful?.get(key);
32
65
  if (checkedAt !== undefined &&
66
+ !relist?.has(name) &&
33
67
  Date.now() - checkedAt < SUCCESS_CACHE_TTL_MS)
34
68
  return null;
35
69
  const priorFailure = failed?.get(key);
@@ -55,7 +89,25 @@ async function verifyMcpServers(servers, signal, successful, failed) {
55
89
  : deadline;
56
90
  try {
57
91
  await client.connect(new client_1.StreamableHTTPClientTransport(new URL(server.url)), { timeout: 10_000, signal: requestSignal });
58
- await client.listTools({}, { timeout: 10_000, signal: requestSignal });
92
+ const listed = await client.listTools({}, { timeout: 10_000, signal: requestSignal });
93
+ if (listings) {
94
+ const resourcesAdvertised = Boolean(client.getServerCapabilities?.()?.resources);
95
+ let resources = [];
96
+ if (resourcesAdvertised) {
97
+ // Pi lists resources on connect. A seeded entry without them
98
+ // would hide the server's `read_*` tools, so a failed resource
99
+ // listing leaves the server unseeded (Pi then connects as before).
100
+ resources = await client
101
+ .listResources({}, { timeout: 10_000, signal: requestSignal })
102
+ .then((result) => (result.resources ?? []))
103
+ .catch(() => null);
104
+ if (signal?.aborted)
105
+ throw new Error('MCP check was cancelled.');
106
+ }
107
+ if (resources) {
108
+ listings.set(name, toolListing(server.url, client, listed, resources, resourcesAdvertised));
109
+ }
110
+ }
59
111
  successful?.set(key, Date.now());
60
112
  failed?.delete(key);
61
113
  if (successful && successful.size > MAX_CACHE_ENTRIES) {
@@ -0,0 +1,79 @@
1
+ import { type McpServerToolListing, type McpToolsetMember } from './mcp-toolset';
2
+ /**
3
+ * ITEM 1(b): THE PER-SESSION MCP TOOL STORE.
4
+ *
5
+ * Pi is started again on every turn. Without this store a warm turn cannot
6
+ * seed Pi's metadata cache: the 5-minute preflight success cache skips the
7
+ * probe, so nothing is listed, every server stays eager and Pi repeats the
8
+ * whole MCP handshake before the first prompt (~19.6 s on the baseline warm
9
+ * turn). The store keeps the last listing of each server per session and
10
+ * gives it back while the toolset is unchanged, so a warm turn sends no MCP
11
+ * request before the prompt.
12
+ *
13
+ * WHEN A LISTING IS REUSED. Only while all of these still hold:
14
+ * - the lease carries `mcpToolScope` (the speaker and the Space whose live
15
+ * permissions the gateway applies). An older control plane does not send
16
+ * it, and then nothing is reused, so a list never crosses speakers;
17
+ * - the scope is the one the listing was taken under. A different speaker in
18
+ * a shared chat drops every stored listing of the session;
19
+ * - the server's own fingerprint (name, upstream URL, allowlist) is the same;
20
+ * - the server is still configured at the URL the listing was taken from;
21
+ * - the listing is younger than `MCP_TOOL_STORE_MAX_AGE_MS` and than the
22
+ * server's own `ttlMs` hint. A `ttlMs: 0` listing (the workspace server
23
+ * sends one) is never reused: that server is listed again by the preflight
24
+ * on every turn, two requests in the 2026-07-28 era instead of Pi's four,
25
+ * until the gateway declares a non-zero private ttl (PR follow-up).
26
+ *
27
+ * Any other server is checked by the preflight as before. A server that had a
28
+ * stored listing which is no longer valid is re-listed even inside the success
29
+ * cache window, so the changed server is seeded again rather than left eager.
30
+ *
31
+ * The store holds only tool metadata (names, descriptions, schemas) and never
32
+ * a token. It is advisory: the gateway still checks every `tools/call`.
33
+ */
34
+ /**
35
+ * How long a stored listing may be reused. Longer than the 5-minute preflight
36
+ * success cache, so a turn a few minutes after the last one skips the
37
+ * preflight too (the baseline turn `91c442db` came 6 minutes after the one
38
+ * before it and spent 9.7 s in it). Short, because a catalogue change that is
39
+ * not in the lease (an app connected, or a grant revoked, for the same speaker)
40
+ * shows only after this; Pi also replaces the cached tools as soon as it
41
+ * connects to a server. A revoked tool can stay listed for up to this long,
42
+ * but the gateway refuses the call: a stale name, never extra access.
43
+ */
44
+ export declare const MCP_TOOL_STORE_MAX_AGE_MS: number;
45
+ interface StoredServer {
46
+ memberKey: string;
47
+ listing: McpServerToolListing;
48
+ }
49
+ interface StoredToolset {
50
+ scopeKey: string;
51
+ servers: Map<string, StoredServer>;
52
+ }
53
+ /** Shared by the harness across the per-turn runners, keyed by session id. */
54
+ export type McpToolStore = Map<string, StoredToolset>;
55
+ export type McpToolScope = Record<string, string>;
56
+ export interface McpToolStorePlan {
57
+ /** Stored listings that this turn uses instead of a preflight. */
58
+ reused: Record<string, McpServerToolListing>;
59
+ /** Servers whose stored listing was dropped; list them again. */
60
+ relist: Set<string>;
61
+ /** Why each dropped listing was dropped (server names only). */
62
+ dropped: Record<string, string>;
63
+ }
64
+ /**
65
+ * Decide which servers this turn can take from the store. Drops every entry
66
+ * that is no longer valid, so a later turn cannot reuse it either.
67
+ */
68
+ export declare function planMcpToolReuse(store: McpToolStore, sessionId: string, members: readonly McpToolsetMember[], scope: McpToolScope | null, configured: Readonly<Record<string, {
69
+ type: string;
70
+ url?: string;
71
+ }>>, now?: number): McpToolStorePlan;
72
+ /**
73
+ * Keep this turn's fresh listings for the next turn of the session. Nothing is
74
+ * kept without a scope. A `ttlMs: 0` listing is kept only as a marker: it is
75
+ * never reused, but the next turn knows to list that server again rather than
76
+ * leave it to Pi's eager handshake.
77
+ */
78
+ export declare function rememberMcpToolListings(store: McpToolStore, sessionId: string, members: readonly McpToolsetMember[], scope: McpToolScope | null, listings: ReadonlyMap<string, McpServerToolListing>): string[];
79
+ export {};
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MCP_TOOL_STORE_MAX_AGE_MS = void 0;
4
+ exports.planMcpToolReuse = planMcpToolReuse;
5
+ exports.rememberMcpToolListings = rememberMcpToolListings;
6
+ const mcp_toolset_1 = require("./mcp-toolset");
7
+ /**
8
+ * ITEM 1(b): THE PER-SESSION MCP TOOL STORE.
9
+ *
10
+ * Pi is started again on every turn. Without this store a warm turn cannot
11
+ * seed Pi's metadata cache: the 5-minute preflight success cache skips the
12
+ * probe, so nothing is listed, every server stays eager and Pi repeats the
13
+ * whole MCP handshake before the first prompt (~19.6 s on the baseline warm
14
+ * turn). The store keeps the last listing of each server per session and
15
+ * gives it back while the toolset is unchanged, so a warm turn sends no MCP
16
+ * request before the prompt.
17
+ *
18
+ * WHEN A LISTING IS REUSED. Only while all of these still hold:
19
+ * - the lease carries `mcpToolScope` (the speaker and the Space whose live
20
+ * permissions the gateway applies). An older control plane does not send
21
+ * it, and then nothing is reused, so a list never crosses speakers;
22
+ * - the scope is the one the listing was taken under. A different speaker in
23
+ * a shared chat drops every stored listing of the session;
24
+ * - the server's own fingerprint (name, upstream URL, allowlist) is the same;
25
+ * - the server is still configured at the URL the listing was taken from;
26
+ * - the listing is younger than `MCP_TOOL_STORE_MAX_AGE_MS` and than the
27
+ * server's own `ttlMs` hint. A `ttlMs: 0` listing (the workspace server
28
+ * sends one) is never reused: that server is listed again by the preflight
29
+ * on every turn, two requests in the 2026-07-28 era instead of Pi's four,
30
+ * until the gateway declares a non-zero private ttl (PR follow-up).
31
+ *
32
+ * Any other server is checked by the preflight as before. A server that had a
33
+ * stored listing which is no longer valid is re-listed even inside the success
34
+ * cache window, so the changed server is seeded again rather than left eager.
35
+ *
36
+ * The store holds only tool metadata (names, descriptions, schemas) and never
37
+ * a token. It is advisory: the gateway still checks every `tools/call`.
38
+ */
39
+ /**
40
+ * How long a stored listing may be reused. Longer than the 5-minute preflight
41
+ * success cache, so a turn a few minutes after the last one skips the
42
+ * preflight too (the baseline turn `91c442db` came 6 minutes after the one
43
+ * before it and spent 9.7 s in it). Short, because a catalogue change that is
44
+ * not in the lease (an app connected, or a grant revoked, for the same speaker)
45
+ * shows only after this; Pi also replaces the cached tools as soon as it
46
+ * connects to a server. A revoked tool can stay listed for up to this long,
47
+ * but the gateway refuses the call: a stale name, never extra access.
48
+ */
49
+ exports.MCP_TOOL_STORE_MAX_AGE_MS = 15 * 60_000;
50
+ const MAX_STORED_SESSIONS = 256;
51
+ function memberKey(member) {
52
+ return (0, mcp_toolset_1.mcpToolsetFingerprint)([member]);
53
+ }
54
+ function scopeKeyOf(scope) {
55
+ return (0, mcp_toolset_1.mcpToolsetFingerprint)([], scope);
56
+ }
57
+ function reusableUntil(listing) {
58
+ const ttl = listing.ttlMs === undefined
59
+ ? exports.MCP_TOOL_STORE_MAX_AGE_MS
60
+ : Math.min(listing.ttlMs, exports.MCP_TOOL_STORE_MAX_AGE_MS);
61
+ return listing.listedAt + ttl;
62
+ }
63
+ /**
64
+ * Decide which servers this turn can take from the store. Drops every entry
65
+ * that is no longer valid, so a later turn cannot reuse it either.
66
+ */
67
+ function planMcpToolReuse(store, sessionId, members, scope, configured, now = Date.now()) {
68
+ const plan = { reused: {}, relist: new Set(), dropped: {} };
69
+ const stored = store.get(sessionId);
70
+ if (!stored)
71
+ return plan;
72
+ const dropAll = (reason) => {
73
+ for (const name of stored.servers.keys()) {
74
+ plan.relist.add(name);
75
+ plan.dropped[name] = reason;
76
+ }
77
+ store.delete(sessionId);
78
+ };
79
+ if (!scope) {
80
+ dropAll('lease has no tool scope');
81
+ return plan;
82
+ }
83
+ if (stored.scopeKey !== scopeKeyOf(scope)) {
84
+ dropAll('speaker or space changed');
85
+ return plan;
86
+ }
87
+ const current = new Map(members.map((member) => [member.name, member]));
88
+ for (const [name, entry] of [...stored.servers]) {
89
+ const member = current.get(name);
90
+ let reason = null;
91
+ if (!member)
92
+ reason = 'server removed';
93
+ else if (entry.memberKey !== memberKey(member))
94
+ reason = 'server changed';
95
+ else if (configured[name]?.type !== 'http')
96
+ reason = 'server not configured';
97
+ else if (configured[name]?.url !== entry.listing.url)
98
+ reason = 'url changed';
99
+ else if (now >= reusableUntil(entry.listing))
100
+ reason =
101
+ entry.listing.ttlMs !== undefined &&
102
+ entry.listing.ttlMs < exports.MCP_TOOL_STORE_MAX_AGE_MS
103
+ ? 'server ttl'
104
+ : 'expired';
105
+ if (reason) {
106
+ stored.servers.delete(name);
107
+ plan.dropped[name] = reason;
108
+ if (member)
109
+ plan.relist.add(name);
110
+ continue;
111
+ }
112
+ plan.reused[name] = entry.listing;
113
+ }
114
+ // A reuse counts as use: move the session to the newest end.
115
+ store.delete(sessionId);
116
+ if (stored.servers.size > 0)
117
+ store.set(sessionId, stored);
118
+ return plan;
119
+ }
120
+ /**
121
+ * Keep this turn's fresh listings for the next turn of the session. Nothing is
122
+ * kept without a scope. A `ttlMs: 0` listing is kept only as a marker: it is
123
+ * never reused, but the next turn knows to list that server again rather than
124
+ * leave it to Pi's eager handshake.
125
+ */
126
+ function rememberMcpToolListings(store, sessionId, members, scope, listings) {
127
+ if (!scope)
128
+ return [];
129
+ const scopeKey = scopeKeyOf(scope);
130
+ let stored = store.get(sessionId);
131
+ if (!stored || stored.scopeKey !== scopeKey) {
132
+ stored = { scopeKey, servers: new Map() };
133
+ }
134
+ const current = new Map(members.map((member) => [member.name, member]));
135
+ const kept = [];
136
+ for (const [name, listing] of listings) {
137
+ const member = current.get(name);
138
+ if (!member)
139
+ continue;
140
+ stored.servers.set(name, { memberKey: memberKey(member), listing });
141
+ kept.push(name);
142
+ }
143
+ // Most recently used last, so the oldest session is evicted first.
144
+ store.delete(sessionId);
145
+ if (stored.servers.size > 0)
146
+ store.set(sessionId, stored);
147
+ while (store.size > MAX_STORED_SESSIONS) {
148
+ store.delete(store.keys().next().value);
149
+ }
150
+ return kept;
151
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * One MCP server's tool listing, taken by the harness preflight.
3
+ *
4
+ * The values are what the server returned for `tools/list` (all pages) and,
5
+ * when the server advertises resources, `resources/list`. They contain no
6
+ * credential: the per-turn MCP token is added by the loopback proxy and never
7
+ * appears in a URL, a header or a listing here.
8
+ */
9
+ export interface McpServerToolListing {
10
+ /** The exact URL the listing was taken from (the loopback proxy URL). */
11
+ url: string;
12
+ tools: Array<Record<string, unknown> & {
13
+ name: string;
14
+ }>;
15
+ resources: Array<Record<string, unknown>>;
16
+ /** True when the server advertises the resources capability. */
17
+ resourcesAdvertised: boolean;
18
+ instructions?: string;
19
+ /** Server cache hints from the first `tools/list` page, when present. */
20
+ ttlMs?: number;
21
+ cacheScope?: 'public' | 'private';
22
+ listedAt: number;
23
+ }
24
+ /**
25
+ * The tool listings for one toolset, keyed by the harness server name.
26
+ *
27
+ * Item 4 uses this only within the turn that took it. Item 1(b) can keep one
28
+ * per session and reuse it on a later turn while `fingerprint` still matches.
29
+ */
30
+ export interface McpToolsetSnapshot {
31
+ fingerprint: string;
32
+ servers: Record<string, McpServerToolListing>;
33
+ }
34
+ /** One leased server, as it enters the toolset fingerprint. */
35
+ export interface McpToolsetMember {
36
+ name: string;
37
+ /** The upstream URL from the lease, not the loopback proxy URL. */
38
+ url: string;
39
+ /** `null` means every tool the server exposes. */
40
+ allowedTools: readonly string[] | null;
41
+ }
42
+ /**
43
+ * A stable hash of what decides a toolset: the server set, each server's URL
44
+ * and its allowlist, plus any caller scope (for example the speaker, the space
45
+ * and a permission version, once the lease carries them).
46
+ *
47
+ * A change to any input gives a different fingerprint, so a cached listing for
48
+ * the old toolset is not reused. Order does not matter. `allowedTools: null`
49
+ * (every tool) and `[]` (no tool) are different toolsets.
50
+ */
51
+ export declare function mcpToolsetFingerprint(members: readonly McpToolsetMember[], scope?: Record<string, string | number | null | undefined>): string;
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mcpToolsetFingerprint = mcpToolsetFingerprint;
4
+ const crypto_1 = require("crypto");
5
+ function stableJson(value) {
6
+ if (value === null || typeof value !== 'object') {
7
+ return JSON.stringify(value) ?? 'null';
8
+ }
9
+ if (Array.isArray(value))
10
+ return `[${value.map(stableJson).join(',')}]`;
11
+ const record = value;
12
+ return `{${Object.keys(record)
13
+ .filter((key) => record[key] !== undefined)
14
+ .sort()
15
+ .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`)
16
+ .join(',')}}`;
17
+ }
18
+ /**
19
+ * A stable hash of what decides a toolset: the server set, each server's URL
20
+ * and its allowlist, plus any caller scope (for example the speaker, the space
21
+ * and a permission version, once the lease carries them).
22
+ *
23
+ * A change to any input gives a different fingerprint, so a cached listing for
24
+ * the old toolset is not reused. Order does not matter. `allowedTools: null`
25
+ * (every tool) and `[]` (no tool) are different toolsets.
26
+ */
27
+ function mcpToolsetFingerprint(members, scope = {}) {
28
+ const servers = [...members]
29
+ .map((member) => ({
30
+ name: member.name,
31
+ url: member.url,
32
+ allowedTools: member.allowedTools === null
33
+ ? null
34
+ : [...new Set(member.allowedTools)].sort()
35
+ }))
36
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
37
+ return (0, crypto_1.createHash)('sha256')
38
+ .update(stableJson({ version: 1, servers, scope }))
39
+ .digest('hex');
40
+ }