autodev-cli 1.4.33 → 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.
- package/out/agentBackup/export.js +69 -2
- package/out/agentBackup/export.js.map +1 -1
- package/out/agentBackup/import.js +1 -7
- package/out/agentBackup/import.js.map +1 -1
- package/out/agentBackup/layout.d.ts +9 -0
- package/out/agentBackup/layout.js +10 -1
- package/out/agentBackup/layout.js.map +1 -1
- package/out/commands/start.js +11 -0
- package/out/commands/start.js.map +1 -1
- package/out/git/gitService.js +9 -1
- package/out/git/gitService.js.map +1 -1
- package/out/taskLoop.d.ts +7 -0
- package/out/taskLoop.js +106 -8
- package/out/taskLoop.js.map +1 -1
- package/out/webSocketPoller.d.ts +1 -1
- package/out/webSocketPoller.js +303 -269
- package/out/webSocketPoller.js.map +1 -1
- package/out/webhookPoller.d.ts +1 -1
- package/out/webhookPoller.js.map +1 -1
- package/package.json +1 -1
package/out/webSocketPoller.js
CHANGED
|
@@ -441,309 +441,331 @@ class WebSocketPoller {
|
|
|
441
441
|
return;
|
|
442
442
|
}
|
|
443
443
|
const msgType = msg['type'];
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
}
|
|
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();
|
|
459
451
|
return;
|
|
460
452
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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;
|
|
466
466
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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);
|
|
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;
|
|
486
474
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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;
|
|
494
482
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
this.
|
|
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);
|
|
500
488
|
return;
|
|
501
489
|
}
|
|
502
|
-
|
|
503
|
-
if (
|
|
504
|
-
const
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
this.sendFrame({ type: 'vnc_close', sessionId, reason: err.message });
|
|
515
|
-
});
|
|
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;
|
|
516
502
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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;
|
|
524
510
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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;
|
|
533
532
|
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
+
}
|
|
540
539
|
return;
|
|
541
540
|
}
|
|
542
|
-
|
|
543
|
-
if (action === 'start') {
|
|
541
|
+
if (msgType === 'vnc_close') {
|
|
544
542
|
const sessionId = msg['sessionId'];
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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,
|
|
568
573
|
};
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
if (opts.password) {
|
|
573
|
-
guacSettings
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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}`);
|
|
583
612
|
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
type: 'rdp_guac_token',
|
|
591
|
-
sessionId,
|
|
592
|
-
wsUrl: guacWsUrl,
|
|
593
|
-
token,
|
|
594
|
-
width: opts.width ?? 1280,
|
|
595
|
-
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 });
|
|
596
619
|
});
|
|
597
|
-
this._log(`RDP guac token sent for session ${sessionId} → ${guacWsUrl}`);
|
|
598
620
|
}
|
|
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
621
|
return;
|
|
633
622
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
+
}
|
|
639
629
|
return;
|
|
640
630
|
}
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
}
|
|
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);
|
|
661
637
|
}
|
|
662
|
-
|
|
663
|
-
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
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') {
|
|
664
646
|
return;
|
|
665
647
|
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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)) {
|
|
652
|
+
this._log(`WS task already processed (id=${taskId}), skipping`);
|
|
653
|
+
return;
|
|
669
654
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
textParts.push(t);
|
|
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(' ');
|
|
690
674
|
}
|
|
691
675
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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;
|
|
689
|
+
}
|
|
690
|
+
if (meta['event'] !== 'user_message') {
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
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);
|
|
701
707
|
}
|
|
702
|
-
|
|
703
|
-
|
|
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
|
+
}
|
|
704
722
|
}
|
|
705
723
|
}
|
|
706
724
|
}
|
|
707
725
|
}
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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);
|
|
726
|
+
// Use parts text only as fallback when task.text is absent
|
|
727
|
+
if (!taskText && textParts.length > 0) {
|
|
728
|
+
taskText = textParts.join(' ');
|
|
728
729
|
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
.
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
if (
|
|
742
|
-
|
|
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;
|
|
743
748
|
}
|
|
744
|
-
this.
|
|
745
|
-
|
|
746
|
-
|
|
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}`); });
|
|
764
|
+
}
|
|
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)}`);
|
|
747
769
|
}
|
|
748
770
|
}
|
|
749
771
|
/** Handle a file-browser request from the server (originated by the browser UI). */
|
|
@@ -920,6 +942,18 @@ class WebSocketPoller {
|
|
|
920
942
|
respond(false, undefined, 'Git not enabled');
|
|
921
943
|
return;
|
|
922
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
|
+
}
|
|
923
957
|
(async () => {
|
|
924
958
|
try {
|
|
925
959
|
switch (action) {
|