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/cli.js +35 -11
- package/dist/assets/{App-BxUDsD3t.js → App-eBSb5fy2.js} +1 -1
- package/dist/assets/{MdxEditorPanel-DHU0Ardx.js → MdxEditorPanel-CWT3SFgC.js} +1 -1
- package/dist/assets/{Mobile-Cqm4xH5j.js → Mobile-DdB1mwp_.js} +1 -1
- package/dist/assets/ProxyModal-DGzTxXqd.js +2 -0
- package/dist/assets/index-BGEXf37A.js +2 -0
- package/dist/index.html +1 -1
- package/findcc.js +20 -14
- package/interceptor.js +7 -4
- package/lib/file-api.js +39 -10
- package/lib/git-diff.js +2 -1
- package/lib/interceptor-core.js +3 -2
- package/lib/log-management.js +6 -4
- package/lib/log-watcher.js +3 -1
- package/package.json +1 -1
- package/server.js +118 -36
- package/workspace-registry.js +9 -15
- package/dist/assets/ProxyModal-83mKKqyB.js +0 -2
- package/dist/assets/index-aBYCRImE.js +0 -2
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-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
|
179
|
-
|
|
180
|
-
if (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,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
44
|
-
const resolved = resolve(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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|
package/lib/interceptor-core.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, readdirSync, readFileSync,
|
|
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
|
-
|
|
277
|
+
renameSyncWithRetry(tempPath, newPath);
|
|
277
278
|
} else {
|
|
278
279
|
unlinkSync(tempPath);
|
|
279
280
|
}
|
package/lib/log-management.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, unlinkSync, realpathSync,
|
|
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
|
-
|
|
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
|
-
|
|
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]));
|
package/lib/log-watcher.js
CHANGED
|
@@ -20,7 +20,9 @@ export function readLogFile(logFile) {
|
|
|
20
20
|
|
|
21
21
|
try {
|
|
22
22
|
const content = readFileSync(logFile, 'utf-8');
|
|
23
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
1788
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2730
|
-
if (realFile !== realDir && !realFile.startsWith(
|
|
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
|
|
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
|
-
//
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
await execFileAsync('git', ['
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4239
|
-
|
|
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
|
}
|