@johnhenry/wsh 0.16.1 → 0.17.1

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.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Control-message subscription for the helper classes that compose their
3
+ * own wire messages — `WshMcpBridge` and `WshFileTransfer`.
4
+ *
5
+ * Both accept "a WshClient, or any object exposing sendControl() plus a
6
+ * way to observe inbound control messages", and both used to inline the
7
+ * same three-way registration. The third branch of it — the fallback for
8
+ * an object whose only hook is a settable `onControl` property, which is
9
+ * what a bare `WshTransport` is — wrapped that property and **never
10
+ * unwrapped it**:
11
+ *
12
+ * ```js
13
+ * const prev = client.onControl;
14
+ * client.onControl = (msg) => { prev?.(msg); listener(msg); };
15
+ * ```
16
+ *
17
+ * The matching `cleanup()` handled `removeControlListener` and
18
+ * `_controlListeners` and did nothing at all for this branch. So every
19
+ * `discover()` / `call()` / `list()` left another wrapper permanently in
20
+ * the chain. Measured against a real `WshTransport`, the number of frames
21
+ * an inbound control message traverses to reach the connection's own
22
+ * handler grew one-for-one with the number of operations — 1, 5, 20 and
23
+ * 50 operations gave 5, 9, 24 and 54 frames — with no ceiling.
24
+ *
25
+ * Attaching once per subscriber and detaching on cleanup keeps that flat.
26
+ */
27
+
28
+ /**
29
+ * Subscribe `listener` to a client's inbound control messages.
30
+ *
31
+ * Supports, in order of preference:
32
+ * 1. `addControlListener()` / `removeControlListener()` — what `WshClient`
33
+ * exposes, and the only shape that can deregister cleanly.
34
+ * 2. A `_controlListeners` array.
35
+ * 3. A settable `onControl` property (a bare `WshTransport`), wrapped
36
+ * once and restored on detach.
37
+ *
38
+ * @param {object} client
39
+ * @param {function(object): void} listener
40
+ * @returns {function(): void} detach — idempotent; safe to call twice.
41
+ */
42
+ export function attachControlListener(client, listener) {
43
+ if (typeof client.addControlListener === 'function') {
44
+ client.addControlListener(listener);
45
+ let detached = false;
46
+ return () => {
47
+ if (detached) return;
48
+ detached = true;
49
+ client.removeControlListener?.(listener);
50
+ };
51
+ }
52
+
53
+ if (Array.isArray(client._controlListeners)) {
54
+ client._controlListeners.push(listener);
55
+ let detached = false;
56
+ return () => {
57
+ if (detached) return;
58
+ detached = true;
59
+ const idx = client._controlListeners.indexOf(listener);
60
+ if (idx !== -1) client._controlListeners.splice(idx, 1);
61
+ };
62
+ }
63
+
64
+ // Fallback: wrap `onControl`. Restoring `prev` is only correct while our
65
+ // wrapper is still the installed handler — if something else has wrapped
66
+ // it since, putting `prev` back would drop that handler. In that case the
67
+ // wrapper stays but is made inert, so the chain stops growing per
68
+ // operation even though this one link cannot be spliced out.
69
+ const prev = client.onControl;
70
+ let live = true;
71
+ const wrapper = (msg) => {
72
+ prev?.(msg);
73
+ if (live) listener(msg);
74
+ };
75
+ client.onControl = wrapper;
76
+
77
+ let detached = false;
78
+ return () => {
79
+ if (detached) return;
80
+ detached = true;
81
+ live = false;
82
+ if (client.onControl === wrapper) client.onControl = prev;
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Wait for the first inbound control message matching `predicate`.
88
+ *
89
+ * @param {object} client
90
+ * @param {function(object): boolean} predicate
91
+ * @param {number} timeoutMs
92
+ * @param {string} label - Used in the timeout message.
93
+ * @returns {Promise<object>}
94
+ */
95
+ export function waitForControlMessage(client, predicate, timeoutMs, label) {
96
+ return new Promise((resolve, reject) => {
97
+ let settled = false;
98
+ let detach = () => {};
99
+ let timer = null;
100
+
101
+ const cleanup = () => {
102
+ settled = true;
103
+ if (timer !== null) clearTimeout(timer);
104
+ detach();
105
+ };
106
+
107
+ // `detach` is assigned after attach() returns, but a client that
108
+ // dispatches synchronously from inside attach() would fire the
109
+ // listener first — hence the `settled` guard rather than relying on
110
+ // detach() having been wired up.
111
+ detach = attachControlListener(client, (msg) => {
112
+ if (settled) return;
113
+ let matches = false;
114
+ try {
115
+ matches = predicate(msg);
116
+ } catch {
117
+ matches = false;
118
+ }
119
+ if (!matches) return;
120
+ cleanup();
121
+ resolve(msg);
122
+ });
123
+
124
+ timer = setTimeout(() => {
125
+ if (settled) return;
126
+ cleanup();
127
+ reject(new Error(`${label} timed out after ${timeoutMs}ms`));
128
+ }, timeoutMs);
129
+ });
130
+ }
package/src/e2e-frame.mjs CHANGED
@@ -138,13 +138,44 @@ export async function sealFrame(key, sessionId, roleTag, counter, plaintext) {
138
138
  * @param {{nonce: Uint8Array, ciphertext: Uint8Array}} frame
139
139
  * @returns {Promise<Uint8Array>} plaintext
140
140
  */
141
- export async function openFrame(key, sessionId, expectedCounter, { nonce, ciphertext }) {
141
+ export async function openFrame(key, sessionId, expectedCounter, { nonce, ciphertext, expectedRoleTag }) {
142
142
  if (!key || typeof key !== 'object') {
143
143
  throw new Error('e2e-frame: openFrame requires a CryptoKey');
144
144
  }
145
145
  if (!(nonce instanceof Uint8Array) || nonce.length !== NONCE_LENGTH) {
146
146
  throw new Error(`e2e-frame: nonce must be a ${NONCE_LENGTH}-byte Uint8Array`);
147
147
  }
148
+ /*
149
+ * The role tag is REQUIRED, and a caller that omits it gets an error rather
150
+ * than a skipped check.
151
+ *
152
+ * The tag exists so the two directions of one session are structurally
153
+ * distinguishable -- it is the reason a colliding nonce is impossible when
154
+ * both directions share one AES-GCM key. WshSession computed the peer's
155
+ * expected tag and stored it, and nothing ever read it: `grep -rn
156
+ * e2eRecvRoleTag` returned the declaration and the assignment and no third
157
+ * line. So the receiver accepted a frame sealed with its OWN tag, and the
158
+ * relay this layer exists to distrust could echo a peer's sealed frame
159
+ * straight back at it. Both counters start at zero, so the first frame of a
160
+ * session reflects cleanly.
161
+ *
162
+ * Optional would have reproduced the defect: a security check nobody is
163
+ * forced to pass is the same as no check, and this file already had one --
164
+ * see TimestampProof.verify's ignored verifyFn for the same shape.
165
+ */
166
+ if (!(expectedRoleTag instanceof Uint8Array) || expectedRoleTag.length !== ROLE_TAG_LENGTH) {
167
+ throw new Error(
168
+ `e2e-frame: openFrame requires expectedRoleTag, a ${ROLE_TAG_LENGTH}-byte Uint8Array`
169
+ );
170
+ }
171
+ const actualRoleTag = nonce.subarray(COUNTER_LENGTH);
172
+ for (let i = 0; i < ROLE_TAG_LENGTH; i += 1) {
173
+ if (actualRoleTag[i] !== expectedRoleTag[i]) {
174
+ throw new Error(
175
+ 'e2e-frame: role tag mismatch -- this frame was sealed by the wrong side of the session'
176
+ );
177
+ }
178
+ }
148
179
  const actualCounter = Number(new DataView(nonce.buffer, nonce.byteOffset, nonce.byteLength).getBigUint64(0, false));
149
180
  if (actualCounter !== expectedCounter) {
150
181
  throw new Error(
@@ -3,33 +3,36 @@
3
3
  *
4
4
  * Uploads and downloads delegate to the underlying client's upload()/
5
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
+ * see client.mjs). list() (wsh #59) delegates to the client's fileList(),
7
+ * which sends a structured FileOp{op:"list"} and reads the typed
8
+ * FileResult.entries -- the same wire path upload/download already use
9
+ * for moving bytes, now extended to directory listings.
10
+ *
11
+ * Before wsh #59, list() ran `ls -la` over a plain exec channel and
12
+ * parsed the text output client-side -- a private, JS-only convention
13
+ * the Rust client had no equivalent of at all (see wsh #58/#59, and the
14
+ * FileEntry nested type in spec/wsh-v1.yaml this now shares with Rust).
8
15
  */
9
16
 
10
- import { MSG, open, CHANNEL_KIND } from './messages.mjs';
11
-
12
17
  /** Default timeout for waiting on control messages (30 seconds). */
13
18
  const RESPONSE_TIMEOUT_MS = 30_000;
14
19
 
15
20
  export class WshFileTransfer {
16
21
  /**
17
22
  * @type {{
18
- * upload: function, download: function,
19
- * sendControl: function, openStream: function, onControl: function,
23
+ * upload: function, download: function, fileList: function,
20
24
  * }}
21
25
  */
22
26
  #client;
23
27
 
24
28
  /**
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:
29
- * - sendControl(msg): send a control message
30
- * - openStream(): open a new bidirectional stream
31
- * - onControl: settable callback for incoming control messages
32
- * (or a method to add a listener — see _waitForMessage)
29
+ * @param {object} client - A WshClient (or any object exposing the same
30
+ * methods used below). `upload()`/`download()` are used directly for
31
+ * file transfer; `list()` uses `fileList()` (the structured
32
+ * FileOp/FileResult request-response helper already on WshClient).
33
+ * Each method checks for the members it specifically needs, so a
34
+ * client exposing only `upload()`/`download()` (and nothing else)
35
+ * stays valid for those two calls.
33
36
  */
34
37
  constructor(client) {
35
38
  if (!client) throw new Error('WshFileTransfer requires a client');
@@ -92,195 +95,47 @@ export class WshFileTransfer {
92
95
  }
93
96
 
94
97
  /**
95
- * List files at a remote path by executing `ls -la` via the client.
98
+ * List files at a remote path over the structured file channel (wsh #59):
99
+ * sends `FileOp{op:"list"}` via the client's `fileList()` and reads the
100
+ * typed `FileResult.entries` -- the same `FileEntry` shape
101
+ * `crates/wsh-core`'s Rust client is generated from, so a symlink can't
102
+ * be reported as a plain file here and correctly here-and-there.
96
103
  *
97
- * This uses a standard exec channel rather than the file transfer protocol,
98
- * parsing the output of `ls -la` into structured entries.
104
+ * A refusal (unimplemented op, unauthorized fs capability, no such
105
+ * directory) throws -- it is never coerced into an empty array, so a
106
+ * caller can't mistake "refused" for "empty directory" (wsh #58).
99
107
  *
100
108
  * @param {string} remotePath - Directory path on the remote host
101
- * @returns {Promise<Array<{ name: string, size: number, modified: string, type: string }>>}
109
+ * @param {object} [opts]
110
+ * @param {number} [opts.timeout] - Timeout in ms for the server response
111
+ * @returns {Promise<Array<{
112
+ * name: string, size: number, modified: Date,
113
+ * type: 'file'|'directory'|'symlink'|'device'|'pipe'|'socket',
114
+ * symlinkTarget?: string,
115
+ * }>>}
102
116
  */
103
- async list(remotePath) {
117
+ async list(remotePath, { timeout = RESPONSE_TIMEOUT_MS } = {}) {
104
118
  if (!remotePath || typeof remotePath !== 'string') {
105
119
  throw new Error('remotePath is required');
106
120
  }
107
-
108
- // Use an exec channel to run ls
109
- const openMsg = open({
110
- kind: CHANNEL_KIND.EXEC,
111
- command: `ls -la ${this._shellEscape(remotePath)}`,
112
- });
113
-
114
- await this.#client.sendControl(openMsg);
115
-
116
- const response = await this._waitForMessage(
117
- (msg) => msg.type === MSG.OPEN_OK || msg.type === MSG.OPEN_FAIL,
118
- RESPONSE_TIMEOUT_MS
119
- );
120
-
121
- if (response.type === MSG.OPEN_FAIL) {
122
- throw new Error(`List failed: ${response.reason || 'unknown reason'}`);
123
- }
124
-
125
- // Read the output stream
126
- const stream = await this.#client.openStream();
127
- const reader = stream.readable.getReader();
128
- const chunks = [];
129
-
130
- try {
131
- while (true) {
132
- const { done, value } = await reader.read();
133
- if (done) break;
134
- chunks.push(value);
135
- }
136
- } finally {
137
- reader.releaseLock();
138
- }
139
-
140
- const output = new TextDecoder().decode(this._concatChunks(chunks));
141
- return this._parseLsOutput(output);
142
- }
143
-
144
- // ── Internal helpers ──────────────────────────────────────────────
145
-
146
- /**
147
- * Wait for a control message matching a predicate.
148
- *
149
- * Temporarily hooks into the client's control message flow and resolves
150
- * when a matching message arrives (or rejects on timeout).
151
- *
152
- * @param {function(object): boolean} predicate
153
- * @param {number} timeoutMs
154
- * @returns {Promise<object>}
155
- */
156
- _waitForMessage(predicate, timeoutMs) {
157
- return new Promise((resolve, reject) => {
158
- let timer = null;
159
- let settled = false;
160
-
161
- const cleanup = () => {
162
- settled = true;
163
- if (timer !== null) clearTimeout(timer);
164
- // Remove listener
165
- if (this.#client.removeControlListener) {
166
- this.#client.removeControlListener(listener);
167
- } else if (this.#client._controlListeners) {
168
- const idx = this.#client._controlListeners.indexOf(listener);
169
- if (idx !== -1) this.#client._controlListeners.splice(idx, 1);
170
- }
171
- };
172
-
173
- const listener = (msg) => {
174
- if (settled) return;
175
- if (predicate(msg)) {
176
- cleanup();
177
- resolve(msg);
178
- }
179
- };
180
-
181
- // Register listener on the client
182
- if (this.#client.addControlListener) {
183
- this.#client.addControlListener(listener);
184
- } else if (this.#client._controlListeners) {
185
- this.#client._controlListeners.push(listener);
186
- } else {
187
- // Fallback: wrap existing onControl
188
- const prev = this.#client.onControl;
189
- this.#client.onControl = (msg) => {
190
- prev?.(msg);
191
- listener(msg);
192
- };
193
- }
194
-
195
- timer = setTimeout(() => {
196
- if (!settled) {
197
- cleanup();
198
- reject(new Error(`Timeout waiting for response (${timeoutMs}ms)`));
199
- }
200
- }, timeoutMs);
201
- });
202
- }
203
-
204
- /**
205
- * Concatenate an array of Uint8Arrays into one.
206
- *
207
- * @param {Uint8Array[]} chunks
208
- * @param {number} [totalLength] - Pre-computed total, avoids re-summing
209
- * @returns {Uint8Array}
210
- */
211
- _concatChunks(chunks, totalLength) {
212
- if (chunks.length === 0) return new Uint8Array(0);
213
- if (chunks.length === 1) return chunks[0];
214
-
215
- const total = totalLength ?? chunks.reduce((sum, c) => sum + c.length, 0);
216
- const result = new Uint8Array(total);
217
- let offset = 0;
218
- for (const chunk of chunks) {
219
- result.set(chunk, offset);
220
- offset += chunk.length;
121
+ if (typeof this.#client.fileList !== 'function') {
122
+ throw new TypeError(
123
+ 'WshFileTransfer.list() requires a client exposing fileList() (e.g. WshClient)'
124
+ );
221
125
  }
222
- return result;
223
- }
224
-
225
- /**
226
- * Parse the output of `ls -la` into structured entries.
227
- *
228
- * Expected format per line:
229
- * drwxr-xr-x 2 user group 4096 Jan 15 12:00 dirname
230
- * -rw-r--r-- 1 user group 12345 Jan 15 12:00 filename.txt
231
- *
232
- * @param {string} output
233
- * @returns {Array<{ name: string, size: number, modified: string, type: string }>}
234
- */
235
- _parseLsOutput(output) {
236
- const lines = output.split('\n').filter((l) => l.trim());
237
- const entries = [];
238
-
239
- for (const line of lines) {
240
- // Skip the "total" header line
241
- if (line.startsWith('total ')) continue;
242
-
243
- // ls -la columns: perms links owner group size month day time name
244
- const parts = line.split(/\s+/);
245
- if (parts.length < 9) continue;
246
126
 
247
- const perms = parts[0];
248
- const size = parseInt(parts[4], 10) || 0;
249
- const month = parts[5];
250
- const day = parts[6];
251
- const time = parts[7];
252
- // Name may contain spaces — rejoin everything from column 8 onward
253
- const name = parts.slice(8).join(' ');
127
+ const result = await this.#client.fileList(remotePath, timeout);
254
128
 
255
- // Skip . and .. entries
256
- if (name === '.' || name === '..') continue;
257
-
258
- // Determine type from permission string first character
259
- let type = 'file';
260
- if (perms.startsWith('d')) type = 'directory';
261
- else if (perms.startsWith('l')) type = 'symlink';
262
- else if (perms.startsWith('c') || perms.startsWith('b')) type = 'device';
263
- else if (perms.startsWith('p')) type = 'pipe';
264
- else if (perms.startsWith('s')) type = 'socket';
265
-
266
- entries.push({
267
- name,
268
- size,
269
- modified: `${month} ${day} ${time}`,
270
- type,
271
- });
129
+ if (!result.success) {
130
+ throw new Error(result.error_message || `List failed: refused for ${remotePath}`);
272
131
  }
273
132
 
274
- return entries;
275
- }
276
-
277
- /**
278
- * Basic shell escaping for a path argument.
279
- * @param {string} str
280
- * @returns {string}
281
- */
282
- _shellEscape(str) {
283
- // Wrap in single quotes, escaping any embedded single quotes
284
- return "'" + str.replace(/'/g, "'\\''") + "'";
133
+ return (result.entries || []).map((entry) => ({
134
+ name: entry.name,
135
+ size: entry.size,
136
+ modified: new Date(entry.modified * 1000),
137
+ type: entry.type,
138
+ ...(entry.symlink_target !== undefined ? { symlinkTarget: entry.symlink_target } : {}),
139
+ }));
285
140
  }
286
141
  }