2020117-agent 0.1.7 → 0.1.8

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/dist/agent.js CHANGED
@@ -356,6 +356,8 @@ async function delegateAPI(kind, input, bidSats, provider) {
356
356
  // --- 4. P2P Swarm Listener ---
357
357
  const p2pJobs = new Map();
358
358
  const activeSessions = new Map();
359
+ // Backend WebSocket connections for WS tunnel (keyed by ws_id)
360
+ const backendWebSockets = new Map();
359
361
  async function startSwarmListener(label) {
360
362
  const node = new SwarmNode();
361
363
  state.swarmNode = node;
@@ -509,6 +511,87 @@ async function startSwarmListener(label) {
509
511
  }
510
512
  return;
511
513
  }
514
+ // --- WebSocket tunnel ---
515
+ if (msg.type === 'ws_open') {
516
+ const session = findSessionBySocket(socket);
517
+ if (!session) {
518
+ node.send(socket, { type: 'error', id: msg.id, message: 'No active session' });
519
+ return;
520
+ }
521
+ const processorUrl = process.env.PROCESSOR;
522
+ if (!processorUrl || (!processorUrl.startsWith('http://') && !processorUrl.startsWith('https://'))) {
523
+ node.send(socket, { type: 'ws_open', id: msg.id, ws_id: msg.ws_id, message: 'No HTTP backend configured' });
524
+ return;
525
+ }
526
+ const wsId = msg.ws_id;
527
+ const wsPath = msg.ws_path || '/';
528
+ const backendWsUrl = processorUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:').replace(/\/$/, '') + wsPath;
529
+ console.log(`[${label}] WS ${wsId}: opening ${backendWsUrl}`);
530
+ try {
531
+ const backendWs = new WebSocket(backendWsUrl, msg.ws_protocols || []);
532
+ backendWebSockets.set(wsId, { ws: backendWs, peerId });
533
+ backendWs.addEventListener('open', () => {
534
+ console.log(`[${label}] WS ${wsId}: backend connected`);
535
+ });
536
+ backendWs.addEventListener('message', (event) => {
537
+ const d = event.data;
538
+ if (typeof d === 'string') {
539
+ node.send(socket, { type: 'ws_message', id: wsId, ws_id: wsId, data: d, ws_frame_type: 'text' });
540
+ }
541
+ else if (d instanceof ArrayBuffer) {
542
+ node.send(socket, { type: 'ws_message', id: wsId, ws_id: wsId, data: Buffer.from(d).toString('base64'), ws_frame_type: 'binary' });
543
+ }
544
+ else if (d instanceof Blob) {
545
+ d.arrayBuffer().then(ab => {
546
+ node.send(socket, { type: 'ws_message', id: wsId, ws_id: wsId, data: Buffer.from(ab).toString('base64'), ws_frame_type: 'binary' });
547
+ });
548
+ }
549
+ });
550
+ backendWs.addEventListener('close', (event) => {
551
+ console.log(`[${label}] WS ${wsId}: backend closed (code=${event.code})`);
552
+ backendWebSockets.delete(wsId);
553
+ node.send(socket, { type: 'ws_close', id: wsId, ws_id: wsId, ws_code: event.code, ws_reason: event.reason || '' });
554
+ });
555
+ backendWs.addEventListener('error', () => {
556
+ console.error(`[${label}] WS ${wsId}: backend error`);
557
+ backendWebSockets.delete(wsId);
558
+ node.send(socket, { type: 'ws_close', id: wsId, ws_id: wsId, ws_code: 1011, ws_reason: 'Backend WebSocket error' });
559
+ });
560
+ }
561
+ catch (e) {
562
+ node.send(socket, { type: 'ws_open', id: msg.id, ws_id: wsId, message: e.message });
563
+ }
564
+ return;
565
+ }
566
+ if (msg.type === 'ws_message') {
567
+ const entry = backendWebSockets.get(msg.ws_id || '');
568
+ if (!entry || entry.ws.readyState !== WebSocket.OPEN)
569
+ return;
570
+ try {
571
+ if (msg.ws_frame_type === 'binary') {
572
+ entry.ws.send(Buffer.from(msg.data || '', 'base64'));
573
+ }
574
+ else {
575
+ entry.ws.send(msg.data || '');
576
+ }
577
+ }
578
+ catch (e) {
579
+ console.error(`[${label}] WS ${msg.ws_id}: send failed: ${e.message}`);
580
+ }
581
+ return;
582
+ }
583
+ if (msg.type === 'ws_close') {
584
+ const entry = backendWebSockets.get(msg.ws_id || '');
585
+ if (entry) {
586
+ console.log(`[${label}] WS ${msg.ws_id}: closing backend`);
587
+ try {
588
+ entry.ws.close(msg.ws_code || 1000, msg.ws_reason || '');
589
+ }
590
+ catch { }
591
+ backendWebSockets.delete(msg.ws_id || '');
592
+ }
593
+ return;
594
+ }
512
595
  // Session-scoped request (no payment negotiation — session pays per-minute)
513
596
  if (msg.type === 'request' && msg.session_id) {
514
597
  const session = activeSessions.get(msg.session_id);
@@ -613,6 +696,16 @@ function endSession(node, session, label) {
613
696
  clearInterval(session.timeoutTimer);
614
697
  session.timeoutTimer = null;
615
698
  }
699
+ // Close all backend WebSockets for this peer
700
+ for (const [wsId, entry] of backendWebSockets) {
701
+ if (entry.peerId === session.peerId) {
702
+ try {
703
+ entry.ws.close(1001, 'Session ended');
704
+ }
705
+ catch { }
706
+ backendWebSockets.delete(wsId);
707
+ }
708
+ }
616
709
  node.send(session.socket, {
617
710
  type: 'session_end',
618
711
  id: session.sessionId,
package/dist/session.js CHANGED
@@ -42,7 +42,7 @@ for (const arg of process.argv.slice(2)) {
42
42
  import { SwarmNode, topicFromKind } from './swarm.js';
43
43
  import { queryProviderSkill } from './p2p-customer.js';
44
44
  import { mintTokens, splitTokens } from './cashu.js';
45
- import { randomBytes } from 'crypto';
45
+ import { randomBytes, createHash } from 'crypto';
46
46
  import { createServer } from 'http';
47
47
  import { createInterface } from 'readline';
48
48
  import { mkdirSync, writeFileSync } from 'fs';
@@ -70,6 +70,7 @@ const state = {
70
70
  shuttingDown: false,
71
71
  pendingRequests: new Map(),
72
72
  chunkBuffers: new Map(),
73
+ activeWebSockets: new Map(),
73
74
  outputCounter: 0,
74
75
  };
75
76
  // --- Helpers ---
@@ -172,6 +173,45 @@ function setupMessageHandler() {
172
173
  warn(`Provider error: ${msg.message}`);
173
174
  break;
174
175
  }
176
+ case 'ws_message': {
177
+ const browserSocket = state.activeWebSockets.get(msg.ws_id || '');
178
+ if (!browserSocket || browserSocket.destroyed) {
179
+ state.activeWebSockets.delete(msg.ws_id || '');
180
+ break;
181
+ }
182
+ const isText = msg.ws_frame_type !== 'binary';
183
+ const payload = isText
184
+ ? Buffer.from(msg.data || '', 'utf-8')
185
+ : Buffer.from(msg.data || '', 'base64');
186
+ try {
187
+ browserSocket.write(buildWsFrame(payload, isText ? 0x01 : 0x02));
188
+ }
189
+ catch { }
190
+ break;
191
+ }
192
+ case 'ws_close': {
193
+ const browserSocket = state.activeWebSockets.get(msg.ws_id || '');
194
+ if (browserSocket && !browserSocket.destroyed) {
195
+ sendWsClose(browserSocket, msg.ws_code || 1000, msg.ws_reason || '');
196
+ browserSocket.end();
197
+ }
198
+ state.activeWebSockets.delete(msg.ws_id || '');
199
+ log(`WS ${msg.ws_id}: closed by provider (code=${msg.ws_code || 1000})`);
200
+ break;
201
+ }
202
+ case 'ws_open': {
203
+ // Provider failed to open backend WS
204
+ if (msg.message) {
205
+ const browserSocket = state.activeWebSockets.get(msg.ws_id || '');
206
+ if (browserSocket && !browserSocket.destroyed) {
207
+ sendWsClose(browserSocket, 1011, msg.message);
208
+ browserSocket.end();
209
+ }
210
+ state.activeWebSockets.delete(msg.ws_id || '');
211
+ warn(`WS ${msg.ws_id}: provider failed: ${msg.message}`);
212
+ }
213
+ break;
214
+ }
175
215
  default:
176
216
  // Unrecognized unsolicited message — ignore
177
217
  break;
@@ -259,9 +299,11 @@ function startHttpProxy() {
259
299
  }, HTTP_TIMEOUT_MS);
260
300
  // Forward response back to browser
261
301
  const respHeaders = { ...(resp.headers || {}) };
262
- // Remove hop-by-hop headers
302
+ // Remove hop-by-hop and size headers (body may differ after P2P relay)
263
303
  delete respHeaders['transfer-encoding'];
264
304
  delete respHeaders['connection'];
305
+ delete respHeaders['content-length'];
306
+ delete respHeaders['content-encoding'];
265
307
  res.writeHead(resp.status || 200, respHeaders);
266
308
  res.end(resp.body || '');
267
309
  }
@@ -270,6 +312,8 @@ function startHttpProxy() {
270
312
  res.end(JSON.stringify({ error: e.message }));
271
313
  }
272
314
  });
315
+ // Enable WebSocket tunneling on the same server
316
+ setupWebSocketProxy(server);
273
317
  server.on('error', (err) => {
274
318
  if (!state.httpServer) {
275
319
  reject(err);
@@ -284,6 +328,172 @@ function startHttpProxy() {
284
328
  });
285
329
  });
286
330
  }
331
+ // --- 4b. WebSocket tunnel (RFC 6455 minimal codec) ---
332
+ function wsAcceptKey(clientKey) {
333
+ return createHash('sha1')
334
+ .update(clientKey + '258EAFA5-E914-47DA-95CA-5AB5DB63F35E')
335
+ .digest('base64');
336
+ }
337
+ function buildWsFrame(data, opcode) {
338
+ const len = data.length;
339
+ let header;
340
+ if (len < 126) {
341
+ header = Buffer.alloc(2);
342
+ header[0] = 0x80 | opcode;
343
+ header[1] = len;
344
+ }
345
+ else if (len < 65536) {
346
+ header = Buffer.alloc(4);
347
+ header[0] = 0x80 | opcode;
348
+ header[1] = 126;
349
+ header.writeUInt16BE(len, 2);
350
+ }
351
+ else {
352
+ header = Buffer.alloc(10);
353
+ header[0] = 0x80 | opcode;
354
+ header[1] = 127;
355
+ header.writeUInt32BE(0, 2);
356
+ header.writeUInt32BE(len, 6);
357
+ }
358
+ return Buffer.concat([header, data]);
359
+ }
360
+ function sendWsClose(socket, code, reason) {
361
+ const reasonBuf = Buffer.from(reason, 'utf-8');
362
+ const payload = Buffer.alloc(2 + reasonBuf.length);
363
+ payload.writeUInt16BE(code, 0);
364
+ reasonBuf.copy(payload, 2);
365
+ try {
366
+ socket.write(buildWsFrame(payload, 0x08));
367
+ }
368
+ catch { }
369
+ }
370
+ class WsFrameParser {
371
+ buf = Buffer.alloc(0);
372
+ onFrame = () => { };
373
+ feed(chunk) {
374
+ this.buf = Buffer.concat([this.buf, chunk]);
375
+ while (this.parseOne()) { }
376
+ }
377
+ parseOne() {
378
+ if (this.buf.length < 2)
379
+ return false;
380
+ const byte1 = this.buf[1];
381
+ const masked = (byte1 & 0x80) !== 0;
382
+ let payloadLen = byte1 & 0x7f;
383
+ let offset = 2;
384
+ if (payloadLen === 126) {
385
+ if (this.buf.length < 4)
386
+ return false;
387
+ payloadLen = this.buf.readUInt16BE(2);
388
+ offset = 4;
389
+ }
390
+ else if (payloadLen === 127) {
391
+ if (this.buf.length < 10)
392
+ return false;
393
+ payloadLen = this.buf.readUInt32BE(6);
394
+ offset = 10;
395
+ }
396
+ const maskSize = masked ? 4 : 0;
397
+ const totalLen = offset + maskSize + payloadLen;
398
+ if (this.buf.length < totalLen)
399
+ return false;
400
+ const opcode = this.buf[0] & 0x0f;
401
+ let payload;
402
+ if (masked) {
403
+ const mask = this.buf.subarray(offset, offset + 4);
404
+ payload = Buffer.alloc(payloadLen);
405
+ for (let i = 0; i < payloadLen; i++) {
406
+ payload[i] = this.buf[offset + 4 + i] ^ mask[i & 3];
407
+ }
408
+ }
409
+ else {
410
+ payload = Buffer.from(this.buf.subarray(offset, offset + payloadLen));
411
+ }
412
+ this.buf = Buffer.from(this.buf.subarray(totalLen));
413
+ this.onFrame(opcode, payload);
414
+ return true;
415
+ }
416
+ }
417
+ function setupWebSocketProxy(server) {
418
+ server.on('upgrade', (req, socket, head) => {
419
+ if (!state.sessionId || state.shuttingDown) {
420
+ socket.end('HTTP/1.1 503 Service Unavailable\r\n\r\n');
421
+ return;
422
+ }
423
+ const wsKey = req.headers['sec-websocket-key'];
424
+ if (!wsKey) {
425
+ socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
426
+ return;
427
+ }
428
+ const wsId = randomBytes(4).toString('hex');
429
+ const path = req.url || '/';
430
+ const protocols = req.headers['sec-websocket-protocol']
431
+ ? req.headers['sec-websocket-protocol'].split(',').map(s => s.trim())
432
+ : undefined;
433
+ log(`WS ${wsId}: upgrade ${path}`);
434
+ // Complete handshake with browser
435
+ const lines = [
436
+ 'HTTP/1.1 101 Switching Protocols',
437
+ 'Upgrade: websocket',
438
+ 'Connection: Upgrade',
439
+ `Sec-WebSocket-Accept: ${wsAcceptKey(wsKey)}`,
440
+ ];
441
+ if (protocols && protocols.length > 0)
442
+ lines.push(`Sec-WebSocket-Protocol: ${protocols[0]}`);
443
+ socket.write(lines.join('\r\n') + '\r\n\r\n');
444
+ state.activeWebSockets.set(wsId, socket);
445
+ // Tell provider to open backend WS
446
+ state.node.send(state.socket, {
447
+ type: 'ws_open',
448
+ id: wsId,
449
+ ws_id: wsId,
450
+ session_id: state.sessionId,
451
+ ws_path: path,
452
+ ws_protocols: protocols,
453
+ });
454
+ // Parse frames from browser → relay to provider
455
+ const parser = new WsFrameParser();
456
+ parser.onFrame = (opcode, payload) => {
457
+ if (opcode === 0x08) { // close
458
+ const code = payload.length >= 2 ? payload.readUInt16BE(0) : 1000;
459
+ state.node.send(state.socket, { type: 'ws_close', id: wsId, ws_id: wsId, ws_code: code, ws_reason: payload.length > 2 ? payload.subarray(2).toString('utf-8') : '' });
460
+ state.activeWebSockets.delete(wsId);
461
+ return;
462
+ }
463
+ if (opcode === 0x09) { // ping → pong
464
+ try {
465
+ socket.write(buildWsFrame(payload, 0x0a));
466
+ }
467
+ catch { }
468
+ return;
469
+ }
470
+ if (opcode === 0x0a)
471
+ return; // pong — ignore
472
+ // text (0x01) or binary (0x02)
473
+ const isText = opcode === 0x01;
474
+ state.node.send(state.socket, {
475
+ type: 'ws_message',
476
+ id: wsId,
477
+ ws_id: wsId,
478
+ data: isText ? payload.toString('utf-8') : payload.toString('base64'),
479
+ ws_frame_type: isText ? 'text' : 'binary',
480
+ });
481
+ };
482
+ socket.on('data', (chunk) => parser.feed(chunk));
483
+ if (head.length > 0)
484
+ parser.feed(head);
485
+ socket.on('close', () => {
486
+ if (state.activeWebSockets.has(wsId)) {
487
+ state.activeWebSockets.delete(wsId);
488
+ try {
489
+ state.node.send(state.socket, { type: 'ws_close', id: wsId, ws_id: wsId, ws_code: 1001, ws_reason: 'Browser disconnected' });
490
+ }
491
+ catch { }
492
+ }
493
+ });
494
+ socket.on('error', () => { state.activeWebSockets.delete(wsId); });
495
+ });
496
+ }
287
497
  // --- 5. CLI REPL ---
288
498
  function startRepl() {
289
499
  // Skip REPL when stdin is not a TTY (e.g. background process, piped input)
@@ -494,6 +704,12 @@ async function endSession() {
494
704
  state.pendingRequests.delete(id);
495
705
  }
496
706
  state.chunkBuffers.clear();
707
+ // Close all WebSocket tunnels
708
+ for (const [wsId, socket] of state.activeWebSockets) {
709
+ sendWsClose(socket, 1001, 'Session ending');
710
+ socket.end();
711
+ }
712
+ state.activeWebSockets.clear();
497
713
  // Send session_end
498
714
  if (state.node && state.socket && state.sessionId) {
499
715
  const duration = elapsedSeconds();
package/dist/swarm.d.ts CHANGED
@@ -24,7 +24,7 @@
24
24
  import Hyperswarm from 'hyperswarm';
25
25
  import { EventEmitter } from 'events';
26
26
  export interface SwarmMessage {
27
- type: 'request' | 'accepted' | 'chunk' | 'result' | 'error' | 'payment' | 'payment_ack' | 'offer' | 'pay_required' | 'stop' | 'skill_request' | 'skill_response' | 'session_start' | 'session_ack' | 'session_tick' | 'session_tick_ack' | 'session_end' | 'http_request' | 'http_response';
27
+ type: 'request' | 'accepted' | 'chunk' | 'result' | 'error' | 'payment' | 'payment_ack' | 'offer' | 'pay_required' | 'stop' | 'skill_request' | 'skill_response' | 'session_start' | 'session_ack' | 'session_tick' | 'session_tick_ack' | 'session_end' | 'http_request' | 'http_response' | 'ws_open' | 'ws_message' | 'ws_close';
28
28
  id: string;
29
29
  kind?: number;
30
30
  input?: string;
@@ -52,6 +52,12 @@ export interface SwarmMessage {
52
52
  status?: number;
53
53
  chunk_index?: number;
54
54
  chunk_total?: number;
55
+ ws_id?: string;
56
+ ws_path?: string;
57
+ ws_protocols?: string[];
58
+ ws_frame_type?: 'text' | 'binary';
59
+ ws_code?: number;
60
+ ws_reason?: string;
55
61
  }
56
62
  /**
57
63
  * Create a deterministic topic hash from a service kind number.
package/dist/swarm.js CHANGED
@@ -51,6 +51,7 @@ export class SwarmNode extends EventEmitter {
51
51
  const combined = existing + buf.toString();
52
52
  const lines = combined.split('\n');
53
53
  // Last element is incomplete (or empty after trailing newline)
54
+ // Last element is incomplete (or empty after trailing newline)
54
55
  this.buffers.set(peerId, lines.pop());
55
56
  for (const line of lines) {
56
57
  if (!line.trim())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "2020117-agent",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "2020117 agent runtime — API polling + Hyperswarm P2P + Cashu streaming payments",
5
5
  "type": "module",
6
6
  "bin": {