@johnhenry/wsh 0.7.0 → 0.14.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
@@ -791,6 +833,21 @@ export class WshSession {
791
833
  /** Session capabilities advertised by OPEN_OK. */
792
834
  readonly capabilities: string[];
793
835
 
836
+ /**
837
+ * The server-assigned session_id this channel belongs to, from OPEN_OK
838
+ * (clawser #48). `undefined` for channel kinds with no Attach/Resume-able
839
+ * session (e.g. file channels). Pass to `WshClient.attachSession`/
840
+ * `resumeSession` from another connection.
841
+ */
842
+ readonly sessionId: string | undefined;
843
+
844
+ /**
845
+ * The session-scoped resume token minted at Open time, if any (clawser
846
+ * #48). Pass to `WshClient.resumeSession` from a later connection to
847
+ * reclaim this exact session.
848
+ */
849
+ readonly resumeToken: Uint8Array | undefined;
850
+
794
851
  /** Called when stdout/stderr data arrives. */
795
852
  onData: ((data: Uint8Array) => void) | null;
796
853
 
@@ -832,6 +889,8 @@ export class WshSession {
832
889
  opts?: {
833
890
  dataMode?: 'stream' | 'virtual';
834
891
  capabilities?: string[];
892
+ sessionId?: string;
893
+ resumeToken?: Uint8Array;
835
894
  },
836
895
  );
837
896
 
@@ -916,6 +975,21 @@ export interface WshOpenSessionOptions {
916
975
  /** Options for WshClient.attachSession(). */
917
976
  export interface WshAttachSessionOptions {
918
977
  readOnly?: boolean;
978
+ /**
979
+ * Session-scoped token (clawser #48; optional). Omit for the common
980
+ * case of attaching via ownership or a `grantSessionAccess` grant --
981
+ * the server accepts EITHER a valid token OR the caller already
982
+ * owning/being ACL-granted access. Pass one (e.g. from
983
+ * `WshSession.resumeToken`) only if you have it.
984
+ */
985
+ token?: Uint8Array;
986
+ timeout?: number;
987
+ }
988
+
989
+ /** Options for WshClient.resumeSession(). */
990
+ export interface WshResumeSessionOptions {
991
+ /** Last sequence number this client already has (currently advisory). */
992
+ lastSeq?: number;
919
993
  timeout?: number;
920
994
  }
921
995
 
@@ -973,6 +1047,33 @@ export interface WshPeerInfo {
973
1047
  supports_echo: boolean;
974
1048
  supports_term_sync: boolean;
975
1049
  last_seen: number | null;
1050
+ /** Raw 32-byte Ed25519 public key from the peer's signed record, if present. */
1051
+ public_key?: Uint8Array;
1052
+ /** The signing peer's own monotonic counter, from the signed record. */
1053
+ seq?: number;
1054
+ /** Raw Ed25519 signature over the peer record's transcript, if present. */
1055
+ record_signature?: Uint8Array;
1056
+ /**
1057
+ * Not a wire field -- computed client-side by `listPeers()` from
1058
+ * `public_key`/`seq`/`record_signature` via `verifyPeerRecord`. `true`
1059
+ * only if the signature verifies AND the key's fingerprint matches
1060
+ * `fingerprint`; `false` for missing/tampered/mismatched records.
1061
+ */
1062
+ verified: boolean;
1063
+ }
1064
+
1065
+ /** Result from WshClient.initiateE2E(). */
1066
+ export interface WshE2EResult {
1067
+ /** Non-extractable AES-256-GCM key derived from the exchange. */
1068
+ sharedSecret: CryptoKey;
1069
+ /** The peer's ephemeral X25519 public key. */
1070
+ peerPublicKey: Uint8Array;
1071
+ /**
1072
+ * Whether hybrid X25519+ML-KEM-768 mode actually took effect --
1073
+ * `false` if `algorithm: 'X25519'` was requested, or if hybrid was
1074
+ * requested but the peer didn't support it (automatic fallback).
1075
+ */
1076
+ hybrid: boolean;
976
1077
  }
977
1078
 
978
1079
  /**
@@ -991,6 +1092,14 @@ export class WshClient {
991
1092
  /** Server-assigned session ID. */
992
1093
  readonly sessionId: string | null;
993
1094
 
1095
+ /**
1096
+ * This connection's own AUTH-level token (from AUTH_OK), scoped to this
1097
+ * connection's auth session_id -- NOT a PTY/exec session token. See
1098
+ * `WshSession.resumeToken` for the per-session credential used by
1099
+ * `resumeSession`.
1100
+ */
1101
+ readonly authToken: Uint8Array | null;
1102
+
994
1103
  /** Read-only view of active sessions. */
995
1104
  readonly sessions: Map<number, WshSession>;
996
1105
 
@@ -1049,13 +1158,38 @@ export class WshClient {
1049
1158
 
1050
1159
  /**
1051
1160
  * Attach to an existing remote session (collaborative or read-only).
1161
+ * Authorization is satisfied by EITHER `opts.token` OR the caller
1162
+ * already owning/being ACL-granted access to `targetSessionId`
1163
+ * server-side -- see `WshAttachSessionOptions.token`.
1052
1164
  */
1053
1165
  attachSession(targetSessionId: string, opts?: WshAttachSessionOptions): Promise<WshMessage>;
1054
1166
 
1055
1167
  /**
1056
- * Resume a previously disconnected session.
1168
+ * Resume a previously disconnected session. Unlike `attachSession()`,
1169
+ * `token` is required and verified unconditionally server-side.
1170
+ */
1171
+ resumeSession(targetSessionId: string, token: Uint8Array, opts?: WshResumeSessionOptions): Promise<WshMessage>;
1172
+
1173
+ /**
1174
+ * Detach from a remote session, leaving it running server-side.
1175
+ */
1176
+ detach(sessionId: string, timeout?: number): Promise<void>;
1177
+
1178
+ /**
1179
+ * List sessions on the server this connection's key can see (a server
1180
+ * round trip — distinct from the purely local listSessions()).
1181
+ */
1182
+ listRemoteSessions(timeout?: number): Promise<object[]>;
1183
+
1184
+ /**
1185
+ * Grant another principal access to a session this connection owns.
1057
1186
  */
1058
- resumeSession(targetSessionId: string, token: string, opts?: { timeout?: number }): Promise<WshMessage>;
1187
+ grantSessionAccess(sessionId: string, principal: string, permissions?: string[]): Promise<void>;
1188
+
1189
+ /**
1190
+ * Revoke a previously granted principal's access to a session.
1191
+ */
1192
+ revokeSessionAccess(sessionId: string, principal: string, reason?: string): Promise<void>;
1059
1193
 
1060
1194
  /**
1061
1195
  * Gracefully disconnect: close all sessions and the transport.
@@ -1095,6 +1229,26 @@ export class WshClient {
1095
1229
  */
1096
1230
  sendRelayControl(msg: WshMessage): Promise<void>;
1097
1231
 
1232
+ /**
1233
+ * Initiate end-to-end encryption for a session: `'X25519'` (default,
1234
+ * classical ECDH) or `'X25519+ML-KEM-768'` (hybrid, with automatic
1235
+ * fallback to classical if the peer doesn't support it). Experimental
1236
+ * -- the derived key is not yet wired to any actual encryption.
1237
+ */
1238
+ initiateE2E(sessionId: string, algorithm?: 'X25519' | 'X25519+ML-KEM-768', timeout?: number): Promise<WshE2EResult>;
1239
+
1240
+ /**
1241
+ * Mark a peer fingerprint as an accepted reverse-connect bridge partner.
1242
+ * Only RelayForward-wrapped messages whose from_fingerprint is trusted
1243
+ * this way are unwrapped and delivered via onRelayMessage.
1244
+ */
1245
+ trustRelayPeer(fingerprint: string): void;
1246
+
1247
+ /**
1248
+ * Stop trusting a peer as a relay-forward bridge partner.
1249
+ */
1250
+ untrustRelayPeer(fingerprint: string): void;
1251
+
1098
1252
  /**
1099
1253
  * Upload a blob to a remote path.
1100
1254
  */
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,
@@ -246,14 +247,11 @@ export function open({ kind, command, cols, rows, env } = {}) {
246
247
  return msg;
247
248
  }
248
249
 
249
- export function openOk({ channelId, streamIds = [], dataMode = "stream", capabilities = [] } = {}) {
250
- return {
251
- type: MSG.OPEN_OK,
252
- channel_id: channelId,
253
- stream_ids: streamIds,
254
- data_mode: dataMode,
255
- capabilities,
256
- };
250
+ export function openOk({ channelId, streamIds = [], dataMode = "stream", capabilities = [], sessionId, token } = {}) {
251
+ const msg = { type: MSG.OPEN_OK, channel_id: channelId, stream_ids: streamIds, data_mode: dataMode, capabilities };
252
+ if (sessionId !== undefined) msg.session_id = sessionId;
253
+ if (token !== undefined) msg.token = token;
254
+ return msg;
257
255
  }
258
256
 
259
257
  export function openFail({ reason } = {}) {
@@ -326,7 +324,8 @@ export function pong({ id } = {}) {
326
324
  }
327
325
 
328
326
  export function attach({ sessionId, token, mode = "control", deviceLabel } = {}) {
329
- const msg = { type: MSG.ATTACH, session_id: sessionId, token, mode };
327
+ const msg = { type: MSG.ATTACH, session_id: sessionId, mode };
328
+ if (token !== undefined) msg.token = token;
330
329
  if (deviceLabel !== undefined) msg.device_label = deviceLabel;
331
330
  return msg;
332
331
  }
@@ -457,7 +456,7 @@ export function mcpResult({ result } = {}) {
457
456
  };
458
457
  }
459
458
 
460
- export function reverseRegister({ username, capabilities = [], peerType = "host", shellBackend = "pty", supportsAttach = false, supportsReplay = false, supportsEcho = false, supportsTermSync = false, publicKey } = {}) {
459
+ export function reverseRegister({ username, capabilities = [], peerType = "host", shellBackend = "pty", supportsAttach = false, supportsReplay = false, supportsEcho = false, supportsTermSync = false, publicKey, seq, recordSignature } = {}) {
461
460
  return {
462
461
  type: MSG.REVERSE_REGISTER,
463
462
  username,
@@ -469,6 +468,8 @@ export function reverseRegister({ username, capabilities = [], peerType = "host"
469
468
  supports_echo: supportsEcho,
470
469
  supports_term_sync: supportsTermSync,
471
470
  public_key: publicKey,
471
+ seq,
472
+ record_signature: recordSignature,
472
473
  };
473
474
  }
474
475
 
@@ -483,11 +484,12 @@ export function reversePeers({ peers } = {}) {
483
484
  };
484
485
  }
485
486
 
486
- export function reverseConnect({ targetFingerprint, username } = {}) {
487
+ export function reverseConnect({ targetFingerprint, username, fromFingerprint } = {}) {
487
488
  return {
488
489
  type: MSG.REVERSE_CONNECT,
489
490
  target_fingerprint: targetFingerprint,
490
491
  username,
492
+ from_fingerprint: fromFingerprint,
491
493
  };
492
494
  }
493
495
 
@@ -515,6 +517,14 @@ export function reverseReject({ targetFingerprint, username, reason } = {}) {
515
517
  };
516
518
  }
517
519
 
520
+ export function relayForward({ fromFingerprint, inner } = {}) {
521
+ return {
522
+ type: MSG.RELAY_FORWARD,
523
+ from_fingerprint: fromFingerprint,
524
+ inner,
525
+ };
526
+ }
527
+
518
528
  export function sessionList({ sessions } = {}) {
519
529
  return {
520
530
  type: MSG.SESSION_LIST,
@@ -759,13 +769,12 @@ export function copilotDetach({ sessionId, reason } = {}) {
759
769
  return msg;
760
770
  }
761
771
 
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
- };
772
+ export function keyExchange({ algorithm, publicKey, sessionId, kemPublicKey, kemCiphertext } = {}) {
773
+ const msg = { type: MSG.KEY_EXCHANGE, algorithm, session_id: sessionId };
774
+ if (publicKey !== undefined) msg.public_key = publicKey;
775
+ if (kemPublicKey !== undefined) msg.kem_public_key = kemPublicKey;
776
+ if (kemCiphertext !== undefined) msg.kem_ciphertext = kemCiphertext;
777
+ return msg;
769
778
  }
770
779
 
771
780
  export function encryptedFrame({ nonce, ciphertext, sessionId } = {}) {
@@ -859,13 +868,14 @@ export function fileResult({ channelId, success, metadata = {}, errorMessage } =
859
868
  return msg;
860
869
  }
861
870
 
862
- export function fileChunk({ channelId, offset, data, isFinal } = {}) {
871
+ export function fileChunk({ channelId, offset, data, isFinal, totalSize } = {}) {
863
872
  return {
864
873
  type: MSG.FILE_CHUNK,
865
874
  channel_id: channelId,
866
875
  offset,
867
876
  data,
868
877
  is_final: isFinal,
878
+ total_size: totalSize,
869
879
  };
870
880
  }
871
881
 
@@ -903,6 +913,50 @@ export function terminalConfig({ channelId, frontend, options = {} } = {}) {
903
913
  };
904
914
  }
905
915
 
916
+ // ── Relay forwarding ─────────────────────────────────────────────────
917
+
918
+ // Message types a relay may forward inside a RelayForward wrapper.
919
+ export const RELAY_FORWARDABLE = new Set([
920
+ MSG.OPEN,
921
+ MSG.OPEN_OK,
922
+ MSG.OPEN_FAIL,
923
+ MSG.CLOSE,
924
+ MSG.EXIT,
925
+ MSG.RESIZE,
926
+ MSG.SIGNAL,
927
+ MSG.SESSION_DATA,
928
+ MSG.GATEWAY_DATA,
929
+ MSG.GATEWAY_OK,
930
+ MSG.GATEWAY_FAIL,
931
+ MSG.GATEWAY_CLOSE,
932
+ MSG.MCP_DISCOVER,
933
+ MSG.MCP_TOOLS,
934
+ MSG.MCP_CALL,
935
+ MSG.MCP_RESULT,
936
+ MSG.REVERSE_ACCEPT,
937
+ MSG.REVERSE_REJECT,
938
+ MSG.GUEST_JOIN,
939
+ MSG.GUEST_REVOKE,
940
+ MSG.COPILOT_ATTACH,
941
+ MSG.COPILOT_DETACH,
942
+ MSG.FILE_OP,
943
+ MSG.FILE_RESULT,
944
+ MSG.FILE_CHUNK,
945
+ MSG.POLICY_EVAL,
946
+ MSG.ECHO_ACK,
947
+ MSG.ECHO_STATE,
948
+ MSG.TERM_SYNC,
949
+ MSG.TERM_DIFF,
950
+ ]);
951
+
952
+ /**
953
+ * @param {number} typeNum
954
+ * @returns {boolean}
955
+ */
956
+ export function isRelayForwardable(typeNum) {
957
+ return RELAY_FORWARDABLE.has(typeNum);
958
+ }
959
+
906
960
  // ── Utility ───────────────────────────────────────────────────────────
907
961
 
908
962
  /**
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';