cc-viewer 1.9.14 → 1.9.16
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 +1 -0
- package/concepts/ar/UltraPlan.md +9 -9
- package/concepts/da/UltraPlan.md +9 -9
- package/concepts/de/UltraPlan.md +9 -9
- package/concepts/en/UltraPlan.md +9 -9
- package/concepts/es/UltraPlan.md +9 -9
- package/concepts/fr/UltraPlan.md +9 -9
- package/concepts/it/UltraPlan.md +9 -9
- package/concepts/ja/UltraPlan.md +9 -9
- package/concepts/ko/UltraPlan.md +9 -9
- package/concepts/no/UltraPlan.md +9 -9
- package/concepts/pl/UltraPlan.md +9 -9
- package/concepts/pt-BR/UltraPlan.md +9 -9
- package/concepts/ru/UltraPlan.md +9 -9
- package/concepts/th/UltraPlan.md +9 -9
- package/concepts/tr/UltraPlan.md +9 -9
- package/concepts/uk/UltraPlan.md +9 -9
- package/concepts/zh/UltraPlan.md +9 -9
- package/concepts/zh-TW/UltraPlan.md +9 -9
- package/dist/assets/App-BvVdj099.css +1 -0
- package/dist/assets/App-CYdOioAz.js +2 -0
- package/dist/assets/{MdxEditorPanel-DI3B4gsM.js → MdxEditorPanel-CuCAe1OG.js} +1 -1
- package/dist/assets/Mobile-CfNYOgRa.js +1 -0
- package/dist/assets/{ProxyStatsModal-B8K0AtAq.js → ProxyStatsModal-Dhj4EVnh.js} +1 -1
- package/dist/assets/index-sgW8MlD3.js +2 -0
- package/dist/assets/seqResourceLoaders-B-5xGs9q.css +41 -0
- package/dist/assets/seqResourceLoaders-Cj0pdz-l.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/ask/perm-bridge.js +4 -1
- package/server/lib/auto-approve.js +160 -0
- package/server/lib/chat-queue.js +350 -120
- package/server/lib/file-access-policy.js +24 -2
- package/server/lib/scheduled-task-executor.js +3 -1
- package/server/lib/sdk-manager.js +377 -76
- 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/ask-perm.js +54 -3
- package/server/routes/files-content.js +352 -16
- 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/routes/skills.js +19 -8
- package/server/server.js +205 -34
- package/server/system-prompt-templates/presets/kimi-k3.md +1 -0
- package/ultraAgents/code-expert.json +1 -1
- 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/index-CU0gt6m1.js +0 -2
- package/dist/assets/seqResourceLoaders-CxUFE36z.css +0 -41
- package/dist/assets/seqResourceLoaders-nGCs11zu.js +0 -2
|
@@ -1,26 +1,36 @@
|
|
|
1
1
|
// Read-oriented file-access routes (moved verbatim from server.js handleRequest).
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, realpathSync, createReadStream } from 'node:fs';
|
|
3
|
-
import { join, basename, dirname, resolve, sep } from 'node:path';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, realpathSync, createReadStream, readdirSync } from 'node:fs';
|
|
3
|
+
import { join, basename, dirname, resolve, relative, sep } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
4
5
|
import { isReadAllowed, reasonToStatus } from '../lib/file-access-policy.js';
|
|
5
|
-
import { ERROR_STATUS_MAP } from '../lib/file-api.js';
|
|
6
|
-
import {
|
|
6
|
+
import { ERROR_STATUS_MAP, isPathContained } from '../lib/file-api.js';
|
|
7
|
+
import { listSkills } from '../lib/skills-api.js';
|
|
8
|
+
import { viewRootOrReply, resolveViewRoot } from '../lib/view-root.js';
|
|
9
|
+
import { findSdkSessionByInstance } from '../lib/sdk-session-index.js';
|
|
10
|
+
import { projectKeyForCwd } from '../lib/system-prompt-snapshots.js';
|
|
7
11
|
import { loadWorkspaces } from '../workspace-registry.js';
|
|
8
12
|
import { listLivePtys } from '../pty-manager.js';
|
|
9
13
|
import { reportSwallowed } from '../lib/error-report.js';
|
|
10
14
|
import { discoverClaudeMdCandidates, readCandidateById } from '../lib/claude-md-discovery.js';
|
|
11
15
|
import { getClaudeConfigDir } from '../../findcc.js';
|
|
12
16
|
import { _projectName } from '../interceptor.js';
|
|
17
|
+
import { isAdminReq } from '../lib/is-admin.js';
|
|
18
|
+
import { isSameOriginBrowserRequest } from '../lib/same-origin.js';
|
|
13
19
|
|
|
14
|
-
// Multi-project
|
|
15
|
-
//
|
|
16
|
-
//
|
|
20
|
+
// Multi-project file operations use the same viewed root for reads and writes.
|
|
21
|
+
// Every file-browser mutation enforces realpath containment in that root;
|
|
22
|
+
// explicit editor sessions retain their existing allowlisted external paths.
|
|
17
23
|
// their tests (and the dispatcher's sync-call convention) assert on res
|
|
18
24
|
// immediately after the call — the override must not async-ify them.
|
|
19
|
-
|
|
25
|
+
// Both reads and writes resolve the viewed project. Absolute editor paths are
|
|
26
|
+
// still governed by the allowlist; browser writes stay within the selected root.
|
|
27
|
+
function _viewRootOrReply(req, res, parsedUrl, bodyProject, bodyInstance) {
|
|
20
28
|
return viewRootOrReply(req, res, parsedUrl, {
|
|
21
29
|
boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
|
|
22
30
|
loadWorkspaces,
|
|
23
31
|
listLivePtys,
|
|
32
|
+
bodyProject,
|
|
33
|
+
bodyInstance,
|
|
24
34
|
});
|
|
25
35
|
}
|
|
26
36
|
|
|
@@ -167,6 +177,37 @@ function projectMemory(req, res, parsedUrl) {
|
|
|
167
177
|
projectMemoryEntry(req, res, parsedUrl, respondJson);
|
|
168
178
|
}
|
|
169
179
|
|
|
180
|
+
// The SDK context snapshot prices MEMORY.md itself, not every linked detail file.
|
|
181
|
+
// Supplement only the index's existing single-segment .md links with a transparent
|
|
182
|
+
// content-length estimate; never allocate the SDK total across unrelated files.
|
|
183
|
+
export function estimateLinkedMemoryTokens(content, dir) {
|
|
184
|
+
const estimates = {};
|
|
185
|
+
const MAX_BYTES = 512 * 1024;
|
|
186
|
+
const MAX_LINKS = 100;
|
|
187
|
+
if (typeof content !== 'string' || typeof dir !== 'string') return estimates;
|
|
188
|
+
const links = new Set();
|
|
189
|
+
for (const match of content.matchAll(/\]\(\s*([^)\s]+\.md)(?:[?#][^)\s]*)?(?:\s+"[^"]*")?\s*\)/gi)) {
|
|
190
|
+
let name;
|
|
191
|
+
try { name = decodeURIComponent(match[1].split('#')[0].split('?')[0]); } catch { continue; }
|
|
192
|
+
if (!name || name.includes('/') || name.includes('\\') || name.includes('\0') || name.startsWith('.')) continue;
|
|
193
|
+
links.add(name);
|
|
194
|
+
if (links.size >= MAX_LINKS) break;
|
|
195
|
+
}
|
|
196
|
+
let realDir;
|
|
197
|
+
try { realDir = realpathSync(dir); } catch { return estimates; }
|
|
198
|
+
const prefix = realDir.endsWith(sep) ? realDir : realDir + sep;
|
|
199
|
+
for (const name of links) {
|
|
200
|
+
try {
|
|
201
|
+
const realFile = realpathSync(join(realDir, name));
|
|
202
|
+
const stat = statSync(realFile);
|
|
203
|
+
if (!realFile.startsWith(prefix) || !stat.isFile() || stat.size > MAX_BYTES) continue;
|
|
204
|
+
if (!isReadAllowed(realFile).ok) continue;
|
|
205
|
+
estimates[name] = { tokens: Math.ceil(stat.size / 4), source: 'file-content-estimate' };
|
|
206
|
+
} catch { /* Missing/unreadable links remain without an estimate. */ }
|
|
207
|
+
}
|
|
208
|
+
return estimates;
|
|
209
|
+
}
|
|
210
|
+
|
|
170
211
|
// The entry wraps BOTH the view-root resolution and the read in the endpoint's
|
|
171
212
|
// single error surface (the searchParams-throw defense test asserts a 500 from
|
|
172
213
|
// THIS catch, regardless of which stage throws).
|
|
@@ -202,7 +243,9 @@ function projectMemoryWithRoot(req, res, parsedUrl, respondJson, cwd) {
|
|
|
202
243
|
if (!st.isFile()) return respondJson(400, { error: 'Not a file' });
|
|
203
244
|
if (st.size > MAX_BYTES) return respondJson(413, { error: 'File too large' });
|
|
204
245
|
const content = readFileSync(policy.real, 'utf-8');
|
|
205
|
-
|
|
246
|
+
// Only SDK mode consumes these estimates. PTY keeps the original cheap index read.
|
|
247
|
+
const memoryTokenEstimates = process.env.CCV_SDK_MODE === '1' ? estimateLinkedMemoryTokens(content, dir) : undefined;
|
|
248
|
+
return respondJson(200, { exists: true, dir, indexPath, content, memoryTokenEstimates });
|
|
206
249
|
}
|
|
207
250
|
|
|
208
251
|
// 明细文件: 仅接受单段 basename + .md 后缀
|
|
@@ -231,7 +274,7 @@ function projectMemoryWithRoot(req, res, parsedUrl, respondJson, cwd) {
|
|
|
231
274
|
if (!st.isFile()) return respondJson(400, { error: 'Not a file' });
|
|
232
275
|
if (st.size > MAX_BYTES) return respondJson(413, { error: 'File too large' });
|
|
233
276
|
const content = readFileSync(realFile, 'utf-8');
|
|
234
|
-
return respondJson(200, { name: fileParam, path: realFile, content });
|
|
277
|
+
return respondJson(200, { name: fileParam, path: realFile, content, tokens: Math.ceil(Buffer.byteLength(content, 'utf8') / 4), tokenSource: 'file-content-estimate' });
|
|
235
278
|
} catch (err) {
|
|
236
279
|
// 与 /api/file-content 一致:已知 errno(ENOENT/EACCES 等)走 ERROR_STATUS_MAP 映射;
|
|
237
280
|
// 500 时不回显 err.message —— 可能含 ~/.claude/projects/<encoded>/memory/ 路径片段。
|
|
@@ -311,13 +354,51 @@ function fileRaw(req, res, parsedUrl) {
|
|
|
311
354
|
const url = parsedUrl.pathname;
|
|
312
355
|
const method = req.method;
|
|
313
356
|
let reqPath;
|
|
314
|
-
|
|
357
|
+
let scopedProject = '';
|
|
358
|
+
let scopedInstance = '';
|
|
359
|
+
if (url.startsWith('/api/file-raw/view/')) {
|
|
360
|
+
const parts = url.slice('/api/file-raw/view/'.length).split('/');
|
|
361
|
+
let project = '';
|
|
362
|
+
let instance = '';
|
|
363
|
+
try {
|
|
364
|
+
project = decodeURIComponent(parts[0] || '');
|
|
365
|
+
instance = decodeURIComponent(parts[1] || '');
|
|
366
|
+
reqPath = decodeURIComponent(parts.slice(2).join('/'));
|
|
367
|
+
} catch {
|
|
368
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
369
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
scopedProject = project;
|
|
373
|
+
scopedInstance = instance;
|
|
374
|
+
} else if (url.startsWith('/api/file-raw/')) {
|
|
315
375
|
const tail = parsedUrl.pathname.slice('/api/file-raw/'.length);
|
|
316
376
|
try { reqPath = decodeURIComponent(tail); } catch { reqPath = tail; }
|
|
317
377
|
} else {
|
|
318
378
|
reqPath = parsedUrl.searchParams.get('path');
|
|
319
379
|
}
|
|
320
|
-
|
|
380
|
+
let resolvedView = null;
|
|
381
|
+
if (scopedProject) {
|
|
382
|
+
resolvedView = resolveViewRoot({
|
|
383
|
+
projectParam: scopedProject,
|
|
384
|
+
instanceParam: scopedInstance,
|
|
385
|
+
boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
|
|
386
|
+
loadWorkspaces,
|
|
387
|
+
listLivePtys,
|
|
388
|
+
});
|
|
389
|
+
// The path already pins its project. A stale/mismatched instance must not
|
|
390
|
+
// silently redirect it to the bound project or a different workspace.
|
|
391
|
+
const instanceRecord = scopedInstance ? findSdkSessionByInstance(scopedInstance) : null;
|
|
392
|
+
const instanceMatches = !scopedInstance || (instanceRecord
|
|
393
|
+
&& (instanceRecord.projectName === scopedProject
|
|
394
|
+
|| projectKeyForCwd(instanceRecord.cwd) === scopedProject));
|
|
395
|
+
if (!resolvedView.ok || (scopedInstance && !instanceMatches)) {
|
|
396
|
+
res.writeHead(resolvedView.ok ? 400 : resolvedView.status, { 'Content-Type': 'application/json' });
|
|
397
|
+
res.end(JSON.stringify({ error: resolvedView.ok ? 'Project scope mismatch' : resolvedView.error }));
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const cwd = resolvedView?.root || _viewRootOrReply(req, res, parsedUrl);
|
|
321
402
|
if (!cwd) return;
|
|
322
403
|
fileRawWithRoot(req, res, parsedUrl, reqPath, cwd, method);
|
|
323
404
|
}
|
|
@@ -460,7 +541,11 @@ function fileDownloadWithRoot(req, res, parsedUrl, reqPath, cwd) {
|
|
|
460
541
|
}
|
|
461
542
|
}
|
|
462
543
|
|
|
463
|
-
function fileContentPost(req, res) {
|
|
544
|
+
function fileContentPost(req, res, parsedUrl, isLocal) {
|
|
545
|
+
// The shared read root keeps its raw cwd for projectMemory's store-key
|
|
546
|
+
// encoding. Normalize only this mutation's containment comparisons.
|
|
547
|
+
let writeRoot;
|
|
548
|
+
const isInsideWriteRoot = (path) => isPathContained(path, writeRoot);
|
|
464
549
|
const MAX_BODY = 5 * 1024 * 1024; // 5MB,与 GET 路由限制对齐
|
|
465
550
|
let body = '';
|
|
466
551
|
let overflow = false;
|
|
@@ -475,8 +560,10 @@ function fileContentPost(req, res) {
|
|
|
475
560
|
return;
|
|
476
561
|
}
|
|
477
562
|
try {
|
|
478
|
-
const { path: reqPath, content } = JSON.parse(body);
|
|
479
|
-
const cwd =
|
|
563
|
+
const { path: reqPath, content, editorSession } = JSON.parse(body);
|
|
564
|
+
const cwd = _viewRootOrReply(req, res, parsedUrl);
|
|
565
|
+
if (!cwd) return;
|
|
566
|
+
try { writeRoot = realpathSync(cwd); } catch { writeRoot = cwd; }
|
|
480
567
|
if (!reqPath) {
|
|
481
568
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
482
569
|
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
@@ -489,7 +576,6 @@ function fileContentPost(req, res) {
|
|
|
489
576
|
}
|
|
490
577
|
const isAbs = /^([a-zA-Z]:[\\/]|[\\/])/.test(reqPath);
|
|
491
578
|
const absPath = isAbs ? reqPath : resolve(cwd, reqPath);
|
|
492
|
-
|
|
493
579
|
// 写路径同样走 policy(收敛 editorSession 后门):允许覆盖现有文件;
|
|
494
580
|
// 新文件场景递归向上找最近存在的祖先目录,只要它在 allowlist 内即放行。
|
|
495
581
|
// (旧实现只查 immediate parent,父目录也不存在时误拒嵌套新建。)
|
|
@@ -525,6 +611,20 @@ function fileContentPost(req, res) {
|
|
|
525
611
|
res.end(JSON.stringify(body));
|
|
526
612
|
return;
|
|
527
613
|
}
|
|
614
|
+
// Check containment before creating any missing parent directories.
|
|
615
|
+
// Explicit $EDITOR sessions retain their allowlisted external paths.
|
|
616
|
+
// editorSession is client-claimed — require admin to honor it, otherwise
|
|
617
|
+
// any caller could use it as a write-root bypass (see /api/target-files).
|
|
618
|
+
if (editorSession && !isAdminReq(req, isLocal)) {
|
|
619
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
620
|
+
res.end(JSON.stringify({ error: 'Forbidden', reason: 'editor-session-requires-admin' }));
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (!editorSession && !isInsideWriteRoot(ancestorPolicy.real)) {
|
|
624
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
625
|
+
res.end(JSON.stringify({ error: 'Forbidden', reason: 'outside-view-root' }));
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
528
628
|
// 在祖先 real 路径下重建目标。Denylist 已在祖先 prefix 层生效,
|
|
529
629
|
// 这里相信 allowlist 祖先的合法性(项目目录 / ~/.claude/cc-viewer 等)。
|
|
530
630
|
targetReal = join(ancestorPolicy.real, ...descent);
|
|
@@ -539,6 +639,16 @@ function fileContentPost(req, res) {
|
|
|
539
639
|
return;
|
|
540
640
|
}
|
|
541
641
|
|
|
642
|
+
if (editorSession && !isAdminReq(req, isLocal)) {
|
|
643
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
644
|
+
res.end(JSON.stringify({ error: 'Forbidden', reason: 'editor-session-requires-admin' }));
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (!editorSession && existsSync(targetReal) && !isInsideWriteRoot(targetReal)) {
|
|
648
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
649
|
+
res.end(JSON.stringify({ error: 'Forbidden', reason: 'outside-view-root' }));
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
542
652
|
writeFileSync(targetReal, content, 'utf-8');
|
|
543
653
|
const stat = statSync(targetReal);
|
|
544
654
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -552,6 +662,231 @@ function fileContentPost(req, res) {
|
|
|
552
662
|
});
|
|
553
663
|
}
|
|
554
664
|
|
|
665
|
+
function targetError(status, message) {
|
|
666
|
+
return Object.assign(new Error(message), { status });
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Map fs errno to a sensible HTTP status: missing → 404, permission → 403,
|
|
670
|
+
// symlink loop → 400 (treat as malformed), anything else → 404 (don't leak).
|
|
671
|
+
function errnoToStatus(err) {
|
|
672
|
+
const code = err?.code;
|
|
673
|
+
if (code === 'EACCES' || code === 'EPERM') return 403;
|
|
674
|
+
if (code === 'ELOOP') return 400;
|
|
675
|
+
return 404;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function realDirectory(path, label) {
|
|
679
|
+
let real;
|
|
680
|
+
try { real = realpathSync(path); } catch (err) { throw targetError(errnoToStatus(err), `${label} not found`); }
|
|
681
|
+
let st;
|
|
682
|
+
try { st = statSync(real); } catch (err) { throw targetError(errnoToStatus(err), `${label} not found`); }
|
|
683
|
+
if (!st.isDirectory()) throw targetError(400, `${label} is not a directory`);
|
|
684
|
+
return real;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function checkedDirectory(path, label) {
|
|
688
|
+
const real = realDirectory(path, label);
|
|
689
|
+
const policy = isReadAllowed(real);
|
|
690
|
+
if (!policy.ok) throw targetError(reasonToStatus(policy.reason), `${label} not readable`);
|
|
691
|
+
return policy.real;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function insidePath(path, root) {
|
|
695
|
+
return path === root || path.startsWith(root + sep);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function targetNavigation(targetReal, cwd) {
|
|
699
|
+
const projectReal = realDirectory(cwd, 'Project');
|
|
700
|
+
if (insidePath(targetReal, projectReal)) return { rootReal: projectReal, rootKind: 'project' };
|
|
701
|
+
// $HOME becomes the navigation root for home-resident targets (user skills,
|
|
702
|
+
// plugins, the global CLAUDE.md). Entries outside the allowlist (e.g. ~/.ssh,
|
|
703
|
+
// ~/.aws) are filtered per-entry by isReadAllowed, and sensitive paths inside
|
|
704
|
+
// ~/.claude are covered by file-access-policy's denylist. The /api/target-files
|
|
705
|
+
// handler additionally gates this endpoint behind isAdminReq + same-origin so
|
|
706
|
+
// a cross-site page cannot enumerate the user's home tree.
|
|
707
|
+
const homeReal = realDirectory(homedir(), 'Home');
|
|
708
|
+
if (insidePath(targetReal, homeReal)) {
|
|
709
|
+
checkedDirectory(targetReal, 'Directory');
|
|
710
|
+
return { rootReal: homeReal, rootKind: 'home' };
|
|
711
|
+
}
|
|
712
|
+
const configReal = realDirectory(getClaudeConfigDir(), 'Claude config');
|
|
713
|
+
if (insidePath(targetReal, configReal)) {
|
|
714
|
+
checkedDirectory(targetReal, 'Directory');
|
|
715
|
+
return { rootReal: realDirectory(dirname(configReal), 'Config root'), rootKind: 'config' };
|
|
716
|
+
}
|
|
717
|
+
// Allowlist fallback: targets that live inside another allowlisted root
|
|
718
|
+
// (registered workspaces, /tmp/cc-viewer-uploads, etc.) are reachable via
|
|
719
|
+
// /api/claude-md's discovery (which uses isReadAllowed), so we must not
|
|
720
|
+
// 403 them here. Use the target's own directory as the navigation root —
|
|
721
|
+
// no parent browsing, but at least the entry the user clicked actually opens.
|
|
722
|
+
const policy = isReadAllowed(targetReal);
|
|
723
|
+
if (!policy.ok) throw targetError(reasonToStatus(policy.reason), 'Target not readable');
|
|
724
|
+
return { rootReal: realDirectory(targetReal, 'Target'), rootKind: 'target' };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function targetScope(targetReal, cwd) {
|
|
728
|
+
const navigation = targetNavigation(targetReal, cwd);
|
|
729
|
+
return { ...navigation, targetPath: relative(navigation.rootReal, targetReal).split(sep).join('/') };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function targetRelPath(raw) {
|
|
733
|
+
if (raw == null) return null;
|
|
734
|
+
if (raw === '') return [];
|
|
735
|
+
if (typeof raw !== 'string' || raw.length > 2048 || raw.includes('\0') || raw.includes('\\')
|
|
736
|
+
|| raw.startsWith('/') || /^[a-zA-Z]:/.test(raw)) {
|
|
737
|
+
throw targetError(400, 'Invalid path');
|
|
738
|
+
}
|
|
739
|
+
const segments = raw.split('/');
|
|
740
|
+
if (segments.some(segment => !segment || segment === '.' || segment === '..')) {
|
|
741
|
+
throw targetError(400, 'Invalid path');
|
|
742
|
+
}
|
|
743
|
+
// Hard cap on navigation depth: each segment triggers at least one
|
|
744
|
+
// synchronous readdir + per-entry realpath/stat in `ancestors`, and a 2KB
|
|
745
|
+
// free-form path could otherwise fan out into hundreds of syscalls per
|
|
746
|
+
// request. 32 is far deeper than any realistic skill / CLAUDE.md location.
|
|
747
|
+
if (segments.length > 32) throw targetError(400, 'Path too deep');
|
|
748
|
+
return segments;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function resolveTargetRoot(parsedUrl, cwd) {
|
|
752
|
+
const kind = parsedUrl.searchParams.get('kind');
|
|
753
|
+
if (kind === 'claude-md') {
|
|
754
|
+
const id = parsedUrl.searchParams.get('id') || '';
|
|
755
|
+
if (!/^[0-9a-f]{12}$/i.test(id)) throw targetError(400, 'Invalid id');
|
|
756
|
+
const candidate = discoverClaudeMdCandidates({
|
|
757
|
+
cwd,
|
|
758
|
+
claudeConfigDir: getClaudeConfigDir(),
|
|
759
|
+
isReadAllowedFn: isReadAllowed,
|
|
760
|
+
}).find(entry => entry.id === id);
|
|
761
|
+
if (!candidate || basename(candidate.realPath) !== 'CLAUDE.md') throw targetError(404, 'Target not found');
|
|
762
|
+
return { ...targetScope(realDirectory(dirname(candidate.realPath), 'Directory'), cwd), targetKind: 'claude-md' };
|
|
763
|
+
}
|
|
764
|
+
if (kind !== 'skill') throw targetError(400, 'Invalid target kind');
|
|
765
|
+
const source = parsedUrl.searchParams.get('source');
|
|
766
|
+
const targetPath = parsedUrl.searchParams.get('targetPath');
|
|
767
|
+
if (!['user', 'project', 'plugin'].includes(source)) throw targetError(400, 'Invalid skill source');
|
|
768
|
+
if (typeof targetPath !== 'string' || !targetPath || targetPath.length > 4096 || targetPath.includes('\0')) {
|
|
769
|
+
throw targetError(400, 'Invalid skill path');
|
|
770
|
+
}
|
|
771
|
+
const skill = listSkills({ projectDir: cwd })
|
|
772
|
+
.find(entry => entry.source === source && entry.path === targetPath);
|
|
773
|
+
if (!skill) throw targetError(404, 'Target not found');
|
|
774
|
+
const rootReal = realDirectory(skill.path, 'Directory');
|
|
775
|
+
const scope = targetScope(rootReal, cwd);
|
|
776
|
+
const anchor = source === 'project'
|
|
777
|
+
? scope.rootReal
|
|
778
|
+
: dirname(dirname(skill.path));
|
|
779
|
+
const anchorReal = realDirectory(anchor, 'Skill root');
|
|
780
|
+
if (!insidePath(rootReal, anchorReal)) throw targetError(403, 'Target outside skill root');
|
|
781
|
+
// Plugin root must follow the configured Claude config dir, not the default
|
|
782
|
+
// homedir()/.claude — otherwise plugin skills break under CLAUDE_CONFIG_DIR.
|
|
783
|
+
if (source === 'plugin' && !insidePath(rootReal, realDirectory(join(getClaudeConfigDir(), 'plugins'), 'Plugin root'))) {
|
|
784
|
+
throw targetError(403, 'Target outside plugin root');
|
|
785
|
+
}
|
|
786
|
+
return { ...scope, targetKind: 'skill' };
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function listTargetDirectory(rootReal, segments, deps, requireReadable = true) {
|
|
790
|
+
const dirReal = requireReadable
|
|
791
|
+
? checkedDirectory(segments.length ? join(rootReal, ...segments) : rootReal, 'Directory')
|
|
792
|
+
: realDirectory(segments.length ? join(rootReal, ...segments) : rootReal, 'Directory');
|
|
793
|
+
if (!isPathContained(dirReal, rootReal)) throw targetError(403, 'Path outside target');
|
|
794
|
+
const ignored = deps?.IGNORED_PATTERNS || new Set();
|
|
795
|
+
const entries = [];
|
|
796
|
+
let dirents;
|
|
797
|
+
try {
|
|
798
|
+
dirents = readdirSync(dirReal, { withFileTypes: true });
|
|
799
|
+
} catch (err) {
|
|
800
|
+
// EACCES → 403 (permission), ENOENT/ENOTDIR → 404 (race with rename/delete),
|
|
801
|
+
// anything else falls back to 404 so we don't leak internal state via 500.
|
|
802
|
+
throw targetError(errnoToStatus(err), 'Directory not readable');
|
|
803
|
+
}
|
|
804
|
+
// Hard cap on returned entries: this is a synchronous loop with realpath +
|
|
805
|
+
// stat + isReadAllowed per entry, and the endpoint fans out via `ancestors`,
|
|
806
|
+
// so an unbounded listing of a huge dir (e.g. ~/.claude/projects/<hash>/)
|
|
807
|
+
// would block the event loop and return a giant JSON payload. 5000 is well
|
|
808
|
+
// past any realistic skill/CLAUDE.md folder.
|
|
809
|
+
const MAX_ENTRIES = 5000;
|
|
810
|
+
for (const entry of dirents) {
|
|
811
|
+
if (entries.length >= MAX_ENTRIES) break;
|
|
812
|
+
if (ignored.has(entry.name)) continue;
|
|
813
|
+
const fullPath = join(dirReal, entry.name);
|
|
814
|
+
let real;
|
|
815
|
+
try { real = realpathSync(fullPath); } catch (err) { reportSwallowed('targetFiles.entry.realpath', err); continue; }
|
|
816
|
+
if (!insidePath(real, rootReal)) continue;
|
|
817
|
+
const policy = isReadAllowed(real);
|
|
818
|
+
if (!policy.ok) continue;
|
|
819
|
+
let st;
|
|
820
|
+
try { st = statSync(policy.real); } catch (err) { reportSwallowed('targetFiles.entry.stat', err); continue; }
|
|
821
|
+
if (!st.isDirectory() && !st.isFile()) continue;
|
|
822
|
+
entries.push({ name: entry.name, type: st.isDirectory() ? 'directory' : 'file', absPath: policy.real });
|
|
823
|
+
}
|
|
824
|
+
entries.sort((a, b) => a.type !== b.type
|
|
825
|
+
? (a.type === 'directory' ? -1 : 1)
|
|
826
|
+
: a.name.localeCompare(b.name));
|
|
827
|
+
return entries;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function targetFiles(req, res, parsedUrl, isLocal, deps) {
|
|
831
|
+
try {
|
|
832
|
+
// /api/target-files exposes directory listings under the user's home and
|
|
833
|
+
// ~/.claude (which contains transcripts, prompt history, env snapshots).
|
|
834
|
+
// Combined with the global `Access-Control-Allow-Origin: *` and loopback's
|
|
835
|
+
// no-auth path, a bare GET would let any web page enumerate the user's
|
|
836
|
+
// ~/.claude tree. Require admin AND a same-origin browser request.
|
|
837
|
+
if (!isAdminReq(req, isLocal) || !isSameOriginBrowserRequest(req, parsedUrl)) {
|
|
838
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
839
|
+
res.end(JSON.stringify({ error: 'forbidden' }));
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
const cwd = _viewRootOrReply(req, res, parsedUrl);
|
|
843
|
+
if (!cwd) return;
|
|
844
|
+
const scope = resolveTargetRoot(parsedUrl, cwd);
|
|
845
|
+
const rawPath = parsedUrl.searchParams.get('path');
|
|
846
|
+
const requestedSegments = targetRelPath(rawPath);
|
|
847
|
+
const segments = requestedSegments === null ? scope.targetPath.split('/').filter(Boolean) : requestedSegments;
|
|
848
|
+
const entries = listTargetDirectory(scope.rootReal, segments, deps, segments.length > 0 || scope.rootKind !== 'home');
|
|
849
|
+
const rootEntries = requestedSegments === null && segments.length > 0
|
|
850
|
+
? listTargetDirectory(scope.rootReal, [], deps, scope.rootKind === 'project')
|
|
851
|
+
: entries;
|
|
852
|
+
const targetEntries = requestedSegments === null
|
|
853
|
+
? entries.filter(entry => insidePath(entry.absPath, join(scope.rootReal, ...segments)))
|
|
854
|
+
: entries;
|
|
855
|
+
const targetSegments = scope.targetPath ? scope.targetPath.split('/') : [];
|
|
856
|
+
const ancestors = [];
|
|
857
|
+
// Ancestors are the chain from the navigation root down to the directory
|
|
858
|
+
// being listed. When the caller passed no `path` that's the chain leading
|
|
859
|
+
// to the target itself; when they passed `path`, the chain is the requested
|
|
860
|
+
// segments. We deliberately DO NOT use path.relative(targetPath, rawPath)
|
|
861
|
+
// here — that helper happily produces "../.." for sibling branches, which
|
|
862
|
+
// would pollute the cache with pseudo-paths and re-list directories the
|
|
863
|
+
// user never navigated into.
|
|
864
|
+
const chainSegments = requestedSegments === null ? targetSegments : segments;
|
|
865
|
+
// Request-scoped memo: targetFiles lists the same directory multiple times
|
|
866
|
+
// (entries + ancestors chain often share prefixes), so we cache by dir key
|
|
867
|
+
// to avoid duplicate readdirSync storms on large directories.
|
|
868
|
+
const listMemo = new Map();
|
|
869
|
+
const listOnce = (segs, requireReadable) => {
|
|
870
|
+
const key = segs.join('/') + (requireReadable ? '|r' : '|n');
|
|
871
|
+
if (!listMemo.has(key)) listMemo.set(key, listTargetDirectory(scope.rootReal, segs, deps, requireReadable));
|
|
872
|
+
return listMemo.get(key);
|
|
873
|
+
};
|
|
874
|
+
const ancestorCount = Math.max(0, chainSegments.length - (requestedSegments === null ? 0 : 1));
|
|
875
|
+
for (let i = 0; i <= ancestorCount; i++) {
|
|
876
|
+
const path = chainSegments.slice(0, i).join('/');
|
|
877
|
+
ancestors.push({ path, entries: listOnce(path ? path.split('/') : [], false) });
|
|
878
|
+
}
|
|
879
|
+
const { rootReal, ...response } = scope;
|
|
880
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
881
|
+
res.end(JSON.stringify({ ...response, targetPath: segments.join('/'), entries: rootEntries, targetEntries, ancestors }));
|
|
882
|
+
} catch (err) {
|
|
883
|
+
const status = err.status || ERROR_STATUS_MAP[err.code] || 500;
|
|
884
|
+
if (status === 500) console.error('[api/target-files]', err);
|
|
885
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
886
|
+
res.end(JSON.stringify({ error: status === 500 ? 'Internal error' : err.message }));
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
555
890
|
export const filesContentRoutes = [
|
|
556
891
|
{ method: 'GET', match: 'exact', path: '/api/plan-file', handler: planFile },
|
|
557
892
|
{ method: 'GET', match: 'exact', path: '/api/file-content', handler: fileContentGet },
|
|
@@ -560,4 +895,5 @@ export const filesContentRoutes = [
|
|
|
560
895
|
{ predicate: (url, method) => (url === '/api/file-raw' || url.startsWith('/api/file-raw/')) && (method === 'GET' || method === 'HEAD'), handler: fileRaw },
|
|
561
896
|
{ predicate: (url, method) => url === '/api/download-file' && (method === 'GET' || method === 'HEAD'), handler: fileDownload },
|
|
562
897
|
{ method: 'POST', match: 'exact', path: '/api/file-content', handler: fileContentPost },
|
|
898
|
+
{ method: 'GET', match: 'exact', path: '/api/target-files', handler: targetFiles },
|
|
563
899
|
];
|