autodev-cli 1.4.33 → 1.4.35

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.
@@ -387,6 +387,13 @@ class WebSocketPoller {
387
387
  return { fin, opcode, payload };
388
388
  }
389
389
  _onFrame(fin, opcode, payload) {
390
+ // Any inbound frame — data (task/user_message/steer/vnc_input/rdp) as well as
391
+ // control (ping/pong) — is proof the connection is alive. Refresh liveness
392
+ // here so a connection actively receiving traffic is never force-reconnected
393
+ // by the heartbeat sweep just because protocol pongs lapsed (e.g. under heavy
394
+ // VNC/RDP streaming, or a server that doesn't echo protocol pings), which
395
+ // would tear down live screen-share sessions.
396
+ this._lastPongAt = Date.now();
390
397
  // Control frames (never fragmented) — handle immediately; they may be
391
398
  // interleaved between data fragments.
392
399
  if (opcode === 0x9) {
@@ -441,309 +448,331 @@ class WebSocketPoller {
441
448
  return;
442
449
  }
443
450
  const msgType = msg['type'];
444
- // ── VNC frames from pixel-office ─────────────────────────────────────────
445
- // ── MCP update from pixel-office ─────────────────────────────────────────
446
- if (msgType === 'mcp_update') {
447
- const entries = msg['mcpServers'];
448
- if (entries && typeof entries === 'object') {
449
- this._onMcpUpdate?.(entries);
450
- }
451
- return;
452
- }
453
- // ── Export / restore requests from pixel-office ───────────────────────────
454
- if (msgType === 'export_request') {
455
- const agentId = msg['agentId'];
456
- if (agentId) {
457
- this._onExportRequest?.(agentId);
458
- }
451
+ // Server app-level heartbeat: reply with a JSON pong so the server's
452
+ // liveness sweep counts this (primary, task-carrying) connection as alive,
453
+ // mirroring OfficeSocket. Without this the workhorse connection can be swept
454
+ // as stale even while the WS-protocol ping/pong keeps flowing.
455
+ if (msgType === 'ping') {
456
+ this._sendTextFrame(JSON.stringify({ type: 'pong' }));
457
+ this._lastPongAt = Date.now();
459
458
  return;
460
459
  }
461
- if (msgType === 'restore_request') {
462
- const agentId = msg['agentId'];
463
- const downloadUrl = msg['downloadUrl'];
464
- if (agentId && downloadUrl) {
465
- this._onRestoreRequest?.(agentId, downloadUrl);
460
+ // Isolate all downstream frame dispatch: a single throwing frame (malformed
461
+ // payload, ENOSPC/EACCES while saving an attachment, a containment-guard
462
+ // throw) must never propagate out of the socket 'data' handler and crash the
463
+ // whole agent process. Log and drop the frame instead of going offline.
464
+ try {
465
+ // ── VNC frames from pixel-office ─────────────────────────────────────────
466
+ // ── MCP update from pixel-office ─────────────────────────────────────────
467
+ if (msgType === 'mcp_update') {
468
+ const entries = msg['mcpServers'];
469
+ if (entries && typeof entries === 'object') {
470
+ this._onMcpUpdate?.(entries);
471
+ }
472
+ return;
466
473
  }
467
- return;
468
- }
469
- if (msgType === 'export_config') {
470
- const exportEnabled = !!msg['exportEnabled'];
471
- const exportDailyBackup = !!msg['exportDailyBackup'];
472
- const agentId = msg['agentId'] ?? '';
473
- this._onExportConfig?.(exportEnabled, exportDailyBackup, agentId);
474
- return;
475
- }
476
- // ── File browser requests from server ─────────────────────────────────────
477
- if (msgType === 'fb_request') {
478
- const requestId = msg['requestId'];
479
- const action = msg['action'];
480
- const relPath = msg['path'] ?? '';
481
- const content = msg['content'];
482
- const newPath = msg['newPath'];
483
- const query = msg['query'];
484
- if (requestId && action) {
485
- this._handleFbRequest(requestId, action, relPath, content, newPath, query);
474
+ // ── Export / restore requests from pixel-office ───────────────────────────
475
+ if (msgType === 'export_request') {
476
+ const agentId = msg['agentId'];
477
+ if (agentId) {
478
+ this._onExportRequest?.(agentId);
479
+ }
480
+ return;
486
481
  }
487
- return;
488
- }
489
- if (msgType === 'git_request') {
490
- const requestId = msg['requestId'];
491
- const action = msg['action'];
492
- if (requestId && action) {
493
- this._handleGitRequest(requestId, action, msg['path'], msg['staged'], msg['message'], msg['branch'], msg['hash']);
482
+ if (msgType === 'restore_request') {
483
+ const agentId = msg['agentId'];
484
+ const downloadUrl = msg['downloadUrl'];
485
+ if (agentId && downloadUrl) {
486
+ this._onRestoreRequest?.(agentId, downloadUrl);
487
+ }
488
+ return;
494
489
  }
495
- return;
496
- }
497
- if (msgType === 'vnc_session') {
498
- if (!this._vncEnabled) {
499
- this._log('vnc_session ignored — VNC not enabled');
490
+ if (msgType === 'export_config') {
491
+ const exportEnabled = !!msg['exportEnabled'];
492
+ const exportDailyBackup = !!msg['exportDailyBackup'];
493
+ const agentId = msg['agentId'] ?? '';
494
+ this._onExportConfig?.(exportEnabled, exportDailyBackup, agentId);
500
495
  return;
501
496
  }
502
- const action = msg['action'];
503
- if (action === 'start') {
504
- const sessionId = msg['sessionId'];
505
- const port = Number(msg['port'] ?? 5900);
506
- // Prefer password from server frame; fall back to locally-configured password
507
- const password = msg['password'] || this._vncPassword;
508
- this._log(`VNC session start: ${sessionId} → port ${port}`);
509
- const session = new vnc_1.VncSession(sessionId, (frame) => this.sendFrame(frame));
510
- this._vncSessions.set(sessionId, session);
511
- session.start(port, password).catch((err) => {
512
- this._log(`VNC session ${sessionId} failed to start: ${err.message}`);
513
- this._vncSessions.delete(sessionId);
514
- this.sendFrame({ type: 'vnc_close', sessionId, reason: err.message });
515
- });
497
+ // ── File browser requests from server ─────────────────────────────────────
498
+ if (msgType === 'fb_request') {
499
+ const requestId = msg['requestId'];
500
+ const action = msg['action'];
501
+ const relPath = msg['path'] ?? '';
502
+ const content = msg['content'];
503
+ const newPath = msg['newPath'];
504
+ const query = msg['query'];
505
+ if (requestId && action) {
506
+ this._handleFbRequest(requestId, action, relPath, content, newPath, query);
507
+ }
508
+ return;
516
509
  }
517
- return;
518
- }
519
- if (msgType === 'vnc_input') {
520
- const sessionId = msg['sessionId'];
521
- const event = msg['event'];
522
- if (sessionId && event) {
523
- this._vncSessions.get(sessionId)?.handleInput(event);
510
+ if (msgType === 'git_request') {
511
+ const requestId = msg['requestId'];
512
+ const action = msg['action'];
513
+ if (requestId && action) {
514
+ this._handleGitRequest(requestId, action, msg['path'], msg['staged'], msg['message'], msg['branch'], msg['hash']);
515
+ }
516
+ return;
524
517
  }
525
- return;
526
- }
527
- if (msgType === 'vnc_close') {
528
- const sessionId = msg['sessionId'];
529
- if (sessionId) {
530
- this._log(`VNC session closed: ${sessionId}`);
531
- this._vncSessions.get(sessionId)?.stop();
532
- this._vncSessions.delete(sessionId);
518
+ if (msgType === 'vnc_session') {
519
+ if (!this._vncEnabled) {
520
+ this._log('vnc_session ignored — VNC not enabled');
521
+ return;
522
+ }
523
+ const action = msg['action'];
524
+ if (action === 'start') {
525
+ const sessionId = msg['sessionId'];
526
+ const port = Number(msg['port'] ?? 5900);
527
+ // Prefer password from server frame; fall back to locally-configured password
528
+ const password = msg['password'] || this._vncPassword;
529
+ this._log(`VNC session start: ${sessionId} → port ${port}`);
530
+ const session = new vnc_1.VncSession(sessionId, (frame) => this.sendFrame(frame));
531
+ this._vncSessions.set(sessionId, session);
532
+ session.start(port, password).catch((err) => {
533
+ this._log(`VNC session ${sessionId} failed to start: ${err.message}`);
534
+ this._vncSessions.delete(sessionId);
535
+ this.sendFrame({ type: 'vnc_close', sessionId, reason: err.message });
536
+ });
537
+ }
538
+ return;
533
539
  }
534
- return;
535
- }
536
- // ── RDP frames from pixel-office ─────────────────────────────────────────
537
- if (msgType === 'rdp_session') {
538
- if (!this._rdpEnabled) {
539
- this._log('rdp_session ignored — RDP not enabled');
540
+ if (msgType === 'vnc_input') {
541
+ const sessionId = msg['sessionId'];
542
+ const event = msg['event'];
543
+ if (sessionId && event) {
544
+ this._vncSessions.get(sessionId)?.handleInput(event);
545
+ }
540
546
  return;
541
547
  }
542
- const action = msg['action'];
543
- if (action === 'start') {
548
+ if (msgType === 'vnc_close') {
544
549
  const sessionId = msg['sessionId'];
545
- const opts = {
546
- // Host/port come ONLY from local settings — never from the WS frame.
547
- // A frame-supplied host/port would let a remote party open an outbound
548
- // RDP bridge to an arbitrary target (SSRF / internal-network pivot).
549
- // Default to loopback (xrdp runs on the same machine as the extension).
550
- host: this._rdpSettings.host || '127.0.0.1',
551
- port: this._rdpSettings.port ?? 3389,
552
- // credentials never sent from server — always use local settings
553
- username: this._rdpSettings.username || msg['username'],
554
- password: this._rdpSettings.password || msg['password'],
555
- domain: this._rdpSettings.domain || msg['domain'],
556
- width: msg['width'] ? Number(msg['width']) : undefined,
557
- height: msg['height'] ? Number(msg['height']) : undefined,
558
- colorDepth: msg['colorDepth'] ? Number(msg['colorDepth']) : undefined,
559
- };
560
- this._log(`RDP session start: ${sessionId} → ${opts.host}:${opts.port ?? 3389}`);
561
- // Send Guacamole token to browser so it can connect via guacamole-lite
562
- // (guacd + guacamole-lite must be running on the same host as xrdp, port 4567)
563
- if (opts.username || opts.password) {
564
- const guacSettings = {
565
- hostname: opts.host,
566
- port: String(opts.port ?? 3389),
567
- 'ignore-cert': true,
550
+ if (sessionId) {
551
+ this._log(`VNC session closed: ${sessionId}`);
552
+ this._vncSessions.get(sessionId)?.stop();
553
+ this._vncSessions.delete(sessionId);
554
+ }
555
+ return;
556
+ }
557
+ // ── RDP frames from pixel-office ─────────────────────────────────────────
558
+ if (msgType === 'rdp_session') {
559
+ if (!this._rdpEnabled) {
560
+ this._log('rdp_session ignored — RDP not enabled');
561
+ return;
562
+ }
563
+ const action = msg['action'];
564
+ if (action === 'start') {
565
+ const sessionId = msg['sessionId'];
566
+ const opts = {
567
+ // Host/port come ONLY from local settings never from the WS frame.
568
+ // A frame-supplied host/port would let a remote party open an outbound
569
+ // RDP bridge to an arbitrary target (SSRF / internal-network pivot).
570
+ // Default to loopback (xrdp runs on the same machine as the extension).
571
+ host: this._rdpSettings.host || '127.0.0.1',
572
+ port: this._rdpSettings.port ?? 3389,
573
+ // credentials never sent from server — always use local settings
574
+ username: this._rdpSettings.username || msg['username'],
575
+ password: this._rdpSettings.password || msg['password'],
576
+ domain: this._rdpSettings.domain || msg['domain'],
577
+ width: msg['width'] ? Number(msg['width']) : undefined,
578
+ height: msg['height'] ? Number(msg['height']) : undefined,
579
+ colorDepth: msg['colorDepth'] ? Number(msg['colorDepth']) : undefined,
568
580
  };
569
- if (opts.username) {
570
- guacSettings['username'] = opts.username;
571
- }
572
- if (opts.password) {
573
- guacSettings['password'] = opts.password;
574
- }
575
- if (opts.domain) {
576
- guacSettings['domain'] = opts.domain;
577
- }
578
- if (opts.width) {
579
- guacSettings['width'] = opts.width;
580
- }
581
- if (opts.height) {
582
- guacSettings['height'] = opts.height;
581
+ this._log(`RDP session start: ${sessionId} → ${opts.host}:${opts.port ?? 3389}`);
582
+ // Send Guacamole token to browser so it can connect via guacamole-lite
583
+ // (guacd + guacamole-lite must be running on the same host as xrdp, port 4567)
584
+ if (opts.username || opts.password) {
585
+ const guacSettings = {
586
+ hostname: opts.host,
587
+ port: String(opts.port ?? 3389),
588
+ 'ignore-cert': true,
589
+ };
590
+ if (opts.username) {
591
+ guacSettings['username'] = opts.username;
592
+ }
593
+ if (opts.password) {
594
+ guacSettings['password'] = opts.password;
595
+ }
596
+ if (opts.domain) {
597
+ guacSettings['domain'] = opts.domain;
598
+ }
599
+ if (opts.width) {
600
+ guacSettings['width'] = opts.width;
601
+ }
602
+ if (opts.height) {
603
+ guacSettings['height'] = opts.height;
604
+ }
605
+ guacSettings['color-depth'] = opts.colorDepth ?? 24;
606
+ const tokenPayload = JSON.stringify({ connection: { type: 'rdp', settings: guacSettings } });
607
+ const token = Buffer.from(tokenPayload).toString('base64');
608
+ // Use configured WSS URL (for HTTPS frontends), else fall back to plain WS on port 4567
609
+ const guacWsUrl = this._rdpSettings.guacWsUrl || `ws://${opts.host}:4567`;
610
+ this.sendFrame({
611
+ type: 'rdp_guac_token',
612
+ sessionId,
613
+ wsUrl: guacWsUrl,
614
+ token,
615
+ width: opts.width ?? 1280,
616
+ height: opts.height ?? 800,
617
+ });
618
+ this._log(`RDP guac token sent for session ${sessionId} → ${guacWsUrl}`);
583
619
  }
584
- guacSettings['color-depth'] = opts.colorDepth ?? 24;
585
- const tokenPayload = JSON.stringify({ connection: { type: 'rdp', settings: guacSettings } });
586
- const token = Buffer.from(tokenPayload).toString('base64');
587
- // Use configured WSS URL (for HTTPS frontends), else fall back to plain WS on port 4567
588
- const guacWsUrl = this._rdpSettings.guacWsUrl || `ws://${opts.host}:4567`;
589
- this.sendFrame({
590
- type: 'rdp_guac_token',
591
- sessionId,
592
- wsUrl: guacWsUrl,
593
- token,
594
- width: opts.width ?? 1280,
595
- height: opts.height ?? 800,
620
+ const session = new rdp_1.RdpSession(sessionId, (frame) => this.sendFrame(frame), (msg) => this._log(msg));
621
+ this._rdpSessions.set(sessionId, session);
622
+ session.start(opts).catch((err) => {
623
+ this._log(`RDP session ${sessionId} failed to start: ${err.message}`);
624
+ this._rdpSessions.delete(sessionId);
625
+ this.sendFrame({ type: 'rdp_close', sessionId, reason: err.message });
596
626
  });
597
- this._log(`RDP guac token sent for session ${sessionId} → ${guacWsUrl}`);
598
627
  }
599
- const session = new rdp_1.RdpSession(sessionId, (frame) => this.sendFrame(frame), (msg) => this._log(msg));
600
- this._rdpSessions.set(sessionId, session);
601
- session.start(opts).catch((err) => {
602
- this._log(`RDP session ${sessionId} failed to start: ${err.message}`);
603
- this._rdpSessions.delete(sessionId);
604
- this.sendFrame({ type: 'rdp_close', sessionId, reason: err.message });
605
- });
606
- }
607
- return;
608
- }
609
- if (msgType === 'rdp_input') {
610
- const sessionId = msg['sessionId'];
611
- const event = msg['event'];
612
- if (sessionId && event) {
613
- this._rdpSessions.get(sessionId)?.handleInput(event);
614
- }
615
- return;
616
- }
617
- if (msgType === 'rdp_close') {
618
- const sessionId = msg['sessionId'];
619
- if (sessionId) {
620
- this._log(`RDP session closed: ${sessionId}`);
621
- this._rdpSessions.get(sessionId)?.stop();
622
- this._rdpSessions.delete(sessionId);
623
- }
624
- return;
625
- }
626
- // ── A2A task frame ────────────────────────────────────────────────────────
627
- // A2A task frame: { task: { id, contextId, status: { state }, metadata: { event, task, parts } } }
628
- if (msg['task']) {
629
- const t = msg['task'];
630
- const state = t['status']?.['state'];
631
- if (state !== 'TASK_STATE_SUBMITTED') {
632
628
  return;
633
629
  }
634
- // Deduplicate by task ID so the same delivery isn't re-processed on reconnect,
635
- // but a new task with identical text is still accepted.
636
- const taskId = t['id'];
637
- if (taskId && this._seenTaskIds.has(taskId)) {
638
- this._log(`WS task already processed (id=${taskId}), skipping`);
630
+ if (msgType === 'rdp_input') {
631
+ const sessionId = msg['sessionId'];
632
+ const event = msg['event'];
633
+ if (sessionId && event) {
634
+ this._rdpSessions.get(sessionId)?.handleInput(event);
635
+ }
639
636
  return;
640
637
  }
641
- // NOTE: the delivery is marked seen only AFTER it has been durably handled
642
- // (TODO append resolved / steer or command dispatched) — see below. Marking
643
- // it here at parse time meant a transient append failure permanently
644
- // dropped the user's message because the server's reconnect replay was
645
- // then skipped by the dedup check above.
646
- const meta = t['metadata'] ?? {};
647
- // ── Instant / steer message ────────────────────────────────────────────
648
- // Delivered live to the running turn (mid-turn injection), NOT appended to
649
- // TODO. Same A2A task-frame shape as a user_message but event='steer'.
650
- if (meta['event'] === 'steer' || meta['event'] === 'instant') {
651
- const steerObj = meta['task'];
652
- let steerText = typeof steerObj?.['text'] === 'string' ? steerObj['text'] : '';
653
- if (!steerText) {
654
- const parts = meta['parts'];
655
- if (parts) {
656
- const texts = parts
657
- .filter(p => p['kind'] === 'text' && typeof p['text'] === 'string')
658
- .map(p => p['text']);
659
- steerText = texts.join(' ');
660
- }
638
+ if (msgType === 'rdp_close') {
639
+ const sessionId = msg['sessionId'];
640
+ if (sessionId) {
641
+ this._log(`RDP session closed: ${sessionId}`);
642
+ this._rdpSessions.get(sessionId)?.stop();
643
+ this._rdpSessions.delete(sessionId);
661
644
  }
662
- steerText = steerText.replace(/\r\n|\r|\n/g, ' ').trim();
663
- if (!steerText) {
645
+ return;
646
+ }
647
+ // ── A2A task frame ────────────────────────────────────────────────────────
648
+ // A2A task frame: { task: { id, contextId, status: { state }, metadata: { event, task, parts } } }
649
+ if (msg['task']) {
650
+ const t = msg['task'];
651
+ const state = t['status']?.['state'];
652
+ if (state !== 'TASK_STATE_SUBMITTED') {
664
653
  return;
665
654
  }
666
- this._log(`WS steer received: "${steerText}"`);
667
- if (taskId) {
668
- this._markSeenDelivery(taskId);
655
+ // Deduplicate by task ID so the same delivery isn't re-processed on reconnect,
656
+ // but a new task with identical text is still accepted.
657
+ const taskId = t['id'];
658
+ if (taskId && this._seenTaskIds.has(taskId)) {
659
+ this._log(`WS task already processed (id=${taskId}), skipping`);
660
+ return;
669
661
  }
670
- this._onSteer?.(steerText);
671
- return;
672
- }
673
- if (meta['event'] !== 'user_message') {
674
- return;
675
- }
676
- const taskObj = meta['task'];
677
- let taskText = typeof taskObj?.['text'] === 'string' ? taskObj['text'] : '';
678
- // Handle A2A parts extract text parts and save file parts as attachments
679
- // Pre-generate task ID so all attachments share the same prefix
680
- const wsTaskId = (0, todo_1.shortId)();
681
- const rawParts = meta['parts'];
682
- const textParts = [];
683
- const attRefs = [];
684
- if (rawParts) {
685
- for (const part of rawParts) {
686
- if (part['kind'] === 'text') {
687
- const t = part['text'] ?? '';
688
- if (t) {
689
- textParts.push(t);
662
+ // NOTE: the delivery is marked seen only AFTER it has been durably handled
663
+ // (TODO append resolved / steer or command dispatched) — see below. Marking
664
+ // it here at parse time meant a transient append failure permanently
665
+ // dropped the user's message because the server's reconnect replay was
666
+ // then skipped by the dedup check above.
667
+ const meta = t['metadata'] ?? {};
668
+ // ── Instant / steer message ────────────────────────────────────────────
669
+ // Delivered live to the running turn (mid-turn injection), NOT appended to
670
+ // TODO. Same A2A task-frame shape as a user_message but event='steer'.
671
+ if (meta['event'] === 'steer' || meta['event'] === 'instant') {
672
+ const steerObj = meta['task'];
673
+ let steerText = typeof steerObj?.['text'] === 'string' ? steerObj['text'] : '';
674
+ if (!steerText) {
675
+ const parts = meta['parts'];
676
+ if (parts) {
677
+ const texts = parts
678
+ .filter(p => p['kind'] === 'text' && typeof p['text'] === 'string')
679
+ .map(p => p['text']);
680
+ steerText = texts.join(' ');
690
681
  }
691
682
  }
692
- else if (part['kind'] === 'file' && this._workspaceRoot) {
693
- const file = part['file'];
694
- if (file) {
695
- const name = file['name'] ?? 'attachment';
696
- const bytesB64 = file['bytes'];
697
- if (bytesB64) {
698
- const buf = Buffer.from(bytesB64, 'base64');
699
- const rel = (0, messageBuilder_1.saveAttachment)(this._workspaceRoot, name, buf, wsTaskId);
700
- attRefs.push(rel);
683
+ steerText = steerText.replace(/\r\n|\r|\n/g, ' ').trim();
684
+ if (!steerText) {
685
+ return;
686
+ }
687
+ this._log(`WS steer received: "${steerText}"`);
688
+ // At-least-once (mirror the user_message path below): mark the delivery
689
+ // seen only AFTER the steer is durably handled — mid-turn injection or a
690
+ // successful TODO append. On failure we leave it unseen so the server's
691
+ // reconnect replay re-delivers it instead of silently dropping it.
692
+ this._onSteer?.(steerText, () => { if (taskId) {
693
+ this._markSeenDelivery(taskId);
694
+ } });
695
+ return;
696
+ }
697
+ if (meta['event'] !== 'user_message') {
698
+ return;
699
+ }
700
+ const taskObj = meta['task'];
701
+ let taskText = typeof taskObj?.['text'] === 'string' ? taskObj['text'] : '';
702
+ // Handle A2A parts — extract text parts and save file parts as attachments
703
+ // Pre-generate task ID so all attachments share the same prefix
704
+ const wsTaskId = (0, todo_1.shortId)();
705
+ const rawParts = meta['parts'];
706
+ const textParts = [];
707
+ const attRefs = [];
708
+ if (rawParts) {
709
+ for (const part of rawParts) {
710
+ if (part['kind'] === 'text') {
711
+ const t = part['text'] ?? '';
712
+ if (t) {
713
+ textParts.push(t);
701
714
  }
702
- else if (typeof file['uri'] === 'string') {
703
- attRefs.push(file['uri']);
715
+ }
716
+ else if (part['kind'] === 'file' && this._workspaceRoot) {
717
+ const file = part['file'];
718
+ if (file) {
719
+ const name = file['name'] ?? 'attachment';
720
+ const bytesB64 = file['bytes'];
721
+ if (bytesB64) {
722
+ const buf = Buffer.from(bytesB64, 'base64');
723
+ const rel = (0, messageBuilder_1.saveAttachment)(this._workspaceRoot, name, buf, wsTaskId);
724
+ attRefs.push(rel);
725
+ }
726
+ else if (typeof file['uri'] === 'string') {
727
+ attRefs.push(file['uri']);
728
+ }
704
729
  }
705
730
  }
706
731
  }
707
732
  }
708
- }
709
- // Use parts text only as fallback when task.text is absent
710
- if (!taskText && textParts.length > 0) {
711
- taskText = textParts.join(' ');
712
- }
713
- if (!taskText) {
714
- return;
715
- }
716
- // Collapse newlines so the entire message becomes a single TODO.md line
717
- taskText = taskText.replace(/\r\n|\r|\n/g, ' ').trim();
718
- const fullText = attRefs.length > 0
719
- ? taskText + ' ' + attRefs.map(p => `[attachment: ${p}]`).join(' ')
720
- : taskText;
721
- this._log(`WS task received: "${taskText}"${attRefs.length > 0 ? ` (+${attRefs.length} attachment(s))` : ''}`);
722
- // Divert ONLY exact known control commands (/restart, /clear, /retry, …).
723
- // Any other slash-prefixed text ("/login is broken", "/etc/nginx ...") is
724
- // an ordinary task and must still be queued — never silently discarded.
725
- if ((0, commands_1.isKnownSlashCommand)(fullText)) {
726
- if (taskId) {
727
- this._markSeenDelivery(taskId);
733
+ // Use parts text only as fallback when task.text is absent
734
+ if (!taskText && textParts.length > 0) {
735
+ taskText = textParts.join(' ');
728
736
  }
729
- this._onCommand?.(fullText);
730
- return;
731
- }
732
- if (!this._todoPath) {
733
- this._log('WS failed to append task to TODO.md: todoPath is empty');
734
- return;
735
- }
736
- todoWriteManager_1.todoWriter.append(this._todoPath, fullText, wsTaskId)
737
- .then(() => {
738
- // At-least-once: only record the delivery as seen AFTER the durable
739
- // TODO append succeeds. On failure we leave it unseen so the server's
740
- // reconnect replay re-delivers it instead of dropping it.
741
- if (taskId) {
742
- this._markSeenDelivery(taskId);
737
+ if (!taskText) {
738
+ return;
739
+ }
740
+ // Collapse newlines so the entire message becomes a single TODO.md line
741
+ taskText = taskText.replace(/\r\n|\r|\n/g, ' ').trim();
742
+ const fullText = attRefs.length > 0
743
+ ? taskText + ' ' + attRefs.map(p => `[attachment: ${p}]`).join(' ')
744
+ : taskText;
745
+ this._log(`WS task received: "${taskText}"${attRefs.length > 0 ? ` (+${attRefs.length} attachment(s))` : ''}`);
746
+ // Divert ONLY exact known control commands (/restart, /clear, /retry, …).
747
+ // Any other slash-prefixed text ("/login is broken", "/etc/nginx ...") is
748
+ // an ordinary task and must still be queued — never silently discarded.
749
+ if ((0, commands_1.isKnownSlashCommand)(fullText)) {
750
+ if (taskId) {
751
+ this._markSeenDelivery(taskId);
752
+ }
753
+ this._onCommand?.(fullText);
754
+ return;
743
755
  }
744
- this._onTaskAppend?.();
745
- })
746
- .catch(err => { this._log(`WS failed to append task to TODO.md: ${err}`); });
756
+ if (!this._todoPath) {
757
+ this._log('WS failed to append task to TODO.md: todoPath is empty');
758
+ return;
759
+ }
760
+ todoWriteManager_1.todoWriter.append(this._todoPath, fullText, wsTaskId)
761
+ .then(() => {
762
+ // At-least-once: only record the delivery as seen AFTER the durable
763
+ // TODO append succeeds. On failure we leave it unseen so the server's
764
+ // reconnect replay re-delivers it instead of dropping it.
765
+ if (taskId) {
766
+ this._markSeenDelivery(taskId);
767
+ }
768
+ this._onTaskAppend?.();
769
+ })
770
+ .catch(err => { this._log(`WS failed to append task to TODO.md: ${err}`); });
771
+ }
772
+ }
773
+ catch (err) {
774
+ // A bad frame must never take down the socket/process — see the try above.
775
+ this._log(`WS frame dispatch failed (type=${msgType ?? 'unknown'}): ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
747
776
  }
748
777
  }
749
778
  /** Handle a file-browser request from the server (originated by the browser UI). */
@@ -920,6 +949,18 @@ class WebSocketPoller {
920
949
  respond(false, undefined, 'Git not enabled');
921
950
  return;
922
951
  }
952
+ // Containment guard — mirror _handleFbRequest. Every path-bearing arg
953
+ // (filePath) must resolve inside the workspace root both lexically AND after
954
+ // resolving symlinks; otherwise a git_request could read arbitrary host
955
+ // files (e.g. path '../../.claude/.credentials.json' via the readFileSync
956
+ // fallback in getDiff, or leak them through `git diff -- <path>`). An empty
957
+ // filePath means "whole repo" and is permitted (allowRoot).
958
+ if (filePath !== undefined && filePath !== '') {
959
+ if ((0, pathSafe_1.resolveWithinRoot)(root, filePath, true) === null) {
960
+ respond(false, undefined, 'Path outside workspace');
961
+ return;
962
+ }
963
+ }
923
964
  (async () => {
924
965
  try {
925
966
  switch (action) {