@johnhenry/wsh 0.19.1 → 0.21.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 +142 -3
- package/package.json +1 -1
- package/src/client.mjs +76 -28
- package/src/index.d.ts +23 -3
- package/src/server/connection.mjs +287 -33
- package/src/server/index.d.ts +60 -0
- package/src/server/index.mjs +27 -2
- package/src/server/json-schema.mjs +204 -0
- package/src/server/mcp.mjs +111 -0
- package/src/server/sessions.mjs +263 -0
- package/src/session.mjs +31 -0
|
@@ -18,12 +18,15 @@ import { QMuxConnection } from '../qmux-connection.mjs';
|
|
|
18
18
|
import { FrameDecoder, frameEncode } from '../cbor.mjs';
|
|
19
19
|
import {
|
|
20
20
|
MSG, serverHello, challenge, authOk, authFail, openOk, openFail, sessionData,
|
|
21
|
-
exit as exitMsg, close as closeMsg, pong, fileResult, fileChunk,
|
|
21
|
+
exit as exitMsg, close as closeMsg, pong, fileResult, fileChunk, mcpTools, mcpResult,
|
|
22
|
+
presence as presenceMsg, error as errorMsg, sessionList, detachOk, detachFail,
|
|
22
23
|
} from '../messages.gen.mjs';
|
|
23
24
|
import {
|
|
24
25
|
generateNonce, verifyChallenge, fingerprint, importPublicKeyRaw,
|
|
25
26
|
} from '../auth.mjs';
|
|
26
27
|
import { FILE_CHUNK_BYTES } from './fs.mjs';
|
|
28
|
+
import { MAX_ATTACHMENTS } from './sessions.mjs';
|
|
29
|
+
import { MCP_CALL_ID_FEATURE } from '../client.mjs';
|
|
27
30
|
import { HOST_KEY_PREFIX, HOST_KEY_SIG_PREFIX, findClientNonce, hostKeyProofMessage, toHex } from '../host-key.mjs';
|
|
28
31
|
|
|
29
32
|
export const STREAM_ANNOUNCE = 'stream-announce';
|
|
@@ -32,6 +35,7 @@ const MAX_RENAME_PATH_BYTES = 4096;
|
|
|
32
35
|
const WRITE_IDLE_MS = 30_000;
|
|
33
36
|
|
|
34
37
|
const MAX_CHANNELS = 64;
|
|
38
|
+
const REPLAY_CHUNK_BYTES = 32 * 1024;
|
|
35
39
|
const USERNAME_RE = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/;
|
|
36
40
|
const enc = new TextEncoder();
|
|
37
41
|
|
|
@@ -47,6 +51,8 @@ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
|
|
|
47
51
|
* @param {object | null} cfg.files - from createFileAccess
|
|
48
52
|
* @param {{ pubkey: boolean, password: Function | null }} cfg.methods - which auth methods are on
|
|
49
53
|
* @param {object | null} cfg.hostKey - from loadHostKey
|
|
54
|
+
* @param {object | null} cfg.mcp - from createMcpHost
|
|
55
|
+
* @param {object | null} cfg.sessions - a SessionRegistry (see sessions.mjs); null = sessions die with their connection
|
|
50
56
|
* @param {{ limiter: object, key: Function, failureDelayMs: number } | null} cfg.rateLimit - password throttle
|
|
51
57
|
* @param {number} cfg.bindTimeoutMs - how long exec output waits for the data stream
|
|
52
58
|
* @param {(line: string) => void} cfg.log
|
|
@@ -59,12 +65,14 @@ export function createConnectionFactory(cfg) {
|
|
|
59
65
|
const log = (m) => cfg.log(`[conn ${cid}] ${m}`);
|
|
60
66
|
const state = {
|
|
61
67
|
sessionId: randomUUID(), nonce: null, username: null, authed: false,
|
|
62
|
-
closed: false, nextChannel: 0,
|
|
68
|
+
closed: false, nextChannel: 0, fingerprint: null,
|
|
63
69
|
};
|
|
64
70
|
/** @type {Map<number, any>} */
|
|
65
71
|
const channels = new Map();
|
|
66
72
|
/** FileOp `write`/`rename`s awaiting their FileChunk frames, by client-chosen channel id. */
|
|
67
73
|
const fileWrites = new Map();
|
|
74
|
+
/** AbortControllers of in-flight MCP calls: aborted on disconnect, counted for the cap. */
|
|
75
|
+
const mcpCalls = new Set();
|
|
68
76
|
/** Channel ids awaiting their client-opened data stream, in OpenOk order. */
|
|
69
77
|
const pendingStreams = [];
|
|
70
78
|
const decoder = new FrameDecoder();
|
|
@@ -85,16 +93,25 @@ export function createConnectionFactory(cfg) {
|
|
|
85
93
|
function shutdown(why) {
|
|
86
94
|
if (state.closed) return;
|
|
87
95
|
state.closed = true;
|
|
88
|
-
|
|
96
|
+
// A hosted session survives its connection (it is detached, then expires or is resumed);
|
|
97
|
+
// everything else dies with it.
|
|
98
|
+
for (const ch of [...channels.values()]) (ch.detach ?? ch.kill)?.call(ch);
|
|
89
99
|
channels.clear();
|
|
100
|
+
for (const c of mcpCalls) c.abort(new Error('connection closed'));
|
|
101
|
+
mcpCalls.clear();
|
|
90
102
|
for (const w of fileWrites.values()) clearTimeout(w.timer);
|
|
91
103
|
fileWrites.clear();
|
|
92
104
|
try { qmux.destroy(new Error(why)); } catch { /* already gone */ }
|
|
93
105
|
log(`closed (${why})`);
|
|
94
106
|
}
|
|
95
107
|
|
|
108
|
+
// A transport that closes right behind a message (a client's `disconnect()` sends Close for
|
|
109
|
+
// every channel and then goes) must not make the host forget that message: it is processed
|
|
110
|
+
// first, then the connection is torn down.
|
|
111
|
+
const shutdownAfterQueue = (why) => { chain = chain.then(() => shutdown(why)); };
|
|
112
|
+
|
|
96
113
|
qmux.onError = (e) => log(`qmux error: ${e.message}`);
|
|
97
|
-
qmux.onClose = () =>
|
|
114
|
+
qmux.onClose = () => shutdownAfterQueue('peer sent CONNECTION_CLOSE');
|
|
98
115
|
qmux.onStreamOpen = (s) => {
|
|
99
116
|
if (s.id !== 0) { bindDataStream(s); return; }
|
|
100
117
|
control = s;
|
|
@@ -103,8 +120,8 @@ export function createConnectionFactory(cfg) {
|
|
|
103
120
|
try { msgs = decoder.feed(d); } catch (e) { log(`frame decode error: ${e.message}`); return; }
|
|
104
121
|
for (const m of msgs) chain = chain.then(() => handle(m)).catch((e) => log(`handler error: ${e.message}`));
|
|
105
122
|
};
|
|
106
|
-
s.onEnd = () =>
|
|
107
|
-
s.onReset = () =>
|
|
123
|
+
s.onEnd = () => shutdownAfterQueue('control stream FIN');
|
|
124
|
+
s.onReset = () => shutdownAfterQueue('control stream reset');
|
|
108
125
|
for (const b of pendingSends.splice(0)) s.write(b).catch(() => {});
|
|
109
126
|
};
|
|
110
127
|
qmux.sendHandshake();
|
|
@@ -130,6 +147,14 @@ export function createConnectionFactory(cfg) {
|
|
|
130
147
|
case MSG.CLOSE: { const ch = channels.get(m.channel_id); if (ch) { ch.kill?.(); } return; }
|
|
131
148
|
case MSG.FILE_OP: return handleFileOp(m);
|
|
132
149
|
case MSG.FILE_CHUNK: return (fileWrites.has(m.channel_id) ? handleWriteChunk(m) : channels.get(m.channel_id)?.chunk?.(m));
|
|
150
|
+
case MSG.ATTACH: return handleAttach(m, false);
|
|
151
|
+
case MSG.RESUME: return handleAttach(m, true);
|
|
152
|
+
case MSG.DETACH: return handleDetach(m);
|
|
153
|
+
case MSG.SESSION_LIST_REQUEST: return handleSessionList();
|
|
154
|
+
case MSG.MCP_DISCOVER: return handleMcpDiscover();
|
|
155
|
+
// Not awaited: the handler chain is serial, and a slow tool must not hold up
|
|
156
|
+
// the next call (or a Close) behind it.
|
|
157
|
+
case MSG.MCP_CALL: handleMcpCall(m); return;
|
|
133
158
|
default: log(`ignored message type 0x${m.type.toString(16)}`);
|
|
134
159
|
}
|
|
135
160
|
}
|
|
@@ -155,6 +180,7 @@ export function createConnectionFactory(cfg) {
|
|
|
155
180
|
state.username = user;
|
|
156
181
|
state.method = method;
|
|
157
182
|
const features = [STREAM_ANNOUNCE];
|
|
183
|
+
if (cfg.mcp) features.push(MCP_CALL_ID_FEATURE);
|
|
158
184
|
if (cfg.files) features.push('file-transfer', 'file-write', 'file-rename');
|
|
159
185
|
let hostFingerprint;
|
|
160
186
|
if (cfg.hostKey) {
|
|
@@ -199,6 +225,7 @@ export function createConnectionFactory(cfg) {
|
|
|
199
225
|
} catch (e) { log(`authorize threw: ${e.message}`); }
|
|
200
226
|
if (!allowed) return refuse('key not authorized');
|
|
201
227
|
state.authed = true;
|
|
228
|
+
state.fingerprint = fp;
|
|
202
229
|
state.nonce = null;
|
|
203
230
|
await send(authOk({ sessionId: state.sessionId, token: randomBytes(16), ttl: 3600 }));
|
|
204
231
|
log(`authenticated ${state.username} (${fp.slice(0, 12)}...)`);
|
|
@@ -228,6 +255,63 @@ export function createConnectionFactory(cfg) {
|
|
|
228
255
|
log(`authenticated ${state.username} (password)`);
|
|
229
256
|
}
|
|
230
257
|
|
|
258
|
+
// ── MCP ───────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
const principal = () => ({ username: state.username, fingerprint: state.fingerprint });
|
|
261
|
+
|
|
262
|
+
async function handleMcpDiscover() {
|
|
263
|
+
let tools = [];
|
|
264
|
+
if (cfg.mcp) {
|
|
265
|
+
try { tools = await cfg.mcp.list(state.username, principal()); } catch (e) { log(`mcp discover failed: ${e.message}`); }
|
|
266
|
+
}
|
|
267
|
+
return send(mcpTools({ tools }));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function handleMcpCall(m) {
|
|
271
|
+
const callId = typeof m.call_id === 'string' ? m.call_id : undefined;
|
|
272
|
+
const reply = (result) => {
|
|
273
|
+
try { return send(mcpResult({ result, callId })); } catch (e) {
|
|
274
|
+
log(`mcp result for ${m.tool} not encodable: ${e.message}`);
|
|
275
|
+
return send(mcpResult({ result: { success: false, error: 'tool result could not be encoded' }, callId }));
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
const fail = (error) => reply({ success: false, error });
|
|
279
|
+
if (!cfg.mcp) { fail('mcp is not enabled on this server'); return; }
|
|
280
|
+
if (mcpCalls.size >= cfg.mcp.maxConcurrent) { fail('too many concurrent MCP calls'); return; }
|
|
281
|
+
|
|
282
|
+
const abort = new AbortController();
|
|
283
|
+
mcpCalls.add(abort);
|
|
284
|
+
(async () => {
|
|
285
|
+
const name = typeof m.tool === 'string' ? m.tool : '';
|
|
286
|
+
const tool = name ? await cfg.mcp.find(name, state.username, principal()) : null;
|
|
287
|
+
if (!tool) return fail(`unknown tool: ${name || String(m.tool)}`);
|
|
288
|
+
const args = m.arguments ?? {};
|
|
289
|
+
const problem = cfg.mcp.validate(tool, args);
|
|
290
|
+
if (problem) return fail(`invalid arguments: ${problem}`);
|
|
291
|
+
|
|
292
|
+
let timer = null;
|
|
293
|
+
const aborted = new Promise((_, reject) => {
|
|
294
|
+
abort.signal.addEventListener('abort', () => reject(abort.signal.reason ?? new Error('aborted')), { once: true });
|
|
295
|
+
});
|
|
296
|
+
aborted.catch(() => {});
|
|
297
|
+
if (cfg.mcp.timeoutMs > 0) {
|
|
298
|
+
timer = setTimeout(() => abort.abort(new Error(`tool ${name} timed out after ${cfg.mcp.timeoutMs}ms`)), cfg.mcp.timeoutMs);
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
const ctx = { user: state.username, fingerprint: state.fingerprint, signal: abort.signal };
|
|
302
|
+
const out = await Promise.race([Promise.resolve().then(() => tool.call(args, ctx)), aborted]);
|
|
303
|
+
return reply(out === undefined ? null : out);
|
|
304
|
+
} catch (e) {
|
|
305
|
+
if (!state.closed) log(`mcp tool ${name} failed: ${e?.message ?? e}`);
|
|
306
|
+
return fail(String(e?.message ?? e));
|
|
307
|
+
} finally {
|
|
308
|
+
clearTimeout(timer);
|
|
309
|
+
}
|
|
310
|
+
})()
|
|
311
|
+
.catch((e) => log(`mcp call ${m.tool}: ${e.message}`))
|
|
312
|
+
.finally(() => mcpCalls.delete(abort));
|
|
313
|
+
}
|
|
314
|
+
|
|
231
315
|
// ── Open ──────────────────────────────────────────────────────────
|
|
232
316
|
|
|
233
317
|
function handleOpen(m) {
|
|
@@ -246,6 +330,118 @@ export function createConnectionFactory(cfg) {
|
|
|
246
330
|
return send(exitMsg({ channelId, code })).then(() => send(closeMsg({ channelId })));
|
|
247
331
|
}
|
|
248
332
|
|
|
333
|
+
// ── Hosted sessions (createWshServer({ sessions })) ───────────────
|
|
334
|
+
|
|
335
|
+
/** Who this connection authenticated as: its key, or its username for a password login. */
|
|
336
|
+
const principalKey = () => state.fingerprint ?? `password:${state.username}`;
|
|
337
|
+
const ownerRecord = () => ({ username: state.username, fingerprint: state.fingerprint, principal: principalKey() });
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* This connection's message-backed attachment to a hosted session: output, Presence and the
|
|
341
|
+
* final Exit/Close all go through one ordered chain, so a replay is never overtaken by live output.
|
|
342
|
+
*/
|
|
343
|
+
function virtualAttachment(hosted, channelId, mode) {
|
|
344
|
+
let out = Promise.resolve();
|
|
345
|
+
const enqueue = (fn) => { out = out.then(fn).catch(() => {}); return out; };
|
|
346
|
+
return {
|
|
347
|
+
connId: cid, channelId, mode, username: state.username, principal: principalKey(),
|
|
348
|
+
write: (bytes) => enqueue(() => send(sessionData({ channelId, data: bytes }))),
|
|
349
|
+
notify: (msg) => enqueue(() => send(presenceMsg(msg))),
|
|
350
|
+
exit: (code) => enqueue(async () => {
|
|
351
|
+
channels.delete(channelId);
|
|
352
|
+
await send(exitMsg({ channelId, code }));
|
|
353
|
+
await send(closeMsg({ channelId }));
|
|
354
|
+
}),
|
|
355
|
+
flush: () => out,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** The `channels` entry for a virtual attachment (an Attach/Resume, or a pty's own channel). */
|
|
360
|
+
function attachedChannel(hosted, att) {
|
|
361
|
+
const control = () => att.mode === 'control';
|
|
362
|
+
const ch = {
|
|
363
|
+
session: hosted, attachment: att,
|
|
364
|
+
input: (d) => { if (control()) hosted.handlers.input?.(d); },
|
|
365
|
+
resize: (c, r) => { if (control()) hosted.handlers.resize?.(c, r); },
|
|
366
|
+
signal: (name) => { if (control()) hosted.handlers.signal?.(name); },
|
|
367
|
+
// The client closed the channel: the session's owner ends it, anyone else just leaves it.
|
|
368
|
+
kill: () => {
|
|
369
|
+
if (att.principal === hosted.owner.principal && control()) hosted.kill();
|
|
370
|
+
else ch.detach();
|
|
371
|
+
},
|
|
372
|
+
detach: () => { channels.delete(att.channelId); hosted.detach(att); },
|
|
373
|
+
};
|
|
374
|
+
channels.set(att.channelId, ch);
|
|
375
|
+
return ch;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function handleAttach(m, isResume) {
|
|
379
|
+
const reg = cfg.sessions;
|
|
380
|
+
const fail = (code, message) => send(errorMsg({ code, message }));
|
|
381
|
+
if (!reg) return fail(3, `${isResume ? 'resume' : 'attach'} is not enabled on this server (no "sessions" option)`);
|
|
382
|
+
const s = reg.get(m.session_id);
|
|
383
|
+
const mine = !!s && s.owner.principal === principalKey();
|
|
384
|
+
const tokenOk = !!s && m.token !== undefined && reg.checkToken(s, m.token);
|
|
385
|
+
// Attach: the token OR ownership. Resume: the token AND ownership (the credentialed connection coming back).
|
|
386
|
+
if (!s || !(isResume ? tokenOk && mine : tokenOk || mine)) {
|
|
387
|
+
log(`${isResume ? 'resume' : 'attach'} refused for ${String(m.session_id).slice(0, 8)}`);
|
|
388
|
+
return fail(2, 'unknown session or not authorized');
|
|
389
|
+
}
|
|
390
|
+
if ([...channels.values()].some((c) => c.session === s)) return fail(3, 'already attached to this session on this connection');
|
|
391
|
+
if (s.attachments.size >= MAX_ATTACHMENTS) return fail(3, 'too many attachments to this session');
|
|
392
|
+
if (channels.size >= MAX_CHANNELS) return fail(3, 'too many open channels');
|
|
393
|
+
|
|
394
|
+
let from = s.ring.start;
|
|
395
|
+
if (isResume) {
|
|
396
|
+
const last = m.last_seq;
|
|
397
|
+
if (!Number.isSafeInteger(last) || last < 0) return fail(3, 'last_seq must be a non-negative integer');
|
|
398
|
+
if (last > s.ring.end) return fail(4, `last_seq ${last} is ahead of the session (it has produced ${s.ring.end} bytes)`);
|
|
399
|
+
if (last < s.ring.start) {
|
|
400
|
+
return fail(4, `output gap: this session's retained output starts at seq ${s.ring.start} but last_seq is ${last}; attach for the retained output instead`);
|
|
401
|
+
}
|
|
402
|
+
from = last;
|
|
403
|
+
}
|
|
404
|
+
const mode = !isResume && /^(readonly|read|view|ro)$/i.test(String(m.mode ?? '')) ? 'readonly' : 'control';
|
|
405
|
+
const channelId = ++state.nextChannel;
|
|
406
|
+
const att = virtualAttachment(s, channelId, mode);
|
|
407
|
+
attachedChannel(s, att);
|
|
408
|
+
|
|
409
|
+
// Reply, replay, then (if the process is already over) its exit -- all before any live output,
|
|
410
|
+
// which only reaches `att` once `s.attach(att)` registers it, after these are queued.
|
|
411
|
+
const self = { session_id: s.id, mode, username: state.username, channel_id: channelId, seq: from };
|
|
412
|
+
att.notify({ attachments: [self, ...s.roster()] });
|
|
413
|
+
const replay = s.ring.read(from);
|
|
414
|
+
for (let off = 0; off < replay.byteLength; off += REPLAY_CHUNK_BYTES) att.write(replay.subarray(off, off + REPLAY_CHUNK_BYTES));
|
|
415
|
+
if (s.state === 'exited') att.exit(s.exitCode ?? 0);
|
|
416
|
+
else s.attach(att);
|
|
417
|
+
log(`${isResume ? 'resumed' : `attached (${mode})`} session ${s.id.slice(0, 8)} on channel ${channelId} from seq ${from}`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function handleDetach(m) {
|
|
421
|
+
const s = cfg.sessions?.get(m.session_id);
|
|
422
|
+
const entry = s && [...channels.entries()].find(([, c]) => c.session === s);
|
|
423
|
+
if (!entry) return send(detachFail({ reason: 'not attached to this session on this connection' }));
|
|
424
|
+
const [channelId, ch] = entry;
|
|
425
|
+
const flushed = ch.attachment.flush();
|
|
426
|
+
ch.detach();
|
|
427
|
+
await flushed;
|
|
428
|
+
await send(closeMsg({ channelId }));
|
|
429
|
+
return send(detachOk({ sessionId: s.id }));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function handleSessionList() {
|
|
433
|
+
const now = Date.now();
|
|
434
|
+
const sessions = (cfg.sessions?.listFor(principalKey()) ?? []).map((s) => ({
|
|
435
|
+
session_id: s.id,
|
|
436
|
+
username: s.owner.username,
|
|
437
|
+
fingerprint_short: (s.owner.fingerprint ?? '').slice(0, 8),
|
|
438
|
+
created_at_secs: Math.floor(s.createdAt / 1000),
|
|
439
|
+
idle_secs: Math.max(0, Math.floor((now - s.lastActivity) / 1000)),
|
|
440
|
+
attached_count: s.attachments.size,
|
|
441
|
+
}));
|
|
442
|
+
return send(sessionList({ sessions }));
|
|
443
|
+
}
|
|
444
|
+
|
|
249
445
|
async function openExec(m) {
|
|
250
446
|
if (!cfg.execRunner) return send(openFail({ reason: 'exec is not enabled on this server' }));
|
|
251
447
|
const command = String(m.command ?? '').trim();
|
|
@@ -253,6 +449,7 @@ export function createConnectionFactory(cfg) {
|
|
|
253
449
|
|
|
254
450
|
const channelId = ++state.nextChannel;
|
|
255
451
|
const abort = new AbortController();
|
|
452
|
+
const hosted = cfg.sessions ? cfg.sessions.create({ owner: ownerRecord(), kind: 'exec', command }) : null;
|
|
256
453
|
let stream = null;
|
|
257
454
|
let primerChecked = false;
|
|
258
455
|
let wq = Promise.resolve();
|
|
@@ -262,13 +459,14 @@ export function createConnectionFactory(cfg) {
|
|
|
262
459
|
const endCbs = [];
|
|
263
460
|
const signalCbs = [];
|
|
264
461
|
|
|
462
|
+
const signalTo = (name) => {
|
|
463
|
+
const n = name.replace(/^SIG/, '').toUpperCase();
|
|
464
|
+
for (const cb of signalCbs) cb(n);
|
|
465
|
+
if (/^(INT|TERM|KILL|HUP)$/.test(n) && signalCbs.length === 0) abort.abort();
|
|
466
|
+
};
|
|
265
467
|
const ch = {
|
|
266
468
|
kill: () => abort.abort(),
|
|
267
|
-
signal:
|
|
268
|
-
const n = name.replace(/^SIG/, '').toUpperCase();
|
|
269
|
-
for (const cb of signalCbs) cb(n);
|
|
270
|
-
if (/^(INT|TERM|KILL|HUP)$/.test(n) && signalCbs.length === 0) abort.abort();
|
|
271
|
-
},
|
|
469
|
+
signal: signalTo,
|
|
272
470
|
bind(s) {
|
|
273
471
|
stream = s;
|
|
274
472
|
s.onData = (d) => {
|
|
@@ -280,7 +478,8 @@ export function createConnectionFactory(cfg) {
|
|
|
280
478
|
if (d.byteLength) for (const cb of inputCbs) cb(d);
|
|
281
479
|
};
|
|
282
480
|
s.onEnd = () => { for (const cb of endCbs) cb(); };
|
|
283
|
-
|
|
481
|
+
// A hosted session outlives its stream; an unhosted one dies with it.
|
|
482
|
+
s.onReset = () => { if (!hosted) abort.abort(); };
|
|
284
483
|
const pending = buffered.splice(0);
|
|
285
484
|
wq = wq.then(async () => { for (const b of pending) await s.write(b).catch(() => {}); });
|
|
286
485
|
for (const r of boundWaiters.splice(0)) r();
|
|
@@ -292,6 +491,49 @@ export function createConnectionFactory(cfg) {
|
|
|
292
491
|
let timer = null;
|
|
293
492
|
if (cfg.execOptions.timeoutMs > 0) timer = setTimeout(() => abort.abort(), cfg.execOptions.timeoutMs);
|
|
294
493
|
|
|
494
|
+
// Output to the opening connection's data stream (held until the client has opened it).
|
|
495
|
+
const toStream = (bytes) => {
|
|
496
|
+
if (!stream) { buffered.push(bytes); return Promise.resolve(); }
|
|
497
|
+
const s = stream;
|
|
498
|
+
wq = wq.then(() => s.write(bytes).catch(() => {}));
|
|
499
|
+
return wq;
|
|
500
|
+
};
|
|
501
|
+
// Output is ready but the client's stream may not have been bound yet (a host without
|
|
502
|
+
// announce support waits for the first byte): give it a bounded chance rather than
|
|
503
|
+
// dropping the output or hanging forever.
|
|
504
|
+
const closeStream = async (code) => {
|
|
505
|
+
if (!stream) await Promise.race([new Promise((r) => boundWaiters.push(r)), new Promise((r) => setTimeout(r, cfg.bindTimeoutMs))]);
|
|
506
|
+
if (!stream) log(`exec channel ${channelId}: data stream never appeared; output dropped`);
|
|
507
|
+
await wq;
|
|
508
|
+
try { await stream?.close(); } catch { /* peer gone */ }
|
|
509
|
+
await finishChannel(channelId, Number.isInteger(code) ? code : 0);
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
if (hosted) {
|
|
513
|
+
const att = {
|
|
514
|
+
connId: cid, channelId, mode: 'control', username: state.username, principal: principalKey(),
|
|
515
|
+
write: toStream,
|
|
516
|
+
notify: (msg) => send(presenceMsg(msg)),
|
|
517
|
+
exit: (code) => closeStream(code),
|
|
518
|
+
flush: () => wq,
|
|
519
|
+
};
|
|
520
|
+
hosted.handlers = {
|
|
521
|
+
input: (d) => { for (const cb of inputCbs) cb(d); },
|
|
522
|
+
inputEnd: () => { for (const cb of endCbs) cb(); },
|
|
523
|
+
signal: signalTo,
|
|
524
|
+
resize: () => {},
|
|
525
|
+
kill: () => abort.abort(),
|
|
526
|
+
};
|
|
527
|
+
ch.session = hosted;
|
|
528
|
+
ch.attachment = att;
|
|
529
|
+
ch.detach = () => {
|
|
530
|
+
channels.delete(channelId);
|
|
531
|
+
hosted.detach(att);
|
|
532
|
+
try { stream?.close().catch(() => {}); } catch { /* gone */ }
|
|
533
|
+
};
|
|
534
|
+
hosted.attach(att);
|
|
535
|
+
}
|
|
536
|
+
|
|
295
537
|
const io = {
|
|
296
538
|
user: state.username,
|
|
297
539
|
env: m.env && typeof m.env === 'object' && cfg.execOptions.clientEnv !== false ? m.env : {},
|
|
@@ -300,9 +542,8 @@ export function createConnectionFactory(cfg) {
|
|
|
300
542
|
signal: abort.signal,
|
|
301
543
|
write(data) {
|
|
302
544
|
const bytes = toBytes(data);
|
|
303
|
-
if (!
|
|
304
|
-
|
|
305
|
-
wq = wq.then(() => s.write(bytes).catch(() => {}));
|
|
545
|
+
if (!hosted) return toStream(bytes);
|
|
546
|
+
hosted.push(bytes);
|
|
306
547
|
return wq;
|
|
307
548
|
},
|
|
308
549
|
onInput: (cb) => inputCbs.push(cb),
|
|
@@ -312,7 +553,7 @@ export function createConnectionFactory(cfg) {
|
|
|
312
553
|
|
|
313
554
|
await send(openOk({
|
|
314
555
|
channelId, dataMode: 'stream', capabilities: ['signal'],
|
|
315
|
-
sessionId: randomUUID(), token: randomBytes(16),
|
|
556
|
+
sessionId: hosted?.id ?? randomUUID(), token: hosted?.token ?? randomBytes(16),
|
|
316
557
|
}));
|
|
317
558
|
log(`exec channel ${channelId}: ${command}`);
|
|
318
559
|
|
|
@@ -321,20 +562,15 @@ export function createConnectionFactory(cfg) {
|
|
|
321
562
|
runExec().catch((e) => log(`exec channel ${channelId}: ${e.message}`));
|
|
322
563
|
|
|
323
564
|
async function runExec() {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
if (!stream) await Promise.race([new Promise((r) => boundWaiters.push(r)), new Promise((r) => setTimeout(r, cfg.bindTimeoutMs))]);
|
|
334
|
-
if (!stream) log(`exec channel ${channelId}: data stream never appeared; output dropped`);
|
|
335
|
-
await wq;
|
|
336
|
-
try { await stream?.close(); } catch { /* peer gone */ }
|
|
337
|
-
await finishChannel(channelId, Number.isInteger(code) ? code : 0);
|
|
565
|
+
let code;
|
|
566
|
+
try { code = await cfg.execRunner(command, io); } catch (e) {
|
|
567
|
+
await io.write(`wsh: ${e.message}\n`);
|
|
568
|
+
code = 1;
|
|
569
|
+
}
|
|
570
|
+
if (timer) clearTimeout(timer);
|
|
571
|
+
// Hosted: everyone attached (this connection's stream included, if it is still there) hears the exit.
|
|
572
|
+
if (hosted) { hosted.finish(Number.isInteger(code) ? code : 0); return; }
|
|
573
|
+
await closeStream(code);
|
|
338
574
|
}
|
|
339
575
|
}
|
|
340
576
|
|
|
@@ -354,13 +590,31 @@ export function createConnectionFactory(cfg) {
|
|
|
354
590
|
} catch (e) {
|
|
355
591
|
return send(openFail({ reason: `pty spawn failed: ${e.message}` }));
|
|
356
592
|
}
|
|
357
|
-
const
|
|
593
|
+
const ctl = {
|
|
358
594
|
input: (data) => { try { proc.write(Buffer.from(data)); } catch { /* exited */ } },
|
|
359
595
|
resize: (c, r) => { try { proc.resize(c || cols, r || rows); } catch { /* exited */ } },
|
|
360
596
|
signal: (name) => { try { proc.kill(`SIG${name.replace(/^SIG/, '').toUpperCase()}`); } catch { /* exited */ } },
|
|
361
597
|
kill: () => { try { proc.kill(); } catch { /* exited */ } },
|
|
362
598
|
};
|
|
363
|
-
|
|
599
|
+
|
|
600
|
+
if (cfg.sessions) {
|
|
601
|
+
const hosted = cfg.sessions.create({ owner: ownerRecord(), kind: 'pty', command: m.command });
|
|
602
|
+
hosted.handlers = ctl;
|
|
603
|
+
const att = virtualAttachment(hosted, channelId, 'control');
|
|
604
|
+
attachedChannel(hosted, att);
|
|
605
|
+
proc.onData((d) => hosted.push(Uint8Array.from(toBytes(d))));
|
|
606
|
+
proc.onExit(({ exitCode }) => hosted.finish(exitCode ?? 0));
|
|
607
|
+
const opened = send(openOk({
|
|
608
|
+
channelId, dataMode: 'virtual', capabilities: ['resize', 'signal'],
|
|
609
|
+
sessionId: hosted.id, token: hosted.token,
|
|
610
|
+
}));
|
|
611
|
+
hosted.attach(att);
|
|
612
|
+
await opened;
|
|
613
|
+
log(`pty channel ${channelId} opened (${cols}x${rows}), session ${hosted.id.slice(0, 8)}`);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
channels.set(channelId, ctl);
|
|
364
618
|
let out = Promise.resolve();
|
|
365
619
|
proc.onData((d) => { out = out.then(() => send(sessionData({ channelId, data: toBytes(d) }))); });
|
|
366
620
|
proc.onExit(({ exitCode }) => { out.then(() => finishChannel(channelId, exitCode ?? 0)); });
|
|
@@ -487,7 +741,7 @@ export function createConnectionFactory(cfg) {
|
|
|
487
741
|
|
|
488
742
|
return {
|
|
489
743
|
receive(bytes) { if (!state.closed) qmux.receiveBytes(bytes); },
|
|
490
|
-
close() {
|
|
744
|
+
close() { shutdownAfterQueue('transport closed'); },
|
|
491
745
|
};
|
|
492
746
|
};
|
|
493
747
|
}
|
package/src/server/index.d.ts
CHANGED
|
@@ -119,6 +119,56 @@ export interface WshServerFsOptions {
|
|
|
119
119
|
maxFileBytes?: number;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/** Context handed to an MCP tool's `call()`. */
|
|
123
|
+
export interface WshMcpCallContext {
|
|
124
|
+
/** Authenticated username. */
|
|
125
|
+
user: string;
|
|
126
|
+
/** Hex SHA-256 of the client's key (`null` for a password login). */
|
|
127
|
+
fingerprint: string | null;
|
|
128
|
+
/** Aborts on client disconnect or `timeoutMs`. A tool that ignores it keeps running but its reply is dropped. */
|
|
129
|
+
signal: AbortSignal;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** One tool exposed over `McpDiscover` / `McpCall`. */
|
|
133
|
+
export interface WshMcpTool {
|
|
134
|
+
name: string;
|
|
135
|
+
description?: string;
|
|
136
|
+
/** JSON Schema for the arguments (default `{ type: 'object' }`). Enforced before `call()` runs; keywords outside the supported subset are refused at startup. Advertised to clients as `parameters`. */
|
|
137
|
+
inputSchema?: Record<string, unknown> | boolean;
|
|
138
|
+
/** The result is sent as `McpResult.result` verbatim (any CBOR-encodable value). Throwing sends `{ success: false, error: message }`. */
|
|
139
|
+
call(args: any, ctx: WshMcpCallContext): unknown | Promise<unknown>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The part of an `@modelcontextprotocol/sdk` `Client` that the server proxies. */
|
|
143
|
+
export interface WshMcpClientLike {
|
|
144
|
+
listTools(): Promise<{ tools: Array<{ name: string; description?: string; inputSchema?: Record<string, unknown> }> }>;
|
|
145
|
+
callTool(params: { name: string; arguments: unknown }, resultSchema?: unknown, options?: { signal?: AbortSignal }): Promise<{ content?: unknown; structuredContent?: unknown; isError?: boolean }>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface WshServerMcpOptions {
|
|
149
|
+
/** Tools to serve: a list, or a `{ name: { description, inputSchema, call } }` map. */
|
|
150
|
+
tools?: WshMcpTool[] | Record<string, Omit<WshMcpTool, 'name'>>;
|
|
151
|
+
/** An MCP client whose tools are proxied (local `tools` win on a name clash). */
|
|
152
|
+
client?: WshMcpClientLike;
|
|
153
|
+
/** Per-principal filter, applied to discovery and to calls alike (a hidden tool looks unknown). */
|
|
154
|
+
authorize?: (user: string, tool: { name: string; description: string; inputSchema: unknown }, who: { username: string; fingerprint: string | null }) => boolean | Promise<boolean>;
|
|
155
|
+
/** In-flight calls per connection (default 8); further calls get an error result. */
|
|
156
|
+
maxConcurrent?: number;
|
|
157
|
+
/** Per-call time limit in ms (default 30000; 0 = none). */
|
|
158
|
+
timeoutMs?: number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface WshServerSessionsOptions {
|
|
162
|
+
/** How long a session with nobody attached keeps running (default 300000). `0` kills it the moment its last connection goes. */
|
|
163
|
+
detachTtlMs?: number;
|
|
164
|
+
/** Unattended sessions kept at once (default 16); beyond that the longest-detached is killed. */
|
|
165
|
+
maxDetached?: number;
|
|
166
|
+
/** Output history kept per session, in bytes (default 1 MiB) -- what a resume can replay. Older output is dropped; a Resume older than it is refused with an "output gap" error. */
|
|
167
|
+
ringBytes?: number;
|
|
168
|
+
/** Secret the session tokens are minted with (default: random per process). Fixing it makes tokens verifiable across restarts. */
|
|
169
|
+
sessionSecret?: string | Uint8Array;
|
|
170
|
+
}
|
|
171
|
+
|
|
122
172
|
export interface WshServerOptions {
|
|
123
173
|
/** Bind address (default `127.0.0.1`). */
|
|
124
174
|
host?: string;
|
|
@@ -138,6 +188,16 @@ export interface WshServerOptions {
|
|
|
138
188
|
hostKey?: true | { file: string } | CryptoKeyPair;
|
|
139
189
|
/** Enable file ops and uploads/downloads under one directory. Off by default. */
|
|
140
190
|
fs?: WshServerFsOptions;
|
|
191
|
+
/**
|
|
192
|
+
* Keep pty/exec sessions alive across disconnects so `resumeSession()` /
|
|
193
|
+
* `attachSession()` / `detach()` / `listRemoteSessions()` work against them.
|
|
194
|
+
* `true` for the defaults. Off by default: sessions then die with their connection.
|
|
195
|
+
*/
|
|
196
|
+
sessions?: true | WshServerSessionsOptions;
|
|
197
|
+
/** Alias for `sessions.sessionSecret`. */
|
|
198
|
+
sessionSecret?: string | Uint8Array;
|
|
199
|
+
/** Serve MCP tools (`McpDiscover` / `McpCall`) and advertise `mcp-call-id`. Off by default. */
|
|
200
|
+
mcp?: WshServerMcpOptions;
|
|
141
201
|
/** How long exec output waits for a client that has not yet opened its data stream (default 3000). */
|
|
142
202
|
bindTimeoutMs?: number;
|
|
143
203
|
onLog?: (line: string) => void;
|
package/src/server/index.mjs
CHANGED
|
@@ -13,6 +13,8 @@ import { loadHostKey } from './host-key.mjs';
|
|
|
13
13
|
import { spawnRunner } from './exec.mjs';
|
|
14
14
|
import { normalizePty } from './pty.mjs';
|
|
15
15
|
import { createFileAccess } from './fs.mjs';
|
|
16
|
+
import { createMcpHost } from './mcp.mjs';
|
|
17
|
+
import { SessionRegistry } from './sessions.mjs';
|
|
16
18
|
import { createConnectionFactory, STREAM_ANNOUNCE } from './connection.mjs';
|
|
17
19
|
|
|
18
20
|
export { parseAuthorizedKeys, STREAM_ANNOUNCE };
|
|
@@ -26,6 +28,14 @@ export { parseAuthorizedKeys, STREAM_ANNOUNCE };
|
|
|
26
28
|
* `{ cwd, env, shell, timeoutMs, clientEnv }`, or a custom `run(command, io)` function.
|
|
27
29
|
* @param {{ spawn: Function, shell?: string, cwd?: string, env?: object }} [options.pty]
|
|
28
30
|
* @param {{ root: string, readOnly?: boolean, maxFileBytes?: number }} [options.fs]
|
|
31
|
+
* @param {{ tools?: object[] | object, client?: object, authorize?: Function, maxConcurrent?: number, timeoutMs?: number }} [options.mcp]
|
|
32
|
+
* MCP tools served over `McpDiscover` / `McpCall`: `tools` (`{ name, description, inputSchema, call(args, { user, signal }) }`),
|
|
33
|
+
* and/or `client` (an `@modelcontextprotocol/sdk` Client to proxy). Off unless given.
|
|
34
|
+
* @param {true | { detachTtlMs?: number, maxDetached?: number, ringBytes?: number, sessionSecret?: string | Uint8Array }} [options.sessions]
|
|
35
|
+
* Keep pty/exec sessions alive across disconnects so a client can `resumeSession()` / `attachSession()`
|
|
36
|
+
* them: a per-server registry, a ring buffer of the newest `ringBytes` (default 1 MiB) of output, and
|
|
37
|
+
* `detachTtlMs` (default 300000; 0 = kill on disconnect) / `maxDetached` (default 16) bounds. Off unless given.
|
|
38
|
+
* @param {string | Uint8Array} [options.sessionSecret] - Alias for `sessions.sessionSecret`.
|
|
29
39
|
* @param {true | { file: string } | CryptoKeyPair} [options.hostKey] - The server's own Ed25519
|
|
30
40
|
* identity, advertised (with proof of possession) so clients can pin it. See `src/host-key.mjs`.
|
|
31
41
|
* @param {object} [options.auth.rateLimit] - Password-failure throttle: `{ maxFailures=5, windowMs=60000,
|
|
@@ -35,7 +45,7 @@ export { parseAuthorizedKeys, STREAM_ANNOUNCE };
|
|
|
35
45
|
* @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
|
|
36
46
|
*/
|
|
37
47
|
export function createWshServer({
|
|
38
|
-
host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, bindTimeoutMs = 3000, onLog = () => {},
|
|
48
|
+
host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, sessions, sessionSecret, bindTimeoutMs = 3000, onLog = () => {},
|
|
39
49
|
} = {}) {
|
|
40
50
|
let wss = null;
|
|
41
51
|
let bound = null;
|
|
@@ -50,6 +60,7 @@ export function createWshServer({
|
|
|
50
60
|
}
|
|
51
61
|
const ptyConfig = pty ? normalizePty(pty) : null;
|
|
52
62
|
const files = fs ? createFileAccess(fs) : null;
|
|
63
|
+
const mcpHost = mcp ? createMcpHost(mcp) : null;
|
|
53
64
|
const methods = authMethods(auth);
|
|
54
65
|
const rl = auth?.rateLimit ?? {};
|
|
55
66
|
const rateLimit = {
|
|
@@ -58,6 +69,8 @@ export function createWshServer({
|
|
|
58
69
|
failureDelayMs: rl.failureDelayMs ?? 250,
|
|
59
70
|
};
|
|
60
71
|
let host_ = null;
|
|
72
|
+
let registry = null;
|
|
73
|
+
const sessionOptions = sessions ? (sessions === true ? {} : sessions) : null;
|
|
61
74
|
|
|
62
75
|
return {
|
|
63
76
|
async listen() {
|
|
@@ -70,9 +83,19 @@ export function createWshServer({
|
|
|
70
83
|
}
|
|
71
84
|
const authorize = await buildAuthorizer(auth);
|
|
72
85
|
host_ = hostKey ? await loadHostKey(hostKey) : null;
|
|
86
|
+
if (sessionOptions) {
|
|
87
|
+
const secret = sessionOptions.sessionSecret ?? sessionSecret;
|
|
88
|
+
registry = new SessionRegistry({
|
|
89
|
+
secret: secret === undefined ? undefined : Buffer.from(secret),
|
|
90
|
+
detachTtlMs: sessionOptions.detachTtlMs,
|
|
91
|
+
maxDetached: sessionOptions.maxDetached,
|
|
92
|
+
ringBytes: sessionOptions.ringBytes,
|
|
93
|
+
log: onLog,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
73
96
|
const attach = createConnectionFactory({
|
|
74
97
|
authorize, execRunner, execOptions, pty: ptyConfig, files, bindTimeoutMs, log: onLog,
|
|
75
|
-
methods, hostKey: host_, rateLimit,
|
|
98
|
+
methods, hostKey: host_, rateLimit, mcp: mcpHost, sessions: registry,
|
|
76
99
|
});
|
|
77
100
|
|
|
78
101
|
wss = new WebSocketServer({ host, port });
|
|
@@ -104,6 +127,8 @@ export function createWshServer({
|
|
|
104
127
|
bound = null;
|
|
105
128
|
for (const client of server.clients) client.terminate();
|
|
106
129
|
await new Promise((resolve) => server.close(resolve));
|
|
130
|
+
registry?.closeAll();
|
|
131
|
+
registry = null;
|
|
107
132
|
},
|
|
108
133
|
|
|
109
134
|
address() {
|