@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.
- package/dist/adapters/coding-agent-adapter.d.ts +19 -0
- package/dist/adapters/pi-adapter.d.ts +27 -1
- package/dist/adapters/pi-adapter.js +170 -6
- package/dist/adapters/pi-mcp-cache-seed.d.ts +66 -0
- package/dist/adapters/pi-mcp-cache-seed.js +188 -0
- package/dist/contract.d.ts +1 -1
- package/dist/contract.js +1 -1
- package/dist/control-plane.d.ts +9 -2
- package/dist/control-plane.js +11 -3
- package/dist/github-proxy-certificates.d.ts +3 -0
- package/dist/github-proxy-certificates.js +7 -1
- package/dist/github-workspace.d.ts +6 -0
- package/dist/github-workspace.js +22 -10
- package/dist/harness.d.ts +3 -0
- package/dist/harness.js +30 -7
- package/dist/index.d.ts +1 -1
- package/dist/runtime/cold-start-batch.d.ts +36 -0
- package/dist/runtime/cold-start-batch.js +96 -0
- package/dist/runtime/credential-proxy.d.ts +44 -0
- package/dist/runtime/credential-proxy.js +230 -0
- package/dist/runtime/mcp-preflight.d.ts +15 -1
- package/dist/runtime/mcp-preflight.js +54 -2
- package/dist/runtime/mcp-tool-store.d.ts +79 -0
- package/dist/runtime/mcp-tool-store.js +151 -0
- package/dist/runtime/mcp-toolset.d.ts +51 -0
- package/dist/runtime/mcp-toolset.js +40 -0
- package/dist/runtime/session-runner.d.ts +41 -1
- package/dist/runtime/session-runner.js +179 -16
- package/dist/types.d.ts +30 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|