autodev-cli 1.4.32 → 1.4.34

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