browser-commander 0.20.0 → 0.21.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.
Files changed (169) hide show
  1. package/.jscpd.json +2 -1
  2. package/.prettierignore +1 -0
  3. package/CHANGELOG.md +159 -0
  4. package/README.md +216 -32
  5. package/bin/browser-commander.js +16 -0
  6. package/eslint.config.js +10 -0
  7. package/examples/react-test-app/src/App.jsx +24 -23
  8. package/extension/background.js +14 -0
  9. package/extension/manifest.json +12 -0
  10. package/extension/relay-handler.js +353 -0
  11. package/package.json +21 -4
  12. package/src/browser/attach/differences.js +131 -0
  13. package/src/browser/attach/extension-relay.js +402 -0
  14. package/src/browser/attach/index.js +81 -0
  15. package/src/browser/attach/snapshot-launch.js +146 -0
  16. package/src/browser/attach/snapshot.js +374 -0
  17. package/src/browser/attach/websocket.js +615 -0
  18. package/src/browser/browser-cookie-cache.js +26 -19
  19. package/src/browser/browser-cookie-credentials.js +6 -9
  20. package/src/browser/browser-cookies.js +5 -2
  21. package/src/browser/cdp-session.js +94 -0
  22. package/src/browser/connector.js +42 -4
  23. package/src/browser/debugging-port.js +155 -0
  24. package/src/browser/launch-options.js +63 -52
  25. package/src/browser/launch-restrictions.json +147 -0
  26. package/src/browser/launcher.js +329 -133
  27. package/src/browser/media.js +5 -0
  28. package/src/browser/migration/bookmarks.js +73 -0
  29. package/src/browser/migration/chromium-crypto.js +89 -0
  30. package/src/browser/migration/cookies.js +155 -0
  31. package/src/browser/migration/extensions.js +187 -0
  32. package/src/browser/migration/firefox-bookmarks.js +144 -0
  33. package/src/browser/migration/firefox-der.js +84 -0
  34. package/src/browser/migration/firefox-nss.js +281 -0
  35. package/src/browser/migration/firefox.js +334 -0
  36. package/src/browser/migration/fs-utils.js +51 -0
  37. package/src/browser/migration/history.js +103 -0
  38. package/src/browser/migration/index.js +307 -0
  39. package/src/browser/migration/os-crypt-keys.js +106 -0
  40. package/src/browser/migration/passwords.js +171 -0
  41. package/src/browser/migration/preferences.js +132 -0
  42. package/src/browser/migration/sqlite-snapshot.js +138 -0
  43. package/src/browser/open-in-user-browser.js +112 -0
  44. package/src/browser/parity.js +439 -0
  45. package/src/browser/profile-directory.js +205 -0
  46. package/src/browser/real-browser.js +545 -306
  47. package/src/browser/restrictions.js +148 -0
  48. package/src/browser/system-browser.js +232 -0
  49. package/src/browser/webdriver.js +572 -0
  50. package/src/cli/args.js +300 -0
  51. package/src/cli/commands.js +332 -0
  52. package/src/cli/dispatcher.js +99 -0
  53. package/src/cli/handle-methods.js +136 -0
  54. package/src/cli/handles.js +273 -0
  55. package/src/cli/high-level-methods.js +310 -0
  56. package/src/cli/main.js +67 -0
  57. package/src/cli/modules.js +100 -0
  58. package/src/cli/rpc-error.js +77 -0
  59. package/src/cli/script.js +77 -0
  60. package/src/cli/serve.js +144 -0
  61. package/src/cli/sessions.js +199 -0
  62. package/src/core/constants.js +7 -1
  63. package/src/core/engine-adapter.js +107 -1
  64. package/src/core/engine-detection.js +18 -3
  65. package/src/core/navigation-safety.js +4 -0
  66. package/src/core/webdriver-conversions.js +347 -0
  67. package/src/core/webdriver-events.js +229 -0
  68. package/src/core/webdriver-page.js +716 -0
  69. package/src/downloads/attach.js +6 -0
  70. package/src/elements/selectors.js +3 -2
  71. package/src/elements/visibility.js +4 -0
  72. package/src/exports.js +61 -2
  73. package/src/factory.js +19 -4
  74. package/src/fingerprint/apply.js +6 -0
  75. package/src/fingerprint/automation-parity.js +7 -8
  76. package/src/fingerprint/limitations.js +1 -1
  77. package/src/fingerprint/limitations.json +45 -2
  78. package/src/parity/harness.js +303 -0
  79. package/src/parity/probe.js +838 -0
  80. package/src/parity/version-page.js +144 -0
  81. package/src/traces/bundle.js +3 -2
  82. package/src/traces/observers.js +14 -5
  83. package/src/traces/recorder.js +3 -0
  84. package/src/traces/viewer.js +16 -8
  85. package/src/utilities/subprocess.js +199 -0
  86. package/tests/e2e/api-coverage.e2e.test.js +174 -0
  87. package/tests/e2e/cli.e2e.test.js +160 -0
  88. package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
  89. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  90. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  91. package/tests/e2e/playwright.e2e.test.js +26 -17
  92. package/tests/e2e/puppeteer.e2e.test.js +18 -23
  93. package/tests/e2e/webdriver.e2e.test.js +239 -0
  94. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  95. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.html +2 -0
  96. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.png +1 -0
  97. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.state.json +54 -0
  98. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +2 -0
  99. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.png +1 -0
  100. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.state.json +60 -0
  101. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +2 -0
  102. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.png +1 -0
  103. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.state.json +60 -0
  104. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +18 -0
  105. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +77 -0
  106. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0001.ndjson +2 -0
  107. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0002.ndjson +1 -0
  108. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +309 -0
  109. package/tests/fixtures/traces/conformance/expected/redaction.json +86 -0
  110. package/tests/fixtures/traces/conformance/expected/trace.lino +27 -0
  111. package/tests/fixtures/traces/conformance/scenario.json +249 -0
  112. package/tests/helpers/cli-fakes.js +214 -0
  113. package/tests/helpers/declared-api.js +80 -0
  114. package/tests/helpers/e2e-browser.js +40 -14
  115. package/tests/helpers/fake-chrome.js +124 -0
  116. package/tests/helpers/migration-fixtures.js +242 -0
  117. package/tests/helpers/temp-directory.js +24 -0
  118. package/tests/helpers/webdriver-mocks.js +183 -0
  119. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  120. package/tests/unit/browser/attach/snapshot-launch.test.js +321 -0
  121. package/tests/unit/browser/attach/snapshot.test.js +409 -0
  122. package/tests/unit/browser/attach/websocket.test.js +240 -0
  123. package/tests/unit/browser/browser-cookie-lock.test.js +102 -0
  124. package/tests/unit/browser/browser-cookies.test.js +2 -32
  125. package/tests/unit/browser/cdp-session.test.js +162 -0
  126. package/tests/unit/browser/connector.test.js +23 -0
  127. package/tests/unit/browser/launch-building-blocks.test.js +361 -0
  128. package/tests/unit/browser/launch-options.test.js +41 -22
  129. package/tests/unit/browser/launcher.test.js +281 -102
  130. package/tests/unit/browser/media.test.js +13 -1
  131. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  132. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  133. package/tests/unit/browser/migration/cookies.test.js +94 -0
  134. package/tests/unit/browser/migration/extensions.test.js +104 -0
  135. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  136. package/tests/unit/browser/migration/firefox.test.js +139 -0
  137. package/tests/unit/browser/migration/history.test.js +48 -0
  138. package/tests/unit/browser/migration/index.test.js +117 -0
  139. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  140. package/tests/unit/browser/migration/passwords.test.js +144 -0
  141. package/tests/unit/browser/migration/preferences.test.js +77 -0
  142. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  143. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  144. package/tests/unit/browser/parity.test.js +281 -0
  145. package/tests/unit/browser/real-browser.test.js +458 -33
  146. package/tests/unit/browser/storage-state.test.js +2 -0
  147. package/tests/unit/browser/webdriver.test.js +473 -0
  148. package/tests/unit/cli/bin.test.js +69 -0
  149. package/tests/unit/cli/declared-api.test.js +49 -0
  150. package/tests/unit/cli/dispatcher.test.js +429 -0
  151. package/tests/unit/cli/handles.test.js +170 -0
  152. package/tests/unit/cli/main.test.js +437 -0
  153. package/tests/unit/cli/serve.test.js +167 -0
  154. package/tests/unit/core/navigation-manager.test.js +11 -1
  155. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  156. package/tests/unit/core/webdriver-page.test.js +534 -0
  157. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  158. package/tests/unit/fingerprint/limitations.test.js +1 -1
  159. package/tests/unit/parity/harness.test.js +138 -0
  160. package/tests/unit/playwright-protocol-coverage.test.js +231 -0
  161. package/tests/unit/puppeteer-bindings-coverage.test.js +214 -0
  162. package/tests/unit/scripts/ci-timeout-budgets.test.js +27 -0
  163. package/tests/unit/scripts/feature-parity.test.js +327 -0
  164. package/tests/unit/traces/conformance.test.js +92 -0
  165. package/tests/unit/traces/reader.test.js +1 -0
  166. package/tests/unit/traces/recorder.test.js +1 -0
  167. package/tests/unit/traces/viewer.test.js +78 -0
  168. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  169. package/tests/unit/utilities/subprocess.test.js +80 -0
@@ -0,0 +1,615 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { EventEmitter } from 'node:events';
3
+ import http from 'node:http';
4
+ import { setImmediate } from 'node:timers';
5
+ import { TextDecoder } from 'node:util';
6
+
7
+ /**
8
+ * A minimal RFC 6455 WebSocket for the extension relay (issue #102,
9
+ * addendum, mode 2).
10
+ *
11
+ * The relay only exchanges small JSON text messages with one local extension,
12
+ * so this implements exactly that part of the protocol and no more:
13
+ *
14
+ * - the opening handshake on a `node:http` `'upgrade'` event (server) or an
15
+ * `http.request` upgrade (client, used by tests and tools);
16
+ * - text messages, fragmented or not, validated as UTF-8;
17
+ * - ping/pong and the close handshake.
18
+ *
19
+ * Binary messages, extensions (permessage-deflate) and subprotocols are not
20
+ * negotiated. A protocol violation closes the connection with the RFC status
21
+ * code: 1002 (protocol error, for example an unmasked client frame), 1003
22
+ * (binary data), 1007 (invalid UTF-8) or 1009 (message too big).
23
+ */
24
+
25
+ /** The GUID RFC 6455 section 1.3 appends to the client key. */
26
+ export const WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
27
+
28
+ /** Frame opcodes (RFC 6455 section 5.2). */
29
+ export const OPCODES = Object.freeze({
30
+ continuation: 0x0,
31
+ text: 0x1,
32
+ binary: 0x2,
33
+ close: 0x8,
34
+ ping: 0x9,
35
+ pong: 0xa,
36
+ });
37
+
38
+ /** Close status codes used here (RFC 6455 section 7.4.1). */
39
+ export const CLOSE_CODES = Object.freeze({
40
+ normal: 1000,
41
+ goingAway: 1001,
42
+ protocolError: 1002,
43
+ unsupportedData: 1003,
44
+ noStatus: 1005,
45
+ abnormal: 1006,
46
+ invalidPayload: 1007,
47
+ policyViolation: 1008,
48
+ messageTooBig: 1009,
49
+ });
50
+
51
+ /** Messages larger than this close the connection with 1009. */
52
+ export const DEFAULT_MAX_PAYLOAD = 64 * 1024 * 1024;
53
+
54
+ const MAX_CONTROL_PAYLOAD = 125;
55
+
56
+ /** A frame or handshake that breaks RFC 6455; `code` is the close code. */
57
+ export class WebSocketProtocolError extends Error {
58
+ constructor(message, code = CLOSE_CODES.protocolError) {
59
+ super(message);
60
+ this.name = 'WebSocketProtocolError';
61
+ this.code = code;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * The `Sec-WebSocket-Accept` value for a client's `Sec-WebSocket-Key`.
67
+ *
68
+ * @param {string} key
69
+ * @returns {string}
70
+ */
71
+ export function computeAcceptKey(key) {
72
+ return createHash('sha1')
73
+ .update(key + WEBSOCKET_GUID)
74
+ .digest('base64');
75
+ }
76
+
77
+ /**
78
+ * Encode one frame.
79
+ *
80
+ * @param {number} opcode - One of {@link OPCODES}
81
+ * @param {Buffer|string} [payload]
82
+ * @param {Object} [options]
83
+ * @param {boolean} [options.mask=false] - Mask the payload (client frames)
84
+ * @param {boolean} [options.fin=true] - Final fragment
85
+ * @returns {Buffer}
86
+ */
87
+ export function encodeFrame(
88
+ opcode,
89
+ payload = Buffer.alloc(0),
90
+ { mask = false, fin = true } = {}
91
+ ) {
92
+ const data = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
93
+ let header;
94
+ if (data.length < 126) {
95
+ header = Buffer.alloc(2);
96
+ header[1] = data.length;
97
+ } else if (data.length < 0x10000) {
98
+ header = Buffer.alloc(4);
99
+ header[1] = 126;
100
+ header.writeUInt16BE(data.length, 2);
101
+ } else {
102
+ header = Buffer.alloc(10);
103
+ header[1] = 127;
104
+ header.writeBigUInt64BE(BigInt(data.length), 2);
105
+ }
106
+ header[0] = (fin ? 0x80 : 0) | opcode;
107
+ if (!mask) {
108
+ return Buffer.concat([header, data]);
109
+ }
110
+ header[1] |= 0x80;
111
+ const maskingKey = randomBytes(4);
112
+ const masked = Buffer.alloc(data.length);
113
+ for (let index = 0; index < data.length; index++) {
114
+ masked[index] = data[index] ^ maskingKey[index % 4];
115
+ }
116
+ return Buffer.concat([header, maskingKey, masked]);
117
+ }
118
+
119
+ /**
120
+ * Incremental frame parser: feed it socket chunks, get whole frames back.
121
+ */
122
+ export class FrameDecoder {
123
+ /**
124
+ * @param {Object} [options]
125
+ * @param {boolean} [options.requireMask=false] - Client-to-server frames must be masked
126
+ * @param {boolean} [options.forbidMask=false] - Server-to-client frames must not be masked
127
+ * @param {number} [options.maxPayload=DEFAULT_MAX_PAYLOAD]
128
+ */
129
+ constructor({
130
+ requireMask = false,
131
+ forbidMask = false,
132
+ maxPayload = DEFAULT_MAX_PAYLOAD,
133
+ } = {}) {
134
+ this.requireMask = requireMask;
135
+ this.forbidMask = forbidMask;
136
+ this.maxPayload = maxPayload;
137
+ this.buffer = Buffer.alloc(0);
138
+ }
139
+
140
+ /**
141
+ * @param {Buffer} chunk
142
+ * @returns {Array<{fin: boolean, opcode: number, payload: Buffer}>}
143
+ * @throws {WebSocketProtocolError}
144
+ */
145
+ push(chunk) {
146
+ this.buffer =
147
+ this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
148
+ const frames = [];
149
+ for (;;) {
150
+ const frame = this.next();
151
+ if (!frame) {
152
+ return frames;
153
+ }
154
+ frames.push(frame);
155
+ }
156
+ }
157
+
158
+ readLength(second) {
159
+ const length = second & 0x7f;
160
+ if (length < 126) {
161
+ return { length, offset: 2 };
162
+ }
163
+ if (length === 126) {
164
+ return this.buffer.length < 4
165
+ ? null
166
+ : { length: this.buffer.readUInt16BE(2), offset: 4 };
167
+ }
168
+ if (this.buffer.length < 10) {
169
+ return null;
170
+ }
171
+ const long = this.buffer.readBigUInt64BE(2);
172
+ if (long > BigInt(this.maxPayload)) {
173
+ throw new WebSocketProtocolError(
174
+ 'Frame too big',
175
+ CLOSE_CODES.messageTooBig
176
+ );
177
+ }
178
+ return { length: Number(long), offset: 10 };
179
+ }
180
+
181
+ next() {
182
+ if (this.buffer.length < 2) {
183
+ return null;
184
+ }
185
+ const [first, second] = this.buffer;
186
+ const fin = (first & 0x80) !== 0;
187
+ const opcode = first & 0x0f;
188
+ const masked = (second & 0x80) !== 0;
189
+ if ((first & 0x70) !== 0) {
190
+ throw new WebSocketProtocolError('Reserved bits must be 0');
191
+ }
192
+ if (!Object.values(OPCODES).includes(opcode)) {
193
+ throw new WebSocketProtocolError(`Unknown opcode ${opcode}`);
194
+ }
195
+ if (this.requireMask && !masked) {
196
+ throw new WebSocketProtocolError('Client frames must be masked');
197
+ }
198
+ if (this.forbidMask && masked) {
199
+ throw new WebSocketProtocolError('Server frames must not be masked');
200
+ }
201
+ const header = this.readLength(second);
202
+ if (!header) {
203
+ return null;
204
+ }
205
+ const { length } = header;
206
+ if (opcode >= OPCODES.close && (length > MAX_CONTROL_PAYLOAD || !fin)) {
207
+ throw new WebSocketProtocolError('Invalid control frame');
208
+ }
209
+ if (length > this.maxPayload) {
210
+ throw new WebSocketProtocolError(
211
+ 'Frame too big',
212
+ CLOSE_CODES.messageTooBig
213
+ );
214
+ }
215
+ const maskOffset = header.offset;
216
+ const payloadOffset = maskOffset + (masked ? 4 : 0);
217
+ if (this.buffer.length < payloadOffset + length) {
218
+ return null;
219
+ }
220
+ const payload = Buffer.from(
221
+ this.buffer.subarray(payloadOffset, payloadOffset + length)
222
+ );
223
+ if (masked) {
224
+ const maskingKey = this.buffer.subarray(maskOffset, payloadOffset);
225
+ for (let index = 0; index < payload.length; index++) {
226
+ payload[index] ^= maskingKey[index % 4];
227
+ }
228
+ }
229
+ this.buffer = this.buffer.subarray(payloadOffset + length);
230
+ return { fin, opcode, payload };
231
+ }
232
+ }
233
+
234
+ const UTF8 = new TextDecoder('utf-8', { fatal: true });
235
+
236
+ function decodeText(payload) {
237
+ try {
238
+ return UTF8.decode(payload);
239
+ } catch {
240
+ throw new WebSocketProtocolError(
241
+ 'Invalid UTF-8',
242
+ CLOSE_CODES.invalidPayload
243
+ );
244
+ }
245
+ }
246
+
247
+ function isValidCloseCode(code) {
248
+ return (
249
+ (code >= 1000 && code <= 1014 && ![1004, 1005, 1006].includes(code)) ||
250
+ (code >= 3000 && code <= 4999)
251
+ );
252
+ }
253
+
254
+ /**
255
+ * One open WebSocket connection over a socket that finished the handshake.
256
+ *
257
+ * Events: `'message'` (string), `'close'` ({code, reason}) once, and
258
+ * `'error'` (Error) for socket errors.
259
+ */
260
+ export class WebSocketConnection extends EventEmitter {
261
+ /**
262
+ * @param {net.Socket} socket
263
+ * @param {Object} [options]
264
+ * @param {'server'|'client'} [options.role='server']
265
+ * @param {Buffer} [options.head] - Bytes read past the handshake
266
+ * @param {number} [options.maxPayload=DEFAULT_MAX_PAYLOAD]
267
+ */
268
+ constructor(
269
+ socket,
270
+ { role = 'server', head, maxPayload = DEFAULT_MAX_PAYLOAD } = {}
271
+ ) {
272
+ super();
273
+ this.socket = socket;
274
+ this.role = role;
275
+ this.maxPayload = maxPayload;
276
+ this.decoder = new FrameDecoder({
277
+ requireMask: role === 'server',
278
+ forbidMask: role === 'client',
279
+ maxPayload,
280
+ });
281
+ this.fragments = null;
282
+ this.closeSent = false;
283
+ this.closeReceived = null;
284
+ this.closed = false;
285
+ socket.setNoDelay?.(true);
286
+ socket.on('data', (chunk) => this.receive(chunk));
287
+ socket.on('error', (error) => {
288
+ if (this.listenerCount('error') > 0) {
289
+ this.emit('error', error);
290
+ }
291
+ });
292
+ socket.on('close', () => this.finish());
293
+ if (head?.length) {
294
+ // After the caller had a chance to add its listeners.
295
+ setImmediate(() => this.receive(head));
296
+ }
297
+ }
298
+
299
+ /** True until a close frame is sent or the socket ends. */
300
+ get open() {
301
+ return !this.closed && !this.closeSent;
302
+ }
303
+
304
+ /**
305
+ * Send a text message.
306
+ *
307
+ * @param {string} text
308
+ * @returns {boolean} False when the connection is already closing
309
+ */
310
+ send(text) {
311
+ if (!this.open) {
312
+ return false;
313
+ }
314
+ this.write(OPCODES.text, Buffer.from(String(text), 'utf8'));
315
+ return true;
316
+ }
317
+
318
+ /** Send a ping; the peer answers with a pong. */
319
+ ping(data = Buffer.alloc(0)) {
320
+ if (this.open) {
321
+ this.write(OPCODES.ping, data);
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Start the close handshake. The socket ends when the peer answers, or
327
+ * after `timeoutMs` without an answer.
328
+ *
329
+ * @param {number} [code=1000]
330
+ * @param {string} [reason='']
331
+ * @param {number} [timeoutMs=1000]
332
+ */
333
+ close(code = CLOSE_CODES.normal, reason = '', timeoutMs = 1_000) {
334
+ if (this.closed) {
335
+ return;
336
+ }
337
+ if (!this.closeSent) {
338
+ this.sendClose(code, reason);
339
+ }
340
+ if (this.closeReceived) {
341
+ this.endSocket();
342
+ return;
343
+ }
344
+ const timer = setTimeout(() => this.socket.destroy(), timeoutMs);
345
+ timer.unref?.();
346
+ this.socket.once('close', () => clearTimeout(timer));
347
+ }
348
+
349
+ /** Drop the connection without a close handshake. */
350
+ terminate() {
351
+ this.socket.destroy();
352
+ }
353
+
354
+ write(opcode, payload) {
355
+ if (this.socket.writable) {
356
+ this.socket.write(
357
+ encodeFrame(opcode, payload, { mask: this.role === 'client' })
358
+ );
359
+ }
360
+ }
361
+
362
+ sendClose(code, reason) {
363
+ this.closeSent = true;
364
+ const reasonBytes = Buffer.from(reason, 'utf8').subarray(0, 123);
365
+ const payload = Buffer.alloc(2 + reasonBytes.length);
366
+ payload.writeUInt16BE(code, 0);
367
+ reasonBytes.copy(payload, 2);
368
+ this.write(OPCODES.close, payload);
369
+ }
370
+
371
+ endSocket() {
372
+ // The server closes the TCP connection first (RFC 6455 section 7.1.1).
373
+ if (this.role === 'server') {
374
+ this.socket.end();
375
+ }
376
+ }
377
+
378
+ fail(error) {
379
+ if (!this.closeSent) {
380
+ this.sendClose(error.code ?? CLOSE_CODES.protocolError, error.message);
381
+ }
382
+ this.socket.end();
383
+ const timer = setTimeout(() => this.socket.destroy(), 1_000);
384
+ timer.unref?.();
385
+ }
386
+
387
+ receive(chunk) {
388
+ if (this.closed || this.closeReceived) {
389
+ return;
390
+ }
391
+ try {
392
+ for (const frame of this.decoder.push(chunk)) {
393
+ this.handleFrame(frame);
394
+ if (this.closeReceived) {
395
+ return;
396
+ }
397
+ }
398
+ } catch (error) {
399
+ if (!(error instanceof WebSocketProtocolError)) {
400
+ throw error;
401
+ }
402
+ this.fail(error);
403
+ }
404
+ }
405
+
406
+ handleFrame({ fin, opcode, payload }) {
407
+ switch (opcode) {
408
+ case OPCODES.ping:
409
+ if (!this.closeSent) {
410
+ this.write(OPCODES.pong, payload);
411
+ }
412
+ return;
413
+ case OPCODES.pong:
414
+ this.emit('pong', payload);
415
+ return;
416
+ case OPCODES.close:
417
+ this.handleClose(payload);
418
+ return;
419
+ case OPCODES.binary:
420
+ throw new WebSocketProtocolError(
421
+ 'Binary messages are not supported',
422
+ CLOSE_CODES.unsupportedData
423
+ );
424
+ default:
425
+ this.handleData({ fin, opcode, payload });
426
+ }
427
+ }
428
+
429
+ handleData({ fin, opcode, payload }) {
430
+ if (opcode === OPCODES.continuation && !this.fragments) {
431
+ throw new WebSocketProtocolError('Unexpected continuation frame');
432
+ }
433
+ if (opcode === OPCODES.text && this.fragments) {
434
+ throw new WebSocketProtocolError('Expected a continuation frame');
435
+ }
436
+ const parts = [...(this.fragments ?? []), payload];
437
+ const size = parts.reduce((total, part) => total + part.length, 0);
438
+ if (size > this.maxPayload) {
439
+ throw new WebSocketProtocolError(
440
+ 'Message too big',
441
+ CLOSE_CODES.messageTooBig
442
+ );
443
+ }
444
+ if (!fin) {
445
+ this.fragments = parts;
446
+ return;
447
+ }
448
+ this.fragments = null;
449
+ const text = decodeText(Buffer.concat(parts));
450
+ if (!this.closeSent) {
451
+ this.emit('message', text);
452
+ }
453
+ }
454
+
455
+ handleClose(payload) {
456
+ if (payload.length === 1) {
457
+ throw new WebSocketProtocolError('Invalid close frame');
458
+ }
459
+ const code =
460
+ payload.length >= 2 ? payload.readUInt16BE(0) : CLOSE_CODES.noStatus;
461
+ if (payload.length >= 2 && !isValidCloseCode(code)) {
462
+ throw new WebSocketProtocolError(`Invalid close code ${code}`);
463
+ }
464
+ const reason = decodeText(payload.subarray(2));
465
+ this.closeReceived = { code, reason };
466
+ if (!this.closeSent) {
467
+ this.sendClose(
468
+ code === CLOSE_CODES.noStatus ? CLOSE_CODES.normal : code,
469
+ ''
470
+ );
471
+ }
472
+ this.endSocket();
473
+ }
474
+
475
+ finish() {
476
+ if (this.closed) {
477
+ return;
478
+ }
479
+ this.closed = true;
480
+ this.emit(
481
+ 'close',
482
+ this.closeReceived ?? { code: CLOSE_CODES.abnormal, reason: '' }
483
+ );
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Answer an HTTP upgrade request with an error status and close the socket.
489
+ *
490
+ * @param {net.Socket} socket
491
+ * @param {number} status - HTTP status code such as 403
492
+ * @param {string} [message] - Plain-text body
493
+ */
494
+ export function rejectUpgrade(socket, status, message = '') {
495
+ const body = Buffer.from(message, 'utf8');
496
+ const statusText = http.STATUS_CODES[status] ?? 'Error';
497
+ socket.end(
498
+ Buffer.concat([
499
+ Buffer.from(
500
+ `HTTP/1.1 ${status} ${statusText}\r\n` +
501
+ 'Connection: close\r\n' +
502
+ 'Content-Type: text/plain; charset=utf-8\r\n' +
503
+ `Content-Length: ${body.length}\r\n\r\n`
504
+ ),
505
+ body,
506
+ ])
507
+ );
508
+ }
509
+
510
+ function headerHasToken(value, token) {
511
+ return String(value ?? '')
512
+ .toLowerCase()
513
+ .split(',')
514
+ .some((part) => part.trim() === token);
515
+ }
516
+
517
+ /**
518
+ * Complete the server side of the opening handshake for a `node:http`
519
+ * `'upgrade'` event. An invalid handshake is answered with 400 (or 426 for an
520
+ * unsupported version) and null is returned.
521
+ *
522
+ * @param {http.IncomingMessage} request
523
+ * @param {net.Socket} socket
524
+ * @param {Buffer} [head]
525
+ * @param {Object} [options]
526
+ * @param {number} [options.maxPayload]
527
+ * @returns {WebSocketConnection|null}
528
+ */
529
+ export function acceptUpgrade(request, socket, head, { maxPayload } = {}) {
530
+ const key = request.headers['sec-websocket-key'];
531
+ if (
532
+ request.method !== 'GET' ||
533
+ !headerHasToken(request.headers.upgrade, 'websocket') ||
534
+ !headerHasToken(request.headers.connection, 'upgrade') ||
535
+ typeof key !== 'string' ||
536
+ Buffer.from(key, 'base64').length !== 16
537
+ ) {
538
+ rejectUpgrade(socket, 400, 'Invalid WebSocket handshake');
539
+ return null;
540
+ }
541
+ if (request.headers['sec-websocket-version'] !== '13') {
542
+ socket.end(
543
+ 'HTTP/1.1 426 Upgrade Required\r\n' +
544
+ 'Sec-WebSocket-Version: 13\r\n' +
545
+ 'Connection: close\r\n' +
546
+ 'Content-Length: 0\r\n\r\n'
547
+ );
548
+ return null;
549
+ }
550
+ socket.write(
551
+ 'HTTP/1.1 101 Switching Protocols\r\n' +
552
+ 'Upgrade: websocket\r\n' +
553
+ 'Connection: Upgrade\r\n' +
554
+ `Sec-WebSocket-Accept: ${computeAcceptKey(key)}\r\n\r\n`
555
+ );
556
+ return new WebSocketConnection(socket, { role: 'server', head, maxPayload });
557
+ }
558
+
559
+ /**
560
+ * Open a client connection. Used by the tests and by tools that talk to the
561
+ * relay; the extension itself uses the browser's WebSocket.
562
+ *
563
+ * @param {string} url - ws:// URL
564
+ * @param {Object} [options]
565
+ * @param {Object<string,string>} [options.headers] - Extra handshake headers such as Origin
566
+ * @param {number} [options.timeoutMs=5000]
567
+ * @returns {Promise<WebSocketConnection>} Rejects with an Error whose `statusCode` is the HTTP status when the server refuses the upgrade
568
+ */
569
+ export function connectWebSocket(
570
+ url,
571
+ { headers = {}, timeoutMs = 5_000 } = {}
572
+ ) {
573
+ const target = new URL(url);
574
+ if (target.protocol !== 'ws:') {
575
+ throw new TypeError(`Only ws:// URLs are supported, got ${url}`);
576
+ }
577
+ const key = randomBytes(16).toString('base64');
578
+ return new Promise((resolve, reject) => {
579
+ const request = http.request({
580
+ host: target.hostname.replace(/^\[|\]$/g, ''),
581
+ port: target.port || 80,
582
+ path: `${target.pathname}${target.search}`,
583
+ timeout: timeoutMs,
584
+ headers: {
585
+ ...headers,
586
+ Connection: 'Upgrade',
587
+ Upgrade: 'websocket',
588
+ 'Sec-WebSocket-Key': key,
589
+ 'Sec-WebSocket-Version': '13',
590
+ },
591
+ });
592
+ request.on('timeout', () =>
593
+ request.destroy(new Error(`WebSocket handshake timed out: ${url}`))
594
+ );
595
+ request.on('error', reject);
596
+ request.on('response', (response) => {
597
+ const error = new Error(
598
+ `WebSocket upgrade refused with HTTP ${response.statusCode}`
599
+ );
600
+ error.statusCode = response.statusCode;
601
+ response.resume();
602
+ reject(error);
603
+ });
604
+ request.on('upgrade', (response, socket, head) => {
605
+ if (response.headers['sec-websocket-accept'] !== computeAcceptKey(key)) {
606
+ socket.destroy();
607
+ reject(new Error('Invalid Sec-WebSocket-Accept'));
608
+ return;
609
+ }
610
+ socket.setTimeout(0);
611
+ resolve(new WebSocketConnection(socket, { role: 'client', head }));
612
+ });
613
+ request.end();
614
+ });
615
+ }
@@ -1,5 +1,4 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { execFile as execFileCallback } from 'node:child_process';
3
2
  import {
4
3
  chmod,
5
4
  mkdir,
@@ -10,13 +9,13 @@ import {
10
9
  stat,
11
10
  } from 'node:fs/promises';
12
11
  import path from 'node:path';
13
- import { promisify } from 'node:util';
12
+
13
+ import { runCommand } from '../utilities/subprocess.js';
14
14
 
15
15
  const DEFAULT_TTL_MINUTES = 60;
16
16
  const LOCK_STALE_MILLISECONDS = 30_000;
17
17
  const LOCK_WAIT_MILLISECONDS = 30_000;
18
18
  const credentialPromises = new Map();
19
- const execFile = promisify(execFileCallback);
20
19
  let windowsPrincipalPromise;
21
20
 
22
21
  function hash(value) {
@@ -47,9 +46,9 @@ export function normalizeCookieCache(cache, homeDir, ttlMinutes) {
47
46
  }
48
47
 
49
48
  function windowsPrincipal() {
50
- windowsPrincipalPromise ??= execFile('whoami', [], {
51
- windowsHide: true,
52
- }).then(({ stdout }) => stdout.trim());
49
+ windowsPrincipalPromise ??= runCommand('whoami').then(({ stdout }) =>
50
+ stdout.trim()
51
+ );
53
52
  return windowsPrincipalPromise;
54
53
  }
55
54
 
@@ -60,17 +59,13 @@ async function restrictOwnerOnly(targetPath, directory) {
60
59
  throw new Error('Could not identify the current Windows user');
61
60
  }
62
61
  const permission = directory ? '(OI)(CI)F' : 'F';
63
- await execFile(
64
- 'icacls',
65
- [
66
- targetPath,
67
- '/inheritance:r',
68
- '/grant:r',
69
- `${principal}:${permission}`,
70
- '/q',
71
- ],
72
- { windowsHide: true }
73
- );
62
+ await runCommand('icacls', [
63
+ targetPath,
64
+ '/inheritance:r',
65
+ '/grant:r',
66
+ `${principal}:${permission}`,
67
+ '/q',
68
+ ]);
74
69
  return;
75
70
  }
76
71
  await chmod(targetPath, directory ? 0o700 : 0o600);
@@ -167,13 +162,21 @@ async function wait(milliseconds) {
167
162
 
168
163
  async function acquireCredentialLock(lockPath, cachedPath, options) {
169
164
  const startedAt = options.now();
165
+ let contention;
170
166
  while (options.now() - startedAt <= LOCK_WAIT_MILLISECONDS) {
171
167
  try {
172
168
  return await open(lockPath, 'wx', 0o600);
173
169
  } catch (error) {
174
- if (error.code !== 'EEXIST') {
170
+ // Windows can report EPERM while another process's unlinked lock is
171
+ // still pending deletion. Retry within the same bounded lock deadline.
172
+ // Preserve the original error if it proves to be permanent.
173
+ if (
174
+ error.code !== 'EEXIST' &&
175
+ !(options.platform === 'win32' && error.code === 'EPERM')
176
+ ) {
175
177
  throw error;
176
178
  }
179
+ contention = error;
177
180
  const cached = await readFreshJson(
178
181
  cachedPath,
179
182
  options.ttlSeconds,
@@ -189,7 +192,9 @@ async function acquireCredentialLock(lockPath, cachedPath, options) {
189
192
  await wait(50);
190
193
  }
191
194
  }
192
- throw new Error('timed out waiting for another cookie credential reader');
195
+ throw new Error('timed out waiting for another cookie credential reader', {
196
+ cause: contention,
197
+ });
193
198
  }
194
199
 
195
200
  async function loadOrCreateCredential({
@@ -199,6 +204,7 @@ async function loadOrCreateCredential({
199
204
  create,
200
205
  now,
201
206
  metadata,
207
+ platform = process.platform,
202
208
  }) {
203
209
  if (!cache.enabled) {
204
210
  return { key: Buffer.from(await create()), savedAt: now() / 1000 };
@@ -219,6 +225,7 @@ async function loadOrCreateCredential({
219
225
  now,
220
226
  refresh,
221
227
  ttlSeconds: cache.ttlSeconds,
228
+ platform,
222
229
  });
223
230
  if (acquired.cached) {
224
231
  return {