cc-viewer 1.6.256 → 1.6.259

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/index.html CHANGED
@@ -19,7 +19,7 @@
19
19
  if (pick) document.documentElement.setAttribute('data-theme', pick);
20
20
  } catch {}
21
21
  </script>
22
- <script type="module" crossorigin src="/assets/index-aBYCRImE.js"></script>
22
+ <script type="module" crossorigin src="/assets/index-BGEXf37A.js"></script>
23
23
  <link rel="modulepreload" crossorigin href="/assets/vendor-antd-BeN8xqGk.js">
24
24
  <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-2nbmPewy.js">
25
25
  <link rel="modulepreload" crossorigin href="/assets/vendor-mdxeditor-C7DYEBoH.js">
package/findcc.js CHANGED
@@ -117,20 +117,23 @@ export function resolveCliPath() {
117
117
  * 返回 node_modules 中的 claude cli.js 路径
118
118
  */
119
119
  export function resolveNpmClaudePath() {
120
- // 1. 尝试 which/command -v 找到 npm 安装的 claude
121
- for (const cmd of [`which ${BINARY_NAME}`, `command -v ${BINARY_NAME}`]) {
120
+ // 1. 尝试 which/command -v 找到 npm 安装的 claude(Windows 上用 `where`,否则 POSIX 二选一)
121
+ const lookupCmds = process.platform === 'win32'
122
+ ? [`where ${BINARY_NAME}`]
123
+ : [`which ${BINARY_NAME}`, `command -v ${BINARY_NAME}`];
124
+ for (const cmd of lookupCmds) {
122
125
  try {
123
- const result = execSync(cmd, { encoding: 'utf-8', shell: true, env: process.env }).trim();
124
- // 排除 shell function 的输出(多行说明不是路径)
125
- if (result && !result.includes('\n') && existsSync(result)) {
126
+ // Windows `where` 输出可能多行 CRLF,取第一行 trim 即可
127
+ const rawOut = execSync(cmd, { encoding: 'utf-8', shell: true, env: process.env });
128
+ const result = rawOut.split(/\r?\n/)[0].trim();
129
+ if (result && existsSync(result)) {
126
130
  // 只接受 npm 安装的符号链接(解析后指向 node_modules)
127
131
  try {
128
132
  const real = realpathSync(result);
129
133
  if (real.includes('node_modules')) {
130
- // 找到 npm 版本,返回 cli.js 的路径
131
- // real 可能是 .../node_modules/@anthropic-ai/claude-code/bin/claude
132
- // 我们需要返回 .../node_modules/@anthropic-ai/claude-code/cli.js
133
- const match = real.match(/(.*node_modules\/@[^/]+\/[^/]+)\//);
134
+ // realpath Win 上是 backslash,统一 normalize 成 '/' 再匹配
135
+ const normReal = real.replace(/\\/g, '/');
136
+ const match = normReal.match(/(.*node_modules\/@[^/]+\/[^/]+)\//);
134
137
  if (match) {
135
138
  const packageDir = match[1];
136
139
  const cliPath = join(packageDir, CLI_ENTRY);
@@ -172,12 +175,15 @@ export function resolveNativePath() {
172
175
  const platformBin = findPlatformBinary(globalRoot);
173
176
  if (platformBin) return platformBin;
174
177
 
175
- // 2. 尝试 which/command -v(继承当前 process.env PATH
176
- for (const cmd of [`which ${BINARY_NAME}`, `command -v ${BINARY_NAME}`]) {
178
+ // 2. 尝试 which/command -v(继承当前 process.env PATH;Win 上用 `where`)
179
+ const lookupCmds = process.platform === 'win32'
180
+ ? [`where ${BINARY_NAME}`]
181
+ : [`which ${BINARY_NAME}`, `command -v ${BINARY_NAME}`];
182
+ for (const cmd of lookupCmds) {
177
183
  try {
178
- const result = execSync(cmd, { encoding: 'utf-8', shell: true, env: process.env }).trim();
179
- // 排除 shell function 的输出(多行说明不是路径)
180
- if (result && !result.includes('\n') && existsSync(result)) {
184
+ const rawOut = execSync(cmd, { encoding: 'utf-8', shell: true, env: process.env });
185
+ const result = rawOut.split(/\r?\n/)[0].trim();
186
+ if (result && existsSync(result)) {
181
187
  // 只排除 .js 文件(老版本 npm 分发的 cli.js,需要 node 运行,
182
188
  // 由 resolveNpmClaudePath 处理)。Claude Code 2.x+ 的 npm 包内
183
189
  // 直接打包了原生二进制(bin/claude.exe),应当作 native 处理。
package/interceptor.js CHANGED
@@ -7,7 +7,8 @@ const _ccvSkipArgs = ['--version', '-v', '--v', '--help', '-h', 'doctor', 'insta
7
7
  const _ccvSkip = _ccvSkipArgs.includes(process.argv[2]);
8
8
 
9
9
  import './lib/proxy-env.js';
10
- import { appendFileSync, mkdirSync, readFileSync, writeFileSync, statSync, renameSync, unlinkSync, existsSync, watchFile } from 'node:fs';
10
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync, statSync, unlinkSync, existsSync, watchFile } from 'node:fs';
11
+ import { renameSyncWithRetry } from './lib/file-api.js';
11
12
  import http from 'node:http';
12
13
  import https from 'node:https';
13
14
  import { homedir } from 'node:os';
@@ -221,7 +222,7 @@ function resolveResumeChoice(choice) {
221
222
  if (existsSync(tempFile)) {
222
223
  const sz = statSync(tempFile).size;
223
224
  if (sz > 0) {
224
- renameSync(tempFile, newPath);
225
+ renameSyncWithRetry(tempFile, newPath);
225
226
  } else {
226
227
  try { unlinkSync(tempFile); } catch { }
227
228
  }
@@ -873,11 +874,13 @@ export function setupInterceptor() {
873
874
  // 此处一次性 join — 流式累积期间唯一的物化点(错误路径除外)。
874
875
  const fullContent = streamedChunks.join('');
875
876
  try {
876
- const events = fullContent.split('\n\n')
877
+ // HTTP SSE 规范是 \r\n\r\n 分块,POSIX 上常被 normalize 成 \n\n
878
+ // 但 Windows 直接收到的就是 CRLF,硬切 '\n\n' 在 Win 上整块响应当一个事件解析失败。
879
+ const events = fullContent.split(/\r?\n\r?\n/)
877
880
  .filter(block => block.trim())
878
881
  .map(block => {
879
882
  // SSE 块可能包含多行: event: xxx\ndata: {...}
880
- const lines = block.split('\n');
883
+ const lines = block.split(/\r?\n/);
881
884
  const dataLine = lines.find(l => l.startsWith('data:'));
882
885
  if (dataLine) {
883
886
  // 处理 "data:" 或 "data: " 两种格式
package/lib/file-api.js CHANGED
@@ -2,8 +2,8 @@
2
2
  * File API business logic — extracted from server.js
3
3
  * Provides path validation, file read/write with security checks.
4
4
  */
5
- import { resolve, join } from 'node:path';
6
- import { realpathSync, existsSync, statSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { resolve, join, sep, isAbsolute } from 'node:path';
6
+ import { realpathSync, existsSync, statSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
7
7
 
8
8
  /**
9
9
  * Check whether targetPath is contained within the project root directory.
@@ -16,7 +16,7 @@ export function isPathContained(targetPath, root) {
16
16
  try {
17
17
  const resolvedRoot = realpathSync(resolve(root || process.env.CCV_PROJECT_DIR || process.cwd()));
18
18
  const real = realpathSync(resolve(targetPath));
19
- return real === resolvedRoot || real.startsWith(resolvedRoot + '/');
19
+ return real === resolvedRoot || real.startsWith(resolvedRoot + sep);
20
20
  } catch { return false; }
21
21
  }
22
22
 
@@ -40,14 +40,14 @@ export function resolveFilePath(cwd, reqPath, isEditorSession) {
40
40
  if (!reqPath) {
41
41
  throw new FileApiError('INVALID_PATH', 'Invalid path');
42
42
  }
43
- if (!isEditorSession && (reqPath.startsWith('/') || reqPath.includes('..'))) {
44
- const resolved = resolve(reqPath.startsWith('/') ? reqPath : join(cwd, reqPath));
43
+ if (!isEditorSession && (isAbsolute(reqPath) || reqPath.includes('..'))) {
44
+ const resolved = resolve(isAbsolute(reqPath) ? reqPath : join(cwd, reqPath));
45
45
  if (!isPathContained(resolved, cwd)) {
46
46
  throw new FileApiError('INVALID_PATH', 'Invalid path');
47
47
  }
48
48
  return resolve(resolved);
49
49
  }
50
- return resolve((isEditorSession && reqPath.startsWith('/')) ? reqPath : join(cwd, reqPath));
50
+ return resolve((isEditorSession && isAbsolute(reqPath)) ? reqPath : join(cwd, reqPath));
51
51
  }
52
52
 
53
53
  /**
@@ -64,7 +64,7 @@ export function readFileContent(cwd, reqPath, isEditorSession) {
64
64
 
65
65
  // For non-editor sessions with absolute / ".." paths that are within project dir,
66
66
  // return the relative path from project root
67
- if (!isEditorSession && (reqPath.startsWith('/') || reqPath.includes('..'))) {
67
+ if (!isEditorSession && (isAbsolute(reqPath) || reqPath.includes('..'))) {
68
68
  const resolved = resolve(reqPath);
69
69
  if (isPathContained(resolved, cwd)) {
70
70
  const root = realpathSync(resolve(cwd));
@@ -75,7 +75,7 @@ export function readFileContent(cwd, reqPath, isEditorSession) {
75
75
  throw new FileApiError('INVALID_PATH', 'Invalid path');
76
76
  }
77
77
 
78
- const targetFile = (isEditorSession && reqPath.startsWith('/')) ? reqPath : join(cwd, reqPath);
78
+ const targetFile = (isEditorSession && isAbsolute(reqPath)) ? reqPath : join(cwd, reqPath);
79
79
  return _readAndReturn(targetFile, reqPath);
80
80
  }
81
81
 
@@ -106,18 +106,45 @@ export function writeFileContent(cwd, reqPath, content, isEditorSession) {
106
106
  if (!reqPath) {
107
107
  throw new FileApiError('INVALID_PATH', 'Invalid path');
108
108
  }
109
- if (!isEditorSession && (reqPath.startsWith('/') || reqPath.includes('..'))) {
109
+ if (!isEditorSession && (isAbsolute(reqPath) || reqPath.includes('..'))) {
110
110
  throw new FileApiError('INVALID_PATH', 'Invalid path');
111
111
  }
112
112
  if (typeof content !== 'string') {
113
113
  throw new FileApiError('INVALID_CONTENT', 'Content must be a string');
114
114
  }
115
- const targetFile = (isEditorSession && reqPath.startsWith('/')) ? reqPath : join(cwd, reqPath);
115
+ const targetFile = (isEditorSession && isAbsolute(reqPath)) ? reqPath : join(cwd, reqPath);
116
116
  writeFileSync(targetFile, content, 'utf-8');
117
117
  const stat = statSync(targetFile);
118
118
  return { path: reqPath, size: stat.size };
119
119
  }
120
120
 
121
+ /**
122
+ * renameSync 带 retry —— Windows 上目标文件被 reader(chokidar / watchFile / 编辑器预览)
123
+ * 持有时会抛 EACCES/EPERM/EBUSY。POSIX 不会,但 helper 上行为相同。
124
+ * 只 retry 这 3 个 code,其它错误(ENOENT / EISDIR / EXDEV)直接抛——retry 无意义。
125
+ *
126
+ * @param {string} src
127
+ * @param {string} dst
128
+ * @param {{retries?: number, delayMs?: number}} [opts]
129
+ */
130
+ export function renameSyncWithRetry(src, dst, opts = {}) {
131
+ const retries = opts.retries ?? 3;
132
+ const delayMs = opts.delayMs ?? 20;
133
+ const RETRYABLE = new Set(['EACCES', 'EPERM', 'EBUSY']);
134
+ let lastErr;
135
+ for (let i = 0; i < retries; i++) {
136
+ try {
137
+ renameSync(src, dst);
138
+ return;
139
+ } catch (err) {
140
+ lastErr = err;
141
+ if (i === retries - 1 || !RETRYABLE.has(err.code)) throw err;
142
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs);
143
+ }
144
+ }
145
+ throw lastErr;
146
+ }
147
+
121
148
  /** Map FileApiError codes to HTTP status codes */
122
149
  export const ERROR_STATUS_MAP = {
123
150
  INVALID_PATH: 400,
@@ -144,6 +171,8 @@ export function validateImportDir(dir) {
144
171
  if (dir.startsWith('/') || dir.startsWith('\\') || dir.includes('\0')) {
145
172
  return { ok: false, error: 'Invalid dir parameter' };
146
173
  }
174
+ // 故意按 '/' 切段:本函数契约要求 POSIX-style 相对路径,所以段内 '\\' 必须被下文 includes 检测拒掉
175
+ // (否则 `src/foo\bar` 在 Win 上会被 join 解释成 `src/foo\bar` 双重含义)。
147
176
  const segs = dir.split('/');
148
177
  const bad = segs.find(s => s === '' || s === '.' || s === '..' || s.includes('\\'));
149
178
  if (bad !== undefined) return { ok: false, error: 'Invalid dir parameter' };
package/lib/git-diff.js CHANGED
@@ -126,7 +126,8 @@ export async function getUnpushedCommits(cwd, { maxCommits = 100 } = {}) {
126
126
  const commits = [];
127
127
  const blocks = stdout.split(COMMIT_SEP).filter(Boolean);
128
128
  for (const block of blocks) {
129
- const lines = block.split('\n');
129
+ // git on Windows 在 piped 模式输出 CRLF;split('\n') 会让 fp 末尾带 \r,前端文件名乱码。
130
+ const lines = block.split(/\r?\n/);
130
131
  const header = lines[0] || '';
131
132
  const parts = header.split(FIELD_SEP);
132
133
  if (parts.length < 4) continue;
@@ -1,4 +1,5 @@
1
- import { appendFileSync, existsSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
1
+ import { appendFileSync, existsSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { renameSyncWithRetry } from './file-api.js';
2
3
  import { join } from 'node:path';
3
4
 
4
5
  const SUBAGENT_SYSTEM_RE = /(?:command execution|file search|planning) specialist|general-purpose agent/i;
@@ -273,7 +274,7 @@ export function cleanupTempFiles(dir, projectName) {
273
274
  // 只有非空 temp 文件才 rename,空文件直接删除
274
275
  const sz = statSync(tempPath).size;
275
276
  if (sz > 0) {
276
- renameSync(tempPath, newPath);
277
+ renameSyncWithRetry(tempPath, newPath);
277
278
  } else {
278
279
  unlinkSync(tempPath);
279
280
  }
@@ -1,4 +1,5 @@
1
- import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, unlinkSync, realpathSync, renameSync, appendFileSync } from 'node:fs';
1
+ import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, unlinkSync, realpathSync, appendFileSync } from 'node:fs';
2
+ import { renameSyncWithRetry } from './file-api.js';
2
3
  import { join } from 'node:path';
3
4
  import { reconstructEntries } from './delta-reconstructor.js';
4
5
  import { streamReconstructedEntries } from './log-stream.js';
@@ -138,7 +139,8 @@ export function mergeLogFiles(logDir, files) {
138
139
  throw err;
139
140
  }
140
141
  // 校验所有文件属于同一 project
141
- const projects = new Set(files.map(f => f.split('/')[0]));
142
+ // 兼容 Win backslash:files 内部可能是 `project\log.json`,按两种 sep 都切才能拿 project 段。
143
+ const projects = new Set(files.map(f => f.split(/[\\/]/)[0]));
142
144
  if (projects.size !== 1) {
143
145
  const err = new Error('All files must belong to the same project');
144
146
  err.code = 'INVALID_INPUT';
@@ -188,8 +190,8 @@ export function mergeLogFiles(logDir, files) {
188
190
  appendFileSync(tmpPath, chunk);
189
191
  });
190
192
  }
191
- // 临时文件写入成功后原子覆盖目标(POSIX renameSync 自动替换)
192
- renameSync(tmpPath, targetPath);
193
+ // 临时文件写入成功后原子覆盖目标(POSIX renameSync 自动替换;Windows reader 持锁时 retry)
194
+ renameSyncWithRetry(tmpPath, targetPath);
193
195
  // 删除其余文件
194
196
  for (let i = 1; i < files.length; i++) {
195
197
  unlinkSync(join(logDir, files[i]));
@@ -20,7 +20,9 @@ export function readLogFile(logFile) {
20
20
 
21
21
  try {
22
22
  const content = readFileSync(logFile, 'utf-8');
23
- const entries = content.split('\n---\n').filter(line => line.trim());
23
+ // Windows 上若 writer 使用 os.EOL,分隔符会变 \r\n---\r\n。固定 LF 切会失败 → 整文件
24
+ // 解析成一条乱码或漏。CRLF-tolerant split 把两边都 cover 住。
25
+ const entries = content.split(/\r?\n---\r?\n/).filter(line => line.trim());
24
26
  const parsed = entries.map(entry => {
25
27
  try {
26
28
  return JSON.parse(entry);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.6.256",
3
+ "version": "1.6.259",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -2,10 +2,10 @@ import { createServer } from 'node:http';
2
2
  import { createServer as createHttpsServer } from 'node:https';
3
3
  import { createConnection } from 'node:net';
4
4
  import { randomBytes } from 'node:crypto';
5
- import { readFileSync, writeFileSync, existsSync, watchFile, unwatchFile, statSync, readdirSync, renameSync, unlinkSync, rmSync, openSync, readSync, closeSync, realpathSync, mkdirSync, createReadStream, cpSync, copyFileSync } from 'node:fs';
5
+ import { readFileSync, writeFileSync, existsSync, watchFile, unwatchFile, statSync, lstatSync, readdirSync, renameSync, unlinkSync, rmSync, openSync, readSync, closeSync, realpathSync, mkdirSync, createReadStream, cpSync, copyFileSync } from 'node:fs';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { dirname, join, extname, resolve, basename, sep } from 'node:path';
8
- import { homedir, platform, networkInterfaces } from 'node:os';
8
+ import { homedir, platform, networkInterfaces, tmpdir } from 'node:os';
9
9
  import { execFile, exec, spawn } from 'node:child_process';
10
10
  import { promisify } from 'node:util';
11
11
  import { Worker } from 'node:worker_threads';
@@ -131,6 +131,16 @@ const ASK_HOOK_MAP_MAX = 50;
131
131
  const pendingPermHooks = new Map(); // Map<id, { toolName, input, res, timer, createdAt }>
132
132
  const PERM_HOOK_MAP_MAX = 50;
133
133
 
134
+ // Windows 保留设备名(CON/PRN/AUX/NUL/COM1-9/LPT1-9)模块级常量——multipart 3 处 upload
135
+ // handler 都用此校验,避免内联 regex 复制粘贴漂移。
136
+ const WINDOWS_RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/;
137
+
138
+ // Per-file mutex for /api/git-restore —— 防多 tab 并发 revert 同文件造成 git status + checkout
139
+ // 子命令序列被插队导致不可预测的工作树状态。Promise chain 串行化同 key 请求;finally 路径
140
+ // 主清理,setTimeout 兜底防 finally 异常吞 entry 累积内存。
141
+ const gitRestoreLocks = new Map(); // Map<absLockKey, Promise<void>>
142
+ const GIT_RESTORE_LOCK_CLEANUP_MS = 30000;
143
+
134
144
  // Notify the parent process (Electron main, when forked under tab-worker) about pending state changes.
135
145
  // No-op outside Electron (process.send is undefined when run as a standalone Node server).
136
146
  // Only ask-hook-* / sdk-ask-* are translated. Permission and SDK plan stay inline-only and do not
@@ -413,12 +423,21 @@ async function handleRequest(req, res) {
413
423
  // Windows 非法字符 <>:"|?* 在 Unix 合法(ISO 时间戳 10:30:45.log、name:v1.txt 等常见),
414
424
  // 不做跨平台代理过滤,让 writeFileSync 在 Windows 上自行抛错即可。
415
425
  const originalName = nameMatch[1].replace(/[\x00-\x1f/\\]/g, '_');
426
+ // Windows 保留设备名守卫(/api/upload-image)——见 WINDOWS_RESERVED_NAMES 注释。
427
+ {
428
+ const base = originalName.split('.')[0].trim().toLowerCase();
429
+ if (WINDOWS_RESERVED_NAMES.test(base)) {
430
+ throw new Error('Reserved filename not allowed');
431
+ }
432
+ }
416
433
  const bodyStart = headerEnd + 4;
417
434
  // Find the closing boundary
418
435
  const closingBoundary = Buffer.from('\r\n--' + boundary);
419
436
  const bodyEnd = buf.indexOf(closingBoundary, bodyStart);
420
437
  const fileData = bodyEnd !== -1 ? buf.slice(bodyStart, bodyEnd) : buf.slice(bodyStart);
421
- const uploadDir = '/tmp/cc-viewer-uploads';
438
+ // Windows 没有 /tmp,走 os.tmpdir() (%TEMP%);POSIX 保留 /tmp/cc-viewer-uploads/
439
+ // 以兼容 1.6.245 PR #81 的 macOS allowlist 修复(/private/tmp 双 realpath)。
440
+ const uploadDir = process.platform === 'win32' ? join(tmpdir(), 'cc-viewer-uploads') : '/tmp/cc-viewer-uploads';
422
441
  mkdirSync(uploadDir, { recursive: true });
423
442
  bumpWorkspacesVersion();
424
443
  // Unique filename: prepend timestamp to avoid silent overwrite
@@ -497,7 +516,7 @@ async function handleRequest(req, res) {
497
516
  mkdirSync(targetDir, { recursive: true });
498
517
  const realDir = realpathSync(targetDir);
499
518
  const realCwd = realpathSync(cwd);
500
- if (realDir !== realCwd && !realDir.startsWith(realCwd + '/')) {
519
+ if (realDir !== realCwd && !realDir.startsWith(realCwd + sep)) {
501
520
  res.writeHead(400, { 'Content-Type': 'application/json' });
502
521
  res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
503
522
  return;
@@ -510,6 +529,13 @@ async function handleRequest(req, res) {
510
529
  if (!nameMatch) throw new Error('No filename');
511
530
  // sanitize 与 /api/upload 一致:只过真正有害的字符,保留 Unix 合法 : " < > | ? * 等
512
531
  const originalName = nameMatch[1].replace(/[\x00-\x1f/\\]/g, '_');
532
+ // Windows 保留设备名守卫(见 WINDOWS_RESERVED_NAMES 注释)。
533
+ {
534
+ const base = originalName.split('.')[0].trim().toLowerCase();
535
+ if (WINDOWS_RESERVED_NAMES.test(base)) {
536
+ throw new Error('Reserved filename not allowed');
537
+ }
538
+ }
513
539
  const bodyStart = headerEnd + 4;
514
540
  const closingBoundary = Buffer.from('\r\n--' + boundary);
515
541
  const bodyEnd = buf.indexOf(closingBoundary, bodyStart);
@@ -1517,6 +1543,13 @@ async function handleRequest(req, res) {
1517
1543
  .normalize('NFKC')
1518
1544
  .replace(/[\x00-\x1f/\\]/g, '_')
1519
1545
  .replace(/[​-‏‪-‮⁠]/g, '');
1546
+ // Windows 保留设备名守卫(见 WINDOWS_RESERVED_NAMES 注释)。
1547
+ {
1548
+ const base = originalName.split('.')[0].trim().toLowerCase();
1549
+ if (WINDOWS_RESERVED_NAMES.test(base)) {
1550
+ throw Object.assign(new Error('Reserved filename not allowed'), { status: 400 });
1551
+ }
1552
+ }
1520
1553
  const lower = originalName.toLowerCase();
1521
1554
  const isZip = lower.endsWith('.zip');
1522
1555
  const isMd = lower.endsWith('.md');
@@ -1765,7 +1798,7 @@ async function handleRequest(req, res) {
1765
1798
  if (statSync(oldFullPath).isDirectory()) {
1766
1799
  const srcResolved = resolve(oldFullPath);
1767
1800
  const destResolved = resolve(toDirFull);
1768
- if (destResolved === srcResolved || destResolved.startsWith(srcResolved + '/')) {
1801
+ if (destResolved === srcResolved || destResolved.startsWith(srcResolved + sep)) {
1769
1802
  res.writeHead(400, { 'Content-Type': 'application/json' });
1770
1803
  res.end(JSON.stringify({ error: 'Cannot move directory into itself' }));
1771
1804
  return;
@@ -1783,9 +1816,18 @@ async function handleRequest(req, res) {
1783
1816
  renameSync(oldFullPath, newFullPath);
1784
1817
  } catch (mvErr) {
1785
1818
  if (mvErr.code === 'EXDEV') {
1786
- // 跨文件系统:fallback to copy + delete
1787
- if (statSync(oldFullPath).isDirectory()) {
1788
- cpSync(oldFullPath, newFullPath, { recursive: true });
1819
+ // 跨文件系统:fallback to copy + delete。先 lstat 拒 symlink ——避免攻击者 swap 让
1820
+ // cpSync 跟随复制 + rmSync 跟随删除(同 /api/delete-file 的 TOCTOU)。
1821
+ const oldStat = lstatSync(oldFullPath);
1822
+ if (oldStat.isSymbolicLink()) {
1823
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1824
+ res.end(JSON.stringify({ error: 'Cannot move symbolic links via this endpoint' }));
1825
+ return;
1826
+ }
1827
+ if (oldStat.isDirectory()) {
1828
+ // dereference: false 是 Node 默认,但显式写明意图——避免未来默认变更让递归 copy
1829
+ // 跟随内嵌 symlink 复制到 newFullPath 形成 cwd 内"指向 cwd 外"的活链。
1830
+ cpSync(oldFullPath, newFullPath, { recursive: true, dereference: false });
1789
1831
  rmSync(oldFullPath, { recursive: true, force: true });
1790
1832
  } else {
1791
1833
  copyFileSync(oldFullPath, newFullPath);
@@ -1799,7 +1841,8 @@ async function handleRequest(req, res) {
1799
1841
  throw mvErr;
1800
1842
  }
1801
1843
  }
1802
- const newRelPath = toDir.endsWith('/') ? toDir + name : toDir + '/' + name;
1844
+ // 返回前端 JSON 统一 POSIX 风格,path.join Win 上会产 backslash,需 normalize '/'
1845
+ const newRelPath = join(toDir, name).replace(/\\/g, '/');
1803
1846
  res.writeHead(200, { 'Content-Type': 'application/json' });
1804
1847
  res.end(JSON.stringify({ ok: true, newPath: newRelPath }));
1805
1848
  } catch (err) {
@@ -1843,19 +1886,36 @@ async function handleRequest(req, res) {
1843
1886
  }
1844
1887
  const realFull = realpathSync(fullPath);
1845
1888
  const realCwd = realpathSync(cwd);
1846
- if (!realFull.startsWith(realCwd + '/')) {
1889
+ if (!realFull.startsWith(realCwd + sep)) {
1847
1890
  res.writeHead(400, { 'Content-Type': 'application/json' });
1848
1891
  res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
1849
1892
  return;
1850
1893
  }
1851
- const stat = statSync(fullPath);
1894
+ // lstatSync 不跟随 symlink ——避免 TOCTOU 攻击者把目标换成软链让 rmSync(recursive)
1895
+ // 在 POSIX 上跟着删 cwd 外的目录。一切 symlink 目标都拒绝。
1896
+ const stat = lstatSync(fullPath);
1897
+ if (stat.isSymbolicLink()) {
1898
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1899
+ res.end(JSON.stringify({ error: 'Cannot delete symbolic links via this endpoint' }));
1900
+ return;
1901
+ }
1852
1902
  if (stat.isDirectory()) {
1903
+ // protectedDirs 守卫得对 Win backslash 路径 & NTFS case-insensitive 同时设防 ——
1904
+ // 否则 `path: "node_modules\\foo"` 或 `".GIT"` 都能绕过 split('/') 直接删整目录。
1853
1905
  const protectedDirs = new Set(['node_modules', '.git', '.svn', '.hg']);
1854
- if (filePath.split('/').some(part => protectedDirs.has(part))) {
1906
+ const normalizedSegs = filePath.split(/[\\/]/).map(s => s.toLowerCase());
1907
+ if (normalizedSegs.some(part => protectedDirs.has(part))) {
1855
1908
  res.writeHead(400, { 'Content-Type': 'application/json' });
1856
1909
  res.end(JSON.stringify({ error: 'Cannot delete protected directory' }));
1857
1910
  return;
1858
1911
  }
1912
+ // Defense-in-depth:lstat 跟 rmSync 间窗内攻击者再次 swap → 再 realpath 确认。
1913
+ const realFull2 = realpathSync(fullPath);
1914
+ if (!realFull2.startsWith(realCwd + sep)) {
1915
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1916
+ res.end(JSON.stringify({ error: 'Path escaped cwd after validation' }));
1917
+ return;
1918
+ }
1859
1919
  rmSync(fullPath, { recursive: true, force: true });
1860
1920
  } else if (stat.isFile()) {
1861
1921
  unlinkSync(fullPath);
@@ -1906,7 +1966,7 @@ async function handleRequest(req, res) {
1906
1966
  }
1907
1967
  const realFull = realpathSync(fullPath);
1908
1968
  const realCwd = realpathSync(cwd);
1909
- if (!realFull.startsWith(realCwd + '/')) {
1969
+ if (!realFull.startsWith(realCwd + sep)) {
1910
1970
  res.writeHead(400, { 'Content-Type': 'application/json' });
1911
1971
  res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
1912
1972
  return;
@@ -1915,7 +1975,8 @@ async function handleRequest(req, res) {
1915
1975
  if (plat === 'darwin') {
1916
1976
  execFile('open', ['-R', fullPath], () => {});
1917
1977
  } else if (plat === 'win32') {
1918
- spawn('explorer', ['/select,', fullPath], { shell: false });
1978
+ // explorer /select,<path> 必须合到一个 arg;分两个会让含空格/中文路径 escape 失败。
1979
+ spawn('explorer.exe', [`/select,${fullPath}`], { shell: false, windowsHide: true });
1919
1980
  } else {
1920
1981
  execFile('xdg-open', [dirname(fullPath)], () => {});
1921
1982
  }
@@ -1961,7 +2022,7 @@ async function handleRequest(req, res) {
1961
2022
  }
1962
2023
  const realFull = realpathSync(fullPath);
1963
2024
  const realCwd = realpathSync(cwd);
1964
- if (!realFull.startsWith(realCwd + '/')) {
2025
+ if (!realFull.startsWith(realCwd + sep)) {
1965
2026
  res.writeHead(400, { 'Content-Type': 'application/json' });
1966
2027
  res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
1967
2028
  return;
@@ -2052,7 +2113,7 @@ async function handleRequest(req, res) {
2052
2113
  }
2053
2114
  const realDir = realpathSync(fullDirPath);
2054
2115
  const realCwd = realpathSync(cwd);
2055
- if (realDir !== realCwd && !realDir.startsWith(realCwd + '/')) {
2116
+ if (realDir !== realCwd && !realDir.startsWith(realCwd + sep)) {
2056
2117
  res.writeHead(400, { 'Content-Type': 'application/json' });
2057
2118
  res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
2058
2119
  return;
@@ -2102,7 +2163,7 @@ async function handleRequest(req, res) {
2102
2163
  }
2103
2164
  const realDir = realpathSync(fullDir);
2104
2165
  const realCwd = realpathSync(cwd);
2105
- if (realDir !== realCwd && !realDir.startsWith(realCwd + '/')) {
2166
+ if (realDir !== realCwd && !realDir.startsWith(realCwd + sep)) {
2106
2167
  res.writeHead(400, { 'Content-Type': 'application/json' });
2107
2168
  res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
2108
2169
  return;
@@ -2111,7 +2172,7 @@ async function handleRequest(req, res) {
2111
2172
  if (plat === 'darwin') {
2112
2173
  spawn('open', ['-a', 'Terminal', fullDir], { stdio: 'ignore', detached: true }).unref();
2113
2174
  } else if (plat === 'win32') {
2114
- spawn('cmd.exe', ['/c', 'start', 'cmd.exe'], { cwd: fullDir, stdio: 'ignore', detached: true }).unref();
2175
+ spawn('cmd.exe', ['/c', 'start', 'cmd.exe'], { cwd: fullDir, stdio: 'ignore', detached: true, windowsHide: true }).unref();
2115
2176
  } else {
2116
2177
  // Linux: try common terminal emulators
2117
2178
  const terminals = ['gnome-terminal', 'konsole', 'xfce4-terminal', 'xterm'];
@@ -2181,7 +2242,7 @@ async function handleRequest(req, res) {
2181
2242
  }
2182
2243
  const realDir = realpathSync(fullDirPath);
2183
2244
  const realCwd = realpathSync(cwd);
2184
- if (realDir !== realCwd && !realDir.startsWith(realCwd + '/')) {
2245
+ if (realDir !== realCwd && !realDir.startsWith(realCwd + sep)) {
2185
2246
  res.writeHead(400, { 'Content-Type': 'application/json' });
2186
2247
  res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
2187
2248
  return;
@@ -2726,8 +2787,8 @@ async function handleRequest(req, res) {
2726
2787
  } catch {
2727
2788
  return respondJson(404, { error: 'File not found' });
2728
2789
  }
2729
- const sep = realDir.endsWith('/') ? realDir : realDir + '/';
2730
- if (realFile !== realDir && !realFile.startsWith(sep)) {
2790
+ const realDirWithSep = realDir.endsWith(sep) ? realDir : realDir + sep;
2791
+ if (realFile !== realDir && !realFile.startsWith(realDirWithSep)) {
2731
2792
  return respondJson(403, { error: 'Path traversal not allowed' });
2732
2793
  }
2733
2794
  const policy = isReadAllowed(realFile);
@@ -2835,7 +2896,7 @@ async function handleRequest(req, res) {
2835
2896
  if (!policy.ok && policy.reason === 'realpath-failed' && isAbs) {
2836
2897
  const pName = _projectName || 'default';
2837
2898
  const persistPrefix = join(getClaudeConfigDir(), 'cc-viewer', pName, 'images');
2838
- const fileName = absPath.split('/').pop();
2899
+ const fileName = basename(absPath);
2839
2900
  if (fileName) {
2840
2901
  const persistFile = join(persistPrefix, fileName);
2841
2902
  const fallbackPolicy = isReadAllowed(persistFile);
@@ -3057,20 +3118,34 @@ async function handleRequest(req, res) {
3057
3118
  if (existsSync(fullPath)) {
3058
3119
  const realFull = realpathSync(fullPath);
3059
3120
  const realCwd = realpathSync(cwd);
3060
- if (!realFull.startsWith(realCwd + '/')) {
3121
+ if (!realFull.startsWith(realCwd + sep)) {
3061
3122
  res.writeHead(400, { 'Content-Type': 'application/json' });
3062
3123
  res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
3063
3124
  return;
3064
3125
  }
3065
3126
  }
3066
- // Check if file is untracked
3067
- const { stdout: statusOut } = await execFileAsync('git', ['status', '--porcelain', '--', filePath], { cwd, encoding: 'utf-8', timeout: 5000 });
3068
- const isUntracked = statusOut.trim().startsWith('??');
3069
- if (isUntracked) {
3070
- await execFileAsync('git', ['clean', '-fd', '--', filePath], { cwd, timeout: 10000 });
3071
- } else {
3072
- await execFileAsync('git', ['checkout', '--', filePath], { cwd, timeout: 10000 });
3073
- }
3127
+ // Per-file mutex 序列化「git status + checkout/clean」子命令对。多 tab 并发同文件
3128
+ // revert 时不再有 race 让两个 checkout 交错执行(最终状态不可预测)。
3129
+ // resolve() 规整 `./foo.js` / `foo.js` / `.//foo.js` 为同一 lockKey,防形变绕过。
3130
+ const lockKey = resolve(join(cwd, filePath));
3131
+ const prev = gitRestoreLocks.get(lockKey) || Promise.resolve();
3132
+ const current = prev.then(async () => {
3133
+ const { stdout: statusOut } = await execFileAsync('git', ['status', '--porcelain', '--', filePath], { cwd, encoding: 'utf-8', timeout: 5000 });
3134
+ const isUntracked = statusOut.trim().startsWith('??');
3135
+ if (isUntracked) {
3136
+ await execFileAsync('git', ['clean', '-fd', '--', filePath], { cwd, timeout: 10000 });
3137
+ } else {
3138
+ await execFileAsync('git', ['checkout', '--', filePath], { cwd, timeout: 10000 });
3139
+ }
3140
+ }).finally(() => {
3141
+ if (gitRestoreLocks.get(lockKey) === current) gitRestoreLocks.delete(lockKey);
3142
+ });
3143
+ gitRestoreLocks.set(lockKey, current);
3144
+ // setTimeout 兜底——防 finally 异常吞 entry 累积内存。
3145
+ setTimeout(() => {
3146
+ if (gitRestoreLocks.get(lockKey) === current) gitRestoreLocks.delete(lockKey);
3147
+ }, GIT_RESTORE_LOCK_CLEANUP_MS).unref();
3148
+ await current;
3074
3149
  res.writeHead(200, { 'Content-Type': 'application/json' });
3075
3150
  res.end(JSON.stringify({ ok: true }));
3076
3151
  } catch (err) {
@@ -3095,7 +3170,8 @@ async function handleRequest(req, res) {
3095
3170
  // maxBuffer 拉到 10MB——默认 1MB 在 node_modules 未 gitignore 之类的极端
3096
3171
  // 场景下会被截断,导致后续 split 解析错位。
3097
3172
  const { stdout: output } = await execFileAsync('git', ['status', '--porcelain', '-uall'], { cwd, encoding: 'utf-8', timeout: 5000, maxBuffer: 10 * 1024 * 1024 });
3098
- const lines = output.split('\n').filter(line => line.trim());
3173
+ // Win git stdout 是 CRLF;现状靠下文 .trim() 兜底,加正则更稳防未来 strict 比较破窗。
3174
+ const lines = output.split(/\r?\n/).filter(line => line.trim());
3099
3175
  const changes = lines.map(line => {
3100
3176
  const status = line.substring(0, 2).trim();
3101
3177
  let file = line.substring(3).trim();
@@ -3344,7 +3420,7 @@ async function handleRequest(req, res) {
3344
3420
  res.end(JSON.stringify({ error: 'Access denied' }));
3345
3421
  return;
3346
3422
  }
3347
- const fileName = file.split('/').pop();
3423
+ const fileName = basename(file);
3348
3424
  const format = parsedUrl.searchParams.get('format');
3349
3425
  // Delta storage: format=raw 下载原始文件;默认下载重建后的全量格式
3350
3426
  if (format === 'raw') {
@@ -4207,8 +4283,10 @@ async function setupTerminalWebSocket(httpServer) {
4207
4283
  } else if (msg.type === 'image-remove-notify' || msg.type === 'image-upload-notify') {
4208
4284
  // Security: only allow paths within upload directories, reject traversal
4209
4285
  const p = msg.path;
4286
+ // Windows 走 tmpdir()/cc-viewer-uploads/,POSIX 仍是 /tmp/cc-viewer-uploads/(macOS realpath 解为 /private/tmp 两种前缀都放行)。
4287
+ const winUploadPrefix = join(tmpdir(), 'cc-viewer-uploads') + sep;
4210
4288
  if (terminalWss && p && !p.includes('..') && (
4211
- p.startsWith('/tmp/cc-viewer-uploads/') || (p.includes('/cc-viewer/') && p.includes('/images/'))
4289
+ p.startsWith('/tmp/cc-viewer-uploads/') || p.startsWith('/private/tmp/cc-viewer-uploads/') || p.startsWith(winUploadPrefix) || (p.includes('/cc-viewer/') && p.includes('/images/'))
4212
4290
  )) {
4213
4291
  const rmsg = msg.type === 'image-upload-notify'
4214
4292
  ? JSON.stringify({ type: 'image-upload-notify', path: p, source: msg.source || 'unknown' })
@@ -4235,8 +4313,12 @@ async function setupTerminalWebSocket(httpServer) {
4235
4313
  if (_needRedrawBootstrap) {
4236
4314
  _needRedrawBootstrap = false;
4237
4315
  try {
4238
- const pid = getClaudePid();
4239
- if (pid && pid !== process.pid) process.kill(pid, 'SIGWINCH');
4316
+ // Windows SIGWINCH;ConPTY 在前面的 resizePty 调用里已经处理过 resize 通知,
4317
+ // 这里仅是 POSIX 上的"等尺寸 noop 不发信号"兜底,Win 上跳过避免抛异常。
4318
+ if (process.platform !== 'win32') {
4319
+ const pid = getClaudePid();
4320
+ if (pid && pid !== process.pid) process.kill(pid, 'SIGWINCH');
4321
+ }
4240
4322
  } catch {}
4241
4323
  }
4242
4324
  }