@johnhenry/wsh 0.7.0 → 0.13.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.
@@ -1,24 +1,31 @@
1
1
  /**
2
- * WshFileTransfer — dedicated stream file transfer (scp-like) over a wsh connection.
2
+ * WshFileTransfer — scp-like file transfer over a wsh connection.
3
3
  *
4
- * Uploads and downloads files using the wsh channel protocol with 'file' kind
5
- * channels. Data flows over a dedicated bidirectional stream in 64KB chunks.
4
+ * Uploads and downloads delegate to the underlying client's upload()/
5
+ * download() (FileChunk control messages over a 'file'-kind channel —
6
+ * see client.mjs). list() is a separate helper that runs `ls -la` over
7
+ * a plain exec channel.
6
8
  */
7
9
 
8
10
  import { MSG, open, CHANNEL_KIND } from './messages.mjs';
9
11
 
10
- /** Transfer chunk size: 64KB. */
11
- const CHUNK_SIZE = 65536;
12
-
13
12
  /** Default timeout for waiting on control messages (30 seconds). */
14
13
  const RESPONSE_TIMEOUT_MS = 30_000;
15
14
 
16
15
  export class WshFileTransfer {
17
- /** @type {{ sendControl: function, openStream: function, onControl: function }} */
16
+ /**
17
+ * @type {{
18
+ * upload: function, download: function,
19
+ * sendControl: function, openStream: function, onControl: function,
20
+ * }}
21
+ */
18
22
  #client;
19
23
 
20
24
  /**
21
- * @param {object} client - A WshClient or any transport object exposing:
25
+ * @param {object} client - A WshClient (or any object exposing upload()/
26
+ * download() with the same signatures — those are used directly for
27
+ * upload/download). list() additionally needs, for the underlying exec
28
+ * channel it runs `ls -la` over:
22
29
  * - sendControl(msg): send a control message
23
30
  * - openStream(): open a new bidirectional stream
24
31
  * - onControl: settable callback for incoming control messages
@@ -32,12 +39,10 @@ export class WshFileTransfer {
32
39
  /**
33
40
  * Upload data to a remote path.
34
41
  *
35
- * Protocol flow:
36
- * 1. Send OPEN { kind: 'file', command: 'upload', path }
37
- * 2. Receive OPEN_OK { channel_id, stream_ids }
38
- * 3. Write data to dedicated stream in 64KB chunks
39
- * 4. Close the write side of the stream
40
- * 5. Wait for EXIT message with status code
42
+ * Delegates to the underlying client's `upload()`, which sends the data
43
+ * as a sequence of `FileChunk` control messages over a `'file'`-kind
44
+ * channel — the single wire scheme for file transfer (see
45
+ * `WshClient.upload` in `client.mjs`).
41
46
  *
42
47
  * @param {Uint8Array | ArrayBuffer} data - File content to upload
43
48
  * @param {string} remotePath - Destination path on the remote host
@@ -47,97 +52,31 @@ export class WshFileTransfer {
47
52
  * @returns {Promise<{ success: boolean, bytesTransferred: number }>}
48
53
  */
49
54
  async upload(data, remotePath, { onProgress, timeout = RESPONSE_TIMEOUT_MS } = {}) {
50
- if (typeof this.#client.upload === 'function') {
51
- await this.#client.upload(data, remotePath, {
52
- onProgress: (value) => {
53
- if (typeof value === 'number') {
54
- onProgress?.({ sent: value, total: data.byteLength ?? data.length ?? 0 });
55
- return;
56
- }
57
- onProgress?.(value);
58
- },
59
- timeout,
60
- });
61
- const bytes = data instanceof Uint8Array ? data.byteLength : data.byteLength ?? data.length ?? 0;
62
- return { success: true, bytesTransferred: bytes };
63
- }
64
-
65
- const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
66
- if (!(bytes instanceof Uint8Array)) {
67
- throw new TypeError('Data must be a Uint8Array or ArrayBuffer');
68
- }
69
- if (!remotePath || typeof remotePath !== 'string') {
70
- throw new Error('remotePath is required');
55
+ if (typeof this.#client.upload !== 'function') {
56
+ throw new Error('WshFileTransfer requires a client exposing upload() (e.g. WshClient)');
71
57
  }
72
58
 
73
- // 1. Request a file upload channel
74
- const openMsg = open({
75
- kind: CHANNEL_KIND.FILE,
76
- command: 'upload',
59
+ await this.#client.upload(data, remotePath, {
60
+ onProgress: (value) => {
61
+ if (typeof value === 'number') {
62
+ onProgress?.({ sent: value, total: data.byteLength ?? data.length ?? 0 });
63
+ return;
64
+ }
65
+ onProgress?.(value);
66
+ },
67
+ timeout,
77
68
  });
78
- openMsg.path = remotePath;
79
- openMsg.size = bytes.length;
80
-
81
- await this.#client.sendControl(openMsg);
82
-
83
- // 2. Wait for OPEN_OK or OPEN_FAIL
84
- const response = await this._waitForMessage(
85
- (msg) => msg.type === MSG.OPEN_OK || msg.type === MSG.OPEN_FAIL,
86
- timeout
87
- );
88
-
89
- if (response.type === MSG.OPEN_FAIL) {
90
- throw new Error(`Upload rejected: ${response.reason || 'unknown reason'}`);
91
- }
92
-
93
- const channelId = response.channel_id;
94
-
95
- // 3. Open a dedicated data stream and write in chunks
96
- const stream = await this.#client.openStream();
97
- const writer = stream.writable.getWriter();
98
- let sent = 0;
99
-
100
- try {
101
- while (sent < bytes.length) {
102
- const end = Math.min(sent + CHUNK_SIZE, bytes.length);
103
- const chunk = bytes.subarray(sent, end);
104
- await writer.write(chunk);
105
- sent = end;
106
- try {
107
- onProgress?.({ sent, total: bytes.length });
108
- } catch { /* ignore callback errors */ }
109
- }
110
- } finally {
111
- // 4. Close the write side to signal end of data
112
- try {
113
- await writer.close();
114
- } catch {
115
- // Stream may already be closed on error
116
- }
117
- }
118
-
119
- // 5. Wait for EXIT confirmation
120
- const exitMsg = await this._waitForMessage(
121
- (msg) => msg.type === MSG.EXIT && msg.channel_id === channelId,
122
- timeout
123
- );
124
-
125
- const success = exitMsg.code === 0;
126
- if (!success) {
127
- throw new Error(`Upload failed with exit code ${exitMsg.code}`);
128
- }
129
-
130
- return { success, bytesTransferred: sent };
69
+ const bytes = data instanceof Uint8Array ? data.byteLength : data.byteLength ?? data.length ?? 0;
70
+ return { success: true, bytesTransferred: bytes };
131
71
  }
132
72
 
133
73
  /**
134
74
  * Download a file from a remote path.
135
75
  *
136
- * Protocol flow:
137
- * 1. Send OPEN { kind: 'file', command: 'download', path }
138
- * 2. Receive OPEN_OK { channel_id, stream_ids }
139
- * 3. Read from the dedicated stream, accumulating chunks
140
- * 4. Return complete file content as Uint8Array
76
+ * Delegates to the underlying client's `download()`, which reads the
77
+ * data as a sequence of `FileChunk` control messages over a
78
+ * `'file'`-kind channel — the single wire scheme for file transfer (see
79
+ * `WshClient.download` in `client.mjs`).
141
80
  *
142
81
  * @param {string} remotePath - File path on the remote host
143
82
  * @param {object} [opts]
@@ -146,76 +85,10 @@ export class WshFileTransfer {
146
85
  * @returns {Promise<Uint8Array>} File content
147
86
  */
148
87
  async download(remotePath, { onProgress, timeout = RESPONSE_TIMEOUT_MS } = {}) {
149
- if (typeof this.#client.download === 'function') {
150
- return await this.#client.download(remotePath, { onProgress, timeout });
151
- }
152
-
153
- if (!remotePath || typeof remotePath !== 'string') {
154
- throw new Error('remotePath is required');
155
- }
156
-
157
- // 1. Request a file download channel
158
- const openMsg = open({
159
- kind: CHANNEL_KIND.FILE,
160
- command: 'download',
161
- });
162
- openMsg.path = remotePath;
163
-
164
- await this.#client.sendControl(openMsg);
165
-
166
- // 2. Wait for OPEN_OK or OPEN_FAIL
167
- const response = await this._waitForMessage(
168
- (msg) => msg.type === MSG.OPEN_OK || msg.type === MSG.OPEN_FAIL,
169
- timeout
170
- );
171
-
172
- if (response.type === MSG.OPEN_FAIL) {
173
- throw new Error(`Download rejected: ${response.reason || 'unknown reason'}`);
174
- }
175
-
176
- const channelId = response.channel_id;
177
- const totalSize = response.size; // Server may include total file size
178
-
179
- // 3. Open a dedicated data stream and read chunks
180
- const stream = await this.#client.openStream();
181
- const reader = stream.readable.getReader();
182
- const chunks = [];
183
- let received = 0;
184
-
185
- try {
186
- while (true) {
187
- const { done, value } = await reader.read();
188
- if (done) break;
189
-
190
- chunks.push(value);
191
- received += value.length;
192
-
193
- try {
194
- onProgress?.({ received, total: totalSize });
195
- } catch { /* ignore callback errors */ }
196
- }
197
- } finally {
198
- reader.releaseLock();
88
+ if (typeof this.#client.download !== 'function') {
89
+ throw new Error('WshFileTransfer requires a client exposing download() (e.g. WshClient)');
199
90
  }
200
-
201
- // 4. Wait for EXIT confirmation
202
- try {
203
- const exitMsg = await this._waitForMessage(
204
- (msg) => msg.type === MSG.EXIT && msg.channel_id === channelId,
205
- timeout
206
- );
207
- if (exitMsg.code !== 0) {
208
- throw new Error(`Download failed with exit code ${exitMsg.code}`);
209
- }
210
- } catch (err) {
211
- // If the stream closed cleanly and we got data, the EXIT may have
212
- // already been consumed or the server may not send one for downloads.
213
- // Only rethrow if we have no data.
214
- if (received === 0) throw err;
215
- }
216
-
217
- // Concatenate chunks into a single Uint8Array
218
- return this._concatChunks(chunks, received);
91
+ return await this.#client.download(remotePath, { onProgress, timeout });
219
92
  }
220
93
 
221
94
  /**
package/src/index.d.ts CHANGED
@@ -421,6 +421,7 @@ export function reversePeers(opts?: {
421
421
  export function reverseConnect(opts?: {
422
422
  targetFingerprint?: string;
423
423
  username?: string;
424
+ fromFingerprint?: string;
424
425
  }): WshMessage;
425
426
 
426
427
  export function reverseAccept(opts?: {
@@ -441,6 +442,16 @@ export function reverseReject(opts?: {
441
442
  reason?: string;
442
443
  }): WshMessage;
443
444
 
445
+ export function relayForward(opts?: {
446
+ fromFingerprint?: string;
447
+ inner?: Uint8Array;
448
+ }): WshMessage;
449
+
450
+ /** Message types a relay may forward inside a RelayForward wrapper. */
451
+ export const RELAY_FORWARDABLE: ReadonlySet<number>;
452
+
453
+ export function isRelayForwardable(typeNum: number): boolean;
454
+
444
455
  // -- Message constructors (gateway) --
445
456
 
446
457
  export function openTcp(opts?: {
@@ -622,6 +633,37 @@ export function verifyChallenge(
622
633
  channelBinding?: Uint8Array,
623
634
  ): Promise<boolean>;
624
635
 
636
+ /** Fields of a signed peer record (reverse-mode registration). See `signPeerRecord`. */
637
+ export interface WshPeerRecord {
638
+ username: string;
639
+ peerType?: string;
640
+ shellBackend?: string;
641
+ capabilities?: string[];
642
+ supportsAttach?: boolean;
643
+ supportsReplay?: boolean;
644
+ supportsEcho?: boolean;
645
+ supportsTermSync?: boolean;
646
+ /** The signing peer's own monotonic counter (current-time-millis in practice). */
647
+ seq: number;
648
+ }
649
+
650
+ /**
651
+ * Build the signed transcript for a peer record -- domain-separated from
652
+ * `buildTranscript`'s auth-challenge transcript even under the same key.
653
+ * @returns 32-byte SHA-256 hash
654
+ */
655
+ export function buildPeerRecordTranscript(record: WshPeerRecord): Promise<Uint8Array>;
656
+
657
+ /** Client-side: sign a peer record with the identity key pair. */
658
+ export function signPeerRecord(
659
+ privateKey: CryptoKey,
660
+ publicKey: CryptoKey,
661
+ record: WshPeerRecord,
662
+ ): Promise<{ signature: Uint8Array; publicKeyRaw: Uint8Array }>;
663
+
664
+ /** Verify a peer record's signature against the claimed record fields. */
665
+ export function verifyPeerRecord(publicKey: CryptoKey, signature: Uint8Array, record: WshPeerRecord): Promise<boolean>;
666
+
625
667
  /**
626
668
  * Compute the SHA-256 fingerprint of a raw public key.
627
669
  * @returns hex-encoded fingerprint
@@ -973,6 +1015,33 @@ export interface WshPeerInfo {
973
1015
  supports_echo: boolean;
974
1016
  supports_term_sync: boolean;
975
1017
  last_seen: number | null;
1018
+ /** Raw 32-byte Ed25519 public key from the peer's signed record, if present. */
1019
+ public_key?: Uint8Array;
1020
+ /** The signing peer's own monotonic counter, from the signed record. */
1021
+ seq?: number;
1022
+ /** Raw Ed25519 signature over the peer record's transcript, if present. */
1023
+ record_signature?: Uint8Array;
1024
+ /**
1025
+ * Not a wire field -- computed client-side by `listPeers()` from
1026
+ * `public_key`/`seq`/`record_signature` via `verifyPeerRecord`. `true`
1027
+ * only if the signature verifies AND the key's fingerprint matches
1028
+ * `fingerprint`; `false` for missing/tampered/mismatched records.
1029
+ */
1030
+ verified: boolean;
1031
+ }
1032
+
1033
+ /** Result from WshClient.initiateE2E(). */
1034
+ export interface WshE2EResult {
1035
+ /** Non-extractable AES-256-GCM key derived from the exchange. */
1036
+ sharedSecret: CryptoKey;
1037
+ /** The peer's ephemeral X25519 public key. */
1038
+ peerPublicKey: Uint8Array;
1039
+ /**
1040
+ * Whether hybrid X25519+ML-KEM-768 mode actually took effect --
1041
+ * `false` if `algorithm: 'X25519'` was requested, or if hybrid was
1042
+ * requested but the peer didn't support it (automatic fallback).
1043
+ */
1044
+ hybrid: boolean;
976
1045
  }
977
1046
 
978
1047
  /**
@@ -1057,6 +1126,27 @@ export class WshClient {
1057
1126
  */
1058
1127
  resumeSession(targetSessionId: string, token: string, opts?: { timeout?: number }): Promise<WshMessage>;
1059
1128
 
1129
+ /**
1130
+ * Detach from a remote session, leaving it running server-side.
1131
+ */
1132
+ detach(sessionId: string, timeout?: number): Promise<void>;
1133
+
1134
+ /**
1135
+ * List sessions on the server this connection's key can see (a server
1136
+ * round trip — distinct from the purely local listSessions()).
1137
+ */
1138
+ listRemoteSessions(timeout?: number): Promise<object[]>;
1139
+
1140
+ /**
1141
+ * Grant another principal access to a session this connection owns.
1142
+ */
1143
+ grantSessionAccess(sessionId: string, principal: string, permissions?: string[]): Promise<void>;
1144
+
1145
+ /**
1146
+ * Revoke a previously granted principal's access to a session.
1147
+ */
1148
+ revokeSessionAccess(sessionId: string, principal: string, reason?: string): Promise<void>;
1149
+
1060
1150
  /**
1061
1151
  * Gracefully disconnect: close all sessions and the transport.
1062
1152
  */
@@ -1095,6 +1185,26 @@ export class WshClient {
1095
1185
  */
1096
1186
  sendRelayControl(msg: WshMessage): Promise<void>;
1097
1187
 
1188
+ /**
1189
+ * Initiate end-to-end encryption for a session: `'X25519'` (default,
1190
+ * classical ECDH) or `'X25519+ML-KEM-768'` (hybrid, with automatic
1191
+ * fallback to classical if the peer doesn't support it). Experimental
1192
+ * -- the derived key is not yet wired to any actual encryption.
1193
+ */
1194
+ initiateE2E(sessionId: string, algorithm?: 'X25519' | 'X25519+ML-KEM-768', timeout?: number): Promise<WshE2EResult>;
1195
+
1196
+ /**
1197
+ * Mark a peer fingerprint as an accepted reverse-connect bridge partner.
1198
+ * Only RelayForward-wrapped messages whose from_fingerprint is trusted
1199
+ * this way are unwrapped and delivered via onRelayMessage.
1200
+ */
1201
+ trustRelayPeer(fingerprint: string): void;
1202
+
1203
+ /**
1204
+ * Stop trusting a peer as a relay-forward bridge partner.
1205
+ */
1206
+ untrustRelayPeer(fingerprint: string): void;
1207
+
1098
1208
  /**
1099
1209
  * Upload a blob to a remote path.
1100
1210
  */
package/src/index.mjs CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  presence, controlChanged, metrics,
18
18
  mcpDiscover, mcpTools, mcpCall, mcpResult,
19
19
  reverseRegister, reverseList, reversePeers, reverseConnect, reverseAccept, reverseReject,
20
+ relayForward, RELAY_FORWARDABLE, isRelayForwardable,
20
21
  openTcp, openUdp, resolveDns, gatewayOk, gatewayFail, gatewayClose,
21
22
  inboundOpen, inboundAccept, inboundReject, dnsResult,
22
23
  listenRequest, listenOk, listenFail, listenClose, gatewayData,
@@ -44,6 +45,7 @@ export {
44
45
  generateKeyPair, exportPublicKeyRaw, exportPublicKeySSH,
45
46
  importPublicKeyRaw, exportPrivateKeyPKCS8, importPrivateKeyPKCS8,
46
47
  sign, verify, buildTranscript, signChallenge, verifyChallenge,
48
+ buildPeerRecordTranscript, signPeerRecord, verifyPeerRecord,
47
49
  fingerprint, shortFingerprint, generateNonce,
48
50
  parseSSHPublicKey, extractRawFromSSHWire, base64Decode,
49
51
  } from './auth.mjs';
@@ -52,6 +54,17 @@ export {
52
54
  export { WshTransport, WebTransportTransport, dispatchSerially, SerialQueue } from './transport.mjs';
53
55
  export { WebSocketTransport, WS_FRAME_TYPE } from './transport-ws.mjs';
54
56
 
57
+ // QMux (QUIC-v1 frames over an ordered byte stream, draft-ietf-quic-qmux-02)
58
+ // -- the wire multiplexing layer WebSocketTransport speaks. Exposed so an
59
+ // alternate server implementation (not just the Rust wsh-server, which
60
+ // vendors its own port) can speak the same framing without reimplementing
61
+ // it -- see clawser's tools/wsh-server.mjs for exactly this use case.
62
+ export { QMuxConnection, DEFAULTS as QMUX_DEFAULTS } from './qmux-connection.mjs';
63
+ export {
64
+ ERROR_CODE as QMUX_ERROR_CODE, STREAM_INITIATOR as QMUX_STREAM_INITIATOR,
65
+ firstBidiStreamId, nextBidiStreamId, isClientInitiated, isBidirectional,
66
+ } from './qmux.mjs';
67
+
55
68
  // Session + Client
56
69
  export { WshSession } from './session.mjs';
57
70
  export { WshVirtualSessionBackend, normalizeSessionData } from './virtual-session.mjs';
@@ -62,6 +62,7 @@ export const MSG = Object.freeze({
62
62
  REVERSE_CONNECT: 0x53,
63
63
  REVERSE_ACCEPT: 0x54,
64
64
  REVERSE_REJECT: 0x55,
65
+ RELAY_FORWARD: 0x56,
65
66
 
66
67
  // Session
67
68
  SESSION_LIST: 0x5f,
@@ -457,7 +458,7 @@ export function mcpResult({ result } = {}) {
457
458
  };
458
459
  }
459
460
 
460
- export function reverseRegister({ username, capabilities = [], peerType = "host", shellBackend = "pty", supportsAttach = false, supportsReplay = false, supportsEcho = false, supportsTermSync = false, publicKey } = {}) {
461
+ export function reverseRegister({ username, capabilities = [], peerType = "host", shellBackend = "pty", supportsAttach = false, supportsReplay = false, supportsEcho = false, supportsTermSync = false, publicKey, seq, recordSignature } = {}) {
461
462
  return {
462
463
  type: MSG.REVERSE_REGISTER,
463
464
  username,
@@ -469,6 +470,8 @@ export function reverseRegister({ username, capabilities = [], peerType = "host"
469
470
  supports_echo: supportsEcho,
470
471
  supports_term_sync: supportsTermSync,
471
472
  public_key: publicKey,
473
+ seq,
474
+ record_signature: recordSignature,
472
475
  };
473
476
  }
474
477
 
@@ -483,11 +486,12 @@ export function reversePeers({ peers } = {}) {
483
486
  };
484
487
  }
485
488
 
486
- export function reverseConnect({ targetFingerprint, username } = {}) {
489
+ export function reverseConnect({ targetFingerprint, username, fromFingerprint } = {}) {
487
490
  return {
488
491
  type: MSG.REVERSE_CONNECT,
489
492
  target_fingerprint: targetFingerprint,
490
493
  username,
494
+ from_fingerprint: fromFingerprint,
491
495
  };
492
496
  }
493
497
 
@@ -515,6 +519,14 @@ export function reverseReject({ targetFingerprint, username, reason } = {}) {
515
519
  };
516
520
  }
517
521
 
522
+ export function relayForward({ fromFingerprint, inner } = {}) {
523
+ return {
524
+ type: MSG.RELAY_FORWARD,
525
+ from_fingerprint: fromFingerprint,
526
+ inner,
527
+ };
528
+ }
529
+
518
530
  export function sessionList({ sessions } = {}) {
519
531
  return {
520
532
  type: MSG.SESSION_LIST,
@@ -759,13 +771,12 @@ export function copilotDetach({ sessionId, reason } = {}) {
759
771
  return msg;
760
772
  }
761
773
 
762
- export function keyExchange({ algorithm, publicKey, sessionId } = {}) {
763
- return {
764
- type: MSG.KEY_EXCHANGE,
765
- algorithm,
766
- public_key: publicKey,
767
- session_id: sessionId,
768
- };
774
+ export function keyExchange({ algorithm, publicKey, sessionId, kemPublicKey, kemCiphertext } = {}) {
775
+ const msg = { type: MSG.KEY_EXCHANGE, algorithm, session_id: sessionId };
776
+ if (publicKey !== undefined) msg.public_key = publicKey;
777
+ if (kemPublicKey !== undefined) msg.kem_public_key = kemPublicKey;
778
+ if (kemCiphertext !== undefined) msg.kem_ciphertext = kemCiphertext;
779
+ return msg;
769
780
  }
770
781
 
771
782
  export function encryptedFrame({ nonce, ciphertext, sessionId } = {}) {
@@ -859,13 +870,14 @@ export function fileResult({ channelId, success, metadata = {}, errorMessage } =
859
870
  return msg;
860
871
  }
861
872
 
862
- export function fileChunk({ channelId, offset, data, isFinal } = {}) {
873
+ export function fileChunk({ channelId, offset, data, isFinal, totalSize } = {}) {
863
874
  return {
864
875
  type: MSG.FILE_CHUNK,
865
876
  channel_id: channelId,
866
877
  offset,
867
878
  data,
868
879
  is_final: isFinal,
880
+ total_size: totalSize,
869
881
  };
870
882
  }
871
883
 
@@ -903,6 +915,50 @@ export function terminalConfig({ channelId, frontend, options = {} } = {}) {
903
915
  };
904
916
  }
905
917
 
918
+ // ── Relay forwarding ─────────────────────────────────────────────────
919
+
920
+ // Message types a relay may forward inside a RelayForward wrapper.
921
+ export const RELAY_FORWARDABLE = new Set([
922
+ MSG.OPEN,
923
+ MSG.OPEN_OK,
924
+ MSG.OPEN_FAIL,
925
+ MSG.CLOSE,
926
+ MSG.EXIT,
927
+ MSG.RESIZE,
928
+ MSG.SIGNAL,
929
+ MSG.SESSION_DATA,
930
+ MSG.GATEWAY_DATA,
931
+ MSG.GATEWAY_OK,
932
+ MSG.GATEWAY_FAIL,
933
+ MSG.GATEWAY_CLOSE,
934
+ MSG.MCP_DISCOVER,
935
+ MSG.MCP_TOOLS,
936
+ MSG.MCP_CALL,
937
+ MSG.MCP_RESULT,
938
+ MSG.REVERSE_ACCEPT,
939
+ MSG.REVERSE_REJECT,
940
+ MSG.GUEST_JOIN,
941
+ MSG.GUEST_REVOKE,
942
+ MSG.COPILOT_ATTACH,
943
+ MSG.COPILOT_DETACH,
944
+ MSG.FILE_OP,
945
+ MSG.FILE_RESULT,
946
+ MSG.FILE_CHUNK,
947
+ MSG.POLICY_EVAL,
948
+ MSG.ECHO_ACK,
949
+ MSG.ECHO_STATE,
950
+ MSG.TERM_SYNC,
951
+ MSG.TERM_DIFF,
952
+ ]);
953
+
954
+ /**
955
+ * @param {number} typeNum
956
+ * @returns {boolean}
957
+ */
958
+ export function isRelayForwardable(typeNum) {
959
+ return RELAY_FORWARDABLE.has(typeNum);
960
+ }
961
+
906
962
  // ── Utility ───────────────────────────────────────────────────────────
907
963
 
908
964
  /**
package/src/messages.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  // Auto-generated messages — DO NOT EDIT messages.gen.mjs directly
2
- // Edit web/packages/wsh/spec/wsh-v1.yaml and run: node web/packages/wsh/spec/codegen.mjs
2
+ // Edit spec/wsh-v1.yaml and run: node spec/codegen.mjs
3
3
  export * from './messages.gen.mjs';