@johnhenry/wsh 0.17.0 → 0.18.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 +186 -19
- package/package.json +21 -4
- package/src/client.mjs +70 -4
- package/src/file-transfer.mjs +45 -156
- package/src/index.d.ts +149 -6
- package/src/index.mjs +5 -1
- package/src/known-hosts.mjs +139 -0
- package/src/messages.gen.mjs +22 -9
- package/src/qmux-connection.mjs +21 -0
- package/src/server/auth.mjs +75 -0
- package/src/server/connection.mjs +381 -0
- package/src/server/exec.mjs +60 -0
- package/src/server/fs.mjs +134 -0
- package/src/server/index.d.ts +130 -0
- package/src/server/index.mjs +95 -0
- package/src/server/pty.mjs +25 -0
- package/src/transport-ws.mjs +4 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File access for `@johnhenry/wsh/server`: FileOp requests (list, stat, read,
|
|
3
|
+
* mkdir, remove) and `upload:`/`download:` file channels, confined to one
|
|
4
|
+
* root directory.
|
|
5
|
+
*
|
|
6
|
+
* Every path a client sends is resolved against `root`; a path that would
|
|
7
|
+
* leave it -- by `..`, an absolute path, or a symlink -- is refused.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
readdir, lstat, stat, readFile, writeFile, mkdir, rmdir, unlink, readlink, realpath, open,
|
|
12
|
+
} from 'node:fs/promises';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_MAX_FILE_BYTES = 64 * 1024 * 1024;
|
|
16
|
+
export const FILE_CHUNK_BYTES = 64 * 1024;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {{ root: string, readOnly?: boolean, maxFileBytes?: number }} opts
|
|
20
|
+
*/
|
|
21
|
+
export function createFileAccess({ root, readOnly = false, maxFileBytes = DEFAULT_MAX_FILE_BYTES }) {
|
|
22
|
+
if (typeof root !== 'string' || !root) throw new TypeError('createWshServer: fs.root is required');
|
|
23
|
+
const base = path.resolve(root);
|
|
24
|
+
let realBase = null;
|
|
25
|
+
|
|
26
|
+
const fail = (message) => Object.assign(new Error(message), { wsh: true });
|
|
27
|
+
|
|
28
|
+
/** Resolve a client path to an absolute path inside `root`, or throw. */
|
|
29
|
+
async function resolveInside(clientPath) {
|
|
30
|
+
if (typeof clientPath !== 'string') throw fail('path is required');
|
|
31
|
+
if (clientPath.includes('\0')) throw fail('illegal path');
|
|
32
|
+
realBase ??= await realpath(base);
|
|
33
|
+
const target = path.resolve(base, '.' + path.sep + clientPath.replace(/^[/\\]+/, ''));
|
|
34
|
+
if (target !== base && !target.startsWith(base + path.sep)) throw fail('path escapes the file root');
|
|
35
|
+
// Symlink escape: the deepest existing ancestor must still be inside the real root.
|
|
36
|
+
let probe = target;
|
|
37
|
+
for (;;) {
|
|
38
|
+
try {
|
|
39
|
+
const real = await realpath(probe);
|
|
40
|
+
if (real !== realBase && !real.startsWith(realBase + path.sep)) throw fail('path escapes the file root');
|
|
41
|
+
break;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (err.wsh) throw err;
|
|
44
|
+
const parent = path.dirname(probe);
|
|
45
|
+
if (parent === probe) break;
|
|
46
|
+
probe = parent;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return target;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const typeOf = (st) => st.isSymbolicLink() ? 'symlink'
|
|
53
|
+
: st.isDirectory() ? 'directory'
|
|
54
|
+
: st.isFile() ? 'file'
|
|
55
|
+
: st.isBlockDevice() || st.isCharacterDevice() ? 'device'
|
|
56
|
+
: st.isFIFO() ? 'pipe'
|
|
57
|
+
: st.isSocket() ? 'socket' : 'file';
|
|
58
|
+
|
|
59
|
+
async function entryFor(full, name) {
|
|
60
|
+
const st = await lstat(full);
|
|
61
|
+
const entry = { name, size: st.size, modified: Math.floor(st.mtimeMs / 1000), type: typeOf(st) };
|
|
62
|
+
if (entry.type === 'symlink') {
|
|
63
|
+
try { entry.symlink_target = await readlink(full); } catch { /* raced */ }
|
|
64
|
+
}
|
|
65
|
+
return entry;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const assertWritable = () => { if (readOnly) throw fail('file root is read-only'); };
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
maxFileBytes,
|
|
72
|
+
|
|
73
|
+
/** @returns {Promise<{ metadata?: object, entries?: object[] }>} */
|
|
74
|
+
async operate(op, clientPath, { offset, length } = {}) {
|
|
75
|
+
switch (op) {
|
|
76
|
+
case 'list': {
|
|
77
|
+
const dir = await resolveInside(clientPath || '/');
|
|
78
|
+
const names = await readdir(dir);
|
|
79
|
+
const entries = [];
|
|
80
|
+
for (const name of names.sort()) {
|
|
81
|
+
try { entries.push(await entryFor(path.join(dir, name), name)); } catch { /* raced */ }
|
|
82
|
+
}
|
|
83
|
+
return { entries, metadata: { path: clientPath || '/' } };
|
|
84
|
+
}
|
|
85
|
+
case 'stat': {
|
|
86
|
+
const full = await resolveInside(clientPath);
|
|
87
|
+
return { metadata: await entryFor(full, path.basename(full)) };
|
|
88
|
+
}
|
|
89
|
+
case 'read': {
|
|
90
|
+
const full = await resolveInside(clientPath);
|
|
91
|
+
const st = await stat(full);
|
|
92
|
+
if (!st.isFile()) throw fail('not a regular file');
|
|
93
|
+
const start = Math.max(0, Number(offset ?? 0));
|
|
94
|
+
const want = Math.min(Number(length ?? FILE_CHUNK_BYTES), FILE_CHUNK_BYTES);
|
|
95
|
+
const fh = await open(full, 'r');
|
|
96
|
+
try {
|
|
97
|
+
const buf = new Uint8Array(want);
|
|
98
|
+
const { bytesRead } = await fh.read(buf, 0, want, start);
|
|
99
|
+
return { metadata: { data: buf.subarray(0, bytesRead), size: st.size } };
|
|
100
|
+
} finally { await fh.close(); }
|
|
101
|
+
}
|
|
102
|
+
case 'mkdir': {
|
|
103
|
+
assertWritable();
|
|
104
|
+
await mkdir(await resolveInside(clientPath), { recursive: true });
|
|
105
|
+
return { metadata: {} };
|
|
106
|
+
}
|
|
107
|
+
case 'remove': {
|
|
108
|
+
assertWritable();
|
|
109
|
+
const full = await resolveInside(clientPath);
|
|
110
|
+
if (full === base) throw fail('refusing to remove the file root');
|
|
111
|
+
await ((await lstat(full)).isDirectory() ? rmdir(full) : unlink(full));
|
|
112
|
+
return { metadata: { removed: clientPath } };
|
|
113
|
+
}
|
|
114
|
+
default:
|
|
115
|
+
throw fail(`"${op}" is not offered by this host (list, stat, read, mkdir, remove; use upload/download to write)`);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
async readWhole(clientPath) {
|
|
120
|
+
const full = await resolveInside(clientPath);
|
|
121
|
+
const st = await stat(full);
|
|
122
|
+
if (!st.isFile()) throw fail('not a regular file');
|
|
123
|
+
if (st.size > maxFileBytes) throw fail(`file is ${st.size} bytes; the limit is ${maxFileBytes}`);
|
|
124
|
+
return new Uint8Array(await readFile(full));
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async writeWhole(clientPath, data) {
|
|
128
|
+
assertWritable();
|
|
129
|
+
const full = await resolveInside(clientPath);
|
|
130
|
+
await mkdir(path.dirname(full), { recursive: true });
|
|
131
|
+
await writeFile(full, data);
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for `@johnhenry/wsh/server` -- the Node host. Node-only; the package
|
|
3
|
+
* root (`@johnhenry/wsh`) never imports it.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Who is asking to authenticate. */
|
|
7
|
+
export interface WshServerPrincipal {
|
|
8
|
+
username: string;
|
|
9
|
+
/** Hex SHA-256 of the raw public key (see `fingerprint()`). */
|
|
10
|
+
fingerprint: string;
|
|
11
|
+
/** Raw 32-byte Ed25519 public key. */
|
|
12
|
+
publicKey: Uint8Array;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface WshServerAuthOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Keys allowed to connect: `ssh-ed25519 AAAA... [comment]` lines (an
|
|
18
|
+
* `authorized_keys` file's text works), raw 32-byte keys, or an array of either.
|
|
19
|
+
*/
|
|
20
|
+
authorizedKeys?: string | Uint8Array | Array<string | Uint8Array>;
|
|
21
|
+
/** Extra policy, run after the signature verifies (and after the allowlist, when given). */
|
|
22
|
+
authorize?: (who: WshServerPrincipal) => boolean | Promise<boolean>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What a custom `exec` runner receives for one session. */
|
|
26
|
+
export interface WshExecIo {
|
|
27
|
+
user: string;
|
|
28
|
+
/** Environment the client asked for (empty when `clientEnv: false`). */
|
|
29
|
+
env: Record<string, string>;
|
|
30
|
+
cols: number;
|
|
31
|
+
rows: number;
|
|
32
|
+
/** Aborts on client Close, disconnect, timeout, or a terminating signal nothing handled. */
|
|
33
|
+
signal: AbortSignal;
|
|
34
|
+
/** Send output; resolves when flow control has accepted it. */
|
|
35
|
+
write(data: Uint8Array | string): Promise<void>;
|
|
36
|
+
/** Client stdin. */
|
|
37
|
+
onInput(cb: (bytes: Uint8Array) => void): void;
|
|
38
|
+
/** Client closed stdin. */
|
|
39
|
+
onInputEnd(cb: () => void): void;
|
|
40
|
+
/** Named signal from the client (`INT`, `TERM`, ...). */
|
|
41
|
+
onSignal(cb: (name: string) => void): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Runs one exec session; resolve with the exit code. */
|
|
45
|
+
export type WshExecRunner = (command: string, io: WshExecIo) => Promise<number>;
|
|
46
|
+
|
|
47
|
+
export interface WshServerExecOptions {
|
|
48
|
+
/** Working directory of spawned commands. */
|
|
49
|
+
cwd?: string;
|
|
50
|
+
/** Base environment (default `process.env`). */
|
|
51
|
+
env?: Record<string, string | undefined>;
|
|
52
|
+
/** `true` (default `/bin/sh`), or a shell path. */
|
|
53
|
+
shell?: boolean | string;
|
|
54
|
+
/** Kill a session after this long (0 / omitted = never). */
|
|
55
|
+
timeoutMs?: number;
|
|
56
|
+
/** Apply `env` sent by the client (default `true`). */
|
|
57
|
+
clientEnv?: boolean;
|
|
58
|
+
/** Replace the child_process runner entirely (e.g. a restricted, no-shell host). */
|
|
59
|
+
run?: WshExecRunner;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The part of a pseudo-terminal process the server uses -- `node-pty`'s `IPty` fits. */
|
|
63
|
+
export interface WshPtyProcess {
|
|
64
|
+
onData(cb: (data: string | Uint8Array) => void): unknown;
|
|
65
|
+
onExit(cb: (e: { exitCode: number; signal?: number }) => void): unknown;
|
|
66
|
+
write(data: string | Uint8Array): void;
|
|
67
|
+
resize(cols: number, rows: number): void;
|
|
68
|
+
kill(signal?: string): void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface WshServerPtyOptions {
|
|
72
|
+
/** `node-pty`'s `spawn(file, args, options)`, or anything shaped like it. */
|
|
73
|
+
spawn(file: string, args: string[], options: {
|
|
74
|
+
name: string; cols: number; rows: number; cwd?: string; env: Record<string, string | undefined>;
|
|
75
|
+
}): WshPtyProcess;
|
|
76
|
+
/** Shell to run (default `$SHELL` or `/bin/sh`). */
|
|
77
|
+
shell?: string;
|
|
78
|
+
cwd?: string;
|
|
79
|
+
env?: Record<string, string | undefined>;
|
|
80
|
+
/** `TERM` value (default `xterm-256color`). */
|
|
81
|
+
term?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface WshServerFsOptions {
|
|
85
|
+
/** Every client path is resolved inside this directory; `..`, absolute paths and symlinks out of it are refused. */
|
|
86
|
+
root: string;
|
|
87
|
+
readOnly?: boolean;
|
|
88
|
+
/** Upload / download size cap in bytes (default 64 MiB). */
|
|
89
|
+
maxFileBytes?: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface WshServerOptions {
|
|
93
|
+
/** Bind address (default `127.0.0.1`). */
|
|
94
|
+
host?: string;
|
|
95
|
+
/** Port (default `0` = pick a free one; read it back from `address()`). */
|
|
96
|
+
port?: number;
|
|
97
|
+
/** Who may connect. Omitted = every connection is refused. A function is shorthand for `{ authorize }`. */
|
|
98
|
+
auth?: WshServerAuthOptions | ((who: WshServerPrincipal) => boolean | Promise<boolean>);
|
|
99
|
+
/** Enable `exec` sessions. `true`, options, or a custom runner. Off by default. */
|
|
100
|
+
exec?: true | WshServerExecOptions | WshExecRunner;
|
|
101
|
+
/** Enable `pty` sessions by injecting a pty implementation. Off by default. */
|
|
102
|
+
pty?: WshServerPtyOptions;
|
|
103
|
+
/** Enable file ops and uploads/downloads under one directory. Off by default. */
|
|
104
|
+
fs?: WshServerFsOptions;
|
|
105
|
+
/** How long exec output waits for a client that has not yet opened its data stream (default 3000). */
|
|
106
|
+
bindTimeoutMs?: number;
|
|
107
|
+
onLog?: (line: string) => void;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface WshServerAddress {
|
|
111
|
+
address: string;
|
|
112
|
+
port: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface WshServer {
|
|
116
|
+
/** Start listening. Requires the `ws` package. */
|
|
117
|
+
listen(): Promise<WshServerAddress>;
|
|
118
|
+
/** Drop every connection and stop listening. */
|
|
119
|
+
close(): Promise<void>;
|
|
120
|
+
/** The bound address, or `null` when not listening. */
|
|
121
|
+
address(): WshServerAddress | null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function createWshServer(options?: WshServerOptions): WshServer;
|
|
125
|
+
|
|
126
|
+
/** Parse `authorized_keys`-style text into raw 32-byte Ed25519 keys (malformed lines skipped). */
|
|
127
|
+
export function parseAuthorizedKeys(text: string): Uint8Array[];
|
|
128
|
+
|
|
129
|
+
/** ServerHello feature: the host discovers client-opened exec data streams itself (no primer byte). */
|
|
130
|
+
export const STREAM_ANNOUNCE: 'stream-announce';
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@johnhenry/wsh/server` -- a Node host for the wsh-v1 protocol, built on the
|
|
3
|
+
* same primitives the client is. Node-only (`ws`, `node:child_process`,
|
|
4
|
+
* `node:fs`); the package root stays browser-safe and never imports this.
|
|
5
|
+
*
|
|
6
|
+
* Everything that grants a client access to the machine -- `exec`, `pty`,
|
|
7
|
+
* `fs` -- is OFF unless you pass it, and with no `auth` every connection is
|
|
8
|
+
* refused.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { buildAuthorizer, parseAuthorizedKeys } from './auth.mjs';
|
|
12
|
+
import { spawnRunner } from './exec.mjs';
|
|
13
|
+
import { normalizePty } from './pty.mjs';
|
|
14
|
+
import { createFileAccess } from './fs.mjs';
|
|
15
|
+
import { createConnectionFactory, STREAM_ANNOUNCE } from './connection.mjs';
|
|
16
|
+
|
|
17
|
+
export { parseAuthorizedKeys, STREAM_ANNOUNCE };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} [options]
|
|
21
|
+
* @param {string} [options.host='127.0.0.1']
|
|
22
|
+
* @param {number} [options.port=0] - 0 picks a free port; read it back from `address()`.
|
|
23
|
+
* @param {object | Function} [options.auth] - See `buildAuthorizer`. Omitted = refuse everyone.
|
|
24
|
+
* @param {true | object | Function} [options.exec] - `true` (shell via child_process),
|
|
25
|
+
* `{ cwd, env, shell, timeoutMs, clientEnv }`, or a custom `run(command, io)` function.
|
|
26
|
+
* @param {{ spawn: Function, shell?: string, cwd?: string, env?: object }} [options.pty]
|
|
27
|
+
* @param {{ root: string, readOnly?: boolean, maxFileBytes?: number }} [options.fs]
|
|
28
|
+
* @param {number} [options.bindTimeoutMs=3000]
|
|
29
|
+
* @param {(line: string) => void} [options.onLog]
|
|
30
|
+
* @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
|
|
31
|
+
*/
|
|
32
|
+
export function createWshServer({
|
|
33
|
+
host = '127.0.0.1', port = 0, auth, exec, pty, fs, bindTimeoutMs = 3000, onLog = () => {},
|
|
34
|
+
} = {}) {
|
|
35
|
+
let wss = null;
|
|
36
|
+
let bound = null;
|
|
37
|
+
|
|
38
|
+
let execRunner = null;
|
|
39
|
+
let execOptions = {};
|
|
40
|
+
if (typeof exec === 'function') {
|
|
41
|
+
execRunner = exec;
|
|
42
|
+
} else if (exec) {
|
|
43
|
+
execOptions = exec === true ? {} : exec;
|
|
44
|
+
execRunner = typeof execOptions.run === 'function' ? execOptions.run : spawnRunner(execOptions);
|
|
45
|
+
}
|
|
46
|
+
const ptyConfig = pty ? normalizePty(pty) : null;
|
|
47
|
+
const files = fs ? createFileAccess(fs) : null;
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
async listen() {
|
|
51
|
+
if (wss) throw new Error('createWshServer: already listening');
|
|
52
|
+
let WebSocketServer;
|
|
53
|
+
try {
|
|
54
|
+
({ WebSocketServer } = await import('ws'));
|
|
55
|
+
} catch (err) {
|
|
56
|
+
throw new Error('@johnhenry/wsh/server needs the "ws" package: npm install ws', { cause: err });
|
|
57
|
+
}
|
|
58
|
+
const authorize = await buildAuthorizer(auth);
|
|
59
|
+
const attach = createConnectionFactory({
|
|
60
|
+
authorize, execRunner, execOptions, pty: ptyConfig, files, bindTimeoutMs, log: onLog,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
wss = new WebSocketServer({ host, port });
|
|
64
|
+
wss.on('connection', (ws) => {
|
|
65
|
+
const conn = attach({ send: (b) => { if (ws.readyState === 1) ws.send(b); } });
|
|
66
|
+
ws.on('message', (d) => {
|
|
67
|
+
const buf = Array.isArray(d) ? Buffer.concat(d) : Buffer.isBuffer(d) ? d : Buffer.from(d);
|
|
68
|
+
conn.receive(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
|
|
69
|
+
});
|
|
70
|
+
ws.on('close', () => conn.close());
|
|
71
|
+
ws.on('error', () => {});
|
|
72
|
+
});
|
|
73
|
+
await new Promise((resolve, reject) => {
|
|
74
|
+
wss.once('listening', resolve);
|
|
75
|
+
wss.once('error', reject);
|
|
76
|
+
});
|
|
77
|
+
const a = wss.address();
|
|
78
|
+
bound = { address: a.address, port: a.port };
|
|
79
|
+
return bound;
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async close() {
|
|
83
|
+
if (!wss) return;
|
|
84
|
+
const server = wss;
|
|
85
|
+
wss = null;
|
|
86
|
+
bound = null;
|
|
87
|
+
for (const client of server.clients) client.terminate();
|
|
88
|
+
await new Promise((resolve) => server.close(resolve));
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
address() {
|
|
92
|
+
return bound;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pty` support for `@johnhenry/wsh/server`.
|
|
3
|
+
*
|
|
4
|
+
* A pseudo-terminal needs a native addon, which this package deliberately
|
|
5
|
+
* does not depend on: the host application injects one. `pty.spawn` is
|
|
6
|
+
* `node-pty`'s `spawn(file, args, options)` (or anything shaped like it):
|
|
7
|
+
* it returns `{ onData(cb), onExit(cb), write(data), resize(cols, rows),
|
|
8
|
+
* kill(signal?) }`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {{ spawn: Function, shell?: string, cwd?: string, env?: object, term?: string }} pty
|
|
13
|
+
*/
|
|
14
|
+
export function normalizePty(pty) {
|
|
15
|
+
if (!pty || typeof pty.spawn !== 'function') {
|
|
16
|
+
throw new TypeError('createWshServer: pty.spawn must be a function (e.g. node-pty\'s spawn)');
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
spawn: pty.spawn,
|
|
20
|
+
shell: pty.shell ?? process.env.SHELL ?? '/bin/sh',
|
|
21
|
+
cwd: pty.cwd,
|
|
22
|
+
env: pty.env,
|
|
23
|
+
term: pty.term ?? 'xterm-256color',
|
|
24
|
+
};
|
|
25
|
+
}
|
package/src/transport-ws.mjs
CHANGED
|
@@ -256,6 +256,10 @@ export class WebSocketTransport extends WshTransport {
|
|
|
256
256
|
/** @override */
|
|
257
257
|
async _doOpenStream() {
|
|
258
258
|
const qs = await this.#qmux.openStream();
|
|
259
|
+
// A stream is invisible to the peer until its first frame; a data stream
|
|
260
|
+
// that may never carry client bytes (exec has no stdin) must announce
|
|
261
|
+
// itself or the host never discovers it.
|
|
262
|
+
qs.announce();
|
|
259
263
|
const adapter = new QMuxStreamAdapter(qs);
|
|
260
264
|
return { readable: adapter.readable, writable: adapter.writable, id: qs.id };
|
|
261
265
|
}
|