@hediet/linkrpc-mcp 0.0.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/README.md +82 -0
- package/dist/chunks/server-Bquqk9Mk.d.ts +248 -0
- package/dist/chunks/server-C4ZM6bfK.js +2530 -0
- package/dist/chunks/server-C4ZM6bfK.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +23 -0
- package/dist/cli.js.map +1 -0
- package/dist/guestMain.d.ts +1 -0
- package/dist/index.d.ts +229 -0
- package/dist/index.js +2 -0
- package/dist/node.d.ts +121 -0
- package/dist/node.js +163 -0
- package/dist/node.js.map +1 -0
- package/dist/src-guest-guestMain.js +168 -0
- package/dist/src-guest-guestMain.js.map +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,2530 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { ErrorCode, InMemoryManagedIdentity, JsonRpcChannel, RpcError, SigningSender, capabilityFreshAt, generateTsInterface, permissionMatchesTarget, traceMessageTransport } from "@hediet/linkrpc";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { LINKRPC_ENDPOINT_VAR, LINKRPC_TOKEN_VAR, WebSocketTransport, connectNdjson, createManagedPrincipal, createSelfManagedPrincipal, createSelfManagedPrincipalFromFile, formatEndpointUri, loadOrCreateIdentity, openWebSocket, parseEndpointUri, runInitializeHandshake } from "@hediet/linkrpc/node";
|
|
7
|
+
import { Hub, HubConnectionAcceptor, anonymousHandler, createHubServiceInterfaces } from "@hediet/linkrpc-hub/hub/server/client";
|
|
8
|
+
import { SocketServer } from "@hediet/linkrpc-hub/hub/server/node";
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { spawnCommand, spawnCommand as spawnCommand$1 } from "@hediet/linkrpc-hub/spawn";
|
|
15
|
+
import { tapTransport } from "@hediet/linkrpc-hub/hub/server/transit";
|
|
16
|
+
import * as net from "node:net";
|
|
17
|
+
import "@hediet/linkrpc-hub/config";
|
|
18
|
+
import { fetchSchema, hubAccessInterface, walkHubDetailed } from "@hediet/linkrpc/hub/common";
|
|
19
|
+
import { Scope, getQuickJS } from "quickjs-emscripten";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { createHash } from "crypto";
|
|
22
|
+
import { ContentBlockSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
23
|
+
//#region ../../packages-private/linkrpc-client/src/localHub.ts
|
|
24
|
+
/** Default serviceId namespace the local-hub child may claim (and sees via `hubGrantedServiceId::get`). */
|
|
25
|
+
const DEFAULT_LOCAL_NAMESPACE = "local";
|
|
26
|
+
/** Subfolder (under the linkrpc data dir) holding provisioned identity slots. */
|
|
27
|
+
const PROVISION_SUBDIR = "provisioned-identities";
|
|
28
|
+
/** Provisioned slots untouched for longer than this are swept on next run. */
|
|
29
|
+
const PROVISION_MAX_AGE_MS = 2592e6;
|
|
30
|
+
/**
|
|
31
|
+
* Start an in-process hub on a private socket, spawn `command` as a participant
|
|
32
|
+
* (handing it the socket + token via `LINKRPC_ENDPOINT` / `LINKRPC_TOKEN`), and
|
|
33
|
+
* resolve once the child has registered a service under {@link LOCAL_NAMESPACE}.
|
|
34
|
+
*
|
|
35
|
+
* The hub is single-tenant and local: no claim policy (every well-formed claim
|
|
36
|
+
* is allowed) and no provenance. Identity is either ephemeral (fresh per run)
|
|
37
|
+
* or, when `provisionSlot` is set, a persisted managed identity so the child's
|
|
38
|
+
* HPKE wrap/unwrap keys survive across runs.
|
|
39
|
+
*/
|
|
40
|
+
async function startLocalHub(opts) {
|
|
41
|
+
const resolveIdentity = _makeIdentityResolver(opts.provisionSlot);
|
|
42
|
+
const grantedNs = DEFAULT_LOCAL_NAMESPACE;
|
|
43
|
+
const hub = new Hub();
|
|
44
|
+
createHubServiceInterfaces(hub);
|
|
45
|
+
const socketPath = SocketServer.allocSocketPath();
|
|
46
|
+
const token = randomBytes(16).toString("hex");
|
|
47
|
+
const socketServer = await SocketServer.start({ endpoint: socketPath });
|
|
48
|
+
const acceptor = new HubConnectionAcceptor({
|
|
49
|
+
server: socketServer,
|
|
50
|
+
hub,
|
|
51
|
+
handlers: [anonymousHandler({
|
|
52
|
+
grantedServiceIdNamespace: grantedNs,
|
|
53
|
+
...resolveIdentity ? { resolveIdentity } : {}
|
|
54
|
+
})]
|
|
55
|
+
});
|
|
56
|
+
const child = spawnCommand(opts.command, {
|
|
57
|
+
stdio: [
|
|
58
|
+
"inherit",
|
|
59
|
+
"inherit",
|
|
60
|
+
"inherit"
|
|
61
|
+
],
|
|
62
|
+
env: {
|
|
63
|
+
...process.env,
|
|
64
|
+
...opts.env,
|
|
65
|
+
LINKRPC_ENDPOINT: socketPath,
|
|
66
|
+
LINKRPC_TOKEN: token
|
|
67
|
+
},
|
|
68
|
+
...opts.cwd !== void 0 ? { cwd: opts.cwd } : {}
|
|
69
|
+
});
|
|
70
|
+
let childExited = false;
|
|
71
|
+
child.once("exit", () => {
|
|
72
|
+
childExited = true;
|
|
73
|
+
});
|
|
74
|
+
const dispose = () => {
|
|
75
|
+
if (!child.killed) child.kill();
|
|
76
|
+
acceptor.dispose();
|
|
77
|
+
socketServer.dispose();
|
|
78
|
+
if (process.platform !== "win32") try {
|
|
79
|
+
fs.unlinkSync(socketPath);
|
|
80
|
+
} catch {}
|
|
81
|
+
};
|
|
82
|
+
try {
|
|
83
|
+
await _waitForClaim(hub, grantedNs, () => childExited, opts.readyTimeoutMs ?? 3e4);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
dispose();
|
|
86
|
+
throw e;
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
socketPath,
|
|
90
|
+
token,
|
|
91
|
+
dispose
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Build the hub's `resolveIdentity`, or `undefined` when no identity should be
|
|
96
|
+
* provided. Without `provisionSlot` the hub serves no identity at all — a child
|
|
97
|
+
* that needs `identity::*` will fail. With it, a single persisted managed
|
|
98
|
+
* identity is shared by all connections (stale slots swept first), so the
|
|
99
|
+
* child's HPKE wrap/unwrap keys survive across runs.
|
|
100
|
+
*/
|
|
101
|
+
function _makeIdentityResolver(provisionSlot) {
|
|
102
|
+
if (provisionSlot === void 0) return;
|
|
103
|
+
const dir = _provisionDir();
|
|
104
|
+
_sweepProvisionedIdentities(dir);
|
|
105
|
+
let shared;
|
|
106
|
+
return () => {
|
|
107
|
+
if (!shared) shared = (async () => {
|
|
108
|
+
const persisted = await loadOrCreateIdentity({
|
|
109
|
+
id: provisionSlot,
|
|
110
|
+
storeDir: dir
|
|
111
|
+
});
|
|
112
|
+
return new InMemoryManagedIdentity(persisted.keypair, persisted.wrapKeypair);
|
|
113
|
+
})();
|
|
114
|
+
return shared;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** Dedicated provisioned-identity folder (sibling of linkrpc's user identities). */
|
|
118
|
+
function _provisionDir() {
|
|
119
|
+
const home = os.homedir();
|
|
120
|
+
let base;
|
|
121
|
+
if (process.platform === "win32") base = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
|
|
122
|
+
else if (process.platform === "darwin") base = path.join(home, "Library", "Application Support");
|
|
123
|
+
else base = process.env.XDG_CONFIG_HOME ?? path.join(home, ".config");
|
|
124
|
+
return path.join(base, "linkrpc", PROVISION_SUBDIR);
|
|
125
|
+
}
|
|
126
|
+
/** Delete provisioned identity files whose mtime is older than the max age. */
|
|
127
|
+
function _sweepProvisionedIdentities(dir) {
|
|
128
|
+
let entries;
|
|
129
|
+
try {
|
|
130
|
+
entries = fs.readdirSync(dir);
|
|
131
|
+
} catch {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const cutoff = Date.now() - PROVISION_MAX_AGE_MS;
|
|
135
|
+
for (const name of entries) {
|
|
136
|
+
if (!name.endsWith(".json")) continue;
|
|
137
|
+
const file = path.join(dir, name);
|
|
138
|
+
try {
|
|
139
|
+
if (fs.statSync(file).mtimeMs < cutoff) fs.unlinkSync(file);
|
|
140
|
+
} catch {}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Resolve once the child claims `prefix` (or a sub-prefix), else reject. */
|
|
144
|
+
function _waitForClaim(hub, prefix, childExited, timeoutMs) {
|
|
145
|
+
const deadline = Date.now() + timeoutMs;
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const check = () => {
|
|
148
|
+
if (hub.claimedPrefixes().some((p) => p === prefix || p.startsWith(`${prefix}/`))) {
|
|
149
|
+
resolve();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (childExited()) {
|
|
153
|
+
reject(/* @__PURE__ */ new Error("connect: command exited before registering a service"));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (Date.now() >= deadline) {
|
|
157
|
+
reject(/* @__PURE__ */ new Error(`connect: timed out waiting for command to register a service under '${prefix}'`));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
setTimeout(check, 50);
|
|
161
|
+
};
|
|
162
|
+
check();
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
//#endregion
|
|
166
|
+
//#region ../../packages-private/linkrpc-client/src/connect.ts
|
|
167
|
+
async function connect(endpoint, log) {
|
|
168
|
+
switch (endpoint.kind) {
|
|
169
|
+
case "cmd-stdio": return _connectCmdStdio(endpoint.command, endpoint.env, endpoint.cwd, log);
|
|
170
|
+
case "cmd-env": return _connectCmdEnv(endpoint.command, endpoint.provisionSlot, endpoint.env, endpoint.cwd, log);
|
|
171
|
+
case "ws": return _connectWs(endpoint, log);
|
|
172
|
+
case "ws-no-init": return _connectWs(endpoint, log);
|
|
173
|
+
case "socket": return _connectSocket(endpoint.path, endpoint.token, log);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Spawn a child from a command spec. `{ command }` is run through the OS shell
|
|
178
|
+
* (so quoting / splitting follows the shell's rules); `{ argv }` is run
|
|
179
|
+
* directly (no shell), except on Windows where `.cmd` shims need one.
|
|
180
|
+
*/
|
|
181
|
+
async function _connectCmdStdio(command, env, cwd, log) {
|
|
182
|
+
const child = spawnCommand$1(command, {
|
|
183
|
+
stdio: [
|
|
184
|
+
"pipe",
|
|
185
|
+
"pipe",
|
|
186
|
+
"inherit"
|
|
187
|
+
],
|
|
188
|
+
...env !== void 0 ? { env: {
|
|
189
|
+
...process.env,
|
|
190
|
+
...env
|
|
191
|
+
} } : {},
|
|
192
|
+
...cwd !== void 0 ? { cwd } : {}
|
|
193
|
+
});
|
|
194
|
+
if (!child.stdin || !child.stdout) throw new Error("connect: child process exposes no stdio");
|
|
195
|
+
const { transport } = await connectNdjson({
|
|
196
|
+
input: child.stdout,
|
|
197
|
+
output: child.stdin,
|
|
198
|
+
onClose: () => {
|
|
199
|
+
if (!child.killed) child.kill();
|
|
200
|
+
},
|
|
201
|
+
trace: log?.trace
|
|
202
|
+
});
|
|
203
|
+
return _makeCliConnection(transport, () => {
|
|
204
|
+
if (!child.killed) child.kill();
|
|
205
|
+
}, log);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Start a private in-process hub, spawn the child as a participant, then
|
|
209
|
+
* connect to the hub's socket. The child registers its services against the
|
|
210
|
+
* hub exactly as it would against a remote one; we tear the hub + child down
|
|
211
|
+
* when the connection closes.
|
|
212
|
+
*/
|
|
213
|
+
async function _connectCmdEnv(command, provisionSlot, env, cwd, log) {
|
|
214
|
+
return connectViaLocalHub({
|
|
215
|
+
command,
|
|
216
|
+
provisionSlot,
|
|
217
|
+
env,
|
|
218
|
+
cwd,
|
|
219
|
+
log
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Spawn a child under an in-process local hub (`startLocalHub`) and connect
|
|
224
|
+
* to that hub over a socket. Backs the standard `cmd-env` endpoint path; the
|
|
225
|
+
* hub + child are torn down when the connection closes.
|
|
226
|
+
*/
|
|
227
|
+
async function connectViaLocalHub(opts) {
|
|
228
|
+
const hub = await startLocalHub({
|
|
229
|
+
command: opts.command,
|
|
230
|
+
provisionSlot: opts.provisionSlot,
|
|
231
|
+
env: opts.env,
|
|
232
|
+
...opts.cwd !== void 0 ? { cwd: opts.cwd } : {}
|
|
233
|
+
});
|
|
234
|
+
try {
|
|
235
|
+
const conn = await _connectSocket(hub.socketPath, hub.token, opts.log);
|
|
236
|
+
return {
|
|
237
|
+
...conn,
|
|
238
|
+
close: () => {
|
|
239
|
+
conn.close();
|
|
240
|
+
hub.dispose();
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
} catch (e) {
|
|
244
|
+
hub.dispose();
|
|
245
|
+
throw e;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function _connectWs(endpoint, log) {
|
|
249
|
+
return openWebSocket(endpoint.url).then(async (ws) => {
|
|
250
|
+
const closeWs = () => {
|
|
251
|
+
try {
|
|
252
|
+
ws.close();
|
|
253
|
+
} catch {}
|
|
254
|
+
};
|
|
255
|
+
const baseTransport = new WebSocketTransport(ws, closeWs);
|
|
256
|
+
const transport = log?.trace === void 0 ? baseTransport : traceMessageTransport(baseTransport, log.trace);
|
|
257
|
+
if (endpoint.kind === "ws") try {
|
|
258
|
+
await runInitializeHandshake(transport, {
|
|
259
|
+
kind: "client",
|
|
260
|
+
token: endpoint.token ?? ""
|
|
261
|
+
});
|
|
262
|
+
} catch (err) {
|
|
263
|
+
transport.dispose();
|
|
264
|
+
closeWs();
|
|
265
|
+
throw err;
|
|
266
|
+
}
|
|
267
|
+
return _makeCliConnection(transport, closeWs, log);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
async function _connectSocket(socketPath, token, log) {
|
|
271
|
+
const socket = net.createConnection(socketPath);
|
|
272
|
+
await new Promise((resolve, reject) => {
|
|
273
|
+
const onConnect = () => {
|
|
274
|
+
socket.removeListener("error", onError);
|
|
275
|
+
resolve();
|
|
276
|
+
};
|
|
277
|
+
const onError = (error) => {
|
|
278
|
+
socket.removeListener("connect", onConnect);
|
|
279
|
+
socket.destroy();
|
|
280
|
+
reject(error);
|
|
281
|
+
};
|
|
282
|
+
socket.once("connect", onConnect);
|
|
283
|
+
socket.once("error", onError);
|
|
284
|
+
});
|
|
285
|
+
socket.on("error", () => socket.destroy());
|
|
286
|
+
const { transport } = await connectNdjson({
|
|
287
|
+
input: socket,
|
|
288
|
+
output: socket,
|
|
289
|
+
onClose: () => socket.destroy(),
|
|
290
|
+
initialize: {
|
|
291
|
+
kind: "client",
|
|
292
|
+
token: token ?? ""
|
|
293
|
+
},
|
|
294
|
+
trace: log?.trace
|
|
295
|
+
});
|
|
296
|
+
return _makeCliConnection(transport, () => socket.destroy(), log);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* In-memory connection — for tests. The caller hands us a transport already
|
|
300
|
+
* wired to a server-side channel (typically via `TransportPair`).
|
|
301
|
+
*/
|
|
302
|
+
function connectViaTransport(transport) {
|
|
303
|
+
return _makeCliConnection(transport, () => {});
|
|
304
|
+
}
|
|
305
|
+
function _makeCliConnection(transport, onClose, log) {
|
|
306
|
+
const tapped = log?.log === void 0 ? transport : tapTransport(transport, {
|
|
307
|
+
log: log.log,
|
|
308
|
+
localLabel: "cli",
|
|
309
|
+
remoteLabel: log.remoteLabel ?? "peer",
|
|
310
|
+
...log.maxPayload !== void 0 ? { maxPayload: log.maxPayload } : {}
|
|
311
|
+
});
|
|
312
|
+
const signing = {};
|
|
313
|
+
const wrapped = SigningSender.wrapChannel(JsonRpcChannel.create(tapped), signing);
|
|
314
|
+
const channel = wrapped.sender;
|
|
315
|
+
return {
|
|
316
|
+
channel,
|
|
317
|
+
rpcChannel: wrapped,
|
|
318
|
+
signing,
|
|
319
|
+
setRequestHandler: (handler) => wrapped.setRequestHandler(handler),
|
|
320
|
+
close: () => {
|
|
321
|
+
channel.close();
|
|
322
|
+
onClose();
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
//#endregion
|
|
327
|
+
//#region ../../packages-private/linkrpc-client/src/principal.ts
|
|
328
|
+
/**
|
|
329
|
+
* Identity slot the managed-with-fallback default falls back to when the peer
|
|
330
|
+
* does not offer a managed identity overlay (e.g. a plain stdio server). Maps
|
|
331
|
+
* to the same on-disk slot `logout` clears.
|
|
332
|
+
*/
|
|
333
|
+
const MANAGED_FALLBACK_USER_ID = "hubrpc-cli";
|
|
334
|
+
/**
|
|
335
|
+
* Resolve a {@link PrincipalSpec} into a concrete {@link Principal}, given the
|
|
336
|
+
* (signed) sender used to bootstrap a managed identity. Cheap/idempotent, so
|
|
337
|
+
* it can be re-derived on each reconnect. Also reports the {@link PrincipalSource}
|
|
338
|
+
* that was actually used, including whether `managed` fell back to a local key.
|
|
339
|
+
*/
|
|
340
|
+
async function resolvePrincipal(spec, sender) {
|
|
341
|
+
switch (spec.kind) {
|
|
342
|
+
case "managed": try {
|
|
343
|
+
return {
|
|
344
|
+
principal: await createManagedPrincipal(sender),
|
|
345
|
+
source: { kind: "managed" }
|
|
346
|
+
};
|
|
347
|
+
} catch {
|
|
348
|
+
return {
|
|
349
|
+
principal: await createSelfManagedPrincipal(MANAGED_FALLBACK_USER_ID),
|
|
350
|
+
source: {
|
|
351
|
+
kind: "managed-fallback",
|
|
352
|
+
userId: MANAGED_FALLBACK_USER_ID
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
case "user": return {
|
|
357
|
+
principal: await createSelfManagedPrincipal(spec.id),
|
|
358
|
+
source: {
|
|
359
|
+
kind: "user",
|
|
360
|
+
id: spec.id
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
case "file": return {
|
|
364
|
+
principal: await createSelfManagedPrincipalFromFile(spec.path),
|
|
365
|
+
source: {
|
|
366
|
+
kind: "file",
|
|
367
|
+
path: spec.path
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
//#endregion
|
|
373
|
+
//#region ../../packages-private/linkrpc-client/src/hubSigning.ts
|
|
374
|
+
/** How long before expiry to refresh a capability (2 seconds = transit + skew). */
|
|
375
|
+
const CAP_FRESHNESS_MARGIN_MS = 2e3;
|
|
376
|
+
/**
|
|
377
|
+
* Install signing on `signing` for any endpoint. Resolves the
|
|
378
|
+
* {@link PrincipalSpec} into a concrete {@link Principal} (managed-with-
|
|
379
|
+
* fallback, a user slot, or a file-backed keypair) and points the channel's
|
|
380
|
+
* `SigningSender` at it so every outbound call is signed.
|
|
381
|
+
*
|
|
382
|
+
* When `negotiateHubCaps` is set (hub endpoints), it also ensures a persistent
|
|
383
|
+
* `hubAccess` capability is cached on the principal's {@link CapBag} — requesting
|
|
384
|
+
* one with a single signed round-trip on first run / cache miss. For hub
|
|
385
|
+
* endpoints, it also installs a sign-time capability provider.
|
|
386
|
+
*
|
|
387
|
+
* By default (`autoNegotiatePerCall !== false`) that provider negotiates
|
|
388
|
+
* per-call authority lazily using `callIntent` (method + params + nonce +
|
|
389
|
+
* signedAtMs + optional interfaceHash), so one-shot grants can be pinned to the
|
|
390
|
+
* exact call bytes being signed. Set `autoNegotiatePerCall: false` to disable
|
|
391
|
+
* that: the provider then only presents caps already in the bag, and the caller
|
|
392
|
+
* is expected to request access explicitly via
|
|
393
|
+
* {@link SigningSession.requestAccess}.
|
|
394
|
+
*/
|
|
395
|
+
async function setupSigning(channel, signing, principalSpec, opts) {
|
|
396
|
+
const { principal, source: principalSource } = await resolvePrincipal(principalSpec, channel);
|
|
397
|
+
signing.principal = principal;
|
|
398
|
+
signing.oneShotCaps = void 0;
|
|
399
|
+
signing.capProvider = void 0;
|
|
400
|
+
const hubAccessMethod = `${hubAccessInterface.info.id}::requestAccess`;
|
|
401
|
+
const consumerPrincipalId = principal.id;
|
|
402
|
+
if (opts.negotiateHubCaps) {
|
|
403
|
+
let inAccessNegotiation = false;
|
|
404
|
+
const provider = async ({ method, params, nonce, signedAtMs, interfaceHash }) => {
|
|
405
|
+
let presentCaps = principal.capBag.capabilities;
|
|
406
|
+
presentCaps = presentCaps.filter((c) => capabilityFreshAt(c, signedAtMs, CAP_FRESHNESS_MARGIN_MS));
|
|
407
|
+
const present = presentCaps.length > 0 ? { capabilities: presentCaps } : {};
|
|
408
|
+
if (inAccessNegotiation) return {};
|
|
409
|
+
const call = _wireMethodToCall(method);
|
|
410
|
+
if (!call) return present;
|
|
411
|
+
if (call.serviceId === "" || call.interfaceId === hubAccessInterface.info.id) return present;
|
|
412
|
+
if (_capBagCovers(presentCaps, call)) return present;
|
|
413
|
+
if (opts.autoNegotiatePerCall === false) return present;
|
|
414
|
+
inAccessNegotiation = true;
|
|
415
|
+
try {
|
|
416
|
+
const granted = await _requestAccessForCall(channel, hubAccessMethod, consumerPrincipalId, {
|
|
417
|
+
call,
|
|
418
|
+
method,
|
|
419
|
+
params,
|
|
420
|
+
nonce,
|
|
421
|
+
signedAtMs,
|
|
422
|
+
interfaceHash
|
|
423
|
+
});
|
|
424
|
+
if (granted.length === 0) return present;
|
|
425
|
+
const oneShot = granted.filter(_isOneShotCap);
|
|
426
|
+
const persistent = granted.filter((c) => !_isOneShotCap(c));
|
|
427
|
+
if (persistent.length > 0) await principal.capBag.add(...persistent);
|
|
428
|
+
const durable = principal.capBag.capabilities.filter((c) => capabilityFreshAt(c, signedAtMs, CAP_FRESHNESS_MARGIN_MS));
|
|
429
|
+
if (oneShot.length > 0) return { capabilities: [...durable, ...oneShot] };
|
|
430
|
+
return durable.length > 0 ? { capabilities: durable } : {};
|
|
431
|
+
} catch (err) {
|
|
432
|
+
process.stderr.write(`linkrpc: capability negotiation skipped (${err.message})\n`);
|
|
433
|
+
return present;
|
|
434
|
+
} finally {
|
|
435
|
+
inAccessNegotiation = false;
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
signing.capProvider = provider;
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
principal,
|
|
442
|
+
principalSource,
|
|
443
|
+
hubAccessMethod,
|
|
444
|
+
listGrants: () => principal.capBag.capabilities,
|
|
445
|
+
requestAccess: (req) => _sessionRequestAccess(channel, hubAccessMethod, principal, req)
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
/** Parse a wire method into a {@link CallTarget}, or `undefined` for form-1/2. */
|
|
449
|
+
function _wireMethodToCall(wireMethod) {
|
|
450
|
+
const parts = wireMethod.split("::");
|
|
451
|
+
if (parts.length !== 3) return void 0;
|
|
452
|
+
const [serviceId, interfaceId, member] = parts;
|
|
453
|
+
return {
|
|
454
|
+
serviceId,
|
|
455
|
+
interfaceId,
|
|
456
|
+
member
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
/** A cap is one-shot when any permission is pinned to a single call via `callBind`. */
|
|
460
|
+
function _isOneShotCap(sc) {
|
|
461
|
+
return sc.permissions.some((p) => p.callBind !== void 0);
|
|
462
|
+
}
|
|
463
|
+
/** True when a durable (non-one-shot) cap in the bag authorises `target`. */
|
|
464
|
+
function _capBagCovers(caps, target) {
|
|
465
|
+
return caps.some((sc) => !_isOneShotCap(sc) && sc.permissions.some((p) => permissionMatchesTarget(target, p)));
|
|
466
|
+
}
|
|
467
|
+
async function _requestAccessForCall(channel, hubAccessMethod, consumerPrincipalId, req) {
|
|
468
|
+
const result = await _sendRequestAccess(channel, hubAccessMethod, {
|
|
469
|
+
consumer: {
|
|
470
|
+
name: "linkrpc-cli",
|
|
471
|
+
principal: consumerPrincipalId,
|
|
472
|
+
purpose: `Invoke ${req.method}.`
|
|
473
|
+
},
|
|
474
|
+
permissions: [{
|
|
475
|
+
target: {
|
|
476
|
+
serviceId: { exact: req.call.serviceId },
|
|
477
|
+
interfaceId: { exact: req.call.interfaceId },
|
|
478
|
+
members: [{ exact: req.call.member }]
|
|
479
|
+
},
|
|
480
|
+
canInvoke: true,
|
|
481
|
+
callIntent: {
|
|
482
|
+
method: req.method,
|
|
483
|
+
params: req.params,
|
|
484
|
+
nonce: req.nonce,
|
|
485
|
+
signedAtMs: req.signedAtMs,
|
|
486
|
+
...req.interfaceHash !== void 0 ? { interfaceHash: req.interfaceHash } : {},
|
|
487
|
+
suggestion: "once"
|
|
488
|
+
}
|
|
489
|
+
}],
|
|
490
|
+
duration: "once"
|
|
491
|
+
});
|
|
492
|
+
if (result.status !== "granted") return [];
|
|
493
|
+
return result.capabilities ?? [];
|
|
494
|
+
}
|
|
495
|
+
async function _sendRequestAccess(channel, hubAccessMethod, params) {
|
|
496
|
+
return await _awaitWithApprovalNotice(channel.sendRequest(hubAccessMethod, params));
|
|
497
|
+
}
|
|
498
|
+
/** Delay before we tell the user an access request is parked awaiting approval. */
|
|
499
|
+
const APPROVAL_NOTICE_DELAY_MS = 750;
|
|
500
|
+
/**
|
|
501
|
+
* Await a `hubAccess::requestAccess` round-trip, printing a one-line hint to
|
|
502
|
+
* stderr if it doesn't resolve quickly. Access requests park at the hub until a
|
|
503
|
+
* human approver (admin) decides them, so without this notice the CLI looks
|
|
504
|
+
* hung — it blocks with no output until approval. Fast requests (auto-approved
|
|
505
|
+
* / open hub) stay silent because the notice only fires after
|
|
506
|
+
* {@link APPROVAL_NOTICE_DELAY_MS}.
|
|
507
|
+
*/
|
|
508
|
+
async function _awaitWithApprovalNotice(pending) {
|
|
509
|
+
const timer = setTimeout(() => {
|
|
510
|
+
process.stderr.write("linkrpc: access request sent — waiting for the hub admin to approve it...\n");
|
|
511
|
+
}, APPROVAL_NOTICE_DELAY_MS);
|
|
512
|
+
timer.unref?.();
|
|
513
|
+
try {
|
|
514
|
+
return await pending;
|
|
515
|
+
} finally {
|
|
516
|
+
clearTimeout(timer);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Backs {@link SigningSession.requestAccess}. Sends a batched
|
|
521
|
+
* `hubAccess::requestAccess`, then adds any durable (non-one-shot) caps the hub
|
|
522
|
+
* minted to the principal's cap bag so later calls present them automatically.
|
|
523
|
+
*/
|
|
524
|
+
async function _sessionRequestAccess(channel, hubAccessMethod, principal, req) {
|
|
525
|
+
const result = await _sendRequestAccess(channel, hubAccessMethod, {
|
|
526
|
+
consumer: {
|
|
527
|
+
...req.consumer,
|
|
528
|
+
principal: principal.id
|
|
529
|
+
},
|
|
530
|
+
permissions: req.permissions,
|
|
531
|
+
...req.duration !== void 0 ? { duration: req.duration } : {}
|
|
532
|
+
});
|
|
533
|
+
if (result.status === "granted") {
|
|
534
|
+
const capabilities = result.capabilities ?? [];
|
|
535
|
+
const durable = capabilities.filter((c) => !_isOneShotCap(c));
|
|
536
|
+
if (durable.length > 0) await principal.capBag.add(...durable);
|
|
537
|
+
return {
|
|
538
|
+
status: "granted",
|
|
539
|
+
capabilities,
|
|
540
|
+
addedDurable: durable.length
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
status: result.status,
|
|
545
|
+
reason: result.reason
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
//#endregion
|
|
549
|
+
//#region src/connectionPool.ts
|
|
550
|
+
/**
|
|
551
|
+
* Maintains live hub connections keyed by the canonical endpoint URI (path/url
|
|
552
|
+
* + token). Connections are created lazily on first use and reused for every
|
|
553
|
+
* subsequent call against the same endpoint. Setting up the hub signing session
|
|
554
|
+
* (which may surface a consent modal the first time) happens once per endpoint
|
|
555
|
+
* and the resulting session is cached on the pool entry.
|
|
556
|
+
*/
|
|
557
|
+
var ConnectionPool = class ConnectionPool {
|
|
558
|
+
_entries = /* @__PURE__ */ new Map();
|
|
559
|
+
_defaultEndpoint;
|
|
560
|
+
_defaultTransport;
|
|
561
|
+
/** Pool key for the in-process default connection (see {@link ConnectionPoolOptions.defaultTransport}). */
|
|
562
|
+
static _DEFAULT_INPROC_KEY = "<default-inproc>";
|
|
563
|
+
constructor(options = {}) {
|
|
564
|
+
this._defaultEndpoint = options.defaultEndpoint;
|
|
565
|
+
this._defaultTransport = options.defaultTransport;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Resolve a pooled connection for `endpointUri` (a strict endpoint URI such
|
|
569
|
+
* as `unix:/path?token=…`, `npipe://./pipe/…?token=…`, or
|
|
570
|
+
* `wss://host?token=…`). When omitted, an in-process default transport (if
|
|
571
|
+
* configured) is used; otherwise it falls back to the configured default
|
|
572
|
+
* endpoint, then to the `LINKRPC_ENDPOINT` / `LINKRPC_TOKEN` env vars. A token
|
|
573
|
+
* absent from the URI is filled in from `LINKRPC_TOKEN` when present.
|
|
574
|
+
*/
|
|
575
|
+
async resolve(endpointUri) {
|
|
576
|
+
if (endpointUri === void 0 && this._defaultTransport) return this._resolveCached(ConnectionPool._DEFAULT_INPROC_KEY, (key) => this._openInProc(this._defaultTransport(), key));
|
|
577
|
+
const spec = this._resolveSpec(endpointUri);
|
|
578
|
+
const key = formatEndpointUri(spec, { revealToken: true });
|
|
579
|
+
return this._resolveCached(key, (k) => this._open(spec, k));
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Shared cache-or-open: returns the in-flight/cached entry for `key`, or
|
|
583
|
+
* starts `open(key)` and caches the promise so concurrent callers share the
|
|
584
|
+
* same signing setup. On failure the entry is evicted so the next call
|
|
585
|
+
* retries cleanly.
|
|
586
|
+
*/
|
|
587
|
+
_resolveCached(key, open) {
|
|
588
|
+
const existing = this._entries.get(key);
|
|
589
|
+
if (existing) return existing;
|
|
590
|
+
const pending = open(key);
|
|
591
|
+
this._entries.set(key, pending);
|
|
592
|
+
pending.catch(() => this._entries.delete(key));
|
|
593
|
+
return pending;
|
|
594
|
+
}
|
|
595
|
+
_resolveSpec(endpointUri) {
|
|
596
|
+
if (endpointUri !== void 0) return _withEnvToken(parseEndpointUri(endpointUri));
|
|
597
|
+
return this._defaultEndpoint ?? _resolveFromEnv();
|
|
598
|
+
}
|
|
599
|
+
async _open(spec, key) {
|
|
600
|
+
const display = formatEndpointUri(spec);
|
|
601
|
+
const cli = await connect(spec);
|
|
602
|
+
return this._finishOpen(cli, display, key);
|
|
603
|
+
}
|
|
604
|
+
async _openInProc(def, key) {
|
|
605
|
+
const cli = connectViaTransport(def.transport);
|
|
606
|
+
return this._finishOpen(cli, def.label ?? "inproc", key, def.dispose);
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Wrap an already-open {@link CliConnection} in a managed signing session
|
|
610
|
+
* and build the {@link PooledConnection}. Shared by the dialed-socket and
|
|
611
|
+
* in-process default paths — both sign as a managed principal and bootstrap
|
|
612
|
+
* the `hubAccess` cap, the only difference being how the transport was
|
|
613
|
+
* obtained. `extraDispose` runs on disposal after the connection is closed.
|
|
614
|
+
*/
|
|
615
|
+
async _finishOpen(cli, display, key, extraDispose) {
|
|
616
|
+
const traceListeners = /* @__PURE__ */ new Set();
|
|
617
|
+
const trace = (line) => {
|
|
618
|
+
process.stderr.write(`${line}\n`);
|
|
619
|
+
for (const l of traceListeners) try {
|
|
620
|
+
l(line);
|
|
621
|
+
} catch {}
|
|
622
|
+
};
|
|
623
|
+
_installTransportTrace(cli.channel, display, trace);
|
|
624
|
+
try {
|
|
625
|
+
trace(`linkrpc-mcp[${display}] requesting hub signing session (may prompt for reflection-cap consent on first use)`);
|
|
626
|
+
const session = await setupSigning(cli.channel, cli.signing, { kind: "managed" }, {
|
|
627
|
+
negotiateHubCaps: true,
|
|
628
|
+
autoNegotiatePerCall: false
|
|
629
|
+
});
|
|
630
|
+
return {
|
|
631
|
+
channel: cli.channel,
|
|
632
|
+
endpoint: display,
|
|
633
|
+
key,
|
|
634
|
+
session,
|
|
635
|
+
lastResultVal: void 0,
|
|
636
|
+
addTraceListener: (listener) => {
|
|
637
|
+
traceListeners.add(listener);
|
|
638
|
+
return () => traceListeners.delete(listener);
|
|
639
|
+
},
|
|
640
|
+
trace,
|
|
641
|
+
dispose: () => {
|
|
642
|
+
cli.close();
|
|
643
|
+
extraDispose?.();
|
|
644
|
+
this._entries.delete(key);
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
} catch (e) {
|
|
648
|
+
cli.close();
|
|
649
|
+
extraDispose?.();
|
|
650
|
+
throw e;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
dispose() {
|
|
654
|
+
for (const pending of this._entries.values()) pending.then((e) => e.dispose(), () => {});
|
|
655
|
+
this._entries.clear();
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
/**
|
|
659
|
+
* Fill a missing socket/ws token from `LINKRPC_TOKEN` when the URI itself didn't
|
|
660
|
+
* carry one. Command endpoints (`cmd:` / `cmd-stdio:`) carry no token.
|
|
661
|
+
*/
|
|
662
|
+
function _withEnvToken(spec) {
|
|
663
|
+
if ((spec.kind === "socket" || spec.kind === "ws") && spec.token === void 0) {
|
|
664
|
+
const token = process.env[LINKRPC_TOKEN_VAR];
|
|
665
|
+
if (token) return {
|
|
666
|
+
...spec,
|
|
667
|
+
token
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
return spec;
|
|
671
|
+
}
|
|
672
|
+
function _resolveFromEnv() {
|
|
673
|
+
const endpoint = process.env[LINKRPC_ENDPOINT_VAR];
|
|
674
|
+
if (!endpoint) throw new Error(`No connection supplied and ${LINKRPC_ENDPOINT_VAR} is not set. Pass a 'connection' endpoint URI (e.g. unix:/path?token=… or wss://host?token=…) to the tool, or run the MCP server inside a VS Code window with the team-tools hub active.`);
|
|
675
|
+
return _withEnvToken(parseEndpointUri(endpoint));
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Wrap `channel.sendRequest` / `channel.sendNotification` so every
|
|
679
|
+
* outbound JSON-RPC envelope and its outcome is emitted as a trace
|
|
680
|
+
* line. Catches everything the MCP server drives (tool-issued `call` /
|
|
681
|
+
* `notify`, `explore`, and the hub's `hubAccess::requestAccess`
|
|
682
|
+
* permission round-trips).
|
|
683
|
+
*
|
|
684
|
+
* IMPORTANT: the wrappers forward the third `opts` argument verbatim. It
|
|
685
|
+
* carries the per-call {@link SigningCallCtx} (e.g. `signerOverride: null` for
|
|
686
|
+
* the unsigned `identity::*` bootstrap round-trips). Dropping it would make a
|
|
687
|
+
* managed identity's `identity::sign` calls get signed — which recurses
|
|
688
|
+
* (signing a call needs another `identity::sign`), blowing the heap.
|
|
689
|
+
*/
|
|
690
|
+
function _installTransportTrace(channel, endpoint, trace) {
|
|
691
|
+
const send = channel.sendRequest.bind(channel);
|
|
692
|
+
const notify = channel.sendNotification.bind(channel);
|
|
693
|
+
let seq = 0;
|
|
694
|
+
channel.sendRequest = async (method, params, opts) => {
|
|
695
|
+
const id = ++seq;
|
|
696
|
+
trace(`linkrpc-mcp[${endpoint}] → #${id} request ${method} ${_fmt(params)}`);
|
|
697
|
+
try {
|
|
698
|
+
const result = await send(method, params, opts);
|
|
699
|
+
trace(`linkrpc-mcp[${endpoint}] ← #${id} result ${_fmt(result)}`);
|
|
700
|
+
return result;
|
|
701
|
+
} catch (e) {
|
|
702
|
+
trace(`linkrpc-mcp[${endpoint}] ← #${id} error ${e.message}`);
|
|
703
|
+
throw e;
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
channel.sendNotification = async (method, params, opts) => {
|
|
707
|
+
trace(`linkrpc-mcp[${endpoint}] → #${++seq} notify ${method} ${_fmt(params)}`);
|
|
708
|
+
await notify(method, params, opts);
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
const _MAX_TRACE_PAYLOAD = 2e3;
|
|
712
|
+
function _fmt(v) {
|
|
713
|
+
if (v === void 0) return "(no params)";
|
|
714
|
+
let s;
|
|
715
|
+
try {
|
|
716
|
+
s = JSON.stringify(v);
|
|
717
|
+
} catch {
|
|
718
|
+
s = String(v);
|
|
719
|
+
}
|
|
720
|
+
if (s === void 0) s = "undefined";
|
|
721
|
+
return s.length > _MAX_TRACE_PAYLOAD ? `${s.slice(0, _MAX_TRACE_PAYLOAD)}…(+${s.length - _MAX_TRACE_PAYLOAD} chars)` : s;
|
|
722
|
+
}
|
|
723
|
+
//#endregion
|
|
724
|
+
//#region src/senderProvider.ts
|
|
725
|
+
/**
|
|
726
|
+
* {@link IConnectionPool} backed by a {@link HubSenderProvider}: caches one
|
|
727
|
+
* {@link HubSigningSender} per distinct `connection` argument and adapts it to
|
|
728
|
+
* the {@link PooledConnection} shape the MCP tools consume. Holds the
|
|
729
|
+
* MCP-layer-only `lastResultVal` and trace fan-out so the sender stays pure.
|
|
730
|
+
*/
|
|
731
|
+
var ProviderPool = class {
|
|
732
|
+
_provider;
|
|
733
|
+
_session;
|
|
734
|
+
_entries = /* @__PURE__ */ new Map();
|
|
735
|
+
constructor(_provider, _session) {
|
|
736
|
+
this._provider = _provider;
|
|
737
|
+
this._session = _session;
|
|
738
|
+
}
|
|
739
|
+
resolve(endpointUri) {
|
|
740
|
+
const key = endpointUri ?? "<default>";
|
|
741
|
+
const existing = this._entries.get(key);
|
|
742
|
+
if (existing) return existing;
|
|
743
|
+
const pending = this._open(endpointUri, key);
|
|
744
|
+
this._entries.set(key, pending);
|
|
745
|
+
pending.catch(() => this._entries.delete(key));
|
|
746
|
+
return pending;
|
|
747
|
+
}
|
|
748
|
+
async _open(endpointUri, key) {
|
|
749
|
+
const sender = await this._provider(this._session, endpointUri);
|
|
750
|
+
const traceListeners = /* @__PURE__ */ new Set();
|
|
751
|
+
return {
|
|
752
|
+
channel: sender,
|
|
753
|
+
endpoint: sender.identity.principal,
|
|
754
|
+
key,
|
|
755
|
+
session: sender,
|
|
756
|
+
lastResultVal: void 0,
|
|
757
|
+
addTraceListener: (listener) => {
|
|
758
|
+
traceListeners.add(listener);
|
|
759
|
+
return () => traceListeners.delete(listener);
|
|
760
|
+
},
|
|
761
|
+
trace: (line) => {
|
|
762
|
+
for (const l of traceListeners) try {
|
|
763
|
+
l(line);
|
|
764
|
+
} catch {}
|
|
765
|
+
},
|
|
766
|
+
dispose: () => {
|
|
767
|
+
sender.close();
|
|
768
|
+
this._entries.delete(key);
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
dispose() {
|
|
773
|
+
for (const pending of this._entries.values()) pending.then((e) => e.dispose(), () => {});
|
|
774
|
+
this._entries.clear();
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
//#endregion
|
|
778
|
+
//#region src/grants.ts
|
|
779
|
+
/** Render a single `serviceId` / `interfaceId` / member matcher as a string. */
|
|
780
|
+
function _fmtPattern(p) {
|
|
781
|
+
if ("exact" in p) return p.exact;
|
|
782
|
+
return p.prefix === "" ? "*" : `${p.prefix}*`;
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Summarise the durable capabilities a connection currently holds into a
|
|
786
|
+
* compact, JSON-friendly shape the model can read to understand what access it
|
|
787
|
+
* already has (and therefore what it still needs to request).
|
|
788
|
+
*/
|
|
789
|
+
function summarizeGrants(caps) {
|
|
790
|
+
const grants = caps.map((c) => {
|
|
791
|
+
const oneShot = c.permissions.some((p) => p.callBind !== void 0);
|
|
792
|
+
return {
|
|
793
|
+
issuer: c.issuer,
|
|
794
|
+
audience: c.audience,
|
|
795
|
+
...c.expiresAtMs !== void 0 ? { expiresAtMs: c.expiresAtMs } : {},
|
|
796
|
+
oneShot,
|
|
797
|
+
permissions: c.permissions.map((p) => ({
|
|
798
|
+
serviceId: _fmtPattern(p.target.serviceId),
|
|
799
|
+
interfaceId: _fmtPattern(p.target.interfaceId),
|
|
800
|
+
members: p.target.members.map(_fmtPattern),
|
|
801
|
+
canInvoke: p.canInvoke ?? false,
|
|
802
|
+
canDelegate: p.canDelegate ?? false
|
|
803
|
+
}))
|
|
804
|
+
};
|
|
805
|
+
});
|
|
806
|
+
return {
|
|
807
|
+
count: grants.length,
|
|
808
|
+
grants
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
//#endregion
|
|
812
|
+
//#region src/connectionDts.ts
|
|
813
|
+
/**
|
|
814
|
+
* Documentation that the MCP server exposes as a resource. The text is the
|
|
815
|
+
* single source of truth for what `runLinkRpcScript` sees inside the QuickJS
|
|
816
|
+
* sandbox — it lives as the real declaration file `connection.d.ts` and is
|
|
817
|
+
* embedded here verbatim. Keep `connection.d.ts` in sync with `sandbox.ts`
|
|
818
|
+
* and `explore.ts`.
|
|
819
|
+
*/
|
|
820
|
+
const CONNECTION_DTS = "// ---- runLinkRpcScript sandbox API ----------------------------------------\n//\n// The MCP tool `runLinkRpcScript` takes `code` (a JS function expression) and\n// invokes it inside a QuickJS sandbox with the signature below. Anything the\n// function returns (sync or async) becomes the tool's result, JSON-encoded.\n//\n// ({ con, lastResultVal, mcp }) => any | Promise<any>\n//\n// Example:\n//\n// ({ con }) => con.call(\"vscode\", \"vscode.window\",\n// \"showInformationMessage\",\n// { message: \"hello\" })\n//\n// The sandbox is isolated: no `require`, no `process`, no host globals\n// other than the ones declared here. Memory is capped (~32 MB) and execution\n// is interrupted after ~5 s.\n\ninterface RunSvcContext {\n /** The live connection wired to the requested hub. */\n readonly con: SvcConnection;\n /**\n * The value returned by the previous `runLinkRpcScript` call on the same hub\n * connection. JSON round-tripped — non-serialisable members are dropped.\n * `undefined` on the first call.\n */\n readonly lastResultVal: unknown;\n /**\n * Optional presentation helpers. Ordinary return values are automatically\n * converted to native MCP image/audio/resource blocks when possible.\n */\n readonly mcp: McpPresentation;\n}\n\ninterface McpAnnotations {\n readonly audience?: ReadonlyArray<\"user\" | \"assistant\">;\n readonly priority?: number;\n readonly lastModified?: string;\n}\n\ntype McpMeta = Record<string, unknown>;\n\ntype McpContentBlock =\n | {\n readonly type: \"text\";\n readonly text: string;\n readonly annotations?: McpAnnotations;\n readonly _meta?: McpMeta;\n }\n | {\n readonly type: \"image\";\n readonly data: string;\n readonly mimeType: string;\n readonly annotations?: McpAnnotations;\n readonly _meta?: McpMeta;\n }\n | {\n readonly type: \"audio\";\n readonly data: string;\n readonly mimeType: string;\n readonly annotations?: McpAnnotations;\n readonly _meta?: McpMeta;\n }\n | {\n readonly type: \"resource\";\n readonly resource:\n | { readonly uri: string; readonly mimeType?: string; readonly text: string; readonly _meta?: McpMeta }\n | { readonly uri: string; readonly mimeType?: string; readonly blob: string; readonly _meta?: McpMeta };\n readonly annotations?: McpAnnotations;\n readonly _meta?: McpMeta;\n }\n | {\n readonly type: \"resource_link\";\n readonly uri: string;\n readonly name: string;\n readonly title?: string;\n readonly description?: string;\n readonly mimeType?: string;\n readonly size?: number;\n readonly annotations?: McpAnnotations;\n readonly icons?: ReadonlyArray<{\n readonly src: string;\n readonly mimeType?: string;\n readonly sizes?: ReadonlyArray<string>;\n readonly theme?: \"light\" | \"dark\";\n }>;\n readonly _meta?: McpMeta;\n };\n\ninterface McpPresentation {\n /** Suppress automatic presentation for this value and expose its original JSON. */\n raw<T>(value: T): T;\n text(text: string, annotations?: McpAnnotations): McpContentBlock;\n image(data: string, mimeType: string, annotations?: McpAnnotations): McpContentBlock;\n audio(data: string, mimeType: string, annotations?: McpAnnotations): McpContentBlock;\n resource(\n resource:\n | {\n readonly uri: string;\n readonly mimeType?: string;\n readonly text: string;\n readonly _meta?: McpMeta;\n }\n | {\n readonly uri: string;\n readonly mimeType?: string;\n readonly blob: string;\n readonly _meta?: McpMeta;\n },\n annotations?: McpAnnotations,\n ): McpContentBlock;\n resourceLink(\n resource: Omit<Extract<McpContentBlock, { readonly type: \"resource_link\" }>, \"type\">,\n ): McpContentBlock;\n /** Mark any MCP content block for validation when the script result is presented. */\n content(block: McpContentBlock): McpContentBlock;\n /**\n * Return custom MCP content alongside a separate logical value. `value` is\n * what the next call receives as `lastResultVal`.\n */\n result(options: {\n readonly value?: unknown;\n readonly structuredContent?: Record<string, unknown>;\n readonly content?: ReadonlyArray<McpContentBlock>;\n readonly isError?: boolean;\n readonly _meta?: McpMeta;\n }): unknown;\n}\n\ninterface SvcConnection {\n /**\n * Cooperative cancellation signal. When the sandbox is ~200 ms away\n * from its hard timeout (and the configured `timeoutMs` was at least\n * 500 ms), the host flips `aborted` to `true`, sets `reason`, and\n * rejects every in-flight host promise with an `AbortError`. This\n * gives the guest a small grace window to run `finally` blocks,\n * persist partial state, or return a partial result before the hard\n * interrupt kicks in. Subsequent `con.call` / `con.notify` /\n * `con.explore` calls in this window reject immediately so the guest\n * doesn't spend its grace window waiting for another RPC.\n *\n * Idiomatic use:\n *\n * try {\n * return await con.call(s, i, m, params);\n * } catch (e) {\n * if (con.abortSignal.aborted) return { partial: true };\n * throw e;\n * }\n */\n readonly abortSignal: {\n readonly aborted: boolean;\n readonly reason: string | undefined;\n throwIfAborted(): void;\n };\n\n /**\n * Invoke a request on the bus.\n * - Form 3 (most common): pass `serviceId` to address a specific\n * participant: `con.call(\"vscode\", \"vscode.window\", \"showInformationMessage\", { message })`.\n * - Form 2: pass `serviceId: \"\"` to address the hub itself (the\n * root): `con.call(\"\", \"hubDirectory\", \"listPrefixes\")`.\n *\n * Rejects with an `Error` that carries the RPC error message when the\n * peer returns a JSON-RPC error.\n *\n * Access is NOT automatic: a gated member you hold no capability for\n * rejects with a permission error. Check `con.grants()` and request\n * access via `con.requestAccess(...)` first — or pass\n * `{ requestPermission: true }` to have this call auto-negotiate the\n * capability it needs (a single `hubAccess` round-trip) and retry once.\n *\n * Streaming methods can report server-to-client messages while the request\n * is pending:\n *\n * const progress = [];\n * const result = await con.call(\"svc\", \"jobs\", \"run\", {}, {\n * onStreamMessage: message => {\n * progress.push(message);\n * console.log(\"progress\", message);\n * },\n * });\n *\n * When `onStreamMessage` is omitted, each stream message is written to\n * `console.log` with the wire method name. This makes a parked streaming\n * call observable through `awaitLinkRpcTask` without extra callback code.\n */\n call(\n serviceId: string,\n interfaceId: string,\n member: string,\n params?: unknown,\n options?: CallOptions,\n ): Promise<unknown>;\n\n /** Fire-and-forget variant of `call`. Returns once the notification has been sent. */\n notify(\n serviceId: string,\n interfaceId: string,\n member: string,\n params?: unknown,\n ): Promise<void>;\n\n /**\n * Escape hatch when you have a fully-formed JSON-RPC method name (e.g.\n * `\"vscode::vscode.window::showInformationMessage\"`). Prefer `call`.\n */\n callRaw(method: string, params?: unknown, options?: CallOptions): Promise<unknown>;\n notifyRaw(method: string, params?: unknown): Promise<void>;\n\n /**\n * Discover and inspect the interfaces on the bus.\n *\n * - `{ kind: \"browse\" }` lists stable virtual-document identities. Use\n * exact `serviceId` / `interfaceId` filters to narrow the directory.\n * - `{ kind: \"grep\", pattern }` searches the generated, self-contained\n * `defineInterface` source for every candidate. `pattern` is a\n * case-insensitive regular expression by default; set `syntax:\n * \"literal\"` for a case-insensitive substring. Results contain the\n * matching line, nearby source context, and enclosing member name.\n * - `{ kind: \"inspect\", serviceId, interfaceId }` returns one complete\n * generated source document. Set `format: \"schema\"` for its exact raw\n * wire schema instead.\n * - Reflection plumbing (`linkrpc.*`) is hidden unless `includeInternal`\n * is true or an exact internal `interfaceId` is requested.\n * - `requestPermission: true` requests one broad reflection grant and\n * walks gated directories. Without it, inaccessible branches are\n * reported explicitly instead of being silently omitted.\n * - Browse and grep use cursor pagination. Pass a returned `nextCursor`\n * back with the same query to continue.\n */\n explore(args: ExploreArgs): Promise<ExploreResult>;\n\n /**\n * List the capabilities this connection currently holds — what\n * `(serviceId, interfaceId, members)` you may already invoke, each grant's\n * expiry, and whether it is one-shot. Call this BEFORE invoking a gated\n * member to check you have access, and to decide what to pass to\n * `requestAccess`. Reflection (`explore`) works without any grant.\n */\n grants(): Promise<GrantsSummary>;\n\n /**\n * Request one or more capabilities from the hub. Calls are NOT\n * auto-granted: if `con.call(...)` fails with a permission error, ask for\n * access here first, then retry the call.\n *\n * - Pass MANY `permissions` at once to batch related access into a\n * SINGLE user prompt (e.g. several members on one service, or read +\n * write together) instead of nagging once per call.\n * - Choose `duration`: `\"once\"` (single use, 5 min), `\"shortLived\"`\n * (5 min), `\"longLived\"` (24 h), or `\"persistent\"` (never expires,\n * remembered across runs).\n * - Granted durable caps are cached on the connection, so later\n * `con.call`s present them automatically — no need to re-request.\n *\n * `target.serviceId` / `interfaceId` / `members` are matchers:\n * `{ exact: \"x\" }` or `{ prefix: \"x\" }` (`{ prefix: \"\" }` = any).\n * Set `canInvoke: true` to actually call the members.\n *\n * con.requestAccess({\n * permissions: [{\n * target: {\n * serviceId: { exact: \"github\" },\n * interfaceId: { exact: \"github.issues\" },\n * members: [{ exact: \"list\" }, { exact: \"get\" }],\n * },\n * canInvoke: true,\n * }],\n * duration: \"longLived\",\n * purpose: \"read the open repo's issues\",\n * })\n */\n requestAccess(args: RequestAccessArgs): Promise<RequestAccessResult>;\n\n /**\n * Return this very text (the TypeScript declarations for the full script context).\n * Synchronous, no host round-trip — just an embedded string. Useful\n * when you want to re-read the API contract before composing a more\n * complicated call.\n */\n getDocs(): string;\n}\n\n// ---- access (capabilities) ----------------------------------------------\n\n/** Per-call options for {@link SvcConnection.call} / {@link SvcConnection.callRaw}. */\ninterface CallOptions {\n /**\n * When `true` and the call is rejected because no capability is held\n * (`permissionRequired`), automatically request access for exactly this\n * `(serviceId, interfaceId, member)` — a single `hubAccess` round-trip —\n * and retry the call once. The granted capability is durable\n * (`longLived`), so later calls to the same member present it\n * automatically. On a gated hub this may prompt the user.\n *\n * Defaults to `false`: a missing capability surfaces as an error directing\n * you to `con.requestAccess(...)`.\n */\n readonly requestPermission?: boolean;\n /**\n * Called for each server-to-client stream message associated with this\n * request. Messages are delivered as they arrive, including while a script\n * is parked as a background task. Throwing from the callback fails the\n * sandbox and cancels its outstanding requests. When omitted, messages are\n * logged automatically.\n */\n readonly onStreamMessage?: (message: unknown) => void;\n}\n\n/**\n * Sandbox-owned timers. Pending timers continue to run while a script is\n * parked and are cancelled automatically when it completes, fails, is\n * cancelled, or reaches its maximum lifetime.\n */\ndeclare function setTimeout(\n callback: (...args: unknown[]) => void,\n timeout?: number,\n ...args: unknown[]\n): number;\ndeclare function clearTimeout(timerId: number | undefined): void;\ndeclare function setInterval(\n callback: (...args: unknown[]) => void,\n timeout?: number,\n ...args: unknown[]\n): number;\ndeclare function clearInterval(timerId: number | undefined): void;\n\n/** A `serviceId` / `interfaceId` / member matcher. `{ prefix: \"\" }` matches anything. */\ntype AccessPattern = { readonly exact: string } | { readonly prefix: string };\n\ninterface AccessPermission {\n readonly target: {\n readonly serviceId: AccessPattern;\n readonly interfaceId: AccessPattern;\n readonly interfaceHash?: string;\n /** `[{ prefix: \"\" }]` covers every member. */\n readonly members: ReadonlyArray<AccessPattern>;\n };\n /** Set true to actually invoke the members (default false). */\n readonly canInvoke?: boolean;\n /** Set true to allow re-delegating this authority (default false). */\n readonly canDelegate?: boolean;\n readonly params?: Record<string, unknown>;\n}\n\ninterface RequestAccessArgs {\n /** One or more authorities to request together in a single prompt. */\n readonly permissions: ReadonlyArray<AccessPermission>;\n readonly duration?: \"once\" | \"shortLived\" | \"longLived\" | \"persistent\";\n /** Short human-readable reason shown to the user. */\n readonly purpose?: string;\n}\n\ntype RequestAccessResult =\n | { readonly status: \"granted\"; readonly capabilities: ReadonlyArray<unknown>; readonly addedDurable: number }\n | { readonly status: \"denied\"; readonly reason?: string }\n | { readonly status: string; readonly reason?: string };\n\ninterface GrantPermissionSummary {\n readonly serviceId: string;\n readonly interfaceId: string;\n readonly members: ReadonlyArray<string>;\n readonly canInvoke: boolean;\n readonly canDelegate: boolean;\n}\n\ninterface GrantSummary {\n readonly issuer: string;\n readonly audience: string;\n readonly expiresAtMs?: number;\n /** True when pinned to a single call (`callBind`); not reusable. */\n readonly oneShot: boolean;\n readonly permissions: ReadonlyArray<GrantPermissionSummary>;\n}\n\ninterface GrantsSummary {\n readonly count: number;\n readonly grants: ReadonlyArray<GrantSummary>;\n}\n\ninterface ExploreCommonArgs {\n /** Exact directory-level filters, applied before browsing or searching. */\n readonly serviceId?: string;\n readonly interfaceId?: string;\n /** Include reflection plumbing such as `hubrpc.directory` and `hubrpc.schemas`. */\n readonly includeInternal?: boolean;\n /** Request one broad reflection grant when a gated directory is encountered. */\n readonly requestPermission?: boolean;\n}\n\ninterface ExploreBrowseArgs extends ExploreCommonArgs {\n readonly kind: \"browse\";\n /** Number of interfaces to return. Defaults to 20; maximum 100. */\n readonly limit?: number;\n /** Continuation cursor returned by a previous browse call. */\n readonly cursor?: string;\n}\n\ninterface ExploreGrepArgs extends ExploreCommonArgs {\n readonly kind: \"grep\";\n /** Pattern searched against generated `defineInterface` source, one line at a time. */\n readonly pattern: string;\n /** Defaults to `regex`. Both modes are case-insensitive. */\n readonly syntax?: \"regex\" | \"literal\";\n /** Number of matching virtual documents to return. Defaults to 20; maximum 100. */\n readonly limit?: number;\n /** Continuation cursor returned by a previous grep call. */\n readonly cursor?: string;\n /** Source lines included before and after each match. Defaults to 1; maximum 5. */\n readonly contextLines?: number;\n}\n\ninterface ExploreInspectArgs extends ExploreCommonArgs {\n readonly kind: \"inspect\";\n readonly serviceId: string;\n readonly interfaceId: string;\n /** Generated source by default; use `schema` for the raw wire schema. */\n readonly format?: \"source\" | \"schema\";\n}\n\ntype ExploreArgs = ExploreBrowseArgs | ExploreGrepArgs | ExploreInspectArgs;\n\ntype ExploreResult = ExploreBrowseResult | ExploreGrepResult | ExploreInspectResult;\n\ninterface ExploreListing {\n readonly serviceId: string;\n readonly serviceDescription?: string;\n readonly interfaceId: string;\n readonly interfaceHash: string;\n readonly documentId: string;\n}\n\ninterface ExploreBrowseResult {\n readonly kind: \"browse\";\n readonly total: number;\n readonly entries: ReadonlyArray<ExploreListing>;\n readonly nextCursor?: string;\n readonly inaccessible?: ReadonlyArray<ExploreInaccessible>;\n}\n\ninterface ExploreGrepMatch {\n /** Matching source plus the requested surrounding context, joined with newlines. */\n readonly searchResult: string;\n /** Inclusive, 1-based line range of `searchResult` in the virtual document. */\n readonly lineRange: readonly [start: number, end: number];\n /** LinkRPC member containing every matched line in this chunk, when unambiguous. */\n readonly member?: string;\n}\n\ninterface ExploreGrepEntry extends ExploreListing {\n readonly matches: ReadonlyArray<ExploreGrepMatch>;\n readonly matchesTruncated?: boolean;\n}\n\ninterface ExploreDocumentError {\n readonly serviceId: string;\n readonly interfaceId: string;\n readonly error: string;\n}\n\ninterface ExploreGrepResult {\n readonly kind: \"grep\";\n readonly pattern: string;\n readonly syntax: \"regex\" | \"literal\";\n readonly total: number;\n readonly entries: ReadonlyArray<ExploreGrepEntry>;\n readonly nextCursor?: string;\n readonly documentErrors?: ReadonlyArray<ExploreDocumentError>;\n readonly inaccessible?: ReadonlyArray<ExploreInaccessible>;\n}\n\ninterface ExploreInspectResult extends ExploreListing {\n readonly kind: \"inspect\";\n readonly format: \"source\" | \"schema\";\n readonly source?: string;\n readonly schema?: unknown;\n readonly inaccessible?: ReadonlyArray<ExploreInaccessible>;\n}\n\ninterface ExploreInaccessible {\n /** The directory target (serviceId) that could not be enumerated. */\n readonly serviceId: string;\n /** The error message from the denied directory lookup. */\n readonly reason: string;\n /** Actionable next step to gain visibility into this directory. */\n readonly hint: string;\n}\n\n// ---- console -------------------------------------------------------------\n// `console.log`, `console.warn`, `console.error` are captured and\n// returned alongside the tool result. There is no `console.debug` /\n// `console.trace` etc.\ndeclare const console: {\n log(...args: unknown[]): void;\n warn(...args: unknown[]): void;\n error(...args: unknown[]): void;\n};\n\n// ---- Notes ---------------------------------------------------------------\n// - Everything is JSON-serialisable. Functions, dates, etc. become `null`\n// when crossing the sandbox boundary.\n// - The user code MUST evaluate to a function. Top-level statements are\n// wrapped, so write an arrow or function expression, not a bare\n// `await con.call(...)`.\n// - `throw` inside user code surfaces as a failed tool call.\n";
|
|
821
|
+
//#endregion
|
|
822
|
+
//#region src/sandbox.ts
|
|
823
|
+
/**
|
|
824
|
+
* The guest runtime (`src/guest/guestMain.ts`) bundled to plain JS, evaluated
|
|
825
|
+
* inside QuickJS before any user code. Authoring it as real TypeScript that is
|
|
826
|
+
* type-checked against `connection.d.ts` is what keeps `con` from drifting out
|
|
827
|
+
* of sync with its declarations.
|
|
828
|
+
*
|
|
829
|
+
* `@vscode/rollup-plugin-esm-url` rewrites the `?esm` URL at build time to the
|
|
830
|
+
* emitted (already-transpiled) chunk next to this module, so the production
|
|
831
|
+
* read returns JS. In dev/test (vite serve, no rewrite) the URL still points
|
|
832
|
+
* at the `.ts` source, so we transpile it on the fly with the `typescript`
|
|
833
|
+
* devDependency — that branch never runs in the shipped `dist`.
|
|
834
|
+
*/
|
|
835
|
+
function _loadGuestRuntime() {
|
|
836
|
+
const filePath = fileURLToPath(new URL("../src-guest-guestMain.js?esm", import.meta.url));
|
|
837
|
+
const code = readFileSync(filePath, "utf8");
|
|
838
|
+
if (!filePath.endsWith(".ts")) return _asGuestScript(code);
|
|
839
|
+
const ts = createRequire(import.meta.url)("typescript");
|
|
840
|
+
const transpiled = ts.transpileModule(code, { compilerOptions: {
|
|
841
|
+
target: ts.ScriptTarget.ES2022,
|
|
842
|
+
module: ts.ModuleKind.ESNext
|
|
843
|
+
} }).outputText;
|
|
844
|
+
return _asGuestScript(transpiled);
|
|
845
|
+
}
|
|
846
|
+
function _asGuestScript(code) {
|
|
847
|
+
return code.replace(/^\s*export\s*\{\s*\};?\s*$/m, "");
|
|
848
|
+
}
|
|
849
|
+
const GUEST_RUNTIME_JS = _loadGuestRuntime();
|
|
850
|
+
const DEFAULT_FOREGROUND_MS = 5e3;
|
|
851
|
+
const DEFAULT_MAX_LIFETIME_MS = 12e4;
|
|
852
|
+
const DEFAULT_MEMORY_LIMIT_BYTES = 33554432;
|
|
853
|
+
/**
|
|
854
|
+
* Grace window granted to the guest after a soft abort (cancel / max
|
|
855
|
+
* lifetime) so it can run `catch`/`finally` and return a partial result
|
|
856
|
+
* before the task is force-settled.
|
|
857
|
+
*/
|
|
858
|
+
const SOFT_CANCEL_GRACE_MS = 200;
|
|
859
|
+
let _quickjsPromise;
|
|
860
|
+
function _qjs() {
|
|
861
|
+
if (!_quickjsPromise) _quickjsPromise = getQuickJS();
|
|
862
|
+
return _quickjsPromise;
|
|
863
|
+
}
|
|
864
|
+
const _delay = () => new Promise((r) => setImmediate(r));
|
|
865
|
+
/**
|
|
866
|
+
* Run-to-completion entry point preserved for callers that just want a result
|
|
867
|
+
* and treat the timeout as a hard wall (the original behaviour). Throws on
|
|
868
|
+
* guest error or deadline; never parks.
|
|
869
|
+
*/
|
|
870
|
+
async function runSandboxed(userCode, host, lastResultVal, options = {}) {
|
|
871
|
+
const budget = options.timeoutMs ?? DEFAULT_FOREGROUND_MS;
|
|
872
|
+
const outcome = await (await SandboxExecution.create(userCode, host, lastResultVal, {
|
|
873
|
+
foregroundMs: budget,
|
|
874
|
+
maxLifetimeMs: budget,
|
|
875
|
+
memoryLimitBytes: options.memoryLimitBytes
|
|
876
|
+
})).runForeground();
|
|
877
|
+
if (outcome.status === "completed") return {
|
|
878
|
+
resultJson: outcome.result === void 0 ? "" : JSON.stringify(outcome.result),
|
|
879
|
+
logs: outcome.logs
|
|
880
|
+
};
|
|
881
|
+
if (outcome.status === "error") throw new Error(outcome.error);
|
|
882
|
+
const final = await outcome.task.cancel();
|
|
883
|
+
throw new Error(final.status === "error" ? final.error : "sandbox deadline exceeded");
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Parking-capable entry point. Runs `userCode` for up to `foregroundMs`; if it
|
|
887
|
+
* settles in that window the result is returned inline, otherwise the live VM
|
|
888
|
+
* is detached into a {@link ParkedTask} that keeps pumping in the background.
|
|
889
|
+
*/
|
|
890
|
+
async function startSandbox(userCode, host, lastResultVal, options = {}) {
|
|
891
|
+
return (await SandboxExecution.create(userCode, host, lastResultVal, {
|
|
892
|
+
foregroundMs: options.foregroundMs ?? DEFAULT_FOREGROUND_MS,
|
|
893
|
+
maxLifetimeMs: options.maxLifetimeMs ?? DEFAULT_MAX_LIFETIME_MS,
|
|
894
|
+
memoryLimitBytes: options.memoryLimitBytes,
|
|
895
|
+
label: options.label
|
|
896
|
+
})).runForeground();
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Encapsulates one QuickJS context and drives it through a foreground phase
|
|
900
|
+
* and (optionally) a background phase. Only one driver loop runs at a time:
|
|
901
|
+
* `runForeground` runs first and, if it parks, hands off to `_runBackground`.
|
|
902
|
+
* `cancel` cooperates with the background loop rather than starting its own.
|
|
903
|
+
*/
|
|
904
|
+
var SandboxExecution = class SandboxExecution {
|
|
905
|
+
_userCode;
|
|
906
|
+
_host;
|
|
907
|
+
_lastResultVal;
|
|
908
|
+
_options;
|
|
909
|
+
static async create(userCode, host, lastResultVal, options) {
|
|
910
|
+
const QuickJS = await _qjs();
|
|
911
|
+
const exec = new SandboxExecution(userCode, host, lastResultVal, options);
|
|
912
|
+
try {
|
|
913
|
+
exec._init(QuickJS);
|
|
914
|
+
} catch (e) {
|
|
915
|
+
exec._dispose();
|
|
916
|
+
throw e;
|
|
917
|
+
}
|
|
918
|
+
return exec;
|
|
919
|
+
}
|
|
920
|
+
_scope = new Scope();
|
|
921
|
+
_logs = [];
|
|
922
|
+
/** In-flight host->guest deferreds keyed by a human-readable call label. */
|
|
923
|
+
_pendingDeferreds = /* @__PURE__ */ new Map();
|
|
924
|
+
_pendingTimers = /* @__PURE__ */ new Map();
|
|
925
|
+
_vmState = {
|
|
926
|
+
alive: true,
|
|
927
|
+
softAborted: false,
|
|
928
|
+
softAbortReason: void 0
|
|
929
|
+
};
|
|
930
|
+
_abortController = new AbortController();
|
|
931
|
+
_cpuBurstMs;
|
|
932
|
+
_startedAt = Date.now();
|
|
933
|
+
_runtime;
|
|
934
|
+
_vm;
|
|
935
|
+
_promiseH;
|
|
936
|
+
/** Deadline for the current synchronous burst; read by the interrupt handler. */
|
|
937
|
+
_cpuDeadline = 0;
|
|
938
|
+
_disposed = false;
|
|
939
|
+
_settled;
|
|
940
|
+
_backgroundDone;
|
|
941
|
+
_cancelGraceDeadline;
|
|
942
|
+
constructor(_userCode, _host, _lastResultVal, _options) {
|
|
943
|
+
this._userCode = _userCode;
|
|
944
|
+
this._host = _host;
|
|
945
|
+
this._lastResultVal = _lastResultVal;
|
|
946
|
+
this._options = _options;
|
|
947
|
+
this._cpuBurstMs = Math.max(_options.foregroundMs, 50);
|
|
948
|
+
}
|
|
949
|
+
get _allowPark() {
|
|
950
|
+
return this._options.maxLifetimeMs > this._options.foregroundMs;
|
|
951
|
+
}
|
|
952
|
+
_init(QuickJS) {
|
|
953
|
+
const runtime = this._scope.manage(QuickJS.newRuntime());
|
|
954
|
+
runtime.setMemoryLimit(this._options.memoryLimitBytes ?? DEFAULT_MEMORY_LIMIT_BYTES);
|
|
955
|
+
runtime.setInterruptHandler(() => Date.now() > this._cpuDeadline);
|
|
956
|
+
this._runtime = runtime;
|
|
957
|
+
this._vm = this._scope.manage(runtime.newContext());
|
|
958
|
+
this._cpuDeadline = Date.now() + this._cpuBurstMs;
|
|
959
|
+
this._installHostApi();
|
|
960
|
+
_evalPrelude(this._vm, this._lastResultVal);
|
|
961
|
+
}
|
|
962
|
+
async runForeground() {
|
|
963
|
+
this._cpuDeadline = Date.now() + this._cpuBurstMs;
|
|
964
|
+
const evalRes = this._vm.evalCode(_wrapUserCode(this._userCode));
|
|
965
|
+
if (evalRes.error) {
|
|
966
|
+
const dumped = this._vm.dump(evalRes.error);
|
|
967
|
+
evalRes.error.dispose();
|
|
968
|
+
this._forceSettle({
|
|
969
|
+
status: "error",
|
|
970
|
+
error: _formatGuestError(dumped),
|
|
971
|
+
logs: this._logs.slice()
|
|
972
|
+
});
|
|
973
|
+
return this._terminalOutcome();
|
|
974
|
+
}
|
|
975
|
+
this._promiseH = evalRes.value;
|
|
976
|
+
this._pump();
|
|
977
|
+
if (await this._loopUntil(Date.now() + this._options.foregroundMs) === "settled") return this._terminalOutcome();
|
|
978
|
+
if (!this._allowPark) {
|
|
979
|
+
this._triggerSoftAbort("sandbox deadline exceeded");
|
|
980
|
+
await this._loopUntil(Date.now() + SOFT_CANCEL_GRACE_MS);
|
|
981
|
+
if (!this._settled) this._forceSettle({
|
|
982
|
+
status: "error",
|
|
983
|
+
error: "sandbox deadline exceeded",
|
|
984
|
+
logs: this._logs.slice()
|
|
985
|
+
});
|
|
986
|
+
return this._terminalOutcome();
|
|
987
|
+
}
|
|
988
|
+
const debugName = this._computeDebugName();
|
|
989
|
+
this._backgroundDone = this._runBackground();
|
|
990
|
+
return {
|
|
991
|
+
status: "parked",
|
|
992
|
+
debugName,
|
|
993
|
+
task: this._asParkedTask()
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
async _runBackground() {
|
|
997
|
+
const hardDeadline = this._startedAt + this._options.maxLifetimeMs;
|
|
998
|
+
while (true) {
|
|
999
|
+
if (this._settled || this._checkPromise()) break;
|
|
1000
|
+
const now = Date.now();
|
|
1001
|
+
if (this._cancelGraceDeadline !== void 0 && now > this._cancelGraceDeadline) {
|
|
1002
|
+
this._forceSettle({
|
|
1003
|
+
status: "cancelled",
|
|
1004
|
+
logs: this._logs.slice()
|
|
1005
|
+
});
|
|
1006
|
+
break;
|
|
1007
|
+
}
|
|
1008
|
+
if (now > hardDeadline) {
|
|
1009
|
+
this._triggerSoftAbort("max lifetime exceeded");
|
|
1010
|
+
this._forceSettle({
|
|
1011
|
+
status: "error",
|
|
1012
|
+
error: "sandbox max lifetime exceeded",
|
|
1013
|
+
logs: this._logs.slice()
|
|
1014
|
+
});
|
|
1015
|
+
break;
|
|
1016
|
+
}
|
|
1017
|
+
await _delay();
|
|
1018
|
+
this._pump();
|
|
1019
|
+
}
|
|
1020
|
+
return this._settled;
|
|
1021
|
+
}
|
|
1022
|
+
/** Pump + poll the user promise until it settles or `deadline` passes. */
|
|
1023
|
+
async _loopUntil(deadline) {
|
|
1024
|
+
while (true) {
|
|
1025
|
+
if (this._settled || this._checkPromise()) return "settled";
|
|
1026
|
+
if (Date.now() > deadline) return "pending";
|
|
1027
|
+
await _delay();
|
|
1028
|
+
this._pump();
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
/** Returns true once the user promise has settled (and disposes the VM). */
|
|
1032
|
+
_checkPromise() {
|
|
1033
|
+
if (this._settled) return true;
|
|
1034
|
+
if (!this._vmState.alive || !this._promiseH) return true;
|
|
1035
|
+
const state = this._vm.getPromiseState(this._promiseH);
|
|
1036
|
+
if (state.type === "fulfilled") {
|
|
1037
|
+
let json;
|
|
1038
|
+
try {
|
|
1039
|
+
json = this._vm.getString(state.value);
|
|
1040
|
+
} finally {
|
|
1041
|
+
state.value.dispose();
|
|
1042
|
+
}
|
|
1043
|
+
const result = json === "" ? void 0 : JSON.parse(json);
|
|
1044
|
+
this._forceSettle({
|
|
1045
|
+
status: "completed",
|
|
1046
|
+
result,
|
|
1047
|
+
logs: this._logs.slice()
|
|
1048
|
+
});
|
|
1049
|
+
return true;
|
|
1050
|
+
}
|
|
1051
|
+
if (state.type === "rejected") {
|
|
1052
|
+
const dumped = this._vm.dump(state.error);
|
|
1053
|
+
state.error.dispose();
|
|
1054
|
+
const e = _toError(dumped);
|
|
1055
|
+
this._settleError(e.message, _formatGuestError(dumped));
|
|
1056
|
+
return true;
|
|
1057
|
+
}
|
|
1058
|
+
return false;
|
|
1059
|
+
}
|
|
1060
|
+
_settleError(message, detail) {
|
|
1061
|
+
if (this._vmState.softAborted && /abort/i.test(message)) {
|
|
1062
|
+
this._forceSettle({
|
|
1063
|
+
status: "cancelled",
|
|
1064
|
+
logs: this._logs.slice()
|
|
1065
|
+
});
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
const normalized = /interrupt/i.test(message) ? "sandbox deadline exceeded" : detail ?? message;
|
|
1069
|
+
this._forceSettle({
|
|
1070
|
+
status: "error",
|
|
1071
|
+
error: normalized,
|
|
1072
|
+
logs: this._logs.slice()
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
_forceSettle(outcome) {
|
|
1076
|
+
if (this._settled) return;
|
|
1077
|
+
this._dispose();
|
|
1078
|
+
this._settled = outcome;
|
|
1079
|
+
}
|
|
1080
|
+
_pump() {
|
|
1081
|
+
if (!this._vmState.alive || this._disposed) return;
|
|
1082
|
+
this._cpuDeadline = Date.now() + this._cpuBurstMs;
|
|
1083
|
+
const r = this._runtime.executePendingJobs();
|
|
1084
|
+
if (r.error) {
|
|
1085
|
+
const dumped = this._vm.dump(r.error);
|
|
1086
|
+
r.error.dispose();
|
|
1087
|
+
const e = _toError(dumped);
|
|
1088
|
+
this._settleError(e.message, _formatGuestError(dumped));
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
_computeDebugName() {
|
|
1092
|
+
if (this._options.label) return this._options.label;
|
|
1093
|
+
const inflight = this._inFlight();
|
|
1094
|
+
if (inflight.length > 0) return inflight.join(", ");
|
|
1095
|
+
return _firstLine(this._userCode);
|
|
1096
|
+
}
|
|
1097
|
+
_inFlight() {
|
|
1098
|
+
return [...this._pendingDeferreds.values(), ...[...this._pendingTimers.values()].map((timer) => `${timer.repeat ? "setInterval" : "setTimeout"}(${timer.delayMs}ms)`)];
|
|
1099
|
+
}
|
|
1100
|
+
_asParkedTask() {
|
|
1101
|
+
return {
|
|
1102
|
+
inFlight: () => this._inFlight(),
|
|
1103
|
+
logs: this._logs,
|
|
1104
|
+
done: this._backgroundDone,
|
|
1105
|
+
cancel: () => this.cancel()
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
async cancel() {
|
|
1109
|
+
if (this._settled) return this._settled;
|
|
1110
|
+
this._triggerSoftAbort("cancelled");
|
|
1111
|
+
this._cancelGraceDeadline = Date.now() + SOFT_CANCEL_GRACE_MS;
|
|
1112
|
+
if (this._backgroundDone) return this._backgroundDone;
|
|
1113
|
+
this._forceSettle({
|
|
1114
|
+
status: "cancelled",
|
|
1115
|
+
logs: this._logs.slice()
|
|
1116
|
+
});
|
|
1117
|
+
return this._settled;
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Flip the guest-visible `con.abortSignal.aborted` flag and reject every
|
|
1121
|
+
* in-flight host promise with an `AbortError` naming the call.
|
|
1122
|
+
*/
|
|
1123
|
+
_triggerSoftAbort(reason) {
|
|
1124
|
+
if (this._vmState.softAborted || !this._vmState.alive) return;
|
|
1125
|
+
this._vmState.softAborted = true;
|
|
1126
|
+
this._vmState.softAbortReason = reason;
|
|
1127
|
+
this._abortController.abort(reason);
|
|
1128
|
+
this._clearAllTimers();
|
|
1129
|
+
this._cpuDeadline = Date.now() + this._cpuBurstMs;
|
|
1130
|
+
const setFlag = this._vm.evalCode(`(() => {
|
|
1131
|
+
if (globalThis.con && globalThis.con.abortSignal) {
|
|
1132
|
+
globalThis.con.abortSignal.aborted = true;
|
|
1133
|
+
globalThis.con.abortSignal.reason = ${JSON.stringify(reason)};
|
|
1134
|
+
}
|
|
1135
|
+
})()`);
|
|
1136
|
+
if (setFlag.error) setFlag.error.dispose();
|
|
1137
|
+
else setFlag.value.dispose();
|
|
1138
|
+
const entries = [...this._pendingDeferreds];
|
|
1139
|
+
this._pendingDeferreds.clear();
|
|
1140
|
+
for (const [d, label] of entries) {
|
|
1141
|
+
if (!d.alive) continue;
|
|
1142
|
+
this._vm.newError(`AbortError: ${reason} (cancelled in-flight: ${label})`).consume((errH) => d.reject(errH));
|
|
1143
|
+
}
|
|
1144
|
+
this._pump();
|
|
1145
|
+
}
|
|
1146
|
+
_dispose() {
|
|
1147
|
+
if (this._disposed) return;
|
|
1148
|
+
this._disposed = true;
|
|
1149
|
+
this._vmState.alive = false;
|
|
1150
|
+
if (!this._abortController.signal.aborted) this._abortController.abort("sandbox disposed");
|
|
1151
|
+
this._clearAllTimers();
|
|
1152
|
+
for (const d of this._pendingDeferreds.keys()) if (d.alive) d.dispose();
|
|
1153
|
+
this._pendingDeferreds.clear();
|
|
1154
|
+
if (this._promiseH && this._promiseH.alive) this._promiseH.dispose();
|
|
1155
|
+
this._scope.dispose();
|
|
1156
|
+
}
|
|
1157
|
+
_terminalOutcome() {
|
|
1158
|
+
const o = this._settled;
|
|
1159
|
+
if (!o) throw new Error("sandbox: terminal outcome requested before settling");
|
|
1160
|
+
if (o.status === "completed") return {
|
|
1161
|
+
status: "completed",
|
|
1162
|
+
result: o.result,
|
|
1163
|
+
logs: o.logs
|
|
1164
|
+
};
|
|
1165
|
+
if (o.status === "cancelled") return {
|
|
1166
|
+
status: "error",
|
|
1167
|
+
error: "sandbox cancelled",
|
|
1168
|
+
logs: o.logs
|
|
1169
|
+
};
|
|
1170
|
+
return {
|
|
1171
|
+
status: "error",
|
|
1172
|
+
error: o.error,
|
|
1173
|
+
logs: o.logs
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
_installHostApi() {
|
|
1177
|
+
this._registerAsyncHostFn("__hostCall", (method, paramsJson, optsJson, emit) => this._host.call(method, paramsJson, optsJson, emit, this._abortController.signal), (method) => `con.call(${JSON.stringify(method)})`, "__dispatchHostStream");
|
|
1178
|
+
this._registerAsyncHostFn("__hostNotify", (m, p) => this._host.notify(m, p), (method) => `con.notify(${JSON.stringify(method)})`);
|
|
1179
|
+
this._registerAsyncHostFn("__hostExplore", (a) => this._host.explore(a), () => `con.explore(...)`);
|
|
1180
|
+
this._registerAsyncHostFn("__hostRequestAccess", (a) => this._host.requestAccess(a), () => `con.requestAccess(...)`);
|
|
1181
|
+
this._registerAsyncHostFn("__hostGrants", (a) => this._host.grants(a), () => `con.grants()`);
|
|
1182
|
+
this._vm.newFunction("__hostLog", (levelH, textH) => {
|
|
1183
|
+
const level = this._vm.getString(levelH);
|
|
1184
|
+
const text = textH ? this._vm.getString(textH) : "";
|
|
1185
|
+
this._logs.push({
|
|
1186
|
+
level,
|
|
1187
|
+
text
|
|
1188
|
+
});
|
|
1189
|
+
}).consume((fn) => this._vm.setProp(this._vm.global, "__hostLog", fn));
|
|
1190
|
+
this._vm.newFunction("__hostSetTimer", (idH, delayH, repeatH) => {
|
|
1191
|
+
this._setTimer(this._vm.getNumber(idH), this._vm.getNumber(delayH), this._vm.getNumber(repeatH) === 1);
|
|
1192
|
+
}).consume((fn) => this._vm.setProp(this._vm.global, "__hostSetTimer", fn));
|
|
1193
|
+
this._vm.newFunction("__hostClearTimer", (idH) => {
|
|
1194
|
+
this._clearTimer(this._vm.getNumber(idH));
|
|
1195
|
+
}).consume((fn) => this._vm.setProp(this._vm.global, "__hostClearTimer", fn));
|
|
1196
|
+
}
|
|
1197
|
+
_setTimer(timerId, delayMs, repeat) {
|
|
1198
|
+
this._clearTimer(timerId);
|
|
1199
|
+
if (!this._vmState.alive || this._vmState.softAborted) return;
|
|
1200
|
+
const normalizedDelay = Math.min(Math.max(0, Math.floor(delayMs)), 2147483647);
|
|
1201
|
+
const callback = () => {
|
|
1202
|
+
if (!this._vmState.alive || this._disposed) return;
|
|
1203
|
+
if (!repeat) this._pendingTimers.delete(timerId);
|
|
1204
|
+
this._dispatchGuestFunction("__dispatchHostTimer", timerId);
|
|
1205
|
+
};
|
|
1206
|
+
const handle = repeat ? setInterval(callback, normalizedDelay) : setTimeout(callback, normalizedDelay);
|
|
1207
|
+
this._pendingTimers.set(timerId, {
|
|
1208
|
+
handle,
|
|
1209
|
+
repeat,
|
|
1210
|
+
delayMs: normalizedDelay
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
_clearTimer(timerId) {
|
|
1214
|
+
const timer = this._pendingTimers.get(timerId);
|
|
1215
|
+
if (!timer) return;
|
|
1216
|
+
if (timer.repeat) clearInterval(timer.handle);
|
|
1217
|
+
else clearTimeout(timer.handle);
|
|
1218
|
+
this._pendingTimers.delete(timerId);
|
|
1219
|
+
}
|
|
1220
|
+
_clearAllTimers() {
|
|
1221
|
+
for (const timerId of [...this._pendingTimers.keys()]) this._clearTimer(timerId);
|
|
1222
|
+
}
|
|
1223
|
+
_dispatchGuestFunction(name, ...args) {
|
|
1224
|
+
if (!this._vmState.alive || this._disposed || this._settled) return;
|
|
1225
|
+
this._cpuDeadline = Date.now() + this._cpuBurstMs;
|
|
1226
|
+
const result = this._vm.evalCode(`globalThis[${JSON.stringify(name)}](...${JSON.stringify(args)})`);
|
|
1227
|
+
if (result.error) {
|
|
1228
|
+
const dumped = this._vm.dump(result.error);
|
|
1229
|
+
result.error.dispose();
|
|
1230
|
+
const error = _toError(dumped);
|
|
1231
|
+
this._settleError(error.message, _formatGuestError(dumped));
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
result.value.dispose();
|
|
1235
|
+
this._pump();
|
|
1236
|
+
}
|
|
1237
|
+
_registerAsyncHostFn(name, impl, label, guestEventHandler) {
|
|
1238
|
+
this._vm.newFunction(name, (aH, bH, cH, eventIdH) => {
|
|
1239
|
+
const a = aH ? this._vm.getString(aH) : "";
|
|
1240
|
+
const b = bH ? this._vm.getString(bH) : "";
|
|
1241
|
+
const c = cH ? this._vm.getString(cH) : "";
|
|
1242
|
+
const eventId = eventIdH ? this._vm.getString(eventIdH) : "";
|
|
1243
|
+
const deferred = this._vm.newPromise();
|
|
1244
|
+
if (this._vmState.softAborted) {
|
|
1245
|
+
const reason = this._vmState.softAbortReason ?? "soft deadline reached";
|
|
1246
|
+
this._vm.newError(`AbortError: ${reason} (refused: ${label(a, b, c)})`).consume((errH) => deferred.reject(errH));
|
|
1247
|
+
deferred.settled.then(() => this._pump());
|
|
1248
|
+
return deferred.handle;
|
|
1249
|
+
}
|
|
1250
|
+
this._pendingDeferreds.set(deferred, label(a, b, c));
|
|
1251
|
+
impl(a, b, c, (payloadJson) => {
|
|
1252
|
+
if (guestEventHandler && eventId !== "") this._dispatchGuestFunction(guestEventHandler, eventId, payloadJson);
|
|
1253
|
+
}).then((value) => {
|
|
1254
|
+
this._pendingDeferreds.delete(deferred);
|
|
1255
|
+
if (!this._vmState.alive || !deferred.alive) return;
|
|
1256
|
+
this._vm.newString(value).consume((sH) => deferred.resolve(sH));
|
|
1257
|
+
}, (err) => {
|
|
1258
|
+
this._pendingDeferreds.delete(deferred);
|
|
1259
|
+
if (!this._vmState.alive || !deferred.alive) return;
|
|
1260
|
+
this._newGuestHostError(err).consume((errH) => deferred.reject(errH));
|
|
1261
|
+
});
|
|
1262
|
+
deferred.settled.then(() => this._pump());
|
|
1263
|
+
return deferred.handle;
|
|
1264
|
+
}).consume((fn) => this._vm.setProp(this._vm.global, name, fn));
|
|
1265
|
+
}
|
|
1266
|
+
_newGuestHostError(thrown) {
|
|
1267
|
+
const snapshot = _snapshotHostRejection(thrown);
|
|
1268
|
+
const source = `(() => {
|
|
1269
|
+
const error = new Error(${JSON.stringify(snapshot.summary)});
|
|
1270
|
+
const properties = JSON.parse(${JSON.stringify(JSON.stringify(snapshot.properties))});
|
|
1271
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
1272
|
+
Object.defineProperty(error, key, {
|
|
1273
|
+
value,
|
|
1274
|
+
writable: true,
|
|
1275
|
+
enumerable: true,
|
|
1276
|
+
configurable: true,
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
return error;
|
|
1280
|
+
})()`;
|
|
1281
|
+
const result = this._vm.evalCode(source);
|
|
1282
|
+
if (result.error) {
|
|
1283
|
+
result.error.dispose();
|
|
1284
|
+
return this._vm.newError(snapshot.summary);
|
|
1285
|
+
}
|
|
1286
|
+
return result.value;
|
|
1287
|
+
}
|
|
1288
|
+
};
|
|
1289
|
+
const MAX_REJECTION_DEPTH = 8;
|
|
1290
|
+
const MAX_REJECTION_PROPERTIES = 100;
|
|
1291
|
+
const MAX_REJECTION_ARRAY_ITEMS = 100;
|
|
1292
|
+
const MAX_REJECTION_STRING_LENGTH = 2e4;
|
|
1293
|
+
const MAX_REJECTION_NODES = 1e3;
|
|
1294
|
+
const MAX_REJECTION_TOTAL_STRING_LENGTH = 1e5;
|
|
1295
|
+
function _snapshotHostRejection(thrown) {
|
|
1296
|
+
return {
|
|
1297
|
+
summary: _rejectionSummary(thrown),
|
|
1298
|
+
properties: _snapshotOwnDataProperties(thrown)
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
function _rejectionSummary(thrown) {
|
|
1302
|
+
if (thrown === null) return "Host operation rejected with null";
|
|
1303
|
+
if (typeof thrown !== "object" && typeof thrown !== "function") return `Host operation rejected with ${String(thrown)}`;
|
|
1304
|
+
return "Host operation failed";
|
|
1305
|
+
}
|
|
1306
|
+
function _snapshotOwnDataProperties(value) {
|
|
1307
|
+
if ((typeof value !== "object" || value === null) && typeof value !== "function") return { value: _snapshotBridgeValue(value, /* @__PURE__ */ new WeakSet(), 0, _newSnapshotBudget()) };
|
|
1308
|
+
let descriptors;
|
|
1309
|
+
try {
|
|
1310
|
+
descriptors = Object.getOwnPropertyDescriptors(value);
|
|
1311
|
+
} catch {
|
|
1312
|
+
return {};
|
|
1313
|
+
}
|
|
1314
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
1315
|
+
seen.add(value);
|
|
1316
|
+
const budget = _newSnapshotBudget();
|
|
1317
|
+
const result = {};
|
|
1318
|
+
for (const [key, descriptor] of Object.entries(descriptors).slice(0, MAX_REJECTION_PROPERTIES)) {
|
|
1319
|
+
if (!("value" in descriptor)) continue;
|
|
1320
|
+
result[key] = _snapshotBridgeValue(descriptor.value, seen, 0, budget);
|
|
1321
|
+
}
|
|
1322
|
+
return result;
|
|
1323
|
+
}
|
|
1324
|
+
function _newSnapshotBudget() {
|
|
1325
|
+
return {
|
|
1326
|
+
nodes: MAX_REJECTION_NODES,
|
|
1327
|
+
stringChars: MAX_REJECTION_TOTAL_STRING_LENGTH
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
function _snapshotBridgeValue(value, seen, depth, budget) {
|
|
1331
|
+
if (budget.nodes <= 0) return "[Truncated]";
|
|
1332
|
+
budget.nodes--;
|
|
1333
|
+
if (value === null || typeof value === "boolean" || typeof value === "number") return value;
|
|
1334
|
+
if (typeof value === "string") {
|
|
1335
|
+
const available = Math.min(MAX_REJECTION_STRING_LENGTH, budget.stringChars);
|
|
1336
|
+
budget.stringChars -= Math.min(value.length, available);
|
|
1337
|
+
return value.length <= available ? value : `${value.slice(0, available)}…`;
|
|
1338
|
+
}
|
|
1339
|
+
if (typeof value === "undefined") return "[undefined]";
|
|
1340
|
+
if (typeof value === "bigint") return `${value}n`;
|
|
1341
|
+
if (typeof value === "symbol") return String(value);
|
|
1342
|
+
if (typeof value === "function") return `[Function${value.name ? `: ${value.name}` : ""}]`;
|
|
1343
|
+
if (depth >= MAX_REJECTION_DEPTH) return "[Truncated]";
|
|
1344
|
+
if (seen.has(value)) return "[Circular]";
|
|
1345
|
+
seen.add(value);
|
|
1346
|
+
if (Array.isArray(value)) return value.slice(0, MAX_REJECTION_ARRAY_ITEMS).map((item) => _snapshotBridgeValue(item, seen, depth + 1, budget));
|
|
1347
|
+
let descriptors;
|
|
1348
|
+
try {
|
|
1349
|
+
descriptors = Object.getOwnPropertyDescriptors(value);
|
|
1350
|
+
} catch {
|
|
1351
|
+
return "[Uninspectable]";
|
|
1352
|
+
}
|
|
1353
|
+
const result = {};
|
|
1354
|
+
for (const [key, descriptor] of Object.entries(descriptors).slice(0, MAX_REJECTION_PROPERTIES)) {
|
|
1355
|
+
if (!("value" in descriptor)) continue;
|
|
1356
|
+
result[key] = _snapshotBridgeValue(descriptor.value, seen, depth + 1, budget);
|
|
1357
|
+
}
|
|
1358
|
+
return result;
|
|
1359
|
+
}
|
|
1360
|
+
function _evalPrelude(vm, lastResultVal) {
|
|
1361
|
+
const header = `globalThis.__lastResultVal = JSON.parse(${JSON.stringify(JSON.stringify(lastResultVal ?? null))});\nglobalThis.__docs = ${JSON.stringify(CONNECTION_DTS)};\n`;
|
|
1362
|
+
const r = vm.evalCode(header + GUEST_RUNTIME_JS);
|
|
1363
|
+
if (r.error) {
|
|
1364
|
+
const err = vm.dump(r.error);
|
|
1365
|
+
r.error.dispose();
|
|
1366
|
+
throw new Error(`sandbox prelude failed: ${JSON.stringify(err)}`);
|
|
1367
|
+
}
|
|
1368
|
+
r.value.dispose();
|
|
1369
|
+
}
|
|
1370
|
+
function _wrapUserCode(userCode) {
|
|
1371
|
+
return `(async () => {
|
|
1372
|
+
const __userFn = (${userCode});
|
|
1373
|
+
if (typeof __userFn !== "function") {
|
|
1374
|
+
throw new Error("runLinkRpcScript: \`code\` must evaluate to a function, got " + typeof __userFn);
|
|
1375
|
+
}
|
|
1376
|
+
const __r = await __userFn({ con, lastResultVal: __lastResultVal, mcp });
|
|
1377
|
+
return __r === undefined ? "" : JSON.stringify(__r);
|
|
1378
|
+
})()`;
|
|
1379
|
+
}
|
|
1380
|
+
function _firstLine(code) {
|
|
1381
|
+
const line = code.split("\n", 1)[0].trim();
|
|
1382
|
+
return line.length > 80 ? `${line.slice(0, 79)}…` : line;
|
|
1383
|
+
}
|
|
1384
|
+
function _toError(dumped) {
|
|
1385
|
+
let json;
|
|
1386
|
+
try {
|
|
1387
|
+
json = JSON.stringify(dumped);
|
|
1388
|
+
} catch {
|
|
1389
|
+
json = String(dumped);
|
|
1390
|
+
}
|
|
1391
|
+
if (dumped && typeof dumped === "object") {
|
|
1392
|
+
const d = dumped;
|
|
1393
|
+
const message = typeof d.message === "string" && d.message.length > 0 ? d.message : `guest error (raw): ${json}`;
|
|
1394
|
+
const e = new Error(message);
|
|
1395
|
+
if (typeof d.stack === "string") e.stack = d.stack;
|
|
1396
|
+
return e;
|
|
1397
|
+
}
|
|
1398
|
+
return /* @__PURE__ */ new Error(`guest error: ${json}`);
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Format a dumped guest error as `message` plus its guest stack (when QuickJS
|
|
1402
|
+
* provided one). The host only forwards the `error` string to the caller, so
|
|
1403
|
+
* folding the stack in here is the only way the guest trace ever reaches them.
|
|
1404
|
+
*/
|
|
1405
|
+
function _formatGuestError(dumped) {
|
|
1406
|
+
const e = _toError(dumped);
|
|
1407
|
+
const stack = e.stack;
|
|
1408
|
+
if (typeof stack === "string" && stack.length > 0) return stack.includes(e.message) ? stack : `${e.message}\n${stack}`;
|
|
1409
|
+
return e.message;
|
|
1410
|
+
}
|
|
1411
|
+
//#endregion
|
|
1412
|
+
//#region src/taskRegistry.ts
|
|
1413
|
+
/**
|
|
1414
|
+
* Tracks parked sandbox tasks. At most one task is "live" at a time across all
|
|
1415
|
+
* connections: starting a new one supersedes (cancels) the previous one via
|
|
1416
|
+
* {@link cancelLive}. Settled tasks are retained so their result can still be
|
|
1417
|
+
* fetched by id, and pruned opportunistically.
|
|
1418
|
+
*/
|
|
1419
|
+
var TaskRegistry = class {
|
|
1420
|
+
_maxSettledAgeMs;
|
|
1421
|
+
_byId = /* @__PURE__ */ new Map();
|
|
1422
|
+
_live;
|
|
1423
|
+
_seq = 0;
|
|
1424
|
+
constructor(_maxSettledAgeMs = 3e5) {
|
|
1425
|
+
this._maxSettledAgeMs = _maxSettledAgeMs;
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* Register a freshly parked task. Assigns a task id and wires up settlement
|
|
1429
|
+
* bookkeeping. `onComplete` fires with the result when the task completes
|
|
1430
|
+
* (used to thread `lastResultVal`).
|
|
1431
|
+
*/
|
|
1432
|
+
register(endpoint, debugName, task, onComplete) {
|
|
1433
|
+
this._prune();
|
|
1434
|
+
const id = `t${++this._seq}`;
|
|
1435
|
+
const entry = {
|
|
1436
|
+
id,
|
|
1437
|
+
debugName,
|
|
1438
|
+
endpoint,
|
|
1439
|
+
startedAt: Date.now(),
|
|
1440
|
+
task,
|
|
1441
|
+
outcome: void 0
|
|
1442
|
+
};
|
|
1443
|
+
this._byId.set(id, entry);
|
|
1444
|
+
this._live = entry;
|
|
1445
|
+
task.done.then((o) => {
|
|
1446
|
+
entry.outcome = o;
|
|
1447
|
+
if (this._live === entry) this._live = void 0;
|
|
1448
|
+
if (o.status === "completed") onComplete?.(o.result);
|
|
1449
|
+
}, () => {});
|
|
1450
|
+
return id;
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Cancel the single live task, if any (the supersede path). Resolves once
|
|
1454
|
+
* the cancellation settles.
|
|
1455
|
+
*/
|
|
1456
|
+
async cancelLive() {
|
|
1457
|
+
const entry = this._live;
|
|
1458
|
+
if (!entry) return void 0;
|
|
1459
|
+
this._live = void 0;
|
|
1460
|
+
await entry.task.cancel();
|
|
1461
|
+
return {
|
|
1462
|
+
taskId: entry.id,
|
|
1463
|
+
debugName: entry.debugName,
|
|
1464
|
+
outcome: "cancelled"
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Wait up to `timeoutMs` for `taskId` to settle. Returns `running` (with a
|
|
1469
|
+
* progress snapshot) if it is still going, otherwise its terminal result.
|
|
1470
|
+
*/
|
|
1471
|
+
async awaitTask(taskId, timeoutMs) {
|
|
1472
|
+
const entry = this._byId.get(taskId);
|
|
1473
|
+
if (!entry) return {
|
|
1474
|
+
status: "unknown",
|
|
1475
|
+
taskId
|
|
1476
|
+
};
|
|
1477
|
+
if (!entry.outcome) {
|
|
1478
|
+
const timeout = new Promise((r) => {
|
|
1479
|
+
setTimeout(() => r("timeout"), timeoutMs).unref?.();
|
|
1480
|
+
});
|
|
1481
|
+
if (await Promise.race([entry.task.done, timeout]) === "timeout") return {
|
|
1482
|
+
status: "running",
|
|
1483
|
+
taskId,
|
|
1484
|
+
debugName: entry.debugName,
|
|
1485
|
+
inFlight: entry.task.inFlight(),
|
|
1486
|
+
logs: [...entry.task.logs]
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
return this._outcomeResult(entry);
|
|
1490
|
+
}
|
|
1491
|
+
/** Explicitly cancel a task by id. */
|
|
1492
|
+
async cancel(taskId) {
|
|
1493
|
+
const entry = this._byId.get(taskId);
|
|
1494
|
+
if (!entry) return {
|
|
1495
|
+
status: "unknown",
|
|
1496
|
+
taskId
|
|
1497
|
+
};
|
|
1498
|
+
await entry.task.cancel();
|
|
1499
|
+
return this._outcomeResult(entry);
|
|
1500
|
+
}
|
|
1501
|
+
/** List live (unsettled) tasks. At most one under the global invariant. */
|
|
1502
|
+
list() {
|
|
1503
|
+
const out = [];
|
|
1504
|
+
for (const e of this._byId.values()) {
|
|
1505
|
+
if (e.outcome) continue;
|
|
1506
|
+
out.push({
|
|
1507
|
+
taskId: e.id,
|
|
1508
|
+
debugName: e.debugName,
|
|
1509
|
+
endpoint: e.endpoint,
|
|
1510
|
+
ageMs: Date.now() - e.startedAt,
|
|
1511
|
+
inFlight: e.task.inFlight(),
|
|
1512
|
+
logCount: e.task.logs.length
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
return out;
|
|
1516
|
+
}
|
|
1517
|
+
/** Cancel every live task. */
|
|
1518
|
+
dispose() {
|
|
1519
|
+
for (const e of this._byId.values()) if (!e.outcome) e.task.cancel();
|
|
1520
|
+
this._byId.clear();
|
|
1521
|
+
this._live = void 0;
|
|
1522
|
+
}
|
|
1523
|
+
_outcomeResult(entry) {
|
|
1524
|
+
const o = entry.outcome;
|
|
1525
|
+
if (!o) return {
|
|
1526
|
+
status: "running",
|
|
1527
|
+
taskId: entry.id,
|
|
1528
|
+
debugName: entry.debugName,
|
|
1529
|
+
inFlight: entry.task.inFlight(),
|
|
1530
|
+
logs: [...entry.task.logs]
|
|
1531
|
+
};
|
|
1532
|
+
if (o.status === "completed") return {
|
|
1533
|
+
status: "completed",
|
|
1534
|
+
taskId: entry.id,
|
|
1535
|
+
debugName: entry.debugName,
|
|
1536
|
+
result: o.result,
|
|
1537
|
+
logs: o.logs
|
|
1538
|
+
};
|
|
1539
|
+
if (o.status === "cancelled") return {
|
|
1540
|
+
status: "cancelled",
|
|
1541
|
+
taskId: entry.id,
|
|
1542
|
+
debugName: entry.debugName,
|
|
1543
|
+
logs: o.logs
|
|
1544
|
+
};
|
|
1545
|
+
return {
|
|
1546
|
+
status: "error",
|
|
1547
|
+
taskId: entry.id,
|
|
1548
|
+
debugName: entry.debugName,
|
|
1549
|
+
error: o.error,
|
|
1550
|
+
logs: o.logs
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
_prune() {
|
|
1554
|
+
const cutoff = Date.now() - this._maxSettledAgeMs;
|
|
1555
|
+
for (const [id, e] of this._byId) if (e.outcome && e.startedAt < cutoff) this._byId.delete(id);
|
|
1556
|
+
}
|
|
1557
|
+
};
|
|
1558
|
+
//#endregion
|
|
1559
|
+
//#region src/explore.ts
|
|
1560
|
+
const DEFAULT_LIMIT = 20;
|
|
1561
|
+
const MAX_LIMIT = 100;
|
|
1562
|
+
const MAX_MATCHES_PER_DOCUMENT = 20;
|
|
1563
|
+
const interfaceCache = /* @__PURE__ */ new WeakMap();
|
|
1564
|
+
/**
|
|
1565
|
+
* Browse the reflected directory, grep generated interface source, or inspect
|
|
1566
|
+
* one exact virtual document. Generated source is cached by the directory
|
|
1567
|
+
* route and interface hash; every call still walks the live directory.
|
|
1568
|
+
*/
|
|
1569
|
+
async function explore(channel, args, deps) {
|
|
1570
|
+
if (args === void 0 || typeof args !== "object" || !("kind" in args)) throw new Error("explore requires `kind: \"browse\" | \"grep\" | \"inspect\"`.");
|
|
1571
|
+
const unlockGatedDirectory = args.requestPermission === true && deps !== void 0 ? (_serviceId) => deps.requestReflectionAccess() : void 0;
|
|
1572
|
+
const walked = await walkHubDetailed(channel, { unlockGatedDirectory });
|
|
1573
|
+
const listings = _filterAndSort(walked.listings, args);
|
|
1574
|
+
const inaccessible = _inaccessible(walked.inaccessible);
|
|
1575
|
+
switch (args.kind) {
|
|
1576
|
+
case "browse": {
|
|
1577
|
+
const page = _page(listings, args.limit, args.cursor);
|
|
1578
|
+
return {
|
|
1579
|
+
kind: "browse",
|
|
1580
|
+
total: listings.length,
|
|
1581
|
+
entries: page.items.map(_toListing),
|
|
1582
|
+
...page.nextCursor !== void 0 ? { nextCursor: page.nextCursor } : {},
|
|
1583
|
+
...inaccessible !== void 0 ? { inaccessible } : {}
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
case "grep": {
|
|
1587
|
+
if (typeof args.pattern !== "string" || args.pattern.length === 0) throw new Error("explore grep requires a non-empty `pattern`.");
|
|
1588
|
+
const syntax = args.syntax ?? "regex";
|
|
1589
|
+
const matchesLine = _createLineMatcher(args.pattern, syntax);
|
|
1590
|
+
const contextLines = _boundedInteger(args.contextLines ?? 1, 0, 5, "contextLines");
|
|
1591
|
+
const loaded = await Promise.all(listings.map(async (listing) => {
|
|
1592
|
+
try {
|
|
1593
|
+
return { document: await _loadDocument(channel, listing) };
|
|
1594
|
+
} catch (error) {
|
|
1595
|
+
return { error: {
|
|
1596
|
+
serviceId: listing.serviceId,
|
|
1597
|
+
interfaceId: listing.interfaceId,
|
|
1598
|
+
error: error.message
|
|
1599
|
+
} };
|
|
1600
|
+
}
|
|
1601
|
+
}));
|
|
1602
|
+
const entries = [];
|
|
1603
|
+
const documentErrors = [];
|
|
1604
|
+
for (const item of loaded) {
|
|
1605
|
+
if (item.error !== void 0) {
|
|
1606
|
+
documentErrors.push(item.error);
|
|
1607
|
+
continue;
|
|
1608
|
+
}
|
|
1609
|
+
const document = item.document;
|
|
1610
|
+
const matches = _grepDocument(document, matchesLine, contextLines);
|
|
1611
|
+
if (matches.length === 0) continue;
|
|
1612
|
+
entries.push({
|
|
1613
|
+
..._toListing(document.listing),
|
|
1614
|
+
matches: matches.slice(0, MAX_MATCHES_PER_DOCUMENT),
|
|
1615
|
+
...matches.length > MAX_MATCHES_PER_DOCUMENT ? { matchesTruncated: true } : {}
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
const page = _page(entries, args.limit, args.cursor);
|
|
1619
|
+
return {
|
|
1620
|
+
kind: "grep",
|
|
1621
|
+
pattern: args.pattern,
|
|
1622
|
+
syntax,
|
|
1623
|
+
total: entries.length,
|
|
1624
|
+
entries: page.items,
|
|
1625
|
+
...page.nextCursor !== void 0 ? { nextCursor: page.nextCursor } : {},
|
|
1626
|
+
...documentErrors.length > 0 ? { documentErrors } : {},
|
|
1627
|
+
...inaccessible !== void 0 ? { inaccessible } : {}
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
case "inspect": {
|
|
1631
|
+
const listing = listings.find((item) => item.serviceId === args.serviceId && item.interfaceId === args.interfaceId);
|
|
1632
|
+
if (listing === void 0) throw new Error(`Interface not found: ${args.serviceId}::${args.interfaceId}. Browse first, or set \`requestPermission: true\` if its directory is gated.`);
|
|
1633
|
+
const document = await _loadDocument(channel, listing);
|
|
1634
|
+
const format = args.format ?? "source";
|
|
1635
|
+
return {
|
|
1636
|
+
kind: "inspect",
|
|
1637
|
+
format,
|
|
1638
|
+
..._toListing(listing),
|
|
1639
|
+
...format === "source" ? { source: document.source } : { schema: document.schema },
|
|
1640
|
+
...inaccessible !== void 0 ? { inaccessible } : {}
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
default: throw new Error(`Unknown explore kind: ${String(args.kind)}`);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
function _filterAndSort(listings, args) {
|
|
1647
|
+
const includeInternal = args.includeInternal === true || args.interfaceId !== void 0 && _isHubrpcInternalInterface(args.interfaceId);
|
|
1648
|
+
return listings.filter((listing) => {
|
|
1649
|
+
if (args.serviceId !== void 0 && listing.serviceId !== args.serviceId) return false;
|
|
1650
|
+
if (args.interfaceId !== void 0 && listing.interfaceId !== args.interfaceId) return false;
|
|
1651
|
+
return includeInternal || !_isHubrpcInternalInterface(listing.interfaceId);
|
|
1652
|
+
}).sort((a, b) => a.serviceId.localeCompare(b.serviceId) || a.interfaceId.localeCompare(b.interfaceId) || a.hash.localeCompare(b.hash));
|
|
1653
|
+
}
|
|
1654
|
+
function _toListing(listing) {
|
|
1655
|
+
return {
|
|
1656
|
+
serviceId: listing.serviceId,
|
|
1657
|
+
...listing.serviceDescription !== void 0 ? { serviceDescription: listing.serviceDescription } : {},
|
|
1658
|
+
interfaceId: listing.interfaceId,
|
|
1659
|
+
interfaceHash: listing.hash,
|
|
1660
|
+
documentId: _documentId(listing)
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
async function _loadDocument(channel, listing) {
|
|
1664
|
+
let cache = interfaceCache.get(channel);
|
|
1665
|
+
if (cache === void 0) {
|
|
1666
|
+
cache = /* @__PURE__ */ new Map();
|
|
1667
|
+
interfaceCache.set(channel, cache);
|
|
1668
|
+
}
|
|
1669
|
+
const cacheKey = `${listing.discoveredFrom}\u0000${listing.interfaceId}\u0000${listing.hash}`;
|
|
1670
|
+
let pending = cache.get(cacheKey);
|
|
1671
|
+
if (pending === void 0) {
|
|
1672
|
+
pending = (async () => {
|
|
1673
|
+
const target = listing.discoveredFrom || listing.serviceId || void 0;
|
|
1674
|
+
const schema = await fetchSchema(channel, listing.interfaceId, listing.hash, target);
|
|
1675
|
+
return {
|
|
1676
|
+
schema,
|
|
1677
|
+
generatedSource: generateTsInterface(schema)
|
|
1678
|
+
};
|
|
1679
|
+
})();
|
|
1680
|
+
cache.set(cacheKey, pending);
|
|
1681
|
+
}
|
|
1682
|
+
let cached;
|
|
1683
|
+
try {
|
|
1684
|
+
cached = await pending;
|
|
1685
|
+
} catch (error) {
|
|
1686
|
+
if (cache.get(cacheKey) === pending) cache.delete(cacheKey);
|
|
1687
|
+
throw error;
|
|
1688
|
+
}
|
|
1689
|
+
const documentId = _documentId(listing);
|
|
1690
|
+
return {
|
|
1691
|
+
...cached,
|
|
1692
|
+
listing,
|
|
1693
|
+
source: `${_documentHeader(listing, documentId)}\n${cached.generatedSource}`
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
1696
|
+
function _documentHeader(listing, documentId) {
|
|
1697
|
+
return [
|
|
1698
|
+
`// virtualDocument: ${JSON.stringify(documentId)}`,
|
|
1699
|
+
`// serviceId: ${JSON.stringify(listing.serviceId)}`,
|
|
1700
|
+
...listing.serviceDescription !== void 0 ? [`// serviceDescription: ${JSON.stringify(listing.serviceDescription)}`] : [],
|
|
1701
|
+
`// interfaceId: ${JSON.stringify(listing.interfaceId)}`,
|
|
1702
|
+
`// interfaceHash: ${JSON.stringify(listing.hash)}`,
|
|
1703
|
+
`// discoveredFrom: ${JSON.stringify(listing.discoveredFrom)}`
|
|
1704
|
+
].join("\n");
|
|
1705
|
+
}
|
|
1706
|
+
function _documentId(listing) {
|
|
1707
|
+
return `linkrpc://${listing.serviceId === "" ? "$root" : encodeURIComponent(listing.serviceId)}/${encodeURIComponent(listing.interfaceId)}@${listing.hash}.ts`;
|
|
1708
|
+
}
|
|
1709
|
+
function _grepDocument(document, matchesLine, contextLines) {
|
|
1710
|
+
const lines = document.source.split("\n");
|
|
1711
|
+
const memberAtLine = _memberMap(lines, document.schema);
|
|
1712
|
+
const ranges = [];
|
|
1713
|
+
for (let index = 0; index < lines.length; index++) {
|
|
1714
|
+
if (!matchesLine(lines[index])) continue;
|
|
1715
|
+
const start = Math.max(0, index - contextLines);
|
|
1716
|
+
const end = Math.min(lines.length, index + contextLines + 1);
|
|
1717
|
+
const previous = ranges.at(-1);
|
|
1718
|
+
if (previous !== void 0 && start <= previous.end) {
|
|
1719
|
+
previous.end = Math.max(previous.end, end);
|
|
1720
|
+
previous.matches.push(index);
|
|
1721
|
+
} else ranges.push({
|
|
1722
|
+
start,
|
|
1723
|
+
end,
|
|
1724
|
+
matches: [index]
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
return ranges.map((range) => {
|
|
1728
|
+
const members = new Set(range.matches.map((line) => memberAtLine[line]));
|
|
1729
|
+
const member = members.size === 1 ? members.values().next().value : void 0;
|
|
1730
|
+
return {
|
|
1731
|
+
searchResult: lines.slice(range.start, range.end).join("\n"),
|
|
1732
|
+
lineRange: [range.start + 1, range.end],
|
|
1733
|
+
...member !== void 0 ? { member } : {}
|
|
1734
|
+
};
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
function _memberMap(lines, schema) {
|
|
1738
|
+
const starts = [];
|
|
1739
|
+
let searchFrom = 0;
|
|
1740
|
+
for (const methodName of Object.keys(schema.methods)) {
|
|
1741
|
+
const barePrefix = `${methodName}: `;
|
|
1742
|
+
const quotedPrefix = `${JSON.stringify(methodName)}: `;
|
|
1743
|
+
const line = lines.findIndex((value, index) => index >= searchFrom && (value.trimStart().startsWith(barePrefix) || value.trimStart().startsWith(quotedPrefix)) && (value.includes("requestType(") || value.includes("notificationType(")));
|
|
1744
|
+
if (line >= 0) {
|
|
1745
|
+
starts.push({
|
|
1746
|
+
line,
|
|
1747
|
+
member: methodName
|
|
1748
|
+
});
|
|
1749
|
+
searchFrom = line + 1;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
const result = new Array(lines.length);
|
|
1753
|
+
for (let i = 0; i < starts.length; i++) {
|
|
1754
|
+
const end = starts[i + 1]?.line ?? lines.length;
|
|
1755
|
+
for (let line = starts[i].line; line < end; line++) result[line] = starts[i].member;
|
|
1756
|
+
}
|
|
1757
|
+
return result;
|
|
1758
|
+
}
|
|
1759
|
+
function _createLineMatcher(pattern, syntax) {
|
|
1760
|
+
if (syntax === "literal") {
|
|
1761
|
+
const needle = pattern.toLocaleLowerCase();
|
|
1762
|
+
return (line) => line.toLocaleLowerCase().includes(needle);
|
|
1763
|
+
}
|
|
1764
|
+
if (syntax !== "regex") throw new Error(`Unknown grep syntax: ${String(syntax)}`);
|
|
1765
|
+
let regex;
|
|
1766
|
+
try {
|
|
1767
|
+
regex = new RegExp(pattern, "iu");
|
|
1768
|
+
} catch (error) {
|
|
1769
|
+
throw new Error(`Invalid grep regular expression: ${error.message}`);
|
|
1770
|
+
}
|
|
1771
|
+
return (line) => regex.test(line);
|
|
1772
|
+
}
|
|
1773
|
+
function _page(items, requestedLimit, cursor) {
|
|
1774
|
+
const limit = _boundedInteger(requestedLimit ?? DEFAULT_LIMIT, 1, MAX_LIMIT, "limit");
|
|
1775
|
+
const offset = _decodeCursor(cursor);
|
|
1776
|
+
const page = items.slice(offset, offset + limit);
|
|
1777
|
+
const nextOffset = offset + page.length;
|
|
1778
|
+
return {
|
|
1779
|
+
items: page,
|
|
1780
|
+
...nextOffset < items.length ? { nextCursor: String(nextOffset) } : {}
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
function _decodeCursor(cursor) {
|
|
1784
|
+
if (cursor === void 0) return 0;
|
|
1785
|
+
if (!/^\d+$/.test(cursor)) throw new Error("Invalid explore cursor.");
|
|
1786
|
+
return Number(cursor);
|
|
1787
|
+
}
|
|
1788
|
+
function _boundedInteger(value, min, max, name) {
|
|
1789
|
+
if (!Number.isInteger(value) || value < min || value > max) throw new Error(`explore ${name} must be an integer from ${min} to ${max}.`);
|
|
1790
|
+
return value;
|
|
1791
|
+
}
|
|
1792
|
+
function _inaccessible(directories) {
|
|
1793
|
+
if (directories.length === 0) return void 0;
|
|
1794
|
+
return directories.map((directory) => ({
|
|
1795
|
+
serviceId: directory.serviceId,
|
|
1796
|
+
reason: directory.reason,
|
|
1797
|
+
hint: "Repeat this explore call with `requestPermission: true` to search all gated directories."
|
|
1798
|
+
}));
|
|
1799
|
+
}
|
|
1800
|
+
function _isHubrpcInternalInterface(interfaceId) {
|
|
1801
|
+
return interfaceId.startsWith("hubrpc.");
|
|
1802
|
+
}
|
|
1803
|
+
//#endregion
|
|
1804
|
+
//#region src/resultPresentation.ts
|
|
1805
|
+
const MCP_PRESENTATION_TAG = "__linkrpcMcpPresentationV1";
|
|
1806
|
+
/**
|
|
1807
|
+
* Convert a JSON tool-result envelope into MCP-native content while preserving a
|
|
1808
|
+
* JSON text fallback for clients that only consume text.
|
|
1809
|
+
*/
|
|
1810
|
+
function presentToolResult(envelope, mode = "auto") {
|
|
1811
|
+
if (mode === "raw") {
|
|
1812
|
+
const rawEnvelope = stripPresentationTags(envelope);
|
|
1813
|
+
const resultOptions = findExplicitResultOptions(envelope);
|
|
1814
|
+
return {
|
|
1815
|
+
content: [{
|
|
1816
|
+
type: "text",
|
|
1817
|
+
text: JSON.stringify(rawEnvelope, null, 2)
|
|
1818
|
+
}],
|
|
1819
|
+
structuredContent: rawEnvelope,
|
|
1820
|
+
...resultOptions
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
const state = { content: [] };
|
|
1824
|
+
const structuredContent = presentValue(envelope, "$", state);
|
|
1825
|
+
return {
|
|
1826
|
+
content: [{
|
|
1827
|
+
type: "text",
|
|
1828
|
+
text: JSON.stringify(structuredContent, null, 2)
|
|
1829
|
+
}, ...state.content],
|
|
1830
|
+
structuredContent,
|
|
1831
|
+
...state.isError !== void 0 ? { isError: state.isError } : {},
|
|
1832
|
+
...state.meta ? { _meta: state.meta } : {}
|
|
1833
|
+
};
|
|
1834
|
+
}
|
|
1835
|
+
/** Remove presentation-only wrappers before persisting a script result. */
|
|
1836
|
+
function scriptResultValue(value) {
|
|
1837
|
+
return stripPresentationTags(value);
|
|
1838
|
+
}
|
|
1839
|
+
function presentValue(value, path, state) {
|
|
1840
|
+
const tag = getPresentationTag(value);
|
|
1841
|
+
if (tag?.kind === "raw") return stripPresentationTags(tag.value);
|
|
1842
|
+
if (tag?.kind === "result") {
|
|
1843
|
+
if (tag.isError !== void 0) {
|
|
1844
|
+
if (typeof tag.isError !== "boolean") throw new Error(`Invalid MCP result isError at ${path}: expected boolean`);
|
|
1845
|
+
state.isError = tag.isError;
|
|
1846
|
+
}
|
|
1847
|
+
if (tag._meta !== void 0) {
|
|
1848
|
+
if (!isRecord(tag._meta)) throw new Error(`Invalid MCP result _meta at ${path}: expected object`);
|
|
1849
|
+
state.meta = tag._meta;
|
|
1850
|
+
}
|
|
1851
|
+
const presented = presentValue(Object.hasOwn(tag, "structuredContent") ? tag.structuredContent : Object.hasOwn(tag, "value") ? tag.value : null, path, state);
|
|
1852
|
+
const content = Array.isArray(tag.content) ? tag.content : [];
|
|
1853
|
+
for (let i = 0; i < content.length; i++) addExplicitContent(content[i], `${path}.content[${i}]`, state);
|
|
1854
|
+
return presented;
|
|
1855
|
+
}
|
|
1856
|
+
if (tag?.kind === "content") {
|
|
1857
|
+
const block = parseExplicitContent(tag.content, path);
|
|
1858
|
+
state.content.push(block);
|
|
1859
|
+
return describeContent(block, path);
|
|
1860
|
+
}
|
|
1861
|
+
const explicitContent = ContentBlockSchema.safeParse(value);
|
|
1862
|
+
if (explicitContent.success) {
|
|
1863
|
+
state.content.push(explicitContent.data);
|
|
1864
|
+
return describeContentWithMetadata(explicitContent.data, contentBlockMetadata(value, explicitContent.data), path, state);
|
|
1865
|
+
}
|
|
1866
|
+
const automaticContent = detectAutomaticContent(value);
|
|
1867
|
+
if (automaticContent) {
|
|
1868
|
+
state.content.push(automaticContent.block);
|
|
1869
|
+
return describeContentWithMetadata(automaticContent.block, automaticContent.metadata, path, state);
|
|
1870
|
+
}
|
|
1871
|
+
if (Array.isArray(value)) return value.map((item, index) => presentValue(item, `${path}[${index}]`, state));
|
|
1872
|
+
if (isRecord(value)) {
|
|
1873
|
+
const result = {};
|
|
1874
|
+
for (const [key, item] of Object.entries(value)) result[key] = presentValue(item, `${path}.${key}`, state);
|
|
1875
|
+
return result;
|
|
1876
|
+
}
|
|
1877
|
+
return value;
|
|
1878
|
+
}
|
|
1879
|
+
function addExplicitContent(value, path, state) {
|
|
1880
|
+
const tag = getPresentationTag(value);
|
|
1881
|
+
state.content.push(parseExplicitContent(tag?.kind === "content" ? tag.content : value, path));
|
|
1882
|
+
}
|
|
1883
|
+
function parseExplicitContent(value, path) {
|
|
1884
|
+
const parsed = ContentBlockSchema.safeParse(value);
|
|
1885
|
+
if (parsed.success) return parsed.data;
|
|
1886
|
+
throw new Error(`Invalid MCP content block at ${path}: ${parsed.error.message}`);
|
|
1887
|
+
}
|
|
1888
|
+
function detectAutomaticContent(value) {
|
|
1889
|
+
if (typeof value === "string") {
|
|
1890
|
+
const dataUrl = parseDataUrl(value);
|
|
1891
|
+
if (dataUrl) return { block: blockForBinary(dataUrl.data, dataUrl.mimeType) };
|
|
1892
|
+
const base64 = normalizeBase64(value);
|
|
1893
|
+
if (!base64) return;
|
|
1894
|
+
const mimeType = sniffMimeType(base64);
|
|
1895
|
+
return mimeType ? { block: blockForBinary(base64, mimeType) } : void 0;
|
|
1896
|
+
}
|
|
1897
|
+
if (!isRecord(value) || typeof value.mimeType !== "string") return;
|
|
1898
|
+
if (typeof value.text === "string") return {
|
|
1899
|
+
block: {
|
|
1900
|
+
type: "resource",
|
|
1901
|
+
resource: {
|
|
1902
|
+
uri: typeof value.uri === "string" ? value.uri : contentUrn(value.mimeType, value.text),
|
|
1903
|
+
mimeType: value.mimeType,
|
|
1904
|
+
text: value.text
|
|
1905
|
+
}
|
|
1906
|
+
},
|
|
1907
|
+
metadata: contentMetadata(value)
|
|
1908
|
+
};
|
|
1909
|
+
const encoded = [
|
|
1910
|
+
value.data,
|
|
1911
|
+
value.base64,
|
|
1912
|
+
value.blob
|
|
1913
|
+
].find((candidate) => typeof candidate === "string");
|
|
1914
|
+
if (!encoded) return;
|
|
1915
|
+
const dataUrl = parseDataUrl(encoded);
|
|
1916
|
+
const data = dataUrl?.data ?? normalizeBase64(encoded);
|
|
1917
|
+
if (!data) return;
|
|
1918
|
+
return {
|
|
1919
|
+
block: blockForBinary(data, dataUrl?.mimeType ?? value.mimeType, typeof value.uri === "string" ? value.uri : void 0),
|
|
1920
|
+
metadata: contentMetadata(value)
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
function blockForBinary(data, mimeType, uri) {
|
|
1924
|
+
if (mimeType.startsWith("image/")) return {
|
|
1925
|
+
type: "image",
|
|
1926
|
+
data,
|
|
1927
|
+
mimeType
|
|
1928
|
+
};
|
|
1929
|
+
if (mimeType.startsWith("audio/")) return {
|
|
1930
|
+
type: "audio",
|
|
1931
|
+
data,
|
|
1932
|
+
mimeType
|
|
1933
|
+
};
|
|
1934
|
+
return {
|
|
1935
|
+
type: "resource",
|
|
1936
|
+
resource: {
|
|
1937
|
+
uri: uri ?? contentUrn(mimeType, data),
|
|
1938
|
+
mimeType,
|
|
1939
|
+
blob: data
|
|
1940
|
+
}
|
|
1941
|
+
};
|
|
1942
|
+
}
|
|
1943
|
+
function describeContent(content, path) {
|
|
1944
|
+
switch (content.type) {
|
|
1945
|
+
case "text": return content.text;
|
|
1946
|
+
case "image":
|
|
1947
|
+
case "audio": return { $content: {
|
|
1948
|
+
type: content.type,
|
|
1949
|
+
mimeType: content.mimeType,
|
|
1950
|
+
bytes: base64ByteLength(content.data),
|
|
1951
|
+
path
|
|
1952
|
+
} };
|
|
1953
|
+
case "resource_link": return { $content: {
|
|
1954
|
+
type: content.type,
|
|
1955
|
+
uri: content.uri,
|
|
1956
|
+
name: content.name,
|
|
1957
|
+
...content.mimeType ? { mimeType: content.mimeType } : {},
|
|
1958
|
+
...content.size !== void 0 ? { size: content.size } : {},
|
|
1959
|
+
path
|
|
1960
|
+
} };
|
|
1961
|
+
case "resource": {
|
|
1962
|
+
const resource = content.resource;
|
|
1963
|
+
return { $content: {
|
|
1964
|
+
type: content.type,
|
|
1965
|
+
uri: resource.uri,
|
|
1966
|
+
...resource.mimeType ? { mimeType: resource.mimeType } : {},
|
|
1967
|
+
..."blob" in resource ? { bytes: base64ByteLength(resource.blob) } : { characters: resource.text.length },
|
|
1968
|
+
path
|
|
1969
|
+
} };
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
function describeContentWithMetadata(content, metadata, path, state) {
|
|
1974
|
+
const description = describeContent(content, path);
|
|
1975
|
+
if (!metadata || Object.keys(metadata).length === 0) return description;
|
|
1976
|
+
const presentedMetadata = presentValue(metadata, path, state);
|
|
1977
|
+
if (isRecord(description)) return {
|
|
1978
|
+
...presentedMetadata,
|
|
1979
|
+
...description
|
|
1980
|
+
};
|
|
1981
|
+
return {
|
|
1982
|
+
...presentedMetadata,
|
|
1983
|
+
$content: {
|
|
1984
|
+
type: content.type,
|
|
1985
|
+
characters: typeof description === "string" ? description.length : void 0,
|
|
1986
|
+
path
|
|
1987
|
+
}
|
|
1988
|
+
};
|
|
1989
|
+
}
|
|
1990
|
+
function stripPresentationTags(value) {
|
|
1991
|
+
const tag = getPresentationTag(value);
|
|
1992
|
+
if (tag?.kind === "raw") return stripPresentationTags(tag.value);
|
|
1993
|
+
if (tag?.kind === "result") {
|
|
1994
|
+
if (Object.hasOwn(tag, "value")) return stripPresentationTags(tag.value);
|
|
1995
|
+
if (Object.hasOwn(tag, "structuredContent")) return stripPresentationTags(tag.structuredContent);
|
|
1996
|
+
return null;
|
|
1997
|
+
}
|
|
1998
|
+
if (tag?.kind === "content") return stripPresentationTags(tag.content);
|
|
1999
|
+
if (Array.isArray(value)) return value.map(stripPresentationTags);
|
|
2000
|
+
if (isRecord(value)) {
|
|
2001
|
+
const result = {};
|
|
2002
|
+
for (const [key, item] of Object.entries(value)) result[key] = stripPresentationTags(item);
|
|
2003
|
+
return result;
|
|
2004
|
+
}
|
|
2005
|
+
return value;
|
|
2006
|
+
}
|
|
2007
|
+
function findExplicitResultOptions(value) {
|
|
2008
|
+
const tag = getPresentationTag(value);
|
|
2009
|
+
if (tag?.kind === "raw") return {};
|
|
2010
|
+
if (tag?.kind === "result") return {
|
|
2011
|
+
...typeof tag.isError === "boolean" ? { isError: tag.isError } : {},
|
|
2012
|
+
...isRecord(tag._meta) ? { _meta: tag._meta } : {}
|
|
2013
|
+
};
|
|
2014
|
+
if (Array.isArray(value)) for (const item of value) {
|
|
2015
|
+
const options = findExplicitResultOptions(item);
|
|
2016
|
+
if (options.isError !== void 0 || options._meta !== void 0) return options;
|
|
2017
|
+
}
|
|
2018
|
+
else if (isRecord(value)) for (const item of Object.values(value)) {
|
|
2019
|
+
const options = findExplicitResultOptions(item);
|
|
2020
|
+
if (options.isError !== void 0 || options._meta !== void 0) return options;
|
|
2021
|
+
}
|
|
2022
|
+
return {};
|
|
2023
|
+
}
|
|
2024
|
+
function getPresentationTag(value) {
|
|
2025
|
+
if (!isRecord(value) || Object.keys(value).length !== 1) return;
|
|
2026
|
+
const candidate = value[MCP_PRESENTATION_TAG];
|
|
2027
|
+
if (!isRecord(candidate) || candidate.kind !== "raw" && candidate.kind !== "result" && candidate.kind !== "content") return;
|
|
2028
|
+
return candidate;
|
|
2029
|
+
}
|
|
2030
|
+
function parseDataUrl(value) {
|
|
2031
|
+
if (!value.startsWith("data:")) return;
|
|
2032
|
+
const comma = value.indexOf(",");
|
|
2033
|
+
if (comma < 5) return;
|
|
2034
|
+
const parts = value.slice(5, comma).split(";");
|
|
2035
|
+
const mimeType = parts[0] || "text/plain";
|
|
2036
|
+
const payload = value.slice(comma + 1);
|
|
2037
|
+
if (parts.includes("base64")) {
|
|
2038
|
+
const data = normalizeBase64(payload);
|
|
2039
|
+
return data ? {
|
|
2040
|
+
data,
|
|
2041
|
+
mimeType
|
|
2042
|
+
} : void 0;
|
|
2043
|
+
}
|
|
2044
|
+
try {
|
|
2045
|
+
return {
|
|
2046
|
+
data: Buffer.from(decodeURIComponent(payload), "utf8").toString("base64"),
|
|
2047
|
+
mimeType
|
|
2048
|
+
};
|
|
2049
|
+
} catch {
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
function normalizeBase64(value) {
|
|
2054
|
+
const compact = value.replace(/\s/g, "");
|
|
2055
|
+
if (compact.length < 8 || compact.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(compact)) return;
|
|
2056
|
+
const withoutPadding = compact.replace(/=+$/, "");
|
|
2057
|
+
const padding = (4 - withoutPadding.length % 4) % 4;
|
|
2058
|
+
return withoutPadding + "=".repeat(padding);
|
|
2059
|
+
}
|
|
2060
|
+
function sniffMimeType(base64) {
|
|
2061
|
+
const bytes = Buffer.from(base64.slice(0, 256), "base64");
|
|
2062
|
+
const ascii = bytes.toString("ascii");
|
|
2063
|
+
if (startsWith(bytes, [
|
|
2064
|
+
137,
|
|
2065
|
+
80,
|
|
2066
|
+
78,
|
|
2067
|
+
71,
|
|
2068
|
+
13,
|
|
2069
|
+
10,
|
|
2070
|
+
26,
|
|
2071
|
+
10
|
|
2072
|
+
])) return "image/png";
|
|
2073
|
+
if (startsWith(bytes, [
|
|
2074
|
+
255,
|
|
2075
|
+
216,
|
|
2076
|
+
255
|
|
2077
|
+
])) return "image/jpeg";
|
|
2078
|
+
if (ascii.startsWith("GIF87a") || ascii.startsWith("GIF89a")) return "image/gif";
|
|
2079
|
+
if (ascii.startsWith("RIFF") && ascii.slice(8, 12) === "WEBP") return "image/webp";
|
|
2080
|
+
if (ascii.startsWith("BM")) return "image/bmp";
|
|
2081
|
+
if (startsWith(bytes, [
|
|
2082
|
+
0,
|
|
2083
|
+
0,
|
|
2084
|
+
1,
|
|
2085
|
+
0
|
|
2086
|
+
])) return "image/x-icon";
|
|
2087
|
+
if (startsWith(bytes, [
|
|
2088
|
+
73,
|
|
2089
|
+
73,
|
|
2090
|
+
42,
|
|
2091
|
+
0
|
|
2092
|
+
]) || startsWith(bytes, [
|
|
2093
|
+
77,
|
|
2094
|
+
77,
|
|
2095
|
+
0,
|
|
2096
|
+
42
|
|
2097
|
+
])) return "image/tiff";
|
|
2098
|
+
if (/^\s*(?:<\?xml[^>]*>\s*)?<svg[\s>]/i.test(bytes.toString("utf8"))) return "image/svg+xml";
|
|
2099
|
+
if (ascii.startsWith("RIFF") && ascii.slice(8, 12) === "WAVE") return "audio/wav";
|
|
2100
|
+
if (ascii.startsWith("ID3") || startsWith(bytes, [255, 251]) || startsWith(bytes, [255, 243]) || startsWith(bytes, [255, 242])) return "audio/mpeg";
|
|
2101
|
+
if (ascii.startsWith("OggS")) return "audio/ogg";
|
|
2102
|
+
if (ascii.startsWith("fLaC")) return "audio/flac";
|
|
2103
|
+
if (startsWith(bytes, [255, 241]) || startsWith(bytes, [255, 249])) return "audio/aac";
|
|
2104
|
+
if (ascii.startsWith("MThd")) return "audio/midi";
|
|
2105
|
+
if (ascii.startsWith("%PDF-")) return "application/pdf";
|
|
2106
|
+
if (startsWith(bytes, [
|
|
2107
|
+
80,
|
|
2108
|
+
75,
|
|
2109
|
+
3,
|
|
2110
|
+
4
|
|
2111
|
+
])) return "application/zip";
|
|
2112
|
+
if (startsWith(bytes, [31, 139])) return "application/gzip";
|
|
2113
|
+
}
|
|
2114
|
+
function startsWith(value, prefix) {
|
|
2115
|
+
return prefix.every((byte, index) => value[index] === byte);
|
|
2116
|
+
}
|
|
2117
|
+
function base64ByteLength(value) {
|
|
2118
|
+
const normalized = normalizeBase64(value);
|
|
2119
|
+
if (!normalized) return 0;
|
|
2120
|
+
const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
|
|
2121
|
+
return normalized.length / 4 * 3 - padding;
|
|
2122
|
+
}
|
|
2123
|
+
function contentUrn(mimeType, value) {
|
|
2124
|
+
return `urn:linkrpc-mcp:content:${createHash("sha256").update(mimeType).update("\0").update(value).digest("base64url")}`;
|
|
2125
|
+
}
|
|
2126
|
+
function contentMetadata(value) {
|
|
2127
|
+
const metadata = {};
|
|
2128
|
+
for (const [key, item] of Object.entries(value)) if (![
|
|
2129
|
+
"data",
|
|
2130
|
+
"base64",
|
|
2131
|
+
"blob",
|
|
2132
|
+
"text",
|
|
2133
|
+
"mimeType",
|
|
2134
|
+
"uri"
|
|
2135
|
+
].includes(key)) metadata[key] = item;
|
|
2136
|
+
return metadata;
|
|
2137
|
+
}
|
|
2138
|
+
function contentBlockMetadata(value, content) {
|
|
2139
|
+
const standardKeys = content.type === "text" ? [
|
|
2140
|
+
"type",
|
|
2141
|
+
"text",
|
|
2142
|
+
"annotations",
|
|
2143
|
+
"_meta"
|
|
2144
|
+
] : content.type === "image" || content.type === "audio" ? [
|
|
2145
|
+
"type",
|
|
2146
|
+
"data",
|
|
2147
|
+
"mimeType",
|
|
2148
|
+
"annotations",
|
|
2149
|
+
"_meta"
|
|
2150
|
+
] : content.type === "resource" ? [
|
|
2151
|
+
"type",
|
|
2152
|
+
"resource",
|
|
2153
|
+
"annotations",
|
|
2154
|
+
"_meta"
|
|
2155
|
+
] : [
|
|
2156
|
+
"type",
|
|
2157
|
+
"uri",
|
|
2158
|
+
"name",
|
|
2159
|
+
"title",
|
|
2160
|
+
"description",
|
|
2161
|
+
"mimeType",
|
|
2162
|
+
"size",
|
|
2163
|
+
"annotations",
|
|
2164
|
+
"_meta",
|
|
2165
|
+
"icons"
|
|
2166
|
+
];
|
|
2167
|
+
const metadata = {};
|
|
2168
|
+
for (const [key, item] of Object.entries(value)) if (!standardKeys.includes(key)) metadata[key] = item;
|
|
2169
|
+
return metadata;
|
|
2170
|
+
}
|
|
2171
|
+
function isRecord(value) {
|
|
2172
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2173
|
+
}
|
|
2174
|
+
//#endregion
|
|
2175
|
+
//#region src/server.ts
|
|
2176
|
+
const CONNECTION_SCHEMA = z.string().optional().describe("Endpoint URI of the hub to connect to, e.g. `unix:/run/hub.sock?token=…`, `npipe://./pipe/vscode-linkrpc-…?token=…`, or `wss://host:port?token=…`. The token may be embedded as a `token` query param or supplied via the LINKRPC_TOKEN environment variable. When omitted entirely, the server falls back to the LINKRPC_ENDPOINT and LINKRPC_TOKEN environment variables.");
|
|
2177
|
+
const PRESENTATION_SCHEMA = z.enum(["auto", "raw"]).optional().describe("How to present the returned value. `auto` (default) recognizes MCP content blocks, data URLs, common base64 media signatures, and `{ data|base64|blob, mimeType }` objects, emitting native image/audio/resource content while keeping the original value available to subsequent scripts through `lastResultVal`. `raw` disables transformation and exposes the original JSON/base64.");
|
|
2178
|
+
const SERVER_NAME = "linkrpc-mcp";
|
|
2179
|
+
const SERVER_VERSION = "0.0.1";
|
|
2180
|
+
/**
|
|
2181
|
+
* MCP server exposing the `runLinkRpcScript` tool plus task-management helpers.
|
|
2182
|
+
*
|
|
2183
|
+
* Documentation for the sandbox API (`con`) is reachable from inside the
|
|
2184
|
+
* sandbox via `con.getDocs()` rather than an MCP resource — that way the
|
|
2185
|
+
* model can pull it in by issuing a one-line `runLinkRpcScript` call.
|
|
2186
|
+
*
|
|
2187
|
+
* Can be hosted over stdio (CLI mode, via {@link startStdio}) or over any
|
|
2188
|
+
* MCP {@link Transport} (e.g. Streamable HTTP, used by the VS Code Team
|
|
2189
|
+
* Tools extension which embeds this server in-process).
|
|
2190
|
+
*/
|
|
2191
|
+
var LinkRpcMcpServer = class LinkRpcMcpServer {
|
|
2192
|
+
static async startStdio(options) {
|
|
2193
|
+
const server = new LinkRpcMcpServer(options);
|
|
2194
|
+
await server.connect(new StdioServerTransport());
|
|
2195
|
+
return server;
|
|
2196
|
+
}
|
|
2197
|
+
_mcp;
|
|
2198
|
+
_pool;
|
|
2199
|
+
_tasks = new TaskRegistry();
|
|
2200
|
+
_onToolCall;
|
|
2201
|
+
_onExploreCall;
|
|
2202
|
+
constructor(options = {}) {
|
|
2203
|
+
this._mcp = new McpServer({
|
|
2204
|
+
name: SERVER_NAME,
|
|
2205
|
+
version: SERVER_VERSION
|
|
2206
|
+
});
|
|
2207
|
+
this._onToolCall = options.onToolCall;
|
|
2208
|
+
this._onExploreCall = options.onExploreCall;
|
|
2209
|
+
this._pool = options.pool ?? (options.provider ? new ProviderPool(options.provider) : new ConnectionPool({
|
|
2210
|
+
defaultEndpoint: options.defaultEndpoint,
|
|
2211
|
+
...options.defaultConnection ? { defaultTransport: options.defaultConnection } : {}
|
|
2212
|
+
}));
|
|
2213
|
+
this._registerTools();
|
|
2214
|
+
}
|
|
2215
|
+
async connect(transport) {
|
|
2216
|
+
await this._mcp.connect(transport);
|
|
2217
|
+
}
|
|
2218
|
+
dispose() {
|
|
2219
|
+
this._tasks.dispose();
|
|
2220
|
+
this._pool.dispose();
|
|
2221
|
+
}
|
|
2222
|
+
_registerTools() {
|
|
2223
|
+
this._mcp.registerTool("runLinkRpcScript", {
|
|
2224
|
+
title: "Run JS against a linkrpc hub",
|
|
2225
|
+
description: "Evaluate a JS function inside a QuickJS sandbox with `con` (the live hub connection), `lastResultVal` (previous call's original result), and `mcp` (optional result-presentation helpers) in scope.\n\nBefore composing non-trivial calls, fetch the full sandbox API docs by running:\n `async ({ con }) => con.getDocs()`\nThat returns the TypeScript declarations for the full script context, including `con`, `mcp`, sandbox limits, and available console.\n\n`code` MUST be a function expression, e.g.\n `({ con }) => con.call(\"vscode\", \"vscode.window\", \"showInformationMessage\", { message: \"hi\" })`\n\nSet `connection` to a hub endpoint URI (e.g. `unix:/path?token=…` or `wss://host?token=…`) to target a specific hub, or leave it out to use the LINKRPC_ENDPOINT / LINKRPC_TOKEN env vars.\n\nACCESS IS NOT AUTOMATIC: a gated `con.call(...)` is NOT silently granted. Reflection (`con.explore`) works out of the box, but calling a service member you have no capability for fails with a permission error. Inspect what you already hold with `con.grants()`, then ask the user for access — ideally for MANY members at once — with `con.requestAccess({ permissions, duration })`. Pick `duration`: `once` for a one-off, `session` for the rest of this connection, `persistent` to remember across runs. Batch related permissions into a single request so the user sees one prompt instead of many.\n\nBACKGROUND TASKS: if the code is still awaiting I/O (an RPC reply, a user dialog, a stream) when `foregroundMs` elapses, the call does NOT fail — it is parked as a background task and the result is `{ status: \"running\", taskId, debugName, inFlight }`. Use `awaitLinkRpcTask` to wait for it or `cancelLinkRpcTask` to abandon it. Set `label` to give the task a clear debug name. Starting a new `runLinkRpcScript` cancels any task still parked (reported as `supersededTask`).\n\nRESULT PRESENTATION: returned image/audio/resource data is automatically emitted as native MCP content when it is a data URL, recognizable base64, an MCP content block, or an object shaped like `{ data|base64|blob, mimeType }`. This happens only after the script finishes: values passed between hub calls and saved in `lastResultVal` remain original and unmodified. Use `presentation: \"raw\"` or `mcp.raw(value)` when the model needs the literal base64. Use `mcp.image`, `mcp.audio`, `mcp.resource`, `mcp.resourceLink`, or `mcp.result` for explicit control. Native content always includes JSON text and structured fallbacks.\n\nThe result contains `{ status, result | taskId, logs, endpoint }`. Pass `trace: true` to also receive a per-call JSON-RPC wire log under `trace` — useful when a call fails or returns unexpected data.",
|
|
2226
|
+
inputSchema: {
|
|
2227
|
+
connection: CONNECTION_SCHEMA,
|
|
2228
|
+
code: z.string().describe("JS function expression. Receives `{ con, lastResultVal, mcp }`. Sync or async. Return value is JSON-stringified."),
|
|
2229
|
+
presentation: PRESENTATION_SCHEMA,
|
|
2230
|
+
label: z.string().optional().describe("Human-readable debug name for the task if it gets parked, e.g. \"ask user to confirm deploy\". When omitted, the name is inferred from the in-flight RPC(s) at park time."),
|
|
2231
|
+
foregroundMs: z.number().int().positive().optional().describe("How long the call runs synchronously before being parked as a background task. Defaults to 5000."),
|
|
2232
|
+
maxLifetimeMs: z.number().int().positive().optional().describe("Absolute lifetime cap for a parked task before it is cancelled. Defaults to 120000."),
|
|
2233
|
+
trace: z.boolean().optional().describe("When true, include a `trace` array in the result with every outbound JSON-RPC request / notification, its outcome, and every permission round-trip. Off by default to keep results small.")
|
|
2234
|
+
},
|
|
2235
|
+
annotations: { readOnlyHint: true }
|
|
2236
|
+
}, async (args) => this._observeToolCall("runLinkRpcScript", args, async () => {
|
|
2237
|
+
let pooled;
|
|
2238
|
+
try {
|
|
2239
|
+
pooled = await this._pool.resolve(args.connection);
|
|
2240
|
+
} catch (e) {
|
|
2241
|
+
return _errorResult(_describeError(e));
|
|
2242
|
+
}
|
|
2243
|
+
const superseded = await this._tasks.cancelLive();
|
|
2244
|
+
const traceEnabled = args.trace === true;
|
|
2245
|
+
const trace = [];
|
|
2246
|
+
const disposeTrace = traceEnabled ? pooled.addTraceListener((line) => trace.push(line)) : () => {};
|
|
2247
|
+
const host = {
|
|
2248
|
+
call: async (method, paramsJson, optsJson, onStreamMessage, signal) => {
|
|
2249
|
+
const params = paramsJson === "" ? {} : JSON.parse(paramsJson);
|
|
2250
|
+
const requestPermission = (optsJson === "" ? {} : JSON.parse(optsJson)).requestPermission === true;
|
|
2251
|
+
const send = async () => {
|
|
2252
|
+
if (signal.aborted) {
|
|
2253
|
+
const error = /* @__PURE__ */ new Error(`AbortError: ${String(signal.reason ?? "cancelled")}`);
|
|
2254
|
+
error.name = "AbortError";
|
|
2255
|
+
throw error;
|
|
2256
|
+
}
|
|
2257
|
+
const call = pooled.channel.sendRequestWithStream(method, params, { onStreamMessage: (payload) => onStreamMessage(JSON.stringify(payload)) });
|
|
2258
|
+
const cancel = () => {
|
|
2259
|
+
const reason = String(signal.reason ?? "cancelled");
|
|
2260
|
+
call.cancel(reason);
|
|
2261
|
+
call.dispose?.(reason);
|
|
2262
|
+
};
|
|
2263
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
2264
|
+
try {
|
|
2265
|
+
return await call.result;
|
|
2266
|
+
} finally {
|
|
2267
|
+
signal.removeEventListener("abort", cancel);
|
|
2268
|
+
}
|
|
2269
|
+
};
|
|
2270
|
+
try {
|
|
2271
|
+
const result = await send();
|
|
2272
|
+
return JSON.stringify(result ?? null);
|
|
2273
|
+
} catch (e) {
|
|
2274
|
+
if (requestPermission && _isPermissionError(e)) {
|
|
2275
|
+
if (await _autoRequestAccess(pooled.session, method)) {
|
|
2276
|
+
const result = await send();
|
|
2277
|
+
return JSON.stringify(result ?? null);
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
throw _enrichPermissionError(e, method);
|
|
2281
|
+
}
|
|
2282
|
+
},
|
|
2283
|
+
notify: async (method, paramsJson) => {
|
|
2284
|
+
const params = paramsJson === "" ? {} : JSON.parse(paramsJson);
|
|
2285
|
+
await pooled.channel.sendNotification(method, params);
|
|
2286
|
+
return "";
|
|
2287
|
+
},
|
|
2288
|
+
explore: async (argsJson) => {
|
|
2289
|
+
const exploreArgs = argsJson === "" ? {} : JSON.parse(argsJson);
|
|
2290
|
+
let reflectionGrant;
|
|
2291
|
+
try {
|
|
2292
|
+
const result = await explore(pooled.channel, exploreArgs, { requestReflectionAccess: () => reflectionGrant ??= _requestReflectionAccess(pooled.session) });
|
|
2293
|
+
this._observeExploreCall({
|
|
2294
|
+
arguments: exploreArgs,
|
|
2295
|
+
result
|
|
2296
|
+
});
|
|
2297
|
+
return JSON.stringify(result);
|
|
2298
|
+
} catch (error) {
|
|
2299
|
+
this._observeExploreCall({
|
|
2300
|
+
arguments: exploreArgs,
|
|
2301
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2302
|
+
});
|
|
2303
|
+
throw error;
|
|
2304
|
+
}
|
|
2305
|
+
},
|
|
2306
|
+
requestAccess: async (argsJson) => {
|
|
2307
|
+
const req = argsJson === "" ? {} : JSON.parse(argsJson);
|
|
2308
|
+
const result = await pooled.session.requestAccess({
|
|
2309
|
+
consumer: {
|
|
2310
|
+
name: "linkrpc-mcp",
|
|
2311
|
+
purpose: req.purpose
|
|
2312
|
+
},
|
|
2313
|
+
permissions: req.permissions ?? [],
|
|
2314
|
+
duration: req.duration
|
|
2315
|
+
});
|
|
2316
|
+
return JSON.stringify(result);
|
|
2317
|
+
},
|
|
2318
|
+
grants: async () => JSON.stringify(summarizeGrants(pooled.session.listGrants()))
|
|
2319
|
+
};
|
|
2320
|
+
try {
|
|
2321
|
+
const outcome = await startSandbox(args.code, host, pooled.lastResultVal, {
|
|
2322
|
+
foregroundMs: args.foregroundMs,
|
|
2323
|
+
maxLifetimeMs: args.maxLifetimeMs,
|
|
2324
|
+
label: args.label
|
|
2325
|
+
});
|
|
2326
|
+
if (outcome.status === "completed") {
|
|
2327
|
+
pooled.lastResultVal = scriptResultValue(outcome.result);
|
|
2328
|
+
return presentToolResult({
|
|
2329
|
+
status: "completed",
|
|
2330
|
+
result: outcome.result,
|
|
2331
|
+
logs: outcome.logs,
|
|
2332
|
+
...traceEnabled ? { trace } : {},
|
|
2333
|
+
endpoint: pooled.endpoint,
|
|
2334
|
+
...superseded ? { supersededTask: superseded } : {}
|
|
2335
|
+
}, args.presentation);
|
|
2336
|
+
}
|
|
2337
|
+
if (outcome.status === "error") return _errorResult(`runLinkRpcScript failed: ${outcome.error}`, traceEnabled ? trace : void 0);
|
|
2338
|
+
return presentToolResult({
|
|
2339
|
+
status: "running",
|
|
2340
|
+
taskId: this._tasks.register(pooled.endpoint, outcome.debugName, outcome.task, (result) => {
|
|
2341
|
+
pooled.lastResultVal = scriptResultValue(result);
|
|
2342
|
+
}),
|
|
2343
|
+
debugName: outcome.debugName,
|
|
2344
|
+
inFlight: outcome.task.inFlight(),
|
|
2345
|
+
logsSoFar: outcome.task.logs,
|
|
2346
|
+
...traceEnabled ? { traceSoFar: trace } : {},
|
|
2347
|
+
endpoint: pooled.endpoint,
|
|
2348
|
+
...superseded ? { supersededTask: superseded } : {}
|
|
2349
|
+
}, "raw");
|
|
2350
|
+
} catch (e) {
|
|
2351
|
+
return _errorResult(`runLinkRpcScript failed: ${_describeError(e)}`, traceEnabled ? trace : void 0);
|
|
2352
|
+
} finally {
|
|
2353
|
+
disposeTrace();
|
|
2354
|
+
}
|
|
2355
|
+
}));
|
|
2356
|
+
this._mcp.registerTool("awaitLinkRpcTask", {
|
|
2357
|
+
title: "Wait for a parked linkrpc task",
|
|
2358
|
+
description: "Wait up to `timeoutMs` for a background task started by `runLinkRpcScript` to settle. Returns `{ status: \"completed\" | \"error\" | \"cancelled\" | \"running\", … }`. A `running` result means it is still going (with a progress snapshot) — call again to keep waiting. Does NOT cancel the task.",
|
|
2359
|
+
inputSchema: {
|
|
2360
|
+
taskId: z.string().describe("Task id returned by `runLinkRpcScript`."),
|
|
2361
|
+
timeoutMs: z.number().int().positive().optional().describe("How long to wait before returning a `running` snapshot. Defaults to 30000."),
|
|
2362
|
+
presentation: PRESENTATION_SCHEMA
|
|
2363
|
+
},
|
|
2364
|
+
annotations: { readOnlyHint: true }
|
|
2365
|
+
}, async (args) => this._observeToolCall("awaitLinkRpcTask", args, async () => {
|
|
2366
|
+
const res = await this._tasks.awaitTask(args.taskId, args.timeoutMs ?? 3e4);
|
|
2367
|
+
if (res.status === "unknown") return _errorResult(`No task with id ${args.taskId}`);
|
|
2368
|
+
return _presentTaskResult(res, args.presentation);
|
|
2369
|
+
}));
|
|
2370
|
+
this._mcp.registerTool("cancelLinkRpcTask", {
|
|
2371
|
+
title: "Cancel a parked linkrpc task",
|
|
2372
|
+
description: "Soft-abort a background task started by `runLinkRpcScript` and return its terminal outcome. The guest gets a short grace window to run cleanup.",
|
|
2373
|
+
inputSchema: {
|
|
2374
|
+
taskId: z.string().describe("Task id returned by `runLinkRpcScript`."),
|
|
2375
|
+
presentation: PRESENTATION_SCHEMA
|
|
2376
|
+
},
|
|
2377
|
+
annotations: { readOnlyHint: true }
|
|
2378
|
+
}, async (args) => this._observeToolCall("cancelLinkRpcTask", args, async () => {
|
|
2379
|
+
const res = await this._tasks.cancel(args.taskId);
|
|
2380
|
+
if (res.status === "unknown") return _errorResult(`No task with id ${args.taskId}`);
|
|
2381
|
+
return _presentTaskResult(res, args.presentation);
|
|
2382
|
+
}));
|
|
2383
|
+
}
|
|
2384
|
+
async _observeToolCall(name, args, invoke) {
|
|
2385
|
+
const result = await invoke();
|
|
2386
|
+
try {
|
|
2387
|
+
this._onToolCall?.({
|
|
2388
|
+
name,
|
|
2389
|
+
arguments: args,
|
|
2390
|
+
result
|
|
2391
|
+
});
|
|
2392
|
+
} catch {}
|
|
2393
|
+
return result;
|
|
2394
|
+
}
|
|
2395
|
+
_observeExploreCall(call) {
|
|
2396
|
+
try {
|
|
2397
|
+
this._onExploreCall?.(call);
|
|
2398
|
+
} catch {}
|
|
2399
|
+
}
|
|
2400
|
+
};
|
|
2401
|
+
function _presentTaskResult(value, mode) {
|
|
2402
|
+
try {
|
|
2403
|
+
return presentToolResult(value, mode);
|
|
2404
|
+
} catch (e) {
|
|
2405
|
+
return _errorResult(`Failed to present task result: ${_describeError(e)}`);
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
function _errorResult(message, trace) {
|
|
2409
|
+
return {
|
|
2410
|
+
content: [{
|
|
2411
|
+
type: "text",
|
|
2412
|
+
text: trace && trace.length > 0 ? `${message}\n\nTrace:\n${trace.join("\n")}` : message
|
|
2413
|
+
}],
|
|
2414
|
+
isError: true
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
/**
|
|
2418
|
+
* Render an unknown thrown value as a debuggable string: the error's stack
|
|
2419
|
+
* (which already begins with `Name: message`) plus the full `cause` chain. The
|
|
2420
|
+
* MCP host only forwards the result text to the model — never a JS stack — so
|
|
2421
|
+
* without this every failure collapses to a single bare message line.
|
|
2422
|
+
*/
|
|
2423
|
+
function _describeError(e) {
|
|
2424
|
+
if (!(e instanceof Error)) return String(e);
|
|
2425
|
+
let out = typeof e.stack === "string" && e.stack.length > 0 ? e.stack : e.message;
|
|
2426
|
+
let cause = e.cause;
|
|
2427
|
+
while (cause != null) if (cause instanceof Error) {
|
|
2428
|
+
out += `\n\nCaused by: ${typeof cause.stack === "string" && cause.stack.length > 0 ? cause.stack : cause.message}`;
|
|
2429
|
+
cause = cause.cause;
|
|
2430
|
+
} else {
|
|
2431
|
+
out += `\n\nCaused by: ${String(cause)}`;
|
|
2432
|
+
break;
|
|
2433
|
+
}
|
|
2434
|
+
return out;
|
|
2435
|
+
}
|
|
2436
|
+
/**
|
|
2437
|
+
* Map a hub `permissionRequired` (-32401) error to an actionable message that
|
|
2438
|
+
* points the model at the explicit access flow. Other errors pass through
|
|
2439
|
+
* unchanged (re-wrapped as an `Error` when they are not already one).
|
|
2440
|
+
*/
|
|
2441
|
+
function _enrichPermissionError(e, method) {
|
|
2442
|
+
if (_isPermissionError(e)) {
|
|
2443
|
+
const { serviceId, interfaceId, member } = _parseMethod(method);
|
|
2444
|
+
const original = e instanceof Error ? e.message : String(e);
|
|
2445
|
+
return /* @__PURE__ */ new Error(`Permission required for ${method} — no capability is held for this call.\nInspect current access with con.grants(), then request it with con.requestAccess({ permissions: [{ target: { serviceId: { exact: ${JSON.stringify(serviceId)} }, interfaceId: { exact: ${JSON.stringify(interfaceId)} }, members: [{ exact: ${JSON.stringify(member)} }] }, canInvoke: true }], duration: "longLived" }).\nOriginal error: ${original}`);
|
|
2446
|
+
}
|
|
2447
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
2448
|
+
}
|
|
2449
|
+
/** True when `e` is a hub `permissionRequired` (-32401) error. */
|
|
2450
|
+
function _isPermissionError(e) {
|
|
2451
|
+
return e instanceof RpcError ? e.code === ErrorCode.permissionRequired : typeof e === "object" && e !== null && e.code === ErrorCode.permissionRequired;
|
|
2452
|
+
}
|
|
2453
|
+
/**
|
|
2454
|
+
* Split a JSON-RPC method name into its `(serviceId, interfaceId, member)`
|
|
2455
|
+
* parts. Form-3 (`serviceId::interfaceId::member`) keeps the serviceId; form-2
|
|
2456
|
+
* (`interfaceId::member`, a root/hub call) reports an empty serviceId.
|
|
2457
|
+
*/
|
|
2458
|
+
function _parseMethod(method) {
|
|
2459
|
+
const parts = method.split("::");
|
|
2460
|
+
if (parts.length >= 3) return {
|
|
2461
|
+
serviceId: parts[0],
|
|
2462
|
+
interfaceId: parts[1],
|
|
2463
|
+
member: parts.slice(2).join("::")
|
|
2464
|
+
};
|
|
2465
|
+
return {
|
|
2466
|
+
serviceId: "",
|
|
2467
|
+
interfaceId: parts[0] ?? "",
|
|
2468
|
+
member: parts[1] ?? ""
|
|
2469
|
+
};
|
|
2470
|
+
}
|
|
2471
|
+
/**
|
|
2472
|
+
* Negotiate durable capabilities for the reflection methods the bus walk uses
|
|
2473
|
+
* — `hubrpc.directory::list` (enumeration) and `hubrpc.schemas::get` (schema
|
|
2474
|
+
* fetch) — across *all* service ids, through the hub's `hubAccess` consent
|
|
2475
|
+
* door. One grant unlocks enumeration of every gated directory the walk
|
|
2476
|
+
* reaches, so `explore({ requestPermission: true })` only needs a single
|
|
2477
|
+
* consent round-trip. Returns `true` when the grant was issued.
|
|
2478
|
+
*/
|
|
2479
|
+
async function _requestReflectionAccess(session) {
|
|
2480
|
+
return (await session.requestAccess({
|
|
2481
|
+
consumer: {
|
|
2482
|
+
name: "linkrpc-mcp",
|
|
2483
|
+
purpose: "explore the hub (reflection)"
|
|
2484
|
+
},
|
|
2485
|
+
permissions: [{
|
|
2486
|
+
target: {
|
|
2487
|
+
serviceId: { prefix: "" },
|
|
2488
|
+
interfaceId: { exact: "hubrpc.directory" },
|
|
2489
|
+
members: [{ exact: "list" }]
|
|
2490
|
+
},
|
|
2491
|
+
canInvoke: true
|
|
2492
|
+
}, {
|
|
2493
|
+
target: {
|
|
2494
|
+
serviceId: { prefix: "" },
|
|
2495
|
+
interfaceId: { exact: "hubrpc.schemas" },
|
|
2496
|
+
members: [{ exact: "get" }]
|
|
2497
|
+
},
|
|
2498
|
+
canInvoke: true
|
|
2499
|
+
}],
|
|
2500
|
+
duration: "longLived"
|
|
2501
|
+
})).status === "granted";
|
|
2502
|
+
}
|
|
2503
|
+
/**
|
|
2504
|
+
* Negotiate a durable capability for exactly `method` through the hub's
|
|
2505
|
+
* `hubAccess` consent door (the `requestPermission: true` path of `con.call`).
|
|
2506
|
+
* Returns `true` when the grant was issued — its durable cap joins the
|
|
2507
|
+
* connection's bag, so the caller can retry the call.
|
|
2508
|
+
*/
|
|
2509
|
+
async function _autoRequestAccess(session, method) {
|
|
2510
|
+
const { serviceId, interfaceId, member } = _parseMethod(method);
|
|
2511
|
+
return (await session.requestAccess({
|
|
2512
|
+
consumer: {
|
|
2513
|
+
name: "linkrpc-mcp",
|
|
2514
|
+
purpose: `invoke ${method}`
|
|
2515
|
+
},
|
|
2516
|
+
permissions: [{
|
|
2517
|
+
target: {
|
|
2518
|
+
serviceId: { exact: serviceId },
|
|
2519
|
+
interfaceId: { exact: interfaceId },
|
|
2520
|
+
members: [{ exact: member }]
|
|
2521
|
+
},
|
|
2522
|
+
canInvoke: true
|
|
2523
|
+
}],
|
|
2524
|
+
duration: "longLived"
|
|
2525
|
+
})).status === "granted";
|
|
2526
|
+
}
|
|
2527
|
+
//#endregion
|
|
2528
|
+
export { CONNECTION_DTS as a, startSandbox as i, explore as n, ProviderPool as o, runSandboxed as r, ConnectionPool as s, LinkRpcMcpServer as t };
|
|
2529
|
+
|
|
2530
|
+
//# sourceMappingURL=server-C4ZM6bfK.js.map
|