@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
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small JSON Schema validator for `@johnhenry/wsh/server`'s MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* MCP arguments come off the wire from an authenticated-but-untrusted client,
|
|
5
|
+
* so the tool's `inputSchema` is enforced before `call()` runs. The package has
|
|
6
|
+
* no runtime dependencies, so this covers the keywords tool schemas actually
|
|
7
|
+
* use rather than all of JSON Schema: `type`, `enum`, `const`, `properties`,
|
|
8
|
+
* `required`, `additionalProperties`, `items`, `min/maxItems`, `uniqueItems`,
|
|
9
|
+
* `min/maxLength`, `pattern`, `minimum`/`maximum` (and exclusive forms),
|
|
10
|
+
* `multipleOf`, `allOf`/`anyOf`/`oneOf`/`not`, and local `$ref` (`#/...`).
|
|
11
|
+
* `format`, `title`, `description`, `default` and other annotations are ignored.
|
|
12
|
+
*
|
|
13
|
+
* A schema that uses a keyword this does not understand is refused up front
|
|
14
|
+
* (`assertSupportedSchema`) rather than silently validating less than the
|
|
15
|
+
* operator wrote.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null', 'object', 'array']);
|
|
19
|
+
const IGNORED = new Set([
|
|
20
|
+
'$schema', '$id', '$comment', 'title', 'description', 'default', 'examples', 'format',
|
|
21
|
+
'definitions', '$defs', 'deprecated', 'readOnly', 'writeOnly',
|
|
22
|
+
]);
|
|
23
|
+
const KNOWN = new Set([
|
|
24
|
+
'type', 'enum', 'const', 'properties', 'required', 'additionalProperties', 'items',
|
|
25
|
+
'minItems', 'maxItems', 'uniqueItems', 'minLength', 'maxLength', 'pattern', 'minimum',
|
|
26
|
+
'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf', 'allOf', 'anyOf',
|
|
27
|
+
'oneOf', 'not', '$ref', 'minProperties', 'maxProperties',
|
|
28
|
+
]);
|
|
29
|
+
const MAX_DEPTH = 64;
|
|
30
|
+
|
|
31
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
32
|
+
|
|
33
|
+
function typeOf(v) {
|
|
34
|
+
if (v === null) return 'null';
|
|
35
|
+
if (Array.isArray(v)) return 'array';
|
|
36
|
+
if (typeof v === 'number') return Number.isInteger(v) ? 'integer' : 'number';
|
|
37
|
+
return typeof v;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function matchesType(v, t) {
|
|
41
|
+
const actual = typeOf(v);
|
|
42
|
+
return actual === t || (t === 'number' && actual === 'integer');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Structural JSON equality (for `enum` / `const` / `uniqueItems`). */
|
|
46
|
+
function deepEqual(a, b) {
|
|
47
|
+
if (a === b) return true;
|
|
48
|
+
if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object') return false;
|
|
49
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
50
|
+
if (Array.isArray(a)) return a.length === b.length && a.every((x, i) => deepEqual(x, b[i]));
|
|
51
|
+
const ka = Object.keys(a);
|
|
52
|
+
const kb = Object.keys(b);
|
|
53
|
+
return ka.length === kb.length && ka.every((k) => Object.hasOwn(b, k) && deepEqual(a[k], b[k]));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Throw if `schema` uses a keyword this validator does not implement, or is
|
|
58
|
+
* otherwise malformed. Run once when the operator's tools are configured.
|
|
59
|
+
*/
|
|
60
|
+
export function assertSupportedSchema(schema, path = '#', depth = 0) {
|
|
61
|
+
if (depth > MAX_DEPTH) throw new TypeError(`inputSchema at ${path} is nested too deeply`);
|
|
62
|
+
if (schema === true || schema === false) return;
|
|
63
|
+
if (!isObject(schema)) throw new TypeError(`inputSchema at ${path} must be an object`);
|
|
64
|
+
for (const key of Object.keys(schema)) {
|
|
65
|
+
if (!KNOWN.has(key) && !IGNORED.has(key)) {
|
|
66
|
+
throw new TypeError(`inputSchema at ${path} uses unsupported keyword "${key}"`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (schema.type !== undefined) {
|
|
70
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
71
|
+
for (const t of types) if (!TYPES.has(t)) throw new TypeError(`inputSchema at ${path} has unknown type ${JSON.stringify(t)}`);
|
|
72
|
+
}
|
|
73
|
+
if (schema.pattern !== undefined) {
|
|
74
|
+
try { new RegExp(schema.pattern, 'u'); } catch { throw new TypeError(`inputSchema at ${path} has an invalid pattern`); }
|
|
75
|
+
}
|
|
76
|
+
if (schema.required !== undefined && !(Array.isArray(schema.required) && schema.required.every((r) => typeof r === 'string'))) {
|
|
77
|
+
throw new TypeError(`inputSchema at ${path}: required must be an array of strings`);
|
|
78
|
+
}
|
|
79
|
+
if (isObject(schema.properties)) {
|
|
80
|
+
for (const [k, s] of Object.entries(schema.properties)) assertSupportedSchema(s, `${path}/properties/${k}`, depth + 1);
|
|
81
|
+
} else if (schema.properties !== undefined) {
|
|
82
|
+
throw new TypeError(`inputSchema at ${path}: properties must be an object`);
|
|
83
|
+
}
|
|
84
|
+
if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== 'boolean') {
|
|
85
|
+
assertSupportedSchema(schema.additionalProperties, `${path}/additionalProperties`, depth + 1);
|
|
86
|
+
}
|
|
87
|
+
if (schema.items !== undefined) assertSupportedSchema(schema.items, `${path}/items`, depth + 1);
|
|
88
|
+
for (const k of ['allOf', 'anyOf', 'oneOf']) {
|
|
89
|
+
if (schema[k] === undefined) continue;
|
|
90
|
+
if (!Array.isArray(schema[k])) throw new TypeError(`inputSchema at ${path}: ${k} must be an array`);
|
|
91
|
+
schema[k].forEach((s, i) => assertSupportedSchema(s, `${path}/${k}/${i}`, depth + 1));
|
|
92
|
+
}
|
|
93
|
+
if (schema.not !== undefined) assertSupportedSchema(schema.not, `${path}/not`, depth + 1);
|
|
94
|
+
for (const k of ['definitions', '$defs']) {
|
|
95
|
+
if (isObject(schema[k])) for (const [n, s] of Object.entries(schema[k])) assertSupportedSchema(s, `${path}/${k}/${n}`, depth + 1);
|
|
96
|
+
}
|
|
97
|
+
if (schema.$ref !== undefined && !(typeof schema.$ref === 'string' && schema.$ref.startsWith('#'))) {
|
|
98
|
+
throw new TypeError(`inputSchema at ${path}: only local "#..." $ref is supported`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function resolveRef(root, ref) {
|
|
103
|
+
if (ref === '#') return root;
|
|
104
|
+
if (!ref.startsWith('#/')) return undefined;
|
|
105
|
+
let node = root;
|
|
106
|
+
for (const raw of ref.slice(2).split('/')) {
|
|
107
|
+
const seg = raw.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
108
|
+
if (!isObject(node) && !Array.isArray(node)) return undefined;
|
|
109
|
+
if (!Object.hasOwn(node, seg)) return undefined;
|
|
110
|
+
node = node[seg];
|
|
111
|
+
}
|
|
112
|
+
return node;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Validate `value` against `schema`.
|
|
117
|
+
* @returns {string | null} `null` when valid, otherwise a short reason naming the path.
|
|
118
|
+
*/
|
|
119
|
+
export function validateSchema(schema, value) {
|
|
120
|
+
const fail = (path, msg) => `${path || '/'}: ${msg}`;
|
|
121
|
+
|
|
122
|
+
function check(s, v, path, depth) {
|
|
123
|
+
if (depth > MAX_DEPTH) return fail(path, 'schema or value nested too deeply');
|
|
124
|
+
if (s === true) return null;
|
|
125
|
+
if (s === false) return fail(path, 'no value is allowed here');
|
|
126
|
+
if (s.$ref !== undefined) {
|
|
127
|
+
const target = resolveRef(schema, s.$ref);
|
|
128
|
+
if (target === undefined) return fail(path, `unresolvable $ref ${s.$ref}`);
|
|
129
|
+
const r = check(target, v, path, depth + 1);
|
|
130
|
+
if (r) return r;
|
|
131
|
+
}
|
|
132
|
+
if (s.type !== undefined) {
|
|
133
|
+
const types = Array.isArray(s.type) ? s.type : [s.type];
|
|
134
|
+
if (!types.some((t) => matchesType(v, t))) return fail(path, `expected ${types.join(' | ')}, got ${typeOf(v)}`);
|
|
135
|
+
}
|
|
136
|
+
if (s.enum !== undefined && !s.enum.some((e) => deepEqual(e, v))) return fail(path, 'not one of the allowed values');
|
|
137
|
+
if (s.const !== undefined && !deepEqual(s.const, v)) return fail(path, 'does not equal the required constant');
|
|
138
|
+
|
|
139
|
+
if (typeof v === 'string') {
|
|
140
|
+
const len = [...v].length;
|
|
141
|
+
if (s.minLength !== undefined && len < s.minLength) return fail(path, `shorter than ${s.minLength}`);
|
|
142
|
+
if (s.maxLength !== undefined && len > s.maxLength) return fail(path, `longer than ${s.maxLength}`);
|
|
143
|
+
if (s.pattern !== undefined && !new RegExp(s.pattern, 'u').test(v)) return fail(path, 'does not match the required pattern');
|
|
144
|
+
}
|
|
145
|
+
if (typeof v === 'number') {
|
|
146
|
+
if (!Number.isFinite(v)) return fail(path, 'must be a finite number');
|
|
147
|
+
if (s.minimum !== undefined && v < s.minimum) return fail(path, `below the minimum ${s.minimum}`);
|
|
148
|
+
if (s.maximum !== undefined && v > s.maximum) return fail(path, `above the maximum ${s.maximum}`);
|
|
149
|
+
if (typeof s.exclusiveMinimum === 'number' && v <= s.exclusiveMinimum) return fail(path, `must be above ${s.exclusiveMinimum}`);
|
|
150
|
+
if (typeof s.exclusiveMaximum === 'number' && v >= s.exclusiveMaximum) return fail(path, `must be below ${s.exclusiveMaximum}`);
|
|
151
|
+
if (s.multipleOf !== undefined) {
|
|
152
|
+
const q = v / s.multipleOf;
|
|
153
|
+
if (Math.abs(q - Math.round(q)) > 1e-9) return fail(path, `not a multiple of ${s.multipleOf}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (Array.isArray(v)) {
|
|
157
|
+
if (s.minItems !== undefined && v.length < s.minItems) return fail(path, `fewer than ${s.minItems} items`);
|
|
158
|
+
if (s.maxItems !== undefined && v.length > s.maxItems) return fail(path, `more than ${s.maxItems} items`);
|
|
159
|
+
if (s.uniqueItems === true) {
|
|
160
|
+
for (let i = 0; i < v.length; i++) {
|
|
161
|
+
for (let j = i + 1; j < v.length; j++) if (deepEqual(v[i], v[j])) return fail(path, 'items are not unique');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (s.items !== undefined) {
|
|
165
|
+
for (let i = 0; i < v.length; i++) {
|
|
166
|
+
const r = check(s.items, v[i], `${path}/${i}`, depth + 1);
|
|
167
|
+
if (r) return r;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (isObject(v)) {
|
|
172
|
+
const keys = Object.keys(v);
|
|
173
|
+
if (s.minProperties !== undefined && keys.length < s.minProperties) return fail(path, `fewer than ${s.minProperties} properties`);
|
|
174
|
+
if (s.maxProperties !== undefined && keys.length > s.maxProperties) return fail(path, `more than ${s.maxProperties} properties`);
|
|
175
|
+
for (const r of s.required ?? []) if (!Object.hasOwn(v, r)) return fail(path, `missing required property "${r}"`);
|
|
176
|
+
const props = isObject(s.properties) ? s.properties : {};
|
|
177
|
+
for (const k of keys) {
|
|
178
|
+
if (Object.hasOwn(props, k)) {
|
|
179
|
+
const r = check(props[k], v[k], `${path}/${k}`, depth + 1);
|
|
180
|
+
if (r) return r;
|
|
181
|
+
} else if (s.additionalProperties === false) {
|
|
182
|
+
return fail(path, `unexpected property "${k}"`);
|
|
183
|
+
} else if (s.additionalProperties !== undefined && s.additionalProperties !== true) {
|
|
184
|
+
const r = check(s.additionalProperties, v[k], `${path}/${k}`, depth + 1);
|
|
185
|
+
if (r) return r;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const sub of s.allOf ?? []) {
|
|
191
|
+
const r = check(sub, v, path, depth + 1);
|
|
192
|
+
if (r) return r;
|
|
193
|
+
}
|
|
194
|
+
if (s.anyOf && !s.anyOf.some((sub) => check(sub, v, path, depth + 1) === null)) return fail(path, 'matches none of the anyOf alternatives');
|
|
195
|
+
if (s.oneOf) {
|
|
196
|
+
const n = s.oneOf.filter((sub) => check(sub, v, path, depth + 1) === null).length;
|
|
197
|
+
if (n !== 1) return fail(path, `matches ${n} of the oneOf alternatives, expected exactly 1`);
|
|
198
|
+
}
|
|
199
|
+
if (s.not !== undefined && check(s.not, v, path, depth + 1) === null) return fail(path, 'matches a schema it must not match');
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return check(schema, value, '', 0);
|
|
204
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tools for `@johnhenry/wsh/server`: what `createWshServer({ mcp })`
|
|
3
|
+
* exposes through `McpDiscover` / `McpCall`.
|
|
4
|
+
*
|
|
5
|
+
* Two sources, which may be combined:
|
|
6
|
+
* - `mcp.tools` -- an array of `{ name, description, inputSchema, call }`, or
|
|
7
|
+
* a `{ name: { description, inputSchema, call } }` map.
|
|
8
|
+
* - `mcp.client` -- anything shaped like an `@modelcontextprotocol/sdk`
|
|
9
|
+
* `Client` (`listTools()` / `callTool({ name, arguments })`); its tools are
|
|
10
|
+
* proxied. Duck-typed, so the SDK is never imported by this package.
|
|
11
|
+
*
|
|
12
|
+
* Tools the operator did not list are never reachable: a call is looked up by
|
|
13
|
+
* exact name in the set built here, after the optional `authorize` filter.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { assertSupportedSchema, validateSchema } from './json-schema.mjs';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_SCHEMA = Object.freeze({ type: 'object' });
|
|
19
|
+
export const MCP_DEFAULTS = Object.freeze({ maxConcurrent: 8, timeoutMs: 30_000 });
|
|
20
|
+
|
|
21
|
+
function normalizeTool(name, spec, origin) {
|
|
22
|
+
if (typeof name !== 'string' || !name) throw new TypeError('createWshServer: every mcp tool needs a name');
|
|
23
|
+
if (!spec || typeof spec.call !== 'function') {
|
|
24
|
+
throw new TypeError(`createWshServer: mcp tool "${name}" needs a call(args, ctx) function`);
|
|
25
|
+
}
|
|
26
|
+
const inputSchema = spec.inputSchema ?? spec.parameters ?? DEFAULT_SCHEMA;
|
|
27
|
+
assertSupportedSchema(inputSchema, `#(${name})`);
|
|
28
|
+
return { name, description: String(spec.description ?? ''), inputSchema, call: spec.call, origin };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {object} mcp - the `createWshServer` `mcp` option
|
|
33
|
+
* @returns {{ list(user: string, who: object): Promise<object[]>, find(name: string, user: string, who: object): Promise<object | null>, maxConcurrent: number, timeoutMs: number }}
|
|
34
|
+
*/
|
|
35
|
+
export function createMcpHost(mcp) {
|
|
36
|
+
if (!mcp || typeof mcp !== 'object') throw new TypeError('createWshServer: mcp must be an object');
|
|
37
|
+
const local = new Map();
|
|
38
|
+
const entries = Array.isArray(mcp.tools)
|
|
39
|
+
? mcp.tools.map((t) => [t?.name, t])
|
|
40
|
+
: Object.entries(mcp.tools ?? {});
|
|
41
|
+
for (const [name, spec] of entries) {
|
|
42
|
+
if (local.has(name)) throw new TypeError(`createWshServer: duplicate mcp tool "${name}"`);
|
|
43
|
+
local.set(name, normalizeTool(name, spec, 'local'));
|
|
44
|
+
}
|
|
45
|
+
const client = mcp.client ?? null;
|
|
46
|
+
if (client && (typeof client.listTools !== 'function' || typeof client.callTool !== 'function')) {
|
|
47
|
+
throw new TypeError('createWshServer: mcp.client needs listTools() and callTool() (an @modelcontextprotocol/sdk Client)');
|
|
48
|
+
}
|
|
49
|
+
if (typeof mcp.authorize !== 'undefined' && typeof mcp.authorize !== 'function') {
|
|
50
|
+
throw new TypeError('createWshServer: mcp.authorize must be a function');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Remote tools, refreshed on every discover; calls use the most recent listing. */
|
|
54
|
+
let proxied = new Map();
|
|
55
|
+
async function refreshProxied() {
|
|
56
|
+
if (!client) return;
|
|
57
|
+
const next = new Map();
|
|
58
|
+
const { tools = [] } = await client.listTools();
|
|
59
|
+
for (const t of tools) {
|
|
60
|
+
if (!t || typeof t.name !== 'string' || local.has(t.name)) continue; // local tools win
|
|
61
|
+
try {
|
|
62
|
+
next.set(t.name, normalizeTool(t.name, {
|
|
63
|
+
description: t.description,
|
|
64
|
+
inputSchema: t.inputSchema,
|
|
65
|
+
call: (args, ctx) => callProxied(t.name, args, ctx),
|
|
66
|
+
}, 'proxy'));
|
|
67
|
+
} catch { /* a schema we cannot enforce is not exposed */ }
|
|
68
|
+
}
|
|
69
|
+
proxied = next;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function callProxied(name, args, { signal }) {
|
|
73
|
+
const res = await client.callTool({ name, arguments: args }, undefined, { signal });
|
|
74
|
+
if (res?.isError) {
|
|
75
|
+
const text = Array.isArray(res.content) ? res.content.filter((c) => c?.type === 'text').map((c) => c.text).join('\n') : '';
|
|
76
|
+
throw new Error(text || `tool ${name} failed`);
|
|
77
|
+
}
|
|
78
|
+
return res?.structuredContent ?? res?.content ?? null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const allowed = async (tool, user, who) => {
|
|
82
|
+
if (!mcp.authorize) return true;
|
|
83
|
+
try { return (await mcp.authorize(user, { name: tool.name, description: tool.description, inputSchema: tool.inputSchema }, who)) === true; } catch { return false; }
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
maxConcurrent: mcp.maxConcurrent > 0 ? mcp.maxConcurrent : MCP_DEFAULTS.maxConcurrent,
|
|
88
|
+
timeoutMs: mcp.timeoutMs >= 0 ? mcp.timeoutMs : MCP_DEFAULTS.timeoutMs,
|
|
89
|
+
|
|
90
|
+
async list(user, who) {
|
|
91
|
+
await refreshProxied();
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const t of [...local.values(), ...proxied.values()]) {
|
|
94
|
+
if (await allowed(t, user, who)) out.push({ name: t.name, description: t.description, parameters: t.inputSchema });
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
/** A tool this principal may call, or `null` -- indistinguishable for hidden and absent. */
|
|
100
|
+
async find(name, user, who) {
|
|
101
|
+
let tool = local.get(name) ?? proxied.get(name);
|
|
102
|
+
if (!tool && client && !local.has(name)) {
|
|
103
|
+
await refreshProxied();
|
|
104
|
+
tool = proxied.get(name);
|
|
105
|
+
}
|
|
106
|
+
return tool && await allowed(tool, user, who) ? tool : null;
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
validate: (tool, args) => validateSchema(tool.inputSchema, args),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session registry for `@johnhenry/wsh/server`: what lets an exec/pty session
|
|
3
|
+
* outlive the connection that opened it (`createWshServer({ sessions })`).
|
|
4
|
+
*
|
|
5
|
+
* The registry is per server and holds a `HostedSession` for every pty/exec
|
|
6
|
+
* session. A connection's channel is an *attachment* to one: output is
|
|
7
|
+
* appended to the session's ring buffer and fanned out to every attachment,
|
|
8
|
+
* so when a socket drops the process keeps running and its newest output keeps
|
|
9
|
+
* accumulating until `detachTtlMs` elapses or a client resumes.
|
|
10
|
+
*
|
|
11
|
+
* `seq` -- the one design question the wire does not answer -- is defined as
|
|
12
|
+
* the cumulative count of output BYTES the host has produced for a session.
|
|
13
|
+
* A client counts what it has received (it sees every byte), passes that as
|
|
14
|
+
* `Resume.last_seq`, and the host replays `ring[last_seq - ringStart ..]`.
|
|
15
|
+
* See spec/wsh-v1.yaml (`Resume`, `AttachmentInfo`).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_RING_BYTES = 1024 * 1024;
|
|
21
|
+
export const DEFAULT_DETACH_TTL_MS = 300_000;
|
|
22
|
+
export const DEFAULT_MAX_DETACHED = 16;
|
|
23
|
+
export const MAX_ATTACHMENTS = 16;
|
|
24
|
+
|
|
25
|
+
const NO_EXPIRY = Buffer.alloc(8, 0xff);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A bounded byte history addressed by absolute stream position.
|
|
29
|
+
* `start` is the seq of the oldest retained byte, `end` the seq one past the newest.
|
|
30
|
+
*/
|
|
31
|
+
export class ByteRing {
|
|
32
|
+
#chunks = [];
|
|
33
|
+
#size = 0;
|
|
34
|
+
start = 0;
|
|
35
|
+
end = 0;
|
|
36
|
+
|
|
37
|
+
constructor(maxBytes) {
|
|
38
|
+
this.max = maxBytes;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
push(bytes) {
|
|
42
|
+
if (!bytes.byteLength) return;
|
|
43
|
+
this.#chunks.push(Uint8Array.from(bytes));
|
|
44
|
+
this.#size += bytes.byteLength;
|
|
45
|
+
this.end += bytes.byteLength;
|
|
46
|
+
while (this.#size > this.max) {
|
|
47
|
+
const excess = this.#size - this.max;
|
|
48
|
+
const first = this.#chunks[0];
|
|
49
|
+
if (first.byteLength <= excess) {
|
|
50
|
+
this.#chunks.shift();
|
|
51
|
+
this.#size -= first.byteLength;
|
|
52
|
+
this.start += first.byteLength;
|
|
53
|
+
} else {
|
|
54
|
+
this.#chunks[0] = first.subarray(excess);
|
|
55
|
+
this.#size -= excess;
|
|
56
|
+
this.start += excess;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Bytes from absolute position `from` (clamped to `start`) to the end. */
|
|
62
|
+
read(from = this.start) {
|
|
63
|
+
const skip = Math.max(0, from - this.start);
|
|
64
|
+
const out = new Uint8Array(Math.max(0, this.#size - skip));
|
|
65
|
+
let at = 0;
|
|
66
|
+
let seen = 0;
|
|
67
|
+
for (const c of this.#chunks) {
|
|
68
|
+
if (seen + c.byteLength > skip) {
|
|
69
|
+
const part = c.subarray(Math.max(0, skip - seen));
|
|
70
|
+
out.set(part, at);
|
|
71
|
+
at += part.byteLength;
|
|
72
|
+
}
|
|
73
|
+
seen += c.byteLength;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Mint a session token in the spec's format: `[8B expiry][32B HMAC-SHA256(secret, session_id || expiry)]`.
|
|
81
|
+
* A session's lifetime is governed by `detachTtlMs`, not by its token, so the expiry is the maximum.
|
|
82
|
+
*/
|
|
83
|
+
export function mintToken(secret, sessionId) {
|
|
84
|
+
const mac = createHmac('sha256', secret).update(sessionId).update(NO_EXPIRY).digest();
|
|
85
|
+
return new Uint8Array(Buffer.concat([NO_EXPIRY, mac]));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Constant-time verification of a token minted by `mintToken` for exactly this session id. */
|
|
89
|
+
export function verifyToken(secret, sessionId, token) {
|
|
90
|
+
if (!(token instanceof Uint8Array) || token.byteLength !== 40) return false;
|
|
91
|
+
const buf = Buffer.from(token.buffer, token.byteOffset, token.byteLength);
|
|
92
|
+
const expiry = buf.subarray(0, 8);
|
|
93
|
+
if (expiry.readBigUInt64BE() < BigInt(Math.floor(Date.now() / 1000))) return false;
|
|
94
|
+
const want = createHmac('sha256', secret).update(sessionId).update(expiry).digest();
|
|
95
|
+
return timingSafeEqual(want, buf.subarray(8));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One attached connection's view of a session; `connection.mjs` builds them.
|
|
100
|
+
* @typedef {object} Attachment
|
|
101
|
+
* @property {number} connId
|
|
102
|
+
* @property {number} channelId
|
|
103
|
+
* @property {'control' | 'readonly'} mode
|
|
104
|
+
* @property {string} username
|
|
105
|
+
* @property {string} principal - who this connection authenticated as (see `principalKey`)
|
|
106
|
+
* @property {(bytes: Uint8Array) => unknown} write - live output
|
|
107
|
+
* @property {(msg: object) => unknown} notify - an ordered control message (e.g. Presence)
|
|
108
|
+
* @property {(code: number) => unknown} exit - the session ended
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
export class HostedSession {
|
|
112
|
+
/** @type {Set<Attachment>} */
|
|
113
|
+
attachments = new Set();
|
|
114
|
+
state = 'attached';
|
|
115
|
+
exitCode = null;
|
|
116
|
+
createdAt = Date.now();
|
|
117
|
+
lastActivity = Date.now();
|
|
118
|
+
detachedAt = 0;
|
|
119
|
+
name;
|
|
120
|
+
#timer = null;
|
|
121
|
+
|
|
122
|
+
constructor(registry, { id, token, owner, kind, command, ringBytes }) {
|
|
123
|
+
this.registry = registry;
|
|
124
|
+
this.id = id;
|
|
125
|
+
this.token = token;
|
|
126
|
+
this.owner = owner;
|
|
127
|
+
this.kind = kind;
|
|
128
|
+
this.command = command;
|
|
129
|
+
this.ring = new ByteRing(ringBytes);
|
|
130
|
+
/** Set by the connection once the process exists: `{ input, inputEnd, resize, signal, kill }`. */
|
|
131
|
+
this.handlers = {};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Output from the process: retained in the ring, delivered to every attachment. */
|
|
135
|
+
push(bytes) {
|
|
136
|
+
this.lastActivity = Date.now();
|
|
137
|
+
this.ring.push(bytes);
|
|
138
|
+
for (const a of this.attachments) a.write(bytes);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
attach(att) {
|
|
142
|
+
this.attachments.add(att);
|
|
143
|
+
this.state = 'attached';
|
|
144
|
+
this.#clearTimer();
|
|
145
|
+
this.broadcastPresence(att);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
detach(att) {
|
|
149
|
+
if (!this.attachments.delete(att)) return;
|
|
150
|
+
this.broadcastPresence();
|
|
151
|
+
if (this.attachments.size === 0) this.#markDetached();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
roster() {
|
|
155
|
+
return [...this.attachments].map((a) => ({ session_id: this.id, mode: a.mode, username: a.username }));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Tell every attachment (except `except`) who is attached now. */
|
|
159
|
+
broadcastPresence(except) {
|
|
160
|
+
if (this.attachments.size === 0) return;
|
|
161
|
+
const msg = { attachments: this.roster() };
|
|
162
|
+
for (const a of this.attachments) if (a !== except) a.notify(msg);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The process ended. Everyone attached hears Exit + Close; the record lingers for late resumes. */
|
|
166
|
+
finish(code) {
|
|
167
|
+
if (this.state === 'exited') return;
|
|
168
|
+
this.exitCode = code;
|
|
169
|
+
this.state = 'exited';
|
|
170
|
+
const att = [...this.attachments];
|
|
171
|
+
this.attachments.clear();
|
|
172
|
+
for (const a of att) a.exit(code);
|
|
173
|
+
this.#markDetached();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
kill() {
|
|
177
|
+
try { this.handlers.kill?.(); } catch { /* already gone */ }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Forget the session (and stop it if it is still running). */
|
|
181
|
+
dispose() {
|
|
182
|
+
this.#clearTimer();
|
|
183
|
+
this.registry._forget(this);
|
|
184
|
+
if (this.state !== 'exited') {
|
|
185
|
+
this.kill();
|
|
186
|
+
this.state = 'exited';
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#markDetached() {
|
|
191
|
+
this.detachedAt = Date.now();
|
|
192
|
+
if (this.state !== 'exited') this.state = 'detached';
|
|
193
|
+
this.#clearTimer();
|
|
194
|
+
const ttl = this.registry.detachTtlMs;
|
|
195
|
+
if (ttl <= 0) { this.dispose(); return; }
|
|
196
|
+
this.#timer = setTimeout(() => {
|
|
197
|
+
this.registry.log(`session ${this.id} expired after ${ttl}ms detached`);
|
|
198
|
+
this.dispose();
|
|
199
|
+
}, ttl);
|
|
200
|
+
this.#timer.unref?.();
|
|
201
|
+
this.registry._enforceMaxDetached(this);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#clearTimer() {
|
|
205
|
+
clearTimeout(this.#timer);
|
|
206
|
+
this.#timer = null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export class SessionRegistry {
|
|
211
|
+
/** @type {Map<string, HostedSession>} */
|
|
212
|
+
#sessions = new Map();
|
|
213
|
+
|
|
214
|
+
constructor({ secret, detachTtlMs = DEFAULT_DETACH_TTL_MS, maxDetached = DEFAULT_MAX_DETACHED, ringBytes = DEFAULT_RING_BYTES, log = () => {} } = {}) {
|
|
215
|
+
this.secret = secret ?? randomBytes(32);
|
|
216
|
+
this.detachTtlMs = detachTtlMs;
|
|
217
|
+
this.maxDetached = maxDetached;
|
|
218
|
+
this.ringBytes = ringBytes;
|
|
219
|
+
this.log = log;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** @param {{ owner: { username: string, principal: string, fingerprint: string | null }, kind: 'pty' | 'exec', command?: string }} spec */
|
|
223
|
+
create({ owner, kind, command }) {
|
|
224
|
+
const id = randomUUID();
|
|
225
|
+
const s = new HostedSession(this, { id, token: mintToken(this.secret, id), owner, kind, command, ringBytes: this.ringBytes });
|
|
226
|
+
this.#sessions.set(id, s);
|
|
227
|
+
return s;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
get(id) {
|
|
231
|
+
return typeof id === 'string' ? this.#sessions.get(id) : undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
checkToken(session, token) {
|
|
235
|
+
return verifyToken(this.secret, session.id, token);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Sessions this principal owns. */
|
|
239
|
+
listFor(principal) {
|
|
240
|
+
return [...this.#sessions.values()].filter((s) => s.owner.principal === principal);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
get size() { return this.#sessions.size; }
|
|
244
|
+
|
|
245
|
+
/** Kill everything (server shutdown). */
|
|
246
|
+
closeAll() {
|
|
247
|
+
for (const s of [...this.#sessions.values()]) s.dispose();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
_forget(session) {
|
|
251
|
+
this.#sessions.delete(session.id);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Keep at most `maxDetached` unattended sessions: the longest-detached are killed first. */
|
|
255
|
+
_enforceMaxDetached() {
|
|
256
|
+
const detached = [...this.#sessions.values()].filter((s) => s.attachments.size === 0).sort((a, b) => a.detachedAt - b.detachedAt);
|
|
257
|
+
while (detached.length > this.maxDetached) {
|
|
258
|
+
const victim = detached.shift();
|
|
259
|
+
this.log(`session ${victim.id} evicted (more than ${this.maxDetached} detached)`);
|
|
260
|
+
victim.dispose();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
package/src/session.mjs
CHANGED
|
@@ -76,6 +76,12 @@ export class WshSession {
|
|
|
76
76
|
*/
|
|
77
77
|
#resumeToken;
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Cumulative session OUTPUT BYTES received so far (see `seq`).
|
|
81
|
+
* @type {number}
|
|
82
|
+
*/
|
|
83
|
+
#seq = 0;
|
|
84
|
+
|
|
79
85
|
/**
|
|
80
86
|
* Stream IDs returned by the server in OPEN_OK.
|
|
81
87
|
* Typically { stdin: number, stdout: number } or a single bidirectional ID.
|
|
@@ -270,6 +276,27 @@ export class WshSession {
|
|
|
270
276
|
return this.#resumeToken;
|
|
271
277
|
}
|
|
272
278
|
|
|
279
|
+
/**
|
|
280
|
+
* How many bytes of this session's output this client has received: the
|
|
281
|
+
* host's `seq` for the next byte. Pass it as `lastSeq` to
|
|
282
|
+
* `WshClient.resumeSession()` so a resume replays only what was missed. For a
|
|
283
|
+
* session obtained from `attachSession()`/`resumeSession()` it starts at the
|
|
284
|
+
* host's position of the first replayed byte, so it is an absolute position
|
|
285
|
+
* either way. Counts bytes delivered to `onData`.
|
|
286
|
+
* @returns {number}
|
|
287
|
+
*/
|
|
288
|
+
get seq() {
|
|
289
|
+
return this.#seq;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Set the starting position of a session obtained by attach/resume.
|
|
294
|
+
* @internal
|
|
295
|
+
*/
|
|
296
|
+
_setSeq(n) {
|
|
297
|
+
this.#seq = n;
|
|
298
|
+
}
|
|
299
|
+
|
|
273
300
|
/** Current session state. */
|
|
274
301
|
get state() {
|
|
275
302
|
return this.#state;
|
|
@@ -657,6 +684,7 @@ export class WshSession {
|
|
|
657
684
|
if (msg.data && msg.data.byteLength > 0) {
|
|
658
685
|
this.#virtualBackend?.pushData(msg.data);
|
|
659
686
|
try {
|
|
687
|
+
this.#seq += msg.data.byteLength;
|
|
660
688
|
this.onData?.(msg.data);
|
|
661
689
|
} catch (err) {
|
|
662
690
|
console.error('[wsh:session] onData handler error:', err);
|
|
@@ -688,6 +716,7 @@ export class WshSession {
|
|
|
688
716
|
.then((plaintext) => {
|
|
689
717
|
this.#virtualBackend?.pushData(plaintext);
|
|
690
718
|
try {
|
|
719
|
+
this.#seq += plaintext.byteLength;
|
|
691
720
|
this.onData?.(plaintext);
|
|
692
721
|
} catch (err) {
|
|
693
722
|
console.error('[wsh:session] onData handler error:', err);
|
|
@@ -898,6 +927,7 @@ export class WshSession {
|
|
|
898
927
|
}
|
|
899
928
|
for (const plaintext of deliverable) {
|
|
900
929
|
try {
|
|
930
|
+
this.#seq += plaintext.byteLength;
|
|
901
931
|
this.onData?.(plaintext);
|
|
902
932
|
} catch (err) {
|
|
903
933
|
console.error('[wsh:session] onData handler error:', err);
|
|
@@ -905,6 +935,7 @@ export class WshSession {
|
|
|
905
935
|
}
|
|
906
936
|
} else {
|
|
907
937
|
try {
|
|
938
|
+
this.#seq += value.byteLength;
|
|
908
939
|
this.onData?.(value);
|
|
909
940
|
} catch (err) {
|
|
910
941
|
console.error('[wsh:session] onData handler error:', err);
|