@johnhenry/wsh 0.23.1 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -0
- package/package.json +11 -4
- package/src/auth.mjs +5 -1
- package/src/client.mjs +141 -1
- package/src/index.d.ts +181 -1
- package/src/index.mjs +8 -0
- package/src/rpc-mcp.mjs +153 -0
- package/src/rpc.mjs +617 -0
- package/src/server/connection.mjs +62 -0
- package/src/server/fs.mjs +36 -3
- package/src/server/index.d.ts +35 -0
- package/src/server/index.mjs +11 -3
- package/src/server/rpc.mjs +171 -0
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import { FILE_CHUNK_BYTES } from './fs.mjs';
|
|
28
28
|
import { MAX_ATTACHMENTS } from './sessions.mjs';
|
|
29
29
|
import { MCP_CALL_ID_FEATURE } from '../client.mjs';
|
|
30
|
+
import { RpcChannel, RPC_FEATURE, RPC_MAX_MESSAGE_PREFIX, rpcProtocolFeature } from '../rpc.mjs';
|
|
30
31
|
import { HOST_KEY_PREFIX, HOST_KEY_SIG_PREFIX, findClientNonce, hostKeyProofMessage, toHex } from '../host-key.mjs';
|
|
31
32
|
|
|
32
33
|
export const STREAM_ANNOUNCE = 'stream-announce';
|
|
@@ -52,6 +53,7 @@ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
|
|
|
52
53
|
* @param {{ pubkey: boolean, password: Function | null }} cfg.methods - which auth methods are on
|
|
53
54
|
* @param {object | null} cfg.hostKey - from loadHostKey
|
|
54
55
|
* @param {object | null} cfg.mcp - from createMcpHost
|
|
56
|
+
* @param {{ protocols: Map<string, Function>, maxMessageBytes: number, maxInflight: number } | null} cfg.rpc - from createRpcHost
|
|
55
57
|
* @param {object | null} cfg.relay - a RelayHub (see relay.mjs); null = not a relay
|
|
56
58
|
* @param {object | null} cfg.sessions - a SessionRegistry (see sessions.mjs); null = sessions die with their connection
|
|
57
59
|
* @param {{ limiter: object, key: Function, failureDelayMs: number } | null} cfg.rateLimit - password throttle
|
|
@@ -202,6 +204,9 @@ export function createConnectionFactory(cfg) {
|
|
|
202
204
|
const features = [STREAM_ANNOUNCE];
|
|
203
205
|
if (cfg.mcp) features.push(MCP_CALL_ID_FEATURE);
|
|
204
206
|
if (cfg.files) features.push('file-transfer', 'file-write', 'file-rename');
|
|
207
|
+
if (cfg.rpc && dataStreams && cfg.rpc.protocols.size) {
|
|
208
|
+
features.push(RPC_FEATURE, ...[...cfg.rpc.protocols.keys()].map(rpcProtocolFeature), RPC_MAX_MESSAGE_PREFIX + cfg.rpc.maxMessageBytes);
|
|
209
|
+
}
|
|
205
210
|
let hostFingerprint;
|
|
206
211
|
if (cfg.hostKey) {
|
|
207
212
|
hostFingerprint = cfg.hostKey.fingerprint;
|
|
@@ -211,6 +216,8 @@ export function createConnectionFactory(cfg) {
|
|
|
211
216
|
features.push(HOST_KEY_PREFIX + toHex(cfg.hostKey.publicKey), HOST_KEY_SIG_PREFIX + toHex(sig));
|
|
212
217
|
}
|
|
213
218
|
}
|
|
219
|
+
state.features = features;
|
|
220
|
+
state.hostFingerprint = hostFingerprint ?? null;
|
|
214
221
|
await send(serverHello({ sessionId: state.sessionId, features, hostFingerprint }));
|
|
215
222
|
if (method === 'pubkey') {
|
|
216
223
|
state.nonce = generateNonce();
|
|
@@ -390,6 +397,7 @@ export function createConnectionFactory(cfg) {
|
|
|
390
397
|
case 'exec': return openExec(m);
|
|
391
398
|
case 'pty': return openPty(m);
|
|
392
399
|
case 'file': return openFile(m);
|
|
400
|
+
case 'rpc': return openRpc(m);
|
|
393
401
|
default: return send(openFail({ reason: `kind "${m.kind}" is not supported by this server` }));
|
|
394
402
|
}
|
|
395
403
|
}
|
|
@@ -650,6 +658,60 @@ export function createConnectionFactory(cfg) {
|
|
|
650
658
|
}
|
|
651
659
|
}
|
|
652
660
|
|
|
661
|
+
// A typed RPC channel (wsh #85): `Open { kind: 'rpc', command: <protocol> }` is answered like an exec channel's -- OpenOk,
|
|
662
|
+
// then the client's data stream binds in OpenOk order -- but the stream carries a CBOR sequence of JSON-RPC messages
|
|
663
|
+
// handled by the protocol's handler, not process I/O.
|
|
664
|
+
async function openRpc(m) {
|
|
665
|
+
const rpc = cfg.rpc;
|
|
666
|
+
const protocol = typeof m.command === 'string' ? m.command : '';
|
|
667
|
+
const handler = rpc?.protocols.get(protocol);
|
|
668
|
+
if (!rpc || !dataStreams || !handler) {
|
|
669
|
+
const why = !rpc || !dataStreams ? 'rpc is not enabled on this server' : `protocol "${protocol}" is not supported by this server`;
|
|
670
|
+
return send(openFail({ reason: `UNSUPPORTED_PROTOCOL: ${why}` }));
|
|
671
|
+
}
|
|
672
|
+
const channelId = ++state.nextChannel;
|
|
673
|
+
let chan = null;
|
|
674
|
+
let stream = null;
|
|
675
|
+
const ch = {
|
|
676
|
+
// The peer closed the channel (or the connection is going away).
|
|
677
|
+
kill: () => {
|
|
678
|
+
channels.delete(channelId);
|
|
679
|
+
chan?.handleClose('peer-closed');
|
|
680
|
+
try { stream?.close().catch(() => {}); } catch { /* gone */ }
|
|
681
|
+
},
|
|
682
|
+
bind(s) {
|
|
683
|
+
stream = s;
|
|
684
|
+
chan = new RpcChannel({
|
|
685
|
+
maxMessageBytes: rpc.maxMessageBytes,
|
|
686
|
+
maxInflight: rpc.maxInflight,
|
|
687
|
+
write: (bytes) => s.write(bytes),
|
|
688
|
+
// The host ended it: close our end of the stream, then tell the client the channel is over.
|
|
689
|
+
close: async () => { try { await s.close(); } catch { /* peer gone */ } await finishChannel(channelId, 0); },
|
|
690
|
+
});
|
|
691
|
+
s.onData = (d) => chan.feed(d);
|
|
692
|
+
s.onEnd = () => { void chan.close('stream-end'); };
|
|
693
|
+
s.onReset = () => chan.handleClose('stream-reset');
|
|
694
|
+
const ctx = {
|
|
695
|
+
protocol, channelId, user: state.username, fingerprint: state.fingerprint, remote,
|
|
696
|
+
features: state.features ?? [], hostFingerprint: state.hostFingerprint,
|
|
697
|
+
log: (line) => log(`rpc ${protocol} channel ${channelId}: ${line}`),
|
|
698
|
+
};
|
|
699
|
+
// Inbound messages wait until the handler (possibly async) has registered its methods.
|
|
700
|
+
chan.hold();
|
|
701
|
+
let ready;
|
|
702
|
+
try { ready = Promise.resolve(handler(chan, ctx)); } catch (e) { ready = Promise.reject(e); }
|
|
703
|
+
ready.then(() => chan.release(), (e) => {
|
|
704
|
+
log(`rpc ${protocol} channel ${channelId}: handler failed: ${e?.message ?? e}`);
|
|
705
|
+
void chan.close('handler-error');
|
|
706
|
+
});
|
|
707
|
+
},
|
|
708
|
+
};
|
|
709
|
+
channels.set(channelId, ch);
|
|
710
|
+
pendingStreams.push(channelId);
|
|
711
|
+
await send(openOk({ channelId, dataMode: 'stream', capabilities: [] }));
|
|
712
|
+
log(`rpc channel ${channelId}: ${protocol}`);
|
|
713
|
+
}
|
|
714
|
+
|
|
653
715
|
async function openPty(m) {
|
|
654
716
|
if (!cfg.pty) return send(openFail({ reason: 'pty is not enabled on this server' }));
|
|
655
717
|
const channelId = ++state.nextChannel;
|
package/src/server/fs.mjs
CHANGED
|
@@ -25,6 +25,8 @@ export function createFileAccess({ root, readOnly = false, maxFileBytes = DEFAUL
|
|
|
25
25
|
let realBase = null;
|
|
26
26
|
|
|
27
27
|
const fail = (message) => Object.assign(new Error(message), { wsh: true });
|
|
28
|
+
/** A refusal on policy grounds (confinement, read-only): `wsh-fs` answers these `-32003`. */
|
|
29
|
+
const deny = (message) => Object.assign(new Error(message), { wsh: true, denied: true });
|
|
28
30
|
|
|
29
31
|
/** Resolve a client path to an absolute path inside `root`, or throw. */
|
|
30
32
|
async function resolveInside(clientPath) {
|
|
@@ -32,13 +34,13 @@ export function createFileAccess({ root, readOnly = false, maxFileBytes = DEFAUL
|
|
|
32
34
|
if (clientPath.includes('\0')) throw fail('illegal path');
|
|
33
35
|
realBase ??= await realpath(base);
|
|
34
36
|
const target = path.resolve(base, '.' + path.sep + clientPath.replace(/^[/\\]+/, ''));
|
|
35
|
-
if (target !== base && !target.startsWith(base + path.sep)) throw
|
|
37
|
+
if (target !== base && !target.startsWith(base + path.sep)) throw deny('path escapes the file root');
|
|
36
38
|
// Symlink escape: the deepest existing ancestor must still be inside the real root.
|
|
37
39
|
let probe = target;
|
|
38
40
|
for (;;) {
|
|
39
41
|
try {
|
|
40
42
|
const real = await realpath(probe);
|
|
41
|
-
if (real !== realBase && !real.startsWith(realBase + path.sep)) throw
|
|
43
|
+
if (real !== realBase && !real.startsWith(realBase + path.sep)) throw deny('path escapes the file root');
|
|
42
44
|
break;
|
|
43
45
|
} catch (err) {
|
|
44
46
|
if (err.wsh) throw err;
|
|
@@ -66,7 +68,7 @@ export function createFileAccess({ root, readOnly = false, maxFileBytes = DEFAUL
|
|
|
66
68
|
return entry;
|
|
67
69
|
}
|
|
68
70
|
|
|
69
|
-
const assertWritable = () => { if (readOnly) throw
|
|
71
|
+
const assertWritable = () => { if (readOnly) throw deny('file root is read-only'); };
|
|
70
72
|
|
|
71
73
|
return {
|
|
72
74
|
maxFileBytes,
|
|
@@ -137,6 +139,37 @@ export function createFileAccess({ root, readOnly = false, maxFileBytes = DEFAUL
|
|
|
137
139
|
}
|
|
138
140
|
},
|
|
139
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Stream a byte range of a regular file in `chunkBytes` (default `FILE_CHUNK_BYTES`) pieces (for `wsh-fs` `read`/`download`).
|
|
144
|
+
* `length` omitted = to the end of the file; a range longer than `maxFileBytes` is refused up front.
|
|
145
|
+
* @returns {Promise<{ size: number, offset: number, length: number, chunks: AsyncGenerator<Uint8Array> }>}
|
|
146
|
+
*/
|
|
147
|
+
async openRange(clientPath, { offset, length, chunkBytes = FILE_CHUNK_BYTES } = {}) {
|
|
148
|
+
const full = await resolveInside(clientPath);
|
|
149
|
+
const st = await stat(full);
|
|
150
|
+
if (!st.isFile()) throw fail('not a regular file');
|
|
151
|
+
const start = offset === undefined || offset === null ? 0 : Number(offset);
|
|
152
|
+
if (!Number.isSafeInteger(start) || start < 0) throw fail('illegal offset');
|
|
153
|
+
if (length !== undefined && length !== null && (!Number.isSafeInteger(Number(length)) || Number(length) < 0)) throw fail('illegal length');
|
|
154
|
+
const available = Math.max(0, st.size - start);
|
|
155
|
+
const want = length === undefined || length === null ? available : Math.min(Number(length), available);
|
|
156
|
+
if (want > maxFileBytes) throw fail(`read of ${want} bytes exceeds the ${maxFileBytes} byte limit`);
|
|
157
|
+
async function* chunks() {
|
|
158
|
+
const fh = await open(full, 'r');
|
|
159
|
+
try {
|
|
160
|
+
let pos = start; let left = want;
|
|
161
|
+
while (left > 0) {
|
|
162
|
+
const buf = new Uint8Array(Math.min(chunkBytes, left));
|
|
163
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, pos);
|
|
164
|
+
if (bytesRead === 0) break; // truncated underneath us
|
|
165
|
+
yield bytesRead === buf.length ? buf : buf.subarray(0, bytesRead);
|
|
166
|
+
pos += bytesRead; left -= bytesRead;
|
|
167
|
+
}
|
|
168
|
+
} finally { await fh.close(); }
|
|
169
|
+
}
|
|
170
|
+
return { size: st.size, offset: start, length: want, chunks: chunks() };
|
|
171
|
+
},
|
|
172
|
+
|
|
140
173
|
async readWhole(clientPath) {
|
|
141
174
|
const full = await resolveInside(clientPath);
|
|
142
175
|
const st = await stat(full);
|
package/src/server/index.d.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* root (`@johnhenry/wsh`) never imports it.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { RpcChannel } from '@johnhenry/wsh';
|
|
7
|
+
|
|
6
8
|
/** Who is asking to authenticate. */
|
|
7
9
|
export interface WshServerPrincipal {
|
|
8
10
|
username: string;
|
|
@@ -290,11 +292,44 @@ export interface WshServerOptions {
|
|
|
290
292
|
webTransport?: WshServerWebTransportOptions;
|
|
291
293
|
/** Serve MCP tools (`McpDiscover` / `McpCall`) and advertise `mcp-call-id`. Off by default. */
|
|
292
294
|
mcp?: WshServerMcpOptions;
|
|
295
|
+
/**
|
|
296
|
+
* Typed RPC channels (wsh #85): `{ [protocol]: handler }`. Each is advertised as `rpc-protocol:<name>` (plus `rpc` and
|
|
297
|
+
* `rpc-max-message:<n>`). Built-ins: `'wsh-host': true` (`host.info`, `host.ping`), `'wsh-fs': true` (the server's
|
|
298
|
+
* `fs`) or `{ root, readOnly?, maxFileBytes? }` (`stat list read write upload download rename mkdir remove`). Anything
|
|
299
|
+
* else is a function run per opened channel, e.g. `mcp: mcpServerAdapter(server)`. Off by default.
|
|
300
|
+
*/
|
|
301
|
+
rpc?: Record<string, true | WshServerFsOptions | WshRpcHandler | false | null | undefined>;
|
|
302
|
+
/** Largest single rpc message (default 1048576). */
|
|
303
|
+
rpcMaxMessageBytes?: number;
|
|
304
|
+
/** Concurrent requests per rpc channel before `-32002` (default 64). */
|
|
305
|
+
rpcMaxInflight?: number;
|
|
293
306
|
/** How long exec output waits for a client that has not yet opened its data stream (default 3000). */
|
|
294
307
|
bindTimeoutMs?: number;
|
|
295
308
|
onLog?: (line: string) => void;
|
|
296
309
|
}
|
|
297
310
|
|
|
311
|
+
/** Per-channel context handed to an rpc protocol handler. */
|
|
312
|
+
export interface WshRpcContext {
|
|
313
|
+
protocol: string;
|
|
314
|
+
channelId: number;
|
|
315
|
+
user: string | null;
|
|
316
|
+
fingerprint: string | null;
|
|
317
|
+
remote: { address?: string; headers?: Record<string, unknown> };
|
|
318
|
+
/** The exact `ServerHello.features` this connection received. */
|
|
319
|
+
features: string[];
|
|
320
|
+
hostFingerprint: string | null;
|
|
321
|
+
log(line: string): void;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Runs once per opened channel; register methods on `channel`. Inbound messages wait until the returned promise settles. */
|
|
325
|
+
export type WshRpcHandler = (channel: RpcChannel, ctx: WshRpcContext) => void | Promise<void>;
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Expose an MCP `Server` (`@modelcontextprotocol/sdk`) over an rpc channel: `rpc: { mcp: mcpServerAdapter(server) }`.
|
|
329
|
+
* An instance serves one channel at a time; pass a factory for one `Server` per channel. The SDK is not imported.
|
|
330
|
+
*/
|
|
331
|
+
export function mcpServerAdapter(serverOrFactory: { connect(transport: any): Promise<void>; close?(): Promise<void> } | ((ctx: WshRpcContext) => any)): WshRpcHandler;
|
|
332
|
+
|
|
298
333
|
export interface WshServerAddress {
|
|
299
334
|
address: string;
|
|
300
335
|
port: number;
|
package/src/server/index.mjs
CHANGED
|
@@ -15,8 +15,9 @@ import { SessionRegistry } from './sessions.mjs';
|
|
|
15
15
|
import { RelayHub } from './relay.mjs';
|
|
16
16
|
import { startWebTransport } from './webtransport.mjs';
|
|
17
17
|
import { createConnectionFactory, STREAM_ANNOUNCE } from './connection.mjs';
|
|
18
|
+
import { createRpcHost, mcpServerAdapter } from './rpc.mjs';
|
|
18
19
|
|
|
19
|
-
export { parseAuthorizedKeys, STREAM_ANNOUNCE };
|
|
20
|
+
export { parseAuthorizedKeys, STREAM_ANNOUNCE, mcpServerAdapter };
|
|
20
21
|
export { createReverseHost } from './reverse-host.mjs';
|
|
21
22
|
export { generateSelfSignedCertificate } from './self-signed.mjs';
|
|
22
23
|
|
|
@@ -45,6 +46,12 @@ export { generateSelfSignedCertificate } from './self-signed.mjs';
|
|
|
45
46
|
* Keep pty/exec sessions alive across disconnects so a client can `resumeSession()` / `attachSession()`
|
|
46
47
|
* them: a per-server registry, a ring buffer of the newest `ringBytes` (default 1 MiB) of output, and
|
|
47
48
|
* `detachTtlMs` (default 300000; 0 = kill on disconnect) / `maxDetached` (default 16) bounds. Off unless given.
|
|
49
|
+
* @param {Record<string, true | object | Function>} [options.rpc] - Typed RPC channels (wsh #85): `{ [protocol]: handler }`.
|
|
50
|
+
* Built-ins: `'wsh-host': true`, `'wsh-fs': true` (confined by `fs`) or `{ root, readOnly?, maxFileBytes? }`; anything else is a
|
|
51
|
+
* function `(channel, ctx) => void` run per opened channel -- e.g. `mcp: mcpServerAdapter(server)`. Each protocol is
|
|
52
|
+
* advertised as `rpc-protocol:<name>`. Off unless given.
|
|
53
|
+
* @param {number} [options.rpcMaxMessageBytes=1048576] - Largest single rpc message (advertised as `rpc-max-message:<n>`).
|
|
54
|
+
* @param {number} [options.rpcMaxInflight=64] - Concurrent requests per rpc channel before `-32002`.
|
|
48
55
|
* @param {string | Uint8Array} [options.sessionSecret] - Alias for `sessions.sessionSecret`.
|
|
49
56
|
* @param {true | { file: string } | CryptoKeyPair} [options.hostKey] - The server's own Ed25519
|
|
50
57
|
* identity, advertised (with proof of possession) so clients can pin it. See `src/host-key.mjs`.
|
|
@@ -55,7 +62,7 @@ export { generateSelfSignedCertificate } from './self-signed.mjs';
|
|
|
55
62
|
* @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
|
|
56
63
|
*/
|
|
57
64
|
export function createWshServer({
|
|
58
|
-
host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, sessions, sessionSecret, relay, webTransport, bindTimeoutMs = 3000, onLog = () => {},
|
|
65
|
+
host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, rpc, rpcMaxMessageBytes, rpcMaxInflight, sessions, sessionSecret, relay, webTransport, bindTimeoutMs = 3000, onLog = () => {},
|
|
59
66
|
} = {}) {
|
|
60
67
|
let wss = null;
|
|
61
68
|
let bound = null;
|
|
@@ -68,6 +75,7 @@ export function createWshServer({
|
|
|
68
75
|
key: typeof rl.key === 'function' ? rl.key : ({ address }) => String(address ?? 'unknown'),
|
|
69
76
|
failureDelayMs: rl.failureDelayMs ?? 250,
|
|
70
77
|
};
|
|
78
|
+
const rpcHost = rpc ? createRpcHost(rpc, { files, maxMessageBytes: rpcMaxMessageBytes, maxInflight: rpcMaxInflight }) : null;
|
|
71
79
|
let host_ = null;
|
|
72
80
|
let registry = null;
|
|
73
81
|
let hub = null;
|
|
@@ -98,7 +106,7 @@ export function createWshServer({
|
|
|
98
106
|
if (relay) hub = new RelayHub({ ...relay, log: onLog });
|
|
99
107
|
const attach = createConnectionFactory({
|
|
100
108
|
authorize, execRunner, execOptions, pty: ptyConfig, files, bindTimeoutMs, log: onLog,
|
|
101
|
-
methods, hostKey: host_, rateLimit, mcp: mcpHost, sessions: registry, relay: hub,
|
|
109
|
+
methods, hostKey: host_, rateLimit, mcp: mcpHost, rpc: rpcHost, sessions: registry, relay: hub,
|
|
102
110
|
});
|
|
103
111
|
|
|
104
112
|
wss = new WebSocketServer({ host, port });
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed RPC channels for `@johnhenry/wsh/server` -- wsh #85.
|
|
3
|
+
*
|
|
4
|
+
* `createWshServer({ rpc: { [protocol]: handler } })`. A handler is
|
|
5
|
+
* - `true` for a built-in (`wsh-host`; `wsh-fs`, confined by the server's `fs` option), or `{ root, ... }` for
|
|
6
|
+
* `wsh-fs` with its own root;
|
|
7
|
+
* - a function `(channel, ctx) => void | Promise<void>` for anything else (including `mcpServerAdapter(server)`),
|
|
8
|
+
* called once per opened channel with an `RpcChannel` to register methods on.
|
|
9
|
+
*
|
|
10
|
+
* Built-ins:
|
|
11
|
+
* wsh-host host.info, host.ping
|
|
12
|
+
* wsh-fs stat, list, read, write, upload, download, rename, mkdir, remove
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { RpcError, RPC_ERROR, RPC_PROTOCOL_NAME_RE, RPC_DEFAULT_MAX_MESSAGE } from '../rpc.mjs';
|
|
17
|
+
import { RpcMcpTransport } from '../rpc-mcp.mjs';
|
|
18
|
+
import { createFileAccess } from './fs.mjs';
|
|
19
|
+
|
|
20
|
+
const { version: PACKAGE_VERSION } = createRequire(import.meta.url)('../../package.json');
|
|
21
|
+
|
|
22
|
+
export const WSH_HOST = 'wsh-host';
|
|
23
|
+
export const WSH_FS = 'wsh-fs';
|
|
24
|
+
const BUILT_INS = new Set([WSH_HOST, WSH_FS, 'mcp']);
|
|
25
|
+
|
|
26
|
+
const needString = (params, key) => {
|
|
27
|
+
const v = params?.[key];
|
|
28
|
+
if (typeof v !== 'string') throw new RpcError(RPC_ERROR.INVALID_PARAMS, `"${key}" (string) is required`);
|
|
29
|
+
return v;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const needBytes = (params, key) => {
|
|
33
|
+
const v = params?.[key];
|
|
34
|
+
if (!(v instanceof Uint8Array)) throw new RpcError(RPC_ERROR.INVALID_PARAMS, `"${key}" (byte string) is required`);
|
|
35
|
+
return v;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Map a file-access failure onto an RpcError. */
|
|
39
|
+
function fsError(e) {
|
|
40
|
+
if (e instanceof RpcError) return e;
|
|
41
|
+
if (e?.denied) return new RpcError(RPC_ERROR.UNAUTHORIZED, e.message);
|
|
42
|
+
if (e?.wsh) return new RpcError(RPC_ERROR.INVALID_PARAMS, e.message);
|
|
43
|
+
if (e?.code === 'ENOENT') return new RpcError(RPC_ERROR.INTERNAL, 'no such file or directory', { code: 'ENOENT' });
|
|
44
|
+
return new RpcError(RPC_ERROR.INTERNAL, 'file operation failed', e?.code ? { code: e.code } : undefined);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @param {ReturnType<typeof createFileAccess>} files */
|
|
48
|
+
function fsProtocol(files) {
|
|
49
|
+
return (channel, ctx) => {
|
|
50
|
+
const guarded = (fn) => async (params, rctx) => {
|
|
51
|
+
try { return await fn(params ?? {}, rctx); } catch (e) {
|
|
52
|
+
if (!(e instanceof RpcError)) ctx.log?.(`wsh-fs: ${e.wsh ? e.message : (e.stack ?? e)}`);
|
|
53
|
+
throw fsError(e);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
channel.onRequest('stat', guarded(async (p) => (await files.operate('stat', needString(p, 'path'))).metadata));
|
|
57
|
+
channel.onRequest('list', guarded(async (p) => {
|
|
58
|
+
const r = await files.operate('list', typeof p.path === 'string' ? p.path : '/');
|
|
59
|
+
return { path: r.metadata?.path ?? p.path ?? '/', entries: r.entries ?? [] };
|
|
60
|
+
}));
|
|
61
|
+
channel.onRequest('mkdir', guarded(async (p) => (await files.operate('mkdir', needString(p, 'path'))).metadata));
|
|
62
|
+
channel.onRequest('remove', guarded(async (p) => (await files.operate('remove', needString(p, 'path'))).metadata));
|
|
63
|
+
channel.onRequest('rename', guarded(async (p) => (await files.operate('rename', needString(p, 'path'), { newPath: needString(p, 'newPath') })).metadata));
|
|
64
|
+
channel.onRequest('write', guarded(async (p) => (await files.operate('write', needString(p, 'path'), { offset: p.offset, data: needBytes(p, 'data') })).metadata));
|
|
65
|
+
// upload: the chunk-friendly write. offset omitted/0 creates or truncates the file; offset > 0 continues it in place.
|
|
66
|
+
channel.onRequest('upload', guarded(async (p) => {
|
|
67
|
+
const data = needBytes(p, 'data');
|
|
68
|
+
const offset = p.offset === undefined || p.offset === null ? 0 : Number(p.offset);
|
|
69
|
+
if (!Number.isSafeInteger(offset) || offset < 0) throw new RpcError(RPC_ERROR.INVALID_PARAMS, 'illegal offset');
|
|
70
|
+
if (offset === 0) await files.writeAt(needString(p, 'path'), data, undefined, { mkdirs: true });
|
|
71
|
+
else await files.writeAt(needString(p, 'path'), data, offset);
|
|
72
|
+
return { written: data.byteLength, offset };
|
|
73
|
+
}));
|
|
74
|
+
// read / download: chunks arrive as `$/progress`, the result is a summary.
|
|
75
|
+
const stream = async (p, rctx) => {
|
|
76
|
+
const range = await files.openRange(needString(p, 'path'), {
|
|
77
|
+
offset: p.offset, length: p.length, chunkBytes: Math.max(64, Math.min(64 * 1024, rctx.channel.maxMessageBytes - 512)),
|
|
78
|
+
});
|
|
79
|
+
let sent = 0;
|
|
80
|
+
for await (const chunk of range.chunks) {
|
|
81
|
+
if (rctx.signal.aborted) break;
|
|
82
|
+
await rctx.progress(chunk);
|
|
83
|
+
sent += chunk.byteLength;
|
|
84
|
+
}
|
|
85
|
+
return { size: range.size, offset: range.offset, length: sent, eof: range.offset + sent >= range.size };
|
|
86
|
+
};
|
|
87
|
+
channel.onRequest('read', guarded(stream));
|
|
88
|
+
channel.onRequest('download', guarded((p, rctx) => stream({ path: p.path }, rctx)));
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function hostProtocol(info) {
|
|
93
|
+
return (channel, ctx) => {
|
|
94
|
+
channel.onRequest('host.info', () => ({
|
|
95
|
+
version: PACKAGE_VERSION,
|
|
96
|
+
protocol: 'wsh-v1',
|
|
97
|
+
features: [...ctx.features],
|
|
98
|
+
hostFingerprint: ctx.hostFingerprint ?? null,
|
|
99
|
+
user: ctx.user,
|
|
100
|
+
rpc: { protocols: info.protocols(), maxMessageBytes: info.maxMessageBytes },
|
|
101
|
+
}));
|
|
102
|
+
channel.onRequest('host.ping', () => ({ time: Date.now() }));
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Validate the `rpc` option and build the per-connection protocol table.
|
|
108
|
+
* @param {Record<string, true | object | Function>} rpc
|
|
109
|
+
* @param {{ files: object | null, maxMessageBytes?: number, maxInflight?: number }} deps
|
|
110
|
+
* @returns {{ protocols: Map<string, Function>, maxMessageBytes: number, maxInflight: number }}
|
|
111
|
+
*/
|
|
112
|
+
export function createRpcHost(rpc, { files, maxMessageBytes = RPC_DEFAULT_MAX_MESSAGE, maxInflight = 64 } = {}) {
|
|
113
|
+
if (!rpc || typeof rpc !== 'object' || Array.isArray(rpc)) throw new TypeError('createWshServer: rpc must be an object of { [protocol]: handler }');
|
|
114
|
+
if (!(maxMessageBytes >= 256)) throw new TypeError('createWshServer: rpcMaxMessageBytes must be at least 256');
|
|
115
|
+
const protocols = new Map();
|
|
116
|
+
const info = { protocols: () => [...protocols.keys()], maxMessageBytes };
|
|
117
|
+
for (const [name, spec] of Object.entries(rpc)) {
|
|
118
|
+
if (spec === undefined || spec === false || spec === null) continue;
|
|
119
|
+
if (!RPC_PROTOCOL_NAME_RE.test(name)) throw new TypeError(`createWshServer: rpc protocol name ${JSON.stringify(name)} is not allowed (letters, digits, ".", "_", "-")`);
|
|
120
|
+
if (typeof spec === 'function') { protocols.set(name, spec); continue; }
|
|
121
|
+
if (name === WSH_HOST && spec === true) { protocols.set(name, hostProtocol(info)); continue; }
|
|
122
|
+
if (name === WSH_FS && (spec === true || (spec && typeof spec === 'object'))) {
|
|
123
|
+
if (spec === true && !files) throw new TypeError('createWshServer: rpc["wsh-fs"]: true needs the fs option (or pass { root })');
|
|
124
|
+
protocols.set(name, fsProtocol(spec === true ? files : createFileAccess(spec)));
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (name === 'mcp') throw new TypeError('createWshServer: rpc.mcp needs a handler: mcpServerAdapter(server)');
|
|
128
|
+
throw new TypeError(`createWshServer: rpc[${JSON.stringify(name)}] needs a handler function (channel, ctx) => void${BUILT_INS.has(name) ? ' or a valid built-in config' : ''}`);
|
|
129
|
+
}
|
|
130
|
+
return { protocols, maxMessageBytes, maxInflight };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Expose an MCP `Server` (`@modelcontextprotocol/sdk`) over an `rpc` channel: `rpc: { mcp: mcpServerAdapter(server) }`.
|
|
135
|
+
*
|
|
136
|
+
* Pass a Server instance to serve one channel at a time (a second concurrent channel is closed with an error),
|
|
137
|
+
* or a factory `(ctx) => Server` to give every channel its own. The SDK is not imported here; any object with
|
|
138
|
+
* `connect(transport)` works.
|
|
139
|
+
*
|
|
140
|
+
* @param {{ connect(transport: object): Promise<void>, close?(): Promise<void> } | ((ctx: object) => any)} serverOrFactory
|
|
141
|
+
* @returns {(channel: import('../rpc.mjs').RpcChannel, ctx: object) => Promise<void>}
|
|
142
|
+
*/
|
|
143
|
+
export function mcpServerAdapter(serverOrFactory) {
|
|
144
|
+
const isFactory = typeof serverOrFactory === 'function' && typeof serverOrFactory.connect !== 'function';
|
|
145
|
+
if (!isFactory && typeof serverOrFactory?.connect !== 'function') {
|
|
146
|
+
throw new TypeError('mcpServerAdapter: expected an MCP Server (with connect(transport)) or a factory returning one');
|
|
147
|
+
}
|
|
148
|
+
let busy = false;
|
|
149
|
+
return async (channel, ctx) => {
|
|
150
|
+
if (!isFactory && busy) {
|
|
151
|
+
// The SDK's Server speaks to one transport at a time; saying so beats silently stealing the first channel.
|
|
152
|
+
throw new Error('mcp server instance is already serving another channel (pass a factory for concurrent channels)');
|
|
153
|
+
}
|
|
154
|
+
const server = isFactory ? await serverOrFactory(ctx) : serverOrFactory;
|
|
155
|
+
if (typeof server?.connect !== 'function') throw new TypeError('mcpServerAdapter: the factory must return an MCP Server');
|
|
156
|
+
const transport = new RpcMcpTransport(channel);
|
|
157
|
+
if (!isFactory) busy = true;
|
|
158
|
+
const prior = channel.onClose;
|
|
159
|
+
channel.onClose = (reason) => {
|
|
160
|
+
if (!isFactory) busy = false;
|
|
161
|
+
try { prior?.(reason); } catch { /* ignore */ }
|
|
162
|
+
if (isFactory) Promise.resolve(server.close?.()).catch(() => {});
|
|
163
|
+
};
|
|
164
|
+
try {
|
|
165
|
+
await server.connect(transport);
|
|
166
|
+
} catch (e) {
|
|
167
|
+
if (!isFactory) busy = false;
|
|
168
|
+
throw e;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|