cc-viewer 1.9.14 → 1.9.15
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/assets/App-BCDnm2Lh.js +2 -0
- package/dist/assets/App-CCnPkf2_.css +1 -0
- package/dist/assets/{MdxEditorPanel-DI3B4gsM.js → MdxEditorPanel-DRLFmdU5.js} +1 -1
- package/dist/assets/Mobile-C8VJqBwa.js +1 -0
- package/dist/assets/{ProxyStatsModal-B8K0AtAq.js → ProxyStatsModal-Ba1E1lu4.js} +1 -1
- package/dist/assets/{index-CU0gt6m1.js → index-Sp3JYYKo.js} +2 -2
- package/dist/assets/seqResourceLoaders-BUfjTaCV.css +41 -0
- package/dist/assets/seqResourceLoaders-CHmtPDFO.js +2 -0
- package/dist/index.html +1 -1
- package/node_modules/@ccv/core/src/contentFilter.js +3 -0
- package/node_modules/@ccv/core/src/queued-prompt.js +66 -0
- package/node_modules/@ccv/core/src/v2-transcript-normalizer.js +78 -14
- package/package.json +1 -1
- package/server/lib/chat-queue.js +350 -120
- package/server/lib/scheduled-task-executor.js +3 -1
- package/server/lib/sdk-manager.js +282 -68
- package/server/lib/v2/conversation-store.js +15 -11
- package/server/lib/v2/v2-writer.js +1 -1
- package/server/lib/view-root.js +4 -4
- package/server/pty-manager.js +8 -8
- package/server/routes/files-content.js +108 -14
- package/server/routes/files-fs.js +122 -36
- package/server/routes/resume.js +1 -1
- package/server/routes/sdk-context.js +13 -2
- package/server/server.js +128 -28
- package/dist/assets/App-BzV1ieDH.js +0 -2
- package/dist/assets/App-DU5jmGrx.css +0 -1
- package/dist/assets/Mobile-qBkAnXy3.js +0 -1
- package/dist/assets/seqResourceLoaders-CxUFE36z.css +0 -41
- package/dist/assets/seqResourceLoaders-nGCs11zu.js +0 -2
package/server/lib/view-root.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// View-root resolution (multi-project, 2026-10): map an optional `?project=`
|
|
2
2
|
// name to the REAL filesystem root of that project, so project-scoped READ
|
|
3
|
-
// routes
|
|
4
|
-
// viewed parallel project instead of always the bound one.
|
|
3
|
+
// routes and boundary-checked file mutations follow the viewed project.
|
|
5
4
|
//
|
|
6
5
|
// Name → path sources (both consulted, in order):
|
|
7
6
|
// 1. workspace-registry (loadWorkspaces) — persistent { path, projectName },
|
|
@@ -22,7 +21,8 @@
|
|
|
22
21
|
// 3. still >1 real candidate → 400, never a silent guess.
|
|
23
22
|
//
|
|
24
23
|
// Pure + dependency-injected (fs/registry/pty seams) so it is directly
|
|
25
|
-
// node-testable.
|
|
24
|
+
// node-testable. File mutation routes use the same resolver as their reads;
|
|
25
|
+
// every target must still pass the resolved root's filesystem boundary checks.
|
|
26
26
|
|
|
27
27
|
import { realpathSync, existsSync, statSync } from 'node:fs';
|
|
28
28
|
import { projectKeyForCwd } from './system-prompt-snapshots.js';
|
|
@@ -192,7 +192,7 @@ export function resolveViewRoot({ projectParam, instanceParam, boundCwd, loadWor
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
/**
|
|
195
|
-
* HTTP-facing wrapper shared by
|
|
195
|
+
* HTTP-facing wrapper shared by project-scoped routes (multi-project,
|
|
196
196
|
* 2026-10): resolves `?project=` (or a POST-body override) via resolveViewRoot
|
|
197
197
|
* and, on failure, writes the error response itself. Returns the resolved root
|
|
198
198
|
* to continue with, or null after replying (the caller must return early).
|
package/server/pty-manager.js
CHANGED
|
@@ -1290,12 +1290,15 @@ export function resolveInstanceForCwd(cwd) {
|
|
|
1290
1290
|
* would make resize/scrollback/getPtyState follow whichever project was last
|
|
1291
1291
|
* SENT to, a new cross-project bleed (review P1, 2026-10-05).
|
|
1292
1292
|
*/
|
|
1293
|
-
function _resolveKeyByAnchor({ sessionId, project, instanceKey } = {}) {
|
|
1293
|
+
function _resolveKeyByAnchor({ sessionId, project, instanceKey, strict = false } = {}) {
|
|
1294
|
+
// Explicit strict targets never fall back to the active conversation. Without this,
|
|
1295
|
+
// a dead project/instance could receive a queued prompt meant for another one.
|
|
1294
1296
|
// Multi-instance: an explicit instanceKey is the strongest anchor — it pins THIS exact
|
|
1295
1297
|
// process (disambiguating two same-cwd instances that basename routing cannot).
|
|
1296
1298
|
if (typeof instanceKey === 'string' && /^ccv-[0-9a-f]+$/.test(instanceKey)) {
|
|
1297
1299
|
const rec = ptys.get(instanceKey);
|
|
1298
1300
|
if (rec && rec.ptyProcess) return instanceKey;
|
|
1301
|
+
if (strict) return null;
|
|
1299
1302
|
}
|
|
1300
1303
|
const projKey = (typeof project === 'string' && project) ? _resolveKey({ project }) : null;
|
|
1301
1304
|
if (typeof sessionId === 'string' && sessionId) {
|
|
@@ -1313,12 +1316,13 @@ function _resolveKeyByAnchor({ sessionId, project, instanceKey } = {}) {
|
|
|
1313
1316
|
}
|
|
1314
1317
|
}
|
|
1315
1318
|
if (projKey) return projKey;
|
|
1319
|
+
if (strict) return null;
|
|
1316
1320
|
return activePtyKey != null && ptys.has(activePtyKey) ? activePtyKey : null;
|
|
1317
1321
|
}
|
|
1318
1322
|
|
|
1319
1323
|
/** writeToPty variant routed by an explicit anchor instead of the global active pointer. */
|
|
1320
|
-
export function writeToPtyFor(data, { sessionId, project, instanceKey } = {}) {
|
|
1321
|
-
const key = _resolveKeyByAnchor({ sessionId, project, instanceKey });
|
|
1324
|
+
export function writeToPtyFor(data, { sessionId, project, instanceKey, strict = false } = {}) {
|
|
1325
|
+
const key = _resolveKeyByAnchor({ sessionId, project, instanceKey, strict });
|
|
1322
1326
|
const s = key ? ptys.get(key) : null;
|
|
1323
1327
|
if (s && s.ptyProcess) {
|
|
1324
1328
|
s.ptyProcess.write(data);
|
|
@@ -1332,11 +1336,7 @@ export function writeToPtyFor(data, { sessionId, project, instanceKey } = {}) {
|
|
|
1332
1336
|
* the write fails (onComplete(false)) WITHOUT falling back to the project/active record —
|
|
1333
1337
|
* scheduled-task injection must never land in the wrong conversation (review P0-2). */
|
|
1334
1338
|
export function writeToPtySequentialFor(chunks, onComplete, opts = {}, anchor = {}) {
|
|
1335
|
-
|
|
1336
|
-
if (opts && opts.strict && typeof anchor.instanceKey === 'string' && anchor.instanceKey) {
|
|
1337
|
-
const rec = ptys.get(anchor.instanceKey);
|
|
1338
|
-
if (!rec || !rec.ptyProcess) key = null; // strict: a dead explicit instance never falls back
|
|
1339
|
-
}
|
|
1339
|
+
const key = _resolveKeyByAnchor({ ...anchor, strict: opts.strict === true });
|
|
1340
1340
|
const s = key ? ptys.get(key) : null;
|
|
1341
1341
|
if (!s || !s.ptyProcess || !chunks || chunks.length === 0) {
|
|
1342
1342
|
if (onComplete) onComplete(false);
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, realpathSync, createReadStream } from 'node:fs';
|
|
3
3
|
import { join, basename, dirname, resolve, sep } from 'node:path';
|
|
4
4
|
import { isReadAllowed, reasonToStatus } from '../lib/file-access-policy.js';
|
|
5
|
-
import { ERROR_STATUS_MAP } from '../lib/file-api.js';
|
|
6
|
-
import { viewRootOrReply } from '../lib/view-root.js';
|
|
5
|
+
import { ERROR_STATUS_MAP, isPathContained } from '../lib/file-api.js';
|
|
6
|
+
import { viewRootOrReply, resolveViewRoot } from '../lib/view-root.js';
|
|
7
|
+
import { findSdkSessionByInstance } from '../lib/sdk-session-index.js';
|
|
8
|
+
import { projectKeyForCwd } from '../lib/system-prompt-snapshots.js';
|
|
7
9
|
import { loadWorkspaces } from '../workspace-registry.js';
|
|
8
10
|
import { listLivePtys } from '../pty-manager.js';
|
|
9
11
|
import { reportSwallowed } from '../lib/error-report.js';
|
|
@@ -11,16 +13,20 @@ import { discoverClaudeMdCandidates, readCandidateById } from '../lib/claude-md-
|
|
|
11
13
|
import { getClaudeConfigDir } from '../../findcc.js';
|
|
12
14
|
import { _projectName } from '../interceptor.js';
|
|
13
15
|
|
|
14
|
-
// Multi-project
|
|
15
|
-
//
|
|
16
|
-
//
|
|
16
|
+
// Multi-project file operations use the same viewed root for reads and writes.
|
|
17
|
+
// Every file-browser mutation enforces realpath containment in that root;
|
|
18
|
+
// explicit editor sessions retain their existing allowlisted external paths.
|
|
17
19
|
// their tests (and the dispatcher's sync-call convention) assert on res
|
|
18
20
|
// immediately after the call — the override must not async-ify them.
|
|
19
|
-
|
|
21
|
+
// Both reads and writes resolve the viewed project. Absolute editor paths are
|
|
22
|
+
// still governed by the allowlist; browser writes stay within the selected root.
|
|
23
|
+
function _viewRootOrReply(req, res, parsedUrl, bodyProject, bodyInstance) {
|
|
20
24
|
return viewRootOrReply(req, res, parsedUrl, {
|
|
21
25
|
boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
|
|
22
26
|
loadWorkspaces,
|
|
23
27
|
listLivePtys,
|
|
28
|
+
bodyProject,
|
|
29
|
+
bodyInstance,
|
|
24
30
|
});
|
|
25
31
|
}
|
|
26
32
|
|
|
@@ -167,6 +173,37 @@ function projectMemory(req, res, parsedUrl) {
|
|
|
167
173
|
projectMemoryEntry(req, res, parsedUrl, respondJson);
|
|
168
174
|
}
|
|
169
175
|
|
|
176
|
+
// The SDK context snapshot prices MEMORY.md itself, not every linked detail file.
|
|
177
|
+
// Supplement only the index's existing single-segment .md links with a transparent
|
|
178
|
+
// content-length estimate; never allocate the SDK total across unrelated files.
|
|
179
|
+
export function estimateLinkedMemoryTokens(content, dir) {
|
|
180
|
+
const estimates = {};
|
|
181
|
+
const MAX_BYTES = 512 * 1024;
|
|
182
|
+
const MAX_LINKS = 100;
|
|
183
|
+
if (typeof content !== 'string' || typeof dir !== 'string') return estimates;
|
|
184
|
+
const links = new Set();
|
|
185
|
+
for (const match of content.matchAll(/\]\(\s*([^)\s]+\.md)(?:[?#][^)\s]*)?(?:\s+"[^"]*")?\s*\)/gi)) {
|
|
186
|
+
let name;
|
|
187
|
+
try { name = decodeURIComponent(match[1].split('#')[0].split('?')[0]); } catch { continue; }
|
|
188
|
+
if (!name || name.includes('/') || name.includes('\\') || name.includes('\0') || name.startsWith('.')) continue;
|
|
189
|
+
links.add(name);
|
|
190
|
+
if (links.size >= MAX_LINKS) break;
|
|
191
|
+
}
|
|
192
|
+
let realDir;
|
|
193
|
+
try { realDir = realpathSync(dir); } catch { return estimates; }
|
|
194
|
+
const prefix = realDir.endsWith(sep) ? realDir : realDir + sep;
|
|
195
|
+
for (const name of links) {
|
|
196
|
+
try {
|
|
197
|
+
const realFile = realpathSync(join(realDir, name));
|
|
198
|
+
const stat = statSync(realFile);
|
|
199
|
+
if (!realFile.startsWith(prefix) || !stat.isFile() || stat.size > MAX_BYTES) continue;
|
|
200
|
+
if (!isReadAllowed(realFile).ok) continue;
|
|
201
|
+
estimates[name] = { tokens: Math.ceil(stat.size / 4), source: 'file-content-estimate' };
|
|
202
|
+
} catch { /* Missing/unreadable links remain without an estimate. */ }
|
|
203
|
+
}
|
|
204
|
+
return estimates;
|
|
205
|
+
}
|
|
206
|
+
|
|
170
207
|
// The entry wraps BOTH the view-root resolution and the read in the endpoint's
|
|
171
208
|
// single error surface (the searchParams-throw defense test asserts a 500 from
|
|
172
209
|
// THIS catch, regardless of which stage throws).
|
|
@@ -202,7 +239,9 @@ function projectMemoryWithRoot(req, res, parsedUrl, respondJson, cwd) {
|
|
|
202
239
|
if (!st.isFile()) return respondJson(400, { error: 'Not a file' });
|
|
203
240
|
if (st.size > MAX_BYTES) return respondJson(413, { error: 'File too large' });
|
|
204
241
|
const content = readFileSync(policy.real, 'utf-8');
|
|
205
|
-
|
|
242
|
+
// Only SDK mode consumes these estimates. PTY keeps the original cheap index read.
|
|
243
|
+
const memoryTokenEstimates = process.env.CCV_SDK_MODE === '1' ? estimateLinkedMemoryTokens(content, dir) : undefined;
|
|
244
|
+
return respondJson(200, { exists: true, dir, indexPath, content, memoryTokenEstimates });
|
|
206
245
|
}
|
|
207
246
|
|
|
208
247
|
// 明细文件: 仅接受单段 basename + .md 后缀
|
|
@@ -231,7 +270,7 @@ function projectMemoryWithRoot(req, res, parsedUrl, respondJson, cwd) {
|
|
|
231
270
|
if (!st.isFile()) return respondJson(400, { error: 'Not a file' });
|
|
232
271
|
if (st.size > MAX_BYTES) return respondJson(413, { error: 'File too large' });
|
|
233
272
|
const content = readFileSync(realFile, 'utf-8');
|
|
234
|
-
return respondJson(200, { name: fileParam, path: realFile, content });
|
|
273
|
+
return respondJson(200, { name: fileParam, path: realFile, content, tokens: Math.ceil(Buffer.byteLength(content, 'utf8') / 4), tokenSource: 'file-content-estimate' });
|
|
235
274
|
} catch (err) {
|
|
236
275
|
// 与 /api/file-content 一致:已知 errno(ENOENT/EACCES 等)走 ERROR_STATUS_MAP 映射;
|
|
237
276
|
// 500 时不回显 err.message —— 可能含 ~/.claude/projects/<encoded>/memory/ 路径片段。
|
|
@@ -311,13 +350,51 @@ function fileRaw(req, res, parsedUrl) {
|
|
|
311
350
|
const url = parsedUrl.pathname;
|
|
312
351
|
const method = req.method;
|
|
313
352
|
let reqPath;
|
|
314
|
-
|
|
353
|
+
let scopedProject = '';
|
|
354
|
+
let scopedInstance = '';
|
|
355
|
+
if (url.startsWith('/api/file-raw/view/')) {
|
|
356
|
+
const parts = url.slice('/api/file-raw/view/'.length).split('/');
|
|
357
|
+
let project = '';
|
|
358
|
+
let instance = '';
|
|
359
|
+
try {
|
|
360
|
+
project = decodeURIComponent(parts[0] || '');
|
|
361
|
+
instance = decodeURIComponent(parts[1] || '');
|
|
362
|
+
reqPath = decodeURIComponent(parts.slice(2).join('/'));
|
|
363
|
+
} catch {
|
|
364
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
365
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
scopedProject = project;
|
|
369
|
+
scopedInstance = instance;
|
|
370
|
+
} else if (url.startsWith('/api/file-raw/')) {
|
|
315
371
|
const tail = parsedUrl.pathname.slice('/api/file-raw/'.length);
|
|
316
372
|
try { reqPath = decodeURIComponent(tail); } catch { reqPath = tail; }
|
|
317
373
|
} else {
|
|
318
374
|
reqPath = parsedUrl.searchParams.get('path');
|
|
319
375
|
}
|
|
320
|
-
|
|
376
|
+
let resolvedView = null;
|
|
377
|
+
if (scopedProject) {
|
|
378
|
+
resolvedView = resolveViewRoot({
|
|
379
|
+
projectParam: scopedProject,
|
|
380
|
+
instanceParam: scopedInstance,
|
|
381
|
+
boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
|
|
382
|
+
loadWorkspaces,
|
|
383
|
+
listLivePtys,
|
|
384
|
+
});
|
|
385
|
+
// The path already pins its project. A stale/mismatched instance must not
|
|
386
|
+
// silently redirect it to the bound project or a different workspace.
|
|
387
|
+
const instanceRecord = scopedInstance ? findSdkSessionByInstance(scopedInstance) : null;
|
|
388
|
+
const instanceMatches = !scopedInstance || (instanceRecord
|
|
389
|
+
&& (instanceRecord.projectName === scopedProject
|
|
390
|
+
|| projectKeyForCwd(instanceRecord.cwd) === scopedProject));
|
|
391
|
+
if (!resolvedView.ok || (scopedInstance && !instanceMatches)) {
|
|
392
|
+
res.writeHead(resolvedView.ok ? 400 : resolvedView.status, { 'Content-Type': 'application/json' });
|
|
393
|
+
res.end(JSON.stringify({ error: resolvedView.ok ? 'Project scope mismatch' : resolvedView.error }));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const cwd = resolvedView?.root || _viewRootOrReply(req, res, parsedUrl);
|
|
321
398
|
if (!cwd) return;
|
|
322
399
|
fileRawWithRoot(req, res, parsedUrl, reqPath, cwd, method);
|
|
323
400
|
}
|
|
@@ -460,7 +537,11 @@ function fileDownloadWithRoot(req, res, parsedUrl, reqPath, cwd) {
|
|
|
460
537
|
}
|
|
461
538
|
}
|
|
462
539
|
|
|
463
|
-
function fileContentPost(req, res) {
|
|
540
|
+
function fileContentPost(req, res, parsedUrl) {
|
|
541
|
+
// The shared read root keeps its raw cwd for projectMemory's store-key
|
|
542
|
+
// encoding. Normalize only this mutation's containment comparisons.
|
|
543
|
+
let writeRoot;
|
|
544
|
+
const isInsideWriteRoot = (path) => isPathContained(path, writeRoot);
|
|
464
545
|
const MAX_BODY = 5 * 1024 * 1024; // 5MB,与 GET 路由限制对齐
|
|
465
546
|
let body = '';
|
|
466
547
|
let overflow = false;
|
|
@@ -475,8 +556,10 @@ function fileContentPost(req, res) {
|
|
|
475
556
|
return;
|
|
476
557
|
}
|
|
477
558
|
try {
|
|
478
|
-
const { path: reqPath, content } = JSON.parse(body);
|
|
479
|
-
const cwd =
|
|
559
|
+
const { path: reqPath, content, editorSession } = JSON.parse(body);
|
|
560
|
+
const cwd = _viewRootOrReply(req, res, parsedUrl);
|
|
561
|
+
if (!cwd) return;
|
|
562
|
+
try { writeRoot = realpathSync(cwd); } catch { writeRoot = cwd; }
|
|
480
563
|
if (!reqPath) {
|
|
481
564
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
482
565
|
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
@@ -489,7 +572,6 @@ function fileContentPost(req, res) {
|
|
|
489
572
|
}
|
|
490
573
|
const isAbs = /^([a-zA-Z]:[\\/]|[\\/])/.test(reqPath);
|
|
491
574
|
const absPath = isAbs ? reqPath : resolve(cwd, reqPath);
|
|
492
|
-
|
|
493
575
|
// 写路径同样走 policy(收敛 editorSession 后门):允许覆盖现有文件;
|
|
494
576
|
// 新文件场景递归向上找最近存在的祖先目录,只要它在 allowlist 内即放行。
|
|
495
577
|
// (旧实现只查 immediate parent,父目录也不存在时误拒嵌套新建。)
|
|
@@ -525,6 +607,13 @@ function fileContentPost(req, res) {
|
|
|
525
607
|
res.end(JSON.stringify(body));
|
|
526
608
|
return;
|
|
527
609
|
}
|
|
610
|
+
// Check containment before creating any missing parent directories.
|
|
611
|
+
// Explicit $EDITOR sessions retain their allowlisted external paths.
|
|
612
|
+
if (!editorSession && !isInsideWriteRoot(ancestorPolicy.real)) {
|
|
613
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
614
|
+
res.end(JSON.stringify({ error: 'Forbidden', reason: 'outside-view-root' }));
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
528
617
|
// 在祖先 real 路径下重建目标。Denylist 已在祖先 prefix 层生效,
|
|
529
618
|
// 这里相信 allowlist 祖先的合法性(项目目录 / ~/.claude/cc-viewer 等)。
|
|
530
619
|
targetReal = join(ancestorPolicy.real, ...descent);
|
|
@@ -539,6 +628,11 @@ function fileContentPost(req, res) {
|
|
|
539
628
|
return;
|
|
540
629
|
}
|
|
541
630
|
|
|
631
|
+
if (!editorSession && existsSync(targetReal) && !isInsideWriteRoot(targetReal)) {
|
|
632
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
633
|
+
res.end(JSON.stringify({ error: 'Forbidden', reason: 'outside-view-root' }));
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
542
636
|
writeFileSync(targetReal, content, 'utf-8');
|
|
543
637
|
const stat = statSync(targetReal);
|
|
544
638
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -7,16 +7,17 @@ import { join, dirname, basename, resolve, sep } from 'node:path';
|
|
|
7
7
|
import { homedir, tmpdir } from 'node:os';
|
|
8
8
|
import { execFile, spawn } from 'node:child_process';
|
|
9
9
|
import { bumpWorkspacesVersion, isReadAllowed } from '../lib/file-access-policy.js';
|
|
10
|
-
import { validateImportDir } from '../lib/file-api.js';
|
|
10
|
+
import { isPathContained, validateImportDir, renameSyncWithRetry } from '../lib/file-api.js';
|
|
11
|
+
import { reportSwallowed } from '../lib/error-report.js';
|
|
11
12
|
import { viewRootOrReply } from '../lib/view-root.js';
|
|
12
13
|
import { loadWorkspaces } from '../workspace-registry.js';
|
|
13
14
|
import { listLivePtys } from '../pty-manager.js';
|
|
14
15
|
import { PROFILE_PATH, _projectName, _logDir } from '../interceptor.js';
|
|
15
16
|
import { LOG_DIR, getClaudeConfigDir } from '../../findcc.js';
|
|
16
17
|
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
18
|
+
// Project-scoped file operations resolve the viewed project via the shared
|
|
19
|
+
// viewRootOrReply (?project=, or POST body {project}/{instance}). Mutations
|
|
20
|
+
// still enforce realpath containment in that root before touching the disk.
|
|
20
21
|
// SYNCHRONOUS: the affected handlers were sync before multi-project and their
|
|
21
22
|
// tests assert on res immediately after the call.
|
|
22
23
|
function _viewRootOrReply(req, res, parsedUrl, bodyProject, bodyInstance) {
|
|
@@ -161,11 +162,31 @@ function importFile(req, res, parsedUrl, isLocal, deps) {
|
|
|
161
162
|
req.on('end', () => {
|
|
162
163
|
if (aborted) return;
|
|
163
164
|
try {
|
|
164
|
-
const cwd =
|
|
165
|
+
const cwd = _viewRootOrReply(req, res, parsedUrl);
|
|
166
|
+
if (!cwd) return;
|
|
165
167
|
const targetDir = join(cwd, dir);
|
|
166
|
-
mkdirSync(targetDir, { recursive: true });
|
|
167
|
-
const realDir = realpathSync(targetDir);
|
|
168
168
|
const realCwd = realpathSync(cwd);
|
|
169
|
+
let cursor = targetDir;
|
|
170
|
+
const missing = [];
|
|
171
|
+
while (!existsSync(cursor)) {
|
|
172
|
+
missing.unshift(basename(cursor));
|
|
173
|
+
const parent = dirname(cursor);
|
|
174
|
+
if (parent === cursor) break;
|
|
175
|
+
cursor = parent;
|
|
176
|
+
}
|
|
177
|
+
const realAncestor = realpathSync(cursor);
|
|
178
|
+
if (realAncestor !== realCwd && !realAncestor.startsWith(realCwd + sep)) {
|
|
179
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
180
|
+
res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
// Create only after the nearest existing ancestor is known to be inside
|
|
184
|
+
// the view; mkdir first would let an escaping symlink create outside dirs.
|
|
185
|
+
for (const segment of missing) {
|
|
186
|
+
cursor = join(cursor, segment);
|
|
187
|
+
mkdirSync(cursor);
|
|
188
|
+
}
|
|
189
|
+
const realDir = realpathSync(targetDir);
|
|
169
190
|
if (realDir !== realCwd && !realDir.startsWith(realCwd + sep)) {
|
|
170
191
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
171
192
|
res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
|
|
@@ -339,7 +360,8 @@ function renameFile(req, res, parsedUrl, isLocal, deps) {
|
|
|
339
360
|
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
340
361
|
return;
|
|
341
362
|
}
|
|
342
|
-
const cwd =
|
|
363
|
+
const cwd = _viewRootOrReply(req, res, parsedUrl, parsed.project, parsed.instance);
|
|
364
|
+
if (!cwd) return;
|
|
343
365
|
const oldFullPath = join(cwd, oldPath);
|
|
344
366
|
const parentDir = dirname(oldFullPath);
|
|
345
367
|
const newFullPath = join(parentDir, newName);
|
|
@@ -349,13 +371,48 @@ function renameFile(req, res, parsedUrl, isLocal, deps) {
|
|
|
349
371
|
res.end(JSON.stringify({ error: 'File not found' }));
|
|
350
372
|
return;
|
|
351
373
|
}
|
|
374
|
+
if (!isPathContained(oldFullPath, cwd)) {
|
|
375
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
376
|
+
res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
// On a case-insensitive filesystem the differently-cased destination is
|
|
380
|
+
// the source itself, not a collision. A temporary name makes the case
|
|
381
|
+
// change work on NTFS and APFS even when realpath keeps the old casing.
|
|
382
|
+
const oldName = basename(oldPath);
|
|
383
|
+
if (oldName !== newName && oldName.toLowerCase() === newName.toLowerCase()) {
|
|
384
|
+
try {
|
|
385
|
+
if (realpathSync.native(newFullPath) === realpathSync.native(oldFullPath)) {
|
|
386
|
+
const tempPath = join(parentDir, `.ccv-rename-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
387
|
+
renameSyncWithRetry(oldFullPath, tempPath);
|
|
388
|
+
try { renameSyncWithRetry(tempPath, newFullPath); }
|
|
389
|
+
catch (err) {
|
|
390
|
+
try { renameSyncWithRetry(tempPath, oldFullPath); }
|
|
391
|
+
catch (rollbackErr) { reportSwallowed('filesFs.renameRollback', rollbackErr, { tempPath }); }
|
|
392
|
+
throw err;
|
|
393
|
+
}
|
|
394
|
+
const newRelPath = oldPath.includes('/') ? oldPath.substring(0, oldPath.lastIndexOf('/') + 1) + newName : newName;
|
|
395
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
396
|
+
res.end(JSON.stringify({ ok: true, newPath: newRelPath }));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
} catch (err) {
|
|
400
|
+
// Case-sensitive filesystems have no destination to realpath yet;
|
|
401
|
+
// that is the normal rename case, not a server error.
|
|
402
|
+
if (err.code !== 'ENOENT') {
|
|
403
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
404
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
352
409
|
// 检查目标是否已存在
|
|
353
410
|
if (existsSync(newFullPath)) {
|
|
354
411
|
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
355
412
|
res.end(JSON.stringify({ error: 'Target already exists' }));
|
|
356
413
|
return;
|
|
357
414
|
}
|
|
358
|
-
|
|
415
|
+
renameSyncWithRetry(oldFullPath, newFullPath);
|
|
359
416
|
const newRelPath = oldPath.includes('/') ? oldPath.substring(0, oldPath.lastIndexOf('/') + 1) + newName : newName;
|
|
360
417
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
361
418
|
res.end(JSON.stringify({ ok: true, newPath: newRelPath }));
|
|
@@ -391,7 +448,8 @@ function moveFile(req, res, parsedUrl, isLocal, deps) {
|
|
|
391
448
|
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
392
449
|
return;
|
|
393
450
|
}
|
|
394
|
-
const cwd =
|
|
451
|
+
const cwd = _viewRootOrReply(req, res, parsedUrl, parsed.project, parsed.instance);
|
|
452
|
+
if (!cwd) return;
|
|
395
453
|
const oldFullPath = join(cwd, fromPath);
|
|
396
454
|
const toDirFull = join(cwd, toDir);
|
|
397
455
|
// 检查源文件/目录存在
|
|
@@ -417,6 +475,11 @@ function moveFile(req, res, parsedUrl, isLocal, deps) {
|
|
|
417
475
|
}
|
|
418
476
|
}
|
|
419
477
|
const name = basename(fromPath);
|
|
478
|
+
if (!isPathContained(oldFullPath, cwd) || !isPathContained(toDirFull, cwd)) {
|
|
479
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
480
|
+
res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
420
483
|
const newFullPath = join(toDirFull, name);
|
|
421
484
|
// 检查目标位置不存在同名文件
|
|
422
485
|
if (existsSync(newFullPath)) {
|
|
@@ -487,7 +550,8 @@ function deleteFile(req, res, parsedUrl, isLocal, deps) {
|
|
|
487
550
|
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
488
551
|
return;
|
|
489
552
|
}
|
|
490
|
-
const cwd =
|
|
553
|
+
const cwd = _viewRootOrReply(req, res, parsedUrl, parsed.project, parsed.instance);
|
|
554
|
+
if (!cwd) return;
|
|
491
555
|
const fullPath = join(cwd, filePath);
|
|
492
556
|
if (!existsSync(fullPath)) {
|
|
493
557
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
@@ -565,7 +629,8 @@ function revealFile(req, res, parsedUrl, isLocal, deps) {
|
|
|
565
629
|
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
566
630
|
return;
|
|
567
631
|
}
|
|
568
|
-
const cwd =
|
|
632
|
+
const cwd = _viewRootOrReply(req, res, parsedUrl, parsed.project, parsed.instance);
|
|
633
|
+
if (!cwd) return;
|
|
569
634
|
const fullPath = join(cwd, filePath);
|
|
570
635
|
if (!existsSync(fullPath)) {
|
|
571
636
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
@@ -623,7 +688,8 @@ function openFile(req, res, parsedUrl, isLocal, deps) {
|
|
|
623
688
|
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
624
689
|
return;
|
|
625
690
|
}
|
|
626
|
-
const cwd =
|
|
691
|
+
const cwd = _viewRootOrReply(req, res, parsedUrl, parsed.project, parsed.instance);
|
|
692
|
+
if (!cwd) return;
|
|
627
693
|
const fullPath = join(cwd, filePath);
|
|
628
694
|
if (!existsSync(fullPath)) {
|
|
629
695
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
@@ -671,12 +737,11 @@ function resolvePath(req, res, parsedUrl, isLocal, deps) {
|
|
|
671
737
|
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
672
738
|
return;
|
|
673
739
|
}
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
|
|
740
|
+
// Resolve a project-relative path against the same viewed root used by
|
|
741
|
+
// reveal/open. Registered projects and live instances use the shared
|
|
742
|
+
// resolver, so same-name projects cannot silently fall back to the bound cwd.
|
|
743
|
+
const cwd = _viewRootOrReply(req, res, parsedUrl, parsed.project, parsed.instance);
|
|
744
|
+
if (!cwd) return;
|
|
680
745
|
const fullPath = relPath ? join(cwd, relPath) : cwd;
|
|
681
746
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
682
747
|
res.end(JSON.stringify({ ok: true, fullPath }));
|
|
@@ -790,7 +855,8 @@ function createFile(req, res, parsedUrl, isLocal, deps) {
|
|
|
790
855
|
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
791
856
|
return;
|
|
792
857
|
}
|
|
793
|
-
const cwd =
|
|
858
|
+
const cwd = _viewRootOrReply(req, res, parsedUrl, parsed.project, parsed.instance);
|
|
859
|
+
if (!cwd) return;
|
|
794
860
|
const fullDirPath = relDir ? join(cwd, relDir) : cwd;
|
|
795
861
|
if (!existsSync(fullDirPath) || !statSync(fullDirPath).isDirectory()) {
|
|
796
862
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
@@ -838,7 +904,8 @@ function openTerminal(req, res, parsedUrl, isLocal, deps) {
|
|
|
838
904
|
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
839
905
|
return;
|
|
840
906
|
}
|
|
841
|
-
const cwd =
|
|
907
|
+
const cwd = _viewRootOrReply(req, res, parsedUrl, parsed.project, parsed.instance);
|
|
908
|
+
if (!cwd) return;
|
|
842
909
|
const fullDir = relDir ? join(cwd, relDir) : cwd;
|
|
843
910
|
if (!existsSync(fullDir) || !statSync(fullDir).isDirectory()) {
|
|
844
911
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
@@ -860,23 +927,40 @@ function openTerminal(req, res, parsedUrl, isLocal, deps) {
|
|
|
860
927
|
} else {
|
|
861
928
|
// Linux: try common terminal emulators
|
|
862
929
|
const terminals = ['gnome-terminal', 'konsole', 'xfce4-terminal', 'xterm'];
|
|
863
|
-
|
|
864
|
-
|
|
930
|
+
// spawn reports a missing executable asynchronously, outside try/catch.
|
|
931
|
+
// Probe each candidate with piped stdio so the next one can be attempted
|
|
932
|
+
// after ENOENT; detach only after a successful spawn.
|
|
933
|
+
const tryTerminal = (index) => {
|
|
934
|
+
if (index >= terminals.length) {
|
|
935
|
+
const child = spawn('xdg-open', [fullDir], { stdio: 'ignore', detached: true });
|
|
936
|
+
child.on('error', (err) => { reportSwallowed('filesFs.openTerminal', err, { terminal: 'xdg-open' }); });
|
|
937
|
+
child.unref();
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
const term = terminals[index];
|
|
941
|
+
let child;
|
|
865
942
|
try {
|
|
866
943
|
if (term === 'gnome-terminal') {
|
|
867
|
-
spawn(term, ['--working-directory=' + fullDir], { stdio: '
|
|
944
|
+
child = spawn(term, ['--working-directory=' + fullDir], { stdio: 'pipe' });
|
|
868
945
|
} else if (term === 'konsole') {
|
|
869
|
-
spawn(term, ['--workdir', fullDir], { stdio: '
|
|
946
|
+
child = spawn(term, ['--workdir', fullDir], { stdio: 'pipe' });
|
|
870
947
|
} else {
|
|
871
|
-
spawn(term, [], { cwd: fullDir, stdio: '
|
|
948
|
+
child = spawn(term, [], { cwd: fullDir, stdio: 'pipe' });
|
|
872
949
|
}
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
950
|
+
} catch {
|
|
951
|
+
tryTerminal(index + 1);
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
child.once('error', (err) => {
|
|
955
|
+
reportSwallowed('filesFs.openTerminal', err, { terminal: term });
|
|
956
|
+
tryTerminal(index + 1);
|
|
957
|
+
});
|
|
958
|
+
child.once('spawn', () => {
|
|
959
|
+
for (const stream of [child.stdin, child.stdout, child.stderr]) stream?.destroy();
|
|
960
|
+
child.unref();
|
|
961
|
+
});
|
|
962
|
+
};
|
|
963
|
+
tryTerminal(0);
|
|
880
964
|
}
|
|
881
965
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
882
966
|
res.end(JSON.stringify({ ok: true }));
|
|
@@ -915,7 +999,8 @@ function createDir(req, res, parsedUrl, isLocal, deps) {
|
|
|
915
999
|
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
916
1000
|
return;
|
|
917
1001
|
}
|
|
918
|
-
const cwd =
|
|
1002
|
+
const cwd = _viewRootOrReply(req, res, parsedUrl, parsed.project, parsed.instance);
|
|
1003
|
+
if (!cwd) return;
|
|
919
1004
|
const fullDirPath = relDir ? join(cwd, relDir) : cwd;
|
|
920
1005
|
if (!existsSync(fullDirPath) || !statSync(fullDirPath).isDirectory()) {
|
|
921
1006
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
@@ -965,8 +1050,9 @@ function openProfileDir(req, res) {
|
|
|
965
1050
|
res.end(JSON.stringify({ ok: true, dir }));
|
|
966
1051
|
}
|
|
967
1052
|
|
|
968
|
-
function openProjectDir(req, res) {
|
|
969
|
-
const dir =
|
|
1053
|
+
function openProjectDir(req, res, parsedUrl) {
|
|
1054
|
+
const dir = _viewRootOrReply(req, res, parsedUrl);
|
|
1055
|
+
if (!dir) return;
|
|
970
1056
|
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
|
|
971
1057
|
execFile(cmd, [dir], { windowsHide: true }, () => {});
|
|
972
1058
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
package/server/routes/resume.js
CHANGED
|
@@ -560,7 +560,7 @@ function postResumeSessionHandler(req, res, parsedUrl, isLocal, deps) {
|
|
|
560
560
|
if (r.ok) {
|
|
561
561
|
// Session identity just changed — drop any still-queued composer messages so a
|
|
562
562
|
// message typed for the OLD conversation never drains into the resumed one.
|
|
563
|
-
try { chatQueue.clear(); } catch (err) { reportSwallowed('resume-route.clear-queue', err); }
|
|
563
|
+
try { chatQueue.clear({ project: targetProject || undefined, instanceKey: instanceKey || undefined }); } catch (err) { reportSwallowed('resume-route.clear-queue', err); }
|
|
564
564
|
sendJson(res, 200, { ok: true });
|
|
565
565
|
return;
|
|
566
566
|
}
|
|
@@ -2,14 +2,25 @@
|
|
|
2
2
|
// the automatic turn-end/compact refreshes flow over the WS broadcast instead. A
|
|
3
3
|
// `session-inactive` outcome is a NORMAL state (no turn run yet / query dead), so it
|
|
4
4
|
// returns 200 with { ok:false, reason } rather than an HTTP error.
|
|
5
|
+
//
|
|
6
|
+
// Multi-session: `?project=` and `?instance=` pin the pull to the VIEWED workspace (same
|
|
7
|
+
// query-param names as the other view-scoped routes). No params → active session. Unknown
|
|
8
|
+
// or ambiguous anchors stay `session-inactive` (never fall back to the active session).
|
|
5
9
|
import { reportSwallowed } from '../lib/error-report.js';
|
|
6
10
|
|
|
7
|
-
async function sdkContextUsage(req, res) {
|
|
11
|
+
async function sdkContextUsage(req, res, parsedUrl) {
|
|
8
12
|
try {
|
|
9
13
|
// Dynamic import: sdk-manager is only loaded in SDK mode (resume.js parity). The
|
|
10
14
|
// literal specifier satisfies the boundary verifier's R7 rule.
|
|
11
15
|
const sdk = await import('../lib/sdk-manager.js');
|
|
12
|
-
const
|
|
16
|
+
const params = parsedUrl && parsedUrl.searchParams;
|
|
17
|
+
const project = (params && params.get('project')) || '';
|
|
18
|
+
const instance = (params && params.get('instance')) || '';
|
|
19
|
+
// No params must stay `undefined` so fetchContextUsage keeps the active-session fallback.
|
|
20
|
+
const anchor = (project || instance)
|
|
21
|
+
? { project: project || undefined, instanceKey: instance || undefined }
|
|
22
|
+
: undefined;
|
|
23
|
+
const r = await sdk.fetchContextUsage(anchor);
|
|
13
24
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
14
25
|
res.end(JSON.stringify(r.ok ? { ok: true, snapshot: r.snapshot } : { ok: false, reason: r.reason }));
|
|
15
26
|
} catch (err) {
|