@ccpocket/bridge 1.74.0 → 1.76.0

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/websocket.js CHANGED
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { execFile, execFileSync } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
4
  import { lstat, readFile, readlink, realpath, stat, unlink } from "node:fs/promises";
5
- import { resolve, extname } from "node:path";
5
+ import { resolve, extname, posix, win32 } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { WebSocketServer, WebSocket } from "ws";
8
8
  import { SessionManager, MAX_HISTORY_PER_SESSION, } from "./session.js";
@@ -11,6 +11,7 @@ import { codexErrorMessage, CodexRpcError, CodexProcess, } from "./codex-process
11
11
  import { stopManagedCodexAppServers } from "./codex-transport.js";
12
12
  import { parseClientMessage, } from "./parser.js";
13
13
  import { getAllRecentSessions, getCodexSessionHistory, getSessionHistory, codexUserTurnUuid, codexThreadToSessionHistory, findSessionsByClaudeIds, extractMessageImages, getClaudeSessionName, getCodexSessionIndexMetadata, loadCodexSessionNames, renameClaudeSession, renameCodexSession, saveCodexSessionProfile, } from "./sessions-index.js";
14
+ import { isSafeUploadFileName, UploadStoreError, } from "./upload-store.js";
14
15
  import { formatResumePerformanceLog, summarizeResumeHistory, } from "./resume-metrics.js";
15
16
  import { ArchiveStore } from "./archive-store.js";
16
17
  import { WorktreeStore } from "./worktree-store.js";
@@ -394,6 +395,44 @@ function normalizeNonNegativeLimit(value, fallback) {
394
395
  ? value
395
396
  : fallback;
396
397
  }
398
+ export function downloadMimeType(filePath) {
399
+ const extension = extname(filePath).toLowerCase();
400
+ const mimeTypes = {
401
+ ".aac": "audio/aac",
402
+ ".avi": "video/x-msvideo",
403
+ ".bmp": "image/bmp",
404
+ ".csv": "text/csv",
405
+ ".doc": "application/msword",
406
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
407
+ ".gif": "image/gif",
408
+ ".gz": "application/gzip",
409
+ ".html": "text/html",
410
+ ".jpeg": "image/jpeg",
411
+ ".jpg": "image/jpeg",
412
+ ".json": "application/json",
413
+ ".m4a": "audio/mp4",
414
+ ".md": "text/markdown",
415
+ ".mov": "video/quicktime",
416
+ ".mp3": "audio/mpeg",
417
+ ".mp4": "video/mp4",
418
+ ".ogg": "audio/ogg",
419
+ ".pdf": "application/pdf",
420
+ ".png": "image/png",
421
+ ".ppt": "application/vnd.ms-powerpoint",
422
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
423
+ ".svg": "image/svg+xml",
424
+ ".tar": "application/x-tar",
425
+ ".txt": "text/plain",
426
+ ".wav": "audio/wav",
427
+ ".webm": "video/webm",
428
+ ".webp": "image/webp",
429
+ ".xls": "application/vnd.ms-excel",
430
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
431
+ ".xml": "application/xml",
432
+ ".zip": "application/zip",
433
+ };
434
+ return mimeTypes[extension] ?? "application/octet-stream";
435
+ }
397
436
  function codexThreadToRecentSession(thread, indexed) {
398
437
  // thread/list only exposes a single preview blob; prefer the real
399
438
  // first/last/summary texts parsed from the rollout file so display-mode
@@ -444,6 +483,8 @@ export class BridgeWebSocketServer {
444
483
  static CONNECT_METADATA_REFRESH_COOLDOWN_MS = 5 * 60 * 1000;
445
484
  static DEFAULT_FILE_LIST_MAX_ENTRIES = 5000;
446
485
  static DEFAULT_FILE_LIST_MAX_BYTES = 512 * 1024;
486
+ static DEFAULT_FILE_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024;
487
+ static DEFAULT_FILE_UPLOAD_MAX_BYTES = 512 * 1024 * 1024;
447
488
  static DEFAULT_DELTA_BATCH_MS = 100;
448
489
  static DEFAULT_DELTA_BATCH_MAX_CHARS = 4096;
449
490
  wss;
@@ -452,6 +493,7 @@ export class BridgeWebSocketServer {
452
493
  allowedDirs;
453
494
  imageStore;
454
495
  mediaStore;
496
+ uploadStore;
455
497
  galleryStore;
456
498
  projectHistory;
457
499
  debugTraceStore;
@@ -495,6 +537,8 @@ export class BridgeWebSocketServer {
495
537
  failSetSandboxMode = envFlagEnabled("BRIDGE_FAIL_SET_SANDBOX_MODE");
496
538
  fileListMaxEntries;
497
539
  fileListMaxBytes;
540
+ fileDownloadMaxBytes;
541
+ fileUploadMaxBytes;
498
542
  deltaBatchMs;
499
543
  deltaBatchMaxChars;
500
544
  deltaBatches = new Map();
@@ -503,11 +547,12 @@ export class BridgeWebSocketServer {
503
547
  pendingClaudeResumeInputs = new WeakMap();
504
548
  resumeOperations = new Map();
505
549
  constructor(options) {
506
- const { server, apiKey, allowedDirs, imageStore, mediaStore, galleryStore, projectHistory, debugTraceStore, recordingStore, firebaseAuth, promptHistoryBackup, promptHistoryStore, platform, fileListMaxEntries, fileListMaxBytes, deltaBatchMs, deltaBatchMaxChars, } = options;
550
+ const { server, apiKey, allowedDirs, imageStore, mediaStore, uploadStore, galleryStore, projectHistory, debugTraceStore, recordingStore, firebaseAuth, promptHistoryBackup, promptHistoryStore, platform, fileListMaxEntries, fileListMaxBytes, fileDownloadMaxBytes, fileUploadMaxBytes, deltaBatchMs, deltaBatchMaxChars, } = options;
507
551
  this.apiKey = apiKey ?? null;
508
552
  this.allowedDirs = allowedDirs ?? [];
509
553
  this.imageStore = imageStore ?? null;
510
554
  this.mediaStore = mediaStore ?? null;
555
+ this.uploadStore = uploadStore ?? null;
511
556
  this.galleryStore = galleryStore ?? null;
512
557
  this.projectHistory = projectHistory ?? null;
513
558
  this.debugTraceStore = debugTraceStore ?? new DebugTraceStore();
@@ -519,6 +564,8 @@ export class BridgeWebSocketServer {
519
564
  this.platform = platform ?? process.platform;
520
565
  this.fileListMaxEntries = normalizePositiveLimit(fileListMaxEntries, positiveEnvInt("BRIDGE_FILE_LIST_MAX_ENTRIES", BridgeWebSocketServer.DEFAULT_FILE_LIST_MAX_ENTRIES));
521
566
  this.fileListMaxBytes = normalizePositiveLimit(fileListMaxBytes, positiveEnvInt("BRIDGE_FILE_LIST_MAX_BYTES", BridgeWebSocketServer.DEFAULT_FILE_LIST_MAX_BYTES));
567
+ this.fileDownloadMaxBytes = normalizePositiveLimit(fileDownloadMaxBytes, positiveEnvInt("BRIDGE_FILE_DOWNLOAD_MAX_SIZE_MB", BridgeWebSocketServer.DEFAULT_FILE_DOWNLOAD_MAX_BYTES / 1024 / 1024) * 1024 * 1024);
568
+ this.fileUploadMaxBytes = normalizePositiveLimit(fileUploadMaxBytes, positiveEnvInt("BRIDGE_FILE_UPLOAD_MAX_SIZE_MB", BridgeWebSocketServer.DEFAULT_FILE_UPLOAD_MAX_BYTES / 1024 / 1024) * 1024 * 1024);
522
569
  this.deltaBatchMs = normalizeNonNegativeLimit(deltaBatchMs, nonNegativeEnvInt("BRIDGE_DELTA_BATCH_MS", BridgeWebSocketServer.DEFAULT_DELTA_BATCH_MS));
523
570
  this.deltaBatchMaxChars = normalizePositiveLimit(deltaBatchMaxChars, positiveEnvInt("BRIDGE_DELTA_BATCH_MAX_CHARS", BridgeWebSocketServer.DEFAULT_DELTA_BATCH_MAX_CHARS));
524
571
  this.archiveStore = new ArchiveStore();
@@ -595,6 +642,155 @@ export class BridgeWebSocketServer {
595
642
  }
596
643
  return false;
597
644
  }
645
+ sendFileDownloadError(ws, request, errorCode, message) {
646
+ this.send(ws, {
647
+ type: "error",
648
+ errorCode,
649
+ message,
650
+ path: request.filePath,
651
+ requestId: request.requestId,
652
+ });
653
+ }
654
+ async prepareFileDownload(ws, request) {
655
+ const pathApi = this.platform === "win32" ? win32 : posix;
656
+ const projectPath = pathApi.resolve(request.projectPath);
657
+ if (pathApi.isAbsolute(request.filePath)) {
658
+ this.sendFileDownloadError(ws, request, "file_download_not_allowed", "Only project-relative file paths can be downloaded.");
659
+ return;
660
+ }
661
+ const requestedPath = pathApi.resolve(projectPath, request.filePath);
662
+ if (!this.isPathAllowed(projectPath) ||
663
+ !isPathWithinAllowedDirectory(requestedPath, projectPath, this.platform)) {
664
+ this.sendFileDownloadError(ws, request, "file_download_not_allowed", "The requested file is outside the current project.");
665
+ return;
666
+ }
667
+ let canonicalProjectPath;
668
+ try {
669
+ canonicalProjectPath = await realpath(projectPath);
670
+ const projectStat = await stat(canonicalProjectPath);
671
+ if (!projectStat.isDirectory())
672
+ throw new Error("not a directory");
673
+ }
674
+ catch {
675
+ this.sendFileDownloadError(ws, request, "file_download_not_allowed", "The current project is unavailable or not allowed.");
676
+ return;
677
+ }
678
+ let canonicalFilePath;
679
+ try {
680
+ canonicalFilePath = await realpath(requestedPath);
681
+ }
682
+ catch {
683
+ this.sendFileDownloadError(ws, request, "file_download_not_found", "File not found.");
684
+ return;
685
+ }
686
+ if (!isPathWithinAllowedDirectory(canonicalFilePath, canonicalProjectPath, this.platform) ||
687
+ !(await this.isCanonicalPathAllowed(canonicalFilePath))) {
688
+ this.sendFileDownloadError(ws, request, "file_download_not_allowed", "The requested file resolves outside the current project.");
689
+ return;
690
+ }
691
+ try {
692
+ const fileStat = await stat(canonicalFilePath);
693
+ if (!fileStat.isFile()) {
694
+ this.sendFileDownloadError(ws, request, "file_download_not_file", "Only regular files can be downloaded.");
695
+ return;
696
+ }
697
+ if (fileStat.size > this.fileDownloadMaxBytes) {
698
+ const maxSizeMb = Math.max(1, Math.ceil(this.fileDownloadMaxBytes / 1024 / 1024));
699
+ this.sendFileDownloadError(ws, request, "file_download_too_large", `File is too large to download. Maximum size is ${maxSizeMb} MB.`);
700
+ return;
701
+ }
702
+ if (!this.mediaStore) {
703
+ this.sendFileDownloadError(ws, request, "file_download_unavailable", "File downloads are unavailable on this Bridge.");
704
+ return;
705
+ }
706
+ const fileName = pathApi.basename(requestedPath);
707
+ const mimeType = downloadMimeType(fileName);
708
+ const ref = await this.mediaStore.register(canonicalFilePath, mimeType, fileStat.size, fileName);
709
+ this.send(ws, {
710
+ type: "file_download_ready",
711
+ requestId: request.requestId,
712
+ filePath: request.filePath,
713
+ fileName,
714
+ mimeType: ref.mimeType,
715
+ sizeBytes: ref.sizeBytes,
716
+ downloadUrl: ref.url,
717
+ });
718
+ }
719
+ catch {
720
+ this.sendFileDownloadError(ws, request, "file_download_failed", "Unable to prepare the file download.");
721
+ }
722
+ }
723
+ sendFileUploadError(ws, requestId, errorCode, message, path) {
724
+ this.send(ws, { type: "error", errorCode, message, requestId, path });
725
+ }
726
+ async prepareFileUpload(ws, request) {
727
+ const pathApi = this.platform === "win32" ? win32 : posix;
728
+ const projectPath = pathApi.resolve(request.projectPath);
729
+ if (!this.uploadStore ||
730
+ pathApi.isAbsolute(request.directoryPath) ||
731
+ !isSafeUploadFileName(request.fileName)) {
732
+ this.sendFileUploadError(ws, request.requestId, !this.uploadStore ? "file_upload_unavailable" : "file_upload_not_allowed", !this.uploadStore
733
+ ? "File uploads are unavailable on this Bridge."
734
+ : "The upload destination or file name is not allowed.", request.directoryPath);
735
+ return;
736
+ }
737
+ if (request.sizeBytes > this.fileUploadMaxBytes) {
738
+ this.sendFileUploadError(ws, request.requestId, "file_upload_too_large", `File is too large to upload. Maximum size is ${Math.ceil(this.fileUploadMaxBytes / 1024 / 1024)} MB.`, request.fileName);
739
+ return;
740
+ }
741
+ const requestedDirectory = pathApi.resolve(projectPath, request.directoryPath || ".");
742
+ if (!this.isPathAllowed(projectPath) ||
743
+ !isPathWithinAllowedDirectory(requestedDirectory, projectPath, this.platform)) {
744
+ this.sendFileUploadError(ws, request.requestId, "file_upload_not_allowed", "The upload destination is outside the current project.", request.directoryPath);
745
+ return;
746
+ }
747
+ try {
748
+ const canonicalProject = await realpath(projectPath);
749
+ const canonicalDirectory = await realpath(requestedDirectory);
750
+ const directoryStat = await stat(canonicalDirectory);
751
+ if (!directoryStat.isDirectory() ||
752
+ !isPathWithinAllowedDirectory(canonicalDirectory, canonicalProject, this.platform) ||
753
+ !(await this.isCanonicalPathAllowed(canonicalDirectory))) {
754
+ throw new UploadStoreError("file_upload_directory_changed", "The upload destination is not allowed.");
755
+ }
756
+ const ref = await this.uploadStore.register({
757
+ directoryPath: canonicalDirectory,
758
+ relativeDirectoryPath: pathApi
759
+ .relative(projectPath, requestedDirectory)
760
+ .split(pathApi.sep)
761
+ .join("/"),
762
+ fileName: request.fileName,
763
+ sizeBytes: request.sizeBytes,
764
+ conflictPolicy: request.conflictPolicy,
765
+ });
766
+ this.send(ws, {
767
+ type: "file_upload_ready",
768
+ requestId: request.requestId,
769
+ fileName: request.fileName,
770
+ sizeBytes: request.sizeBytes,
771
+ uploadUrl: ref.url,
772
+ uploadToken: ref.token,
773
+ });
774
+ }
775
+ catch (error) {
776
+ const known = error instanceof UploadStoreError ? error : null;
777
+ this.sendFileUploadError(ws, request.requestId, known?.code ?? "file_upload_directory_not_found", known?.message ?? "The upload destination was not found or is not allowed.", request.directoryPath);
778
+ }
779
+ }
780
+ async finalizeFileUpload(ws, request) {
781
+ if (!this.uploadStore) {
782
+ this.sendFileUploadError(ws, request.requestId, "file_upload_unavailable", "File uploads are unavailable on this Bridge.");
783
+ return;
784
+ }
785
+ try {
786
+ const result = await this.uploadStore.finalize(request.uploadToken, request.sha256);
787
+ this.send(ws, { type: "file_upload_complete", requestId: request.requestId, ...result });
788
+ }
789
+ catch (error) {
790
+ const known = error instanceof UploadStoreError ? error : null;
791
+ this.sendFileUploadError(ws, request.requestId, known?.code ?? "file_upload_failed", known?.message ?? "Unable to finish the file upload.");
792
+ }
793
+ }
598
794
  /** Build a user-friendly error for disallowed project paths. */
599
795
  buildPathNotAllowedError(projectPath) {
600
796
  return {
@@ -4013,6 +4209,23 @@ export class BridgeWebSocketServer {
4013
4209
  }
4014
4210
  break;
4015
4211
  }
4212
+ case "prepare_file_download": {
4213
+ void this.prepareFileDownload(ws, msg);
4214
+ break;
4215
+ }
4216
+ case "prepare_file_upload": {
4217
+ void this.prepareFileUpload(ws, msg);
4218
+ break;
4219
+ }
4220
+ case "finalize_file_upload": {
4221
+ void this.finalizeFileUpload(ws, msg);
4222
+ break;
4223
+ }
4224
+ case "cancel_file_upload": {
4225
+ if (this.uploadStore)
4226
+ void this.uploadStore.cancel(msg.uploadToken);
4227
+ break;
4228
+ }
4016
4229
  case "read_file":
4017
4230
  case "read_media_file": {
4018
4231
  const absPath = resolve(msg.projectPath, msg.filePath);