@johnhenry/wsh 0.13.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Web Shell \u2014 browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
package/src/client.mjs CHANGED
@@ -134,8 +134,18 @@ export class WshClient {
134
134
  /** @type {string|null} Session ID assigned by the server after authentication. */
135
135
  #sessionId = null;
136
136
 
137
- /** @type {string|null} Resume token from AUTH_OK. */
138
- #resumeToken = null;
137
+ /**
138
+ * @type {string|null} Connection-level token from AUTH_OK, bound to
139
+ * this connection's own auth session_id (from CHALLENGE/SERVER_HELLO) --
140
+ * NOT a PTY/exec session's token. Renamed from `#resumeToken` (clawser
141
+ * #48): the old name invited exactly the bug this fix addresses --
142
+ * attachSession() used to send this as if it were a valid token for the
143
+ * *target* session_id, which it can never be (it's scoped to a
144
+ * different session_id entirely). Not used for anything else currently;
145
+ * kept and exposed via `authToken` for potential future connection-level
146
+ * resume use, mirroring the Rust client's `WshClient::token()`.
147
+ */
148
+ #authToken = null;
139
149
 
140
150
  /** @type {import('./transport.mjs').WshTransport|null} Active transport. */
141
151
  #transport = null;
@@ -282,6 +292,16 @@ export class WshClient {
282
292
  return this.#sessionId;
283
293
  }
284
294
 
295
+ /**
296
+ * This connection's own AUTH-level token (from AUTH_OK), scoped to this
297
+ * connection's auth session_id -- NOT a PTY/exec session token. See
298
+ * `WshSession.resumeToken` for the per-session credential used by
299
+ * `resumeSession`.
300
+ */
301
+ get authToken() {
302
+ return this.#authToken;
303
+ }
304
+
285
305
  /** Read-only view of active sessions. */
286
306
  get sessions() {
287
307
  return new Map(this.#sessions);
@@ -341,7 +361,7 @@ export class WshClient {
341
361
  this.#channelCounter = 0;
342
362
  this.#waiters.clear();
343
363
  this.#sessionId = null;
344
- this.#resumeToken = null;
364
+ this.#authToken = null;
345
365
 
346
366
  try {
347
367
  // ── Select and connect transport ──────────────────────────────
@@ -384,7 +404,7 @@ export class WshClient {
384
404
  if (challengeMsg.type === MSG.AUTH_OK) {
385
405
  // Server accepted without challenge (e.g. trusted key).
386
406
  this.#sessionId = challengeMsg.session_id || tempSessionId;
387
- this.#resumeToken = challengeMsg.token || null;
407
+ this.#authToken = challengeMsg.token || null;
388
408
  this.#state = STATE_AUTHENTICATED;
389
409
  this.#startPing();
390
410
  return this.#sessionId;
@@ -460,7 +480,7 @@ export class WshClient {
460
480
  }
461
481
 
462
482
  this.#sessionId = authResult.session_id || tempSessionId;
463
- this.#resumeToken = authResult.token || null;
483
+ this.#authToken = authResult.token || null;
464
484
  this.#state = STATE_AUTHENTICATED;
465
485
  this.#startPing();
466
486
 
@@ -538,37 +558,48 @@ export class WshClient {
538
558
  /**
539
559
  * Attach to an existing remote session (collaborative or read-only).
540
560
  *
561
+ * Authorization is satisfied by EITHER `token` OR the caller already
562
+ * owning/being ACL-granted access to `targetSessionId` server-side (see
563
+ * wsh-server's `check_session_access`) -- `token` is optional and, for
564
+ * the common case of attaching via ownership or a `grantSessionAccess`
565
+ * grant, should simply be omitted (a granted principal never receives
566
+ * the session's token to begin with -- only the opener does, via
567
+ * `WshSession.resumeToken`). Pass one explicitly only if you have it
568
+ * (e.g. this same process opened the session earlier, or the owner
569
+ * shared it with you out of band).
570
+ *
571
+ * Previously (clawser #48) this always sent this connection's own
572
+ * AUTH-level token, which can never verify against a *different*
573
+ * session_id -- making attachSession() unreachable for everyone,
574
+ * including the session's own owner from a fresh connection. Also fixed
575
+ * alongside that: the server actually replies with PRESENCE (success)
576
+ * or ERROR (failure), not OPEN_OK/OPEN_FAIL -- this used to wait on the
577
+ * wrong response stream entirely and would have hung even once the
578
+ * token bug was fixed.
579
+ *
541
580
  * @param {string} targetSessionId - Remote session ID to attach to
542
581
  * @param {object} [opts]
543
582
  * @param {boolean} [opts.readOnly=false] - Attach in read-only mode
583
+ * @param {Uint8Array} [opts.token] - Session-scoped token (optional; see above)
544
584
  * @param {number} [opts.timeout] - Timeout in ms
545
- * @returns {Promise<object>} Server's response
585
+ * @returns {Promise<object>} Server's PRESENCE response
546
586
  */
547
- async attachSession(targetSessionId, { readOnly = false, timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
587
+ async attachSession(targetSessionId, { readOnly = false, token, timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
548
588
  this.#assertAuthenticated('attachSession');
549
589
 
550
590
  const attachMode = readOnly ? 'readonly' : 'control';
551
591
  await this.#transport.sendControl(
552
- attachMsg({ sessionId: targetSessionId, token: this.#resumeToken, mode: attachMode })
592
+ attachMsg({ sessionId: targetSessionId, token, mode: attachMode })
553
593
  );
554
594
 
555
- // Shares #pendingOpens with openSession() -- both wait on the same
556
- // OPEN_OK/OPEN_FAIL response stream, in the order their requests were
557
- // sent (see #pendingOpens's doc comment). Unlike openSession(),
558
- // attachSession() doesn't construct a WshSession, so it just wants
559
- // the raw response either way.
560
- const response = await new Promise((resolve, reject) => {
561
- const entry = { mode: 'raw', resolve, reject };
562
- entry.timer = setTimeout(() => {
563
- const idx = this.#pendingOpens.indexOf(entry);
564
- if (idx !== -1) this.#pendingOpens.splice(idx, 1);
565
- reject(new Error('Timed out waiting for attach response'));
566
- }, timeout);
567
- this.#pendingOpens.push(entry);
568
- });
595
+ const response = await this.#waitForMessage(
596
+ [MSG.PRESENCE, MSG.ERROR],
597
+ timeout,
598
+ 'Timed out waiting for attach response'
599
+ );
569
600
 
570
- if (response.type === MSG.OPEN_FAIL) {
571
- throw new Error(`Failed to attach: ${response.reason || 'rejected'}`);
601
+ if (response.type === MSG.ERROR) {
602
+ throw new Error(`Failed to attach: ${response.message || 'rejected'}`);
572
603
  }
573
604
 
574
605
  return response;
@@ -577,26 +608,35 @@ export class WshClient {
577
608
  /**
578
609
  * Resume a previously disconnected session.
579
610
  *
611
+ * Unlike attachSession(), `token` is required and verified
612
+ * unconditionally server-side -- Resume is specifically for the
613
+ * connection that was handed this exact token (via
614
+ * `WshSession.resumeToken`, when it originally opened the session)
615
+ * coming back. A principal who only has ACL/ownership access but never
616
+ * held the token should use attachSession() instead.
617
+ *
580
618
  * @param {string} targetSessionId - Session ID to resume
581
- * @param {string} token - Resume token from original AUTH_OK
582
- * @param {number} [opts.timeout] - Timeout in ms
583
- * @returns {Promise<object>} Server's response
619
+ * @param {Uint8Array} token - Session-scoped resume token (see `WshSession.resumeToken`)
620
+ * @param {object} [opts]
621
+ * @param {number} [opts.lastSeq=0] - Last sequence number this client already has (currently advisory -- the ring buffer replays its full contents regardless)
622
+ * @param {number} [opts.timeout=10000] - Timeout in ms
623
+ * @returns {Promise<object>} Server's PRESENCE response
584
624
  */
585
- async resumeSession(targetSessionId, token, { timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
625
+ async resumeSession(targetSessionId, token, { lastSeq = 0, timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
586
626
  this.#assertAuthenticated('resumeSession');
587
627
 
588
628
  await this.#transport.sendControl(
589
- resumeMsg({ sessionId: targetSessionId, token })
629
+ resumeMsg({ sessionId: targetSessionId, token, lastSeq })
590
630
  );
591
631
 
592
632
  const response = await this.#waitForMessage(
593
- [MSG.AUTH_OK, MSG.AUTH_FAIL],
633
+ [MSG.PRESENCE, MSG.ERROR],
594
634
  timeout,
595
635
  'Timed out waiting for resume response'
596
636
  );
597
637
 
598
- if (response.type === MSG.AUTH_FAIL) {
599
- throw new Error(`Failed to resume: ${response.reason || 'rejected'}`);
638
+ if (response.type === MSG.ERROR) {
639
+ throw new Error(`Failed to resume: ${response.message || 'rejected'}`);
600
640
  }
601
641
 
602
642
  return response;
@@ -1667,7 +1707,7 @@ export class WshClient {
1667
1707
 
1668
1708
  if (challengeMsg.type === MSG.AUTH_OK) {
1669
1709
  this.#sessionId = challengeMsg.session_id || tempSessionId;
1670
- this.#resumeToken = challengeMsg.token || null;
1710
+ this.#authToken = challengeMsg.token || null;
1671
1711
  this.#state = STATE_AUTHENTICATED;
1672
1712
  this.#startPing();
1673
1713
  return this.#sessionId;
@@ -1713,7 +1753,7 @@ export class WshClient {
1713
1753
  }
1714
1754
 
1715
1755
  this.#sessionId = authResult.session_id || tempSessionId;
1716
- this.#resumeToken = authResult.token || null;
1756
+ this.#authToken = authResult.token || null;
1717
1757
  this.#state = STATE_AUTHENTICATED;
1718
1758
  this.#startPing();
1719
1759
 
@@ -1773,14 +1813,6 @@ export class WshClient {
1773
1813
  if (!pending) return;
1774
1814
  clearTimeout(pending.timer);
1775
1815
 
1776
- // attachSession() wants the raw response either way and never
1777
- // constructs a session -- it has no #sessions-registration race to
1778
- // protect against.
1779
- if (pending.mode === 'raw') {
1780
- pending.resolve(msg);
1781
- return;
1782
- }
1783
-
1784
1816
  if (type === MSG.OPEN_FAIL) {
1785
1817
  pending.reject(new Error(`Failed to open session: ${msg.reason || 'rejected'}`));
1786
1818
  return;
@@ -1796,7 +1828,18 @@ export class WshClient {
1796
1828
  serverChannelId,
1797
1829
  streamIds,
1798
1830
  pending.kind,
1799
- { dataMode, capabilities }
1831
+ {
1832
+ dataMode,
1833
+ capabilities,
1834
+ // clawser #48: OpenOk now carries the session_id/token this
1835
+ // channel belongs to (pty/exec only -- undefined for e.g. file
1836
+ // channels, which have no Attach/Resume-able session). Exposed
1837
+ // via WshSession.sessionId/resumeToken for a later
1838
+ // attachSession()/resumeSession() call, possibly from a
1839
+ // different connection.
1840
+ sessionId: msg.session_id,
1841
+ resumeToken: msg.token,
1842
+ }
1800
1843
  );
1801
1844
  this.#sessions.set(serverChannelId, session);
1802
1845
 
package/src/index.d.ts CHANGED
@@ -833,6 +833,21 @@ export class WshSession {
833
833
  /** Session capabilities advertised by OPEN_OK. */
834
834
  readonly capabilities: string[];
835
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
+
836
851
  /** Called when stdout/stderr data arrives. */
837
852
  onData: ((data: Uint8Array) => void) | null;
838
853
 
@@ -874,6 +889,8 @@ export class WshSession {
874
889
  opts?: {
875
890
  dataMode?: 'stream' | 'virtual';
876
891
  capabilities?: string[];
892
+ sessionId?: string;
893
+ resumeToken?: Uint8Array;
877
894
  },
878
895
  );
879
896
 
@@ -958,6 +975,21 @@ export interface WshOpenSessionOptions {
958
975
  /** Options for WshClient.attachSession(). */
959
976
  export interface WshAttachSessionOptions {
960
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;
961
993
  timeout?: number;
962
994
  }
963
995
 
@@ -1060,6 +1092,14 @@ export class WshClient {
1060
1092
  /** Server-assigned session ID. */
1061
1093
  readonly sessionId: string | null;
1062
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
+
1063
1103
  /** Read-only view of active sessions. */
1064
1104
  readonly sessions: Map<number, WshSession>;
1065
1105
 
@@ -1118,13 +1158,17 @@ export class WshClient {
1118
1158
 
1119
1159
  /**
1120
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`.
1121
1164
  */
1122
1165
  attachSession(targetSessionId: string, opts?: WshAttachSessionOptions): Promise<WshMessage>;
1123
1166
 
1124
1167
  /**
1125
- * Resume a previously disconnected session.
1168
+ * Resume a previously disconnected session. Unlike `attachSession()`,
1169
+ * `token` is required and verified unconditionally server-side.
1126
1170
  */
1127
- resumeSession(targetSessionId: string, token: string, opts?: { timeout?: number }): Promise<WshMessage>;
1171
+ resumeSession(targetSessionId: string, token: Uint8Array, opts?: WshResumeSessionOptions): Promise<WshMessage>;
1128
1172
 
1129
1173
  /**
1130
1174
  * Detach from a remote session, leaving it running server-side.
@@ -247,14 +247,11 @@ export function open({ kind, command, cols, rows, env } = {}) {
247
247
  return msg;
248
248
  }
249
249
 
250
- export function openOk({ channelId, streamIds = [], dataMode = "stream", capabilities = [] } = {}) {
251
- return {
252
- type: MSG.OPEN_OK,
253
- channel_id: channelId,
254
- stream_ids: streamIds,
255
- data_mode: dataMode,
256
- capabilities,
257
- };
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;
258
255
  }
259
256
 
260
257
  export function openFail({ reason } = {}) {
@@ -327,7 +324,8 @@ export function pong({ id } = {}) {
327
324
  }
328
325
 
329
326
  export function attach({ sessionId, token, mode = "control", deviceLabel } = {}) {
330
- 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;
331
329
  if (deviceLabel !== undefined) msg.device_label = deviceLabel;
332
330
  return msg;
333
331
  }
package/src/session.mjs CHANGED
@@ -44,6 +44,23 @@ export class WshSession {
44
44
  /** @type {string[]} Advertised session capabilities. */
45
45
  #capabilities = [];
46
46
 
47
+ /**
48
+ * The server-assigned session_id this channel belongs to, from OPEN_OK
49
+ * (clawser #48). `undefined` for channel kinds with no Attach/Resume-able
50
+ * session (e.g. file channels).
51
+ * @type {string|undefined}
52
+ */
53
+ #sessionId;
54
+
55
+ /**
56
+ * The session-scoped HMAC token minted by the server at Open time
57
+ * (clawser #48), also from OPEN_OK. Pass it to `WshClient.resumeSession`
58
+ * (or optionally `attachSession`) from a later connection to reclaim
59
+ * this exact session. `undefined` alongside `#sessionId`.
60
+ * @type {Uint8Array|undefined}
61
+ */
62
+ #resumeToken;
63
+
47
64
  /**
48
65
  * Stream IDs returned by the server in OPEN_OK.
49
66
  * Typically { stdin: number, stdout: number } or a single bidirectional ID.
@@ -151,8 +168,13 @@ export class WshSession {
151
168
  * @param {object} [opts]
152
169
  * @param {'stream'|'virtual'} [opts.dataMode='stream']
153
170
  * @param {string[]} [opts.capabilities=[]]
171
+ * @param {string} [opts.sessionId] - Server session_id from OPEN_OK (clawser #48; pty/exec only)
172
+ * @param {Uint8Array} [opts.resumeToken] - Session-scoped resume token from OPEN_OK (clawser #48)
154
173
  */
155
- constructor(transport, channelId, streamIds, kind, { dataMode = 'stream', capabilities = [] } = {}) {
174
+ constructor(
175
+ transport, channelId, streamIds, kind,
176
+ { dataMode = 'stream', capabilities = [], sessionId, resumeToken } = {}
177
+ ) {
156
178
  this.#transport = transport;
157
179
  this.channelId = channelId;
158
180
  this.#streamIds = streamIds;
@@ -160,6 +182,28 @@ export class WshSession {
160
182
  this.id = String(channelId);
161
183
  this.#dataMode = dataMode === 'virtual' ? 'virtual' : 'stream';
162
184
  this.#capabilities = Array.isArray(capabilities) ? [...capabilities] : [];
185
+ this.#sessionId = sessionId;
186
+ this.#resumeToken = resumeToken;
187
+ }
188
+
189
+ /**
190
+ * The server-assigned session_id this channel belongs to, if any
191
+ * (clawser #48; `undefined` for channel kinds with no Attach/Resume-able
192
+ * session, e.g. file channels). Pass to `WshClient.attachSession`/
193
+ * `resumeSession` from another connection.
194
+ */
195
+ get sessionId() {
196
+ return this.#sessionId;
197
+ }
198
+
199
+ /**
200
+ * The session-scoped resume token minted at Open time, if any (clawser
201
+ * #48). Pass it to `WshClient.resumeSession` from a later connection to
202
+ * reclaim this exact session, proving you're the same credentialed
203
+ * opener rather than merely an authorized principal.
204
+ */
205
+ get resumeToken() {
206
+ return this.#resumeToken;
163
207
  }
164
208
 
165
209
  /** Current session state. */