cc-viewer 1.9.16 → 1.9.18

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.
@@ -0,0 +1,174 @@
1
+ // Shared resolution of a "target" (CLAUDE.md / skill folder) into the
2
+ // navigation root the file browser walks, plus the path validation both the
3
+ // read route (/api/target-files in routes/files-content.js) and the write
4
+ // routes (/api/target-* in routes/target-files-fs.js) rely on.
5
+ //
6
+ // These helpers were moved verbatim out of routes/files-content.js so the new
7
+ // target write routes resolve the EXACT same root as the read route — never
8
+ // trust a client-supplied absolute root, always re-derive it from the target
9
+ // identity (kind + id, or source + targetPath) via resolveTargetRoot.
10
+ //
11
+ // Layering: this is L1 (lib). It must NOT import from routes/ — routes import
12
+ // lib, never the reverse (enforced by pnpm run verify:boundaries).
13
+ import { realpathSync, statSync } from 'node:fs';
14
+ import { basename, dirname, join, relative, sep } from 'node:path';
15
+ import { homedir } from 'node:os';
16
+ import { isReadAllowed, reasonToStatus } from './file-access-policy.js';
17
+ import { listSkills } from './skills-api.js';
18
+ import { discoverClaudeMdCandidates } from './claude-md-discovery.js';
19
+ import { getClaudeConfigDir } from '../../findcc.js';
20
+
21
+ export function targetError(status, message) {
22
+ return Object.assign(new Error(message), { status });
23
+ }
24
+
25
+ // Map fs errno to a sensible HTTP status: missing → 404, permission → 403,
26
+ // symlink loop → 400 (treat as malformed), anything else → 404 (don't leak).
27
+ export function errnoToStatus(err) {
28
+ const code = err?.code;
29
+ if (code === 'EACCES' || code === 'EPERM') return 403;
30
+ if (code === 'ELOOP') return 400;
31
+ return 404;
32
+ }
33
+
34
+ export function realDirectory(path, label) {
35
+ let real;
36
+ try { real = realpathSync(path); } catch (err) { throw targetError(errnoToStatus(err), `${label} not found`); }
37
+ let st;
38
+ try { st = statSync(real); } catch (err) { throw targetError(errnoToStatus(err), `${label} not found`); }
39
+ if (!st.isDirectory()) throw targetError(400, `${label} is not a directory`);
40
+ return real;
41
+ }
42
+
43
+ export function checkedDirectory(path, label) {
44
+ const real = realDirectory(path, label);
45
+ const policy = isReadAllowed(real);
46
+ if (!policy.ok) throw targetError(reasonToStatus(policy.reason), `${label} not readable`);
47
+ return policy.real;
48
+ }
49
+
50
+ export function insidePath(path, root) {
51
+ return path === root || path.startsWith(root + sep);
52
+ }
53
+
54
+ export function targetNavigation(targetReal, cwd) {
55
+ const projectReal = realDirectory(cwd, 'Project');
56
+ if (insidePath(targetReal, projectReal)) return { rootReal: projectReal, rootKind: 'project' };
57
+ // $HOME becomes the navigation root for home-resident targets (user skills,
58
+ // plugins, the global CLAUDE.md). Entries outside the allowlist (e.g. ~/.ssh,
59
+ // ~/.aws) are filtered per-entry by isReadAllowed, and sensitive paths inside
60
+ // ~/.claude are covered by file-access-policy's denylist. The /api/target-files
61
+ // handler additionally gates this endpoint behind isAdminReq + same-origin so
62
+ // a cross-site page cannot enumerate the user's home tree.
63
+ const homeReal = realDirectory(homedir(), 'Home');
64
+ if (insidePath(targetReal, homeReal)) {
65
+ checkedDirectory(targetReal, 'Directory');
66
+ return { rootReal: homeReal, rootKind: 'home' };
67
+ }
68
+ const configReal = realDirectory(getClaudeConfigDir(), 'Claude config');
69
+ if (insidePath(targetReal, configReal)) {
70
+ checkedDirectory(targetReal, 'Directory');
71
+ return { rootReal: realDirectory(dirname(configReal), 'Config root'), rootKind: 'config' };
72
+ }
73
+ // Allowlist fallback: targets that live inside another allowlisted root
74
+ // (registered workspaces, /tmp/cc-viewer-uploads, etc.) are reachable via
75
+ // /api/claude-md's discovery (which uses isReadAllowed), so we must not
76
+ // 403 them here. Use the target's own directory as the navigation root —
77
+ // no parent browsing, but at least the entry the user clicked actually opens.
78
+ const policy = isReadAllowed(targetReal);
79
+ if (!policy.ok) throw targetError(reasonToStatus(policy.reason), 'Target not readable');
80
+ return { rootReal: realDirectory(targetReal, 'Target'), rootKind: 'target' };
81
+ }
82
+
83
+ export function targetScope(targetReal, cwd) {
84
+ const navigation = targetNavigation(targetReal, cwd);
85
+ return { ...navigation, targetPath: relative(navigation.rootReal, targetReal).split(sep).join('/') };
86
+ }
87
+
88
+ export function targetRelPath(raw) {
89
+ if (raw == null) return null;
90
+ if (raw === '') return [];
91
+ if (typeof raw !== 'string' || raw.length > 2048 || raw.includes('\0') || raw.includes('\\')
92
+ || raw.startsWith('/') || /^[a-zA-Z]:/.test(raw)) {
93
+ throw targetError(400, 'Invalid path');
94
+ }
95
+ const segments = raw.split('/');
96
+ if (segments.some(segment => !segment || segment === '.' || segment === '..')) {
97
+ throw targetError(400, 'Invalid path');
98
+ }
99
+ // Hard cap on navigation depth: each segment triggers at least one
100
+ // synchronous readdir + per-entry realpath/stat in `ancestors`, and a 2KB
101
+ // free-form path could otherwise fan out into hundreds of syscalls per
102
+ // request. 32 is far deeper than any realistic skill / CLAUDE.md location.
103
+ if (segments.length > 32) throw targetError(400, 'Path too deep');
104
+ return segments;
105
+ }
106
+
107
+ export function resolveTargetRoot(parsedUrl, cwd) {
108
+ const kind = parsedUrl.searchParams.get('kind');
109
+ if (kind === 'claude-md') {
110
+ const id = parsedUrl.searchParams.get('id') || '';
111
+ if (!/^[0-9a-f]{12}$/i.test(id)) throw targetError(400, 'Invalid id');
112
+ const candidate = discoverClaudeMdCandidates({
113
+ cwd,
114
+ claudeConfigDir: getClaudeConfigDir(),
115
+ isReadAllowedFn: isReadAllowed,
116
+ }).find(entry => entry.id === id);
117
+ if (!candidate || basename(candidate.realPath) !== 'CLAUDE.md') throw targetError(404, 'Target not found');
118
+ return { ...targetScope(realDirectory(dirname(candidate.realPath), 'Directory'), cwd), targetKind: 'claude-md' };
119
+ }
120
+ if (kind !== 'skill') throw targetError(400, 'Invalid target kind');
121
+ const source = parsedUrl.searchParams.get('source');
122
+ const targetPath = parsedUrl.searchParams.get('targetPath');
123
+ if (!['user', 'project', 'plugin'].includes(source)) throw targetError(400, 'Invalid skill source');
124
+ if (typeof targetPath !== 'string' || !targetPath || targetPath.length > 4096 || targetPath.includes('\0')) {
125
+ throw targetError(400, 'Invalid skill path');
126
+ }
127
+ const skill = listSkills({ projectDir: cwd })
128
+ .find(entry => entry.source === source && entry.path === targetPath);
129
+ if (!skill) throw targetError(404, 'Target not found');
130
+ const rootReal = realDirectory(skill.path, 'Directory');
131
+ const scope = targetScope(rootReal, cwd);
132
+ const anchor = source === 'project'
133
+ ? scope.rootReal
134
+ : dirname(dirname(skill.path));
135
+ const anchorReal = realDirectory(anchor, 'Skill root');
136
+ if (!insidePath(rootReal, anchorReal)) throw targetError(403, 'Target outside skill root');
137
+ // Plugin root must follow the configured Claude config dir, not the default
138
+ // homedir()/.claude — otherwise plugin skills break under CLAUDE_CONFIG_DIR.
139
+ if (source === 'plugin' && !insidePath(rootReal, realDirectory(join(getClaudeConfigDir(), 'plugins'), 'Plugin root'))) {
140
+ throw targetError(403, 'Target outside plugin root');
141
+ }
142
+ return { ...scope, targetKind: 'skill' };
143
+ }
144
+
145
+ // ===========================================================================
146
+ // Write-side scope resolution (used only by the /api/target-* write routes).
147
+ // ===========================================================================
148
+
149
+ // SECURITY / INTENTIONAL DESIGN (do NOT tighten in review without product sign-off):
150
+ // the writable area for a target browser is the WHOLE navigation root returned by
151
+ // targetNavigation (project / home / config / target), NOT just the target's own
152
+ // subtree. This is a deliberate product decision — the user navigates the browser
153
+ // up to ancestors (e.g. from a skill folder to ~ or ~/.claude) and expects the
154
+ // context menu to keep working there. The sensitive-file / sensitive-directory
155
+ // backstop for that wider surface is isWriteAllowed (lib/file-access-policy.js),
156
+ // which every mutation runs through; containment in the navigation root plus
157
+ // isWriteAllowed is the boundary, not the target subtree.
158
+ export function resolveTargetWriteScope(parsedUrl, cwd) {
159
+ // Re-derive the target identity server-side (never trust a client-sent root).
160
+ // The writable surface is the WHOLE navigation root (scope.rootReal) — the
161
+ // target's own directory is NOT separately resolved here because the product
162
+ // decision is to allow writes anywhere in the navigation root, with the
163
+ // sensitive-file backstop living in isWriteAllowed (see file-access-policy.js).
164
+ return resolveTargetRoot(parsedUrl, cwd);
165
+ }
166
+
167
+ // targetRelPath treats null as "caller omitted path" (the read route uses that
168
+ // to default to the target's own directory). The write routes always need an
169
+ // explicit path, so this is the required variant: omitted → 400.
170
+ export function requiredTargetRelPath(raw) {
171
+ const segs = targetRelPath(raw);
172
+ if (segs === null) throw targetError(400, 'Missing path');
173
+ return segs;
174
+ }
package/server/proxy.js CHANGED
@@ -1,5 +1,6 @@
1
1
 
2
2
  import { createServer } from 'node:http';
3
+ import { Readable, Transform, pipeline } from 'node:stream';
3
4
  import { readFileSync, existsSync } from 'node:fs';
4
5
  import { join, basename } from 'node:path';
5
6
  import { homedir } from 'node:os';
@@ -11,7 +12,7 @@ import { getClaudeConfigDir } from '../findcc.js';
11
12
  import { isAnthropicApiPath, classifyProxyRole } from './lib/interceptor-core.js';
12
13
  import { parseAgentId, findHeader } from './lib/v2/agent-id.js';
13
14
  import { liveSystemPromptEnabled } from './lib/system-prompt-live.js';
14
- import { executeRequest, extractModel } from './lib/proxy/proxy-retry.js';
15
+ import { executeRequest, extractModel, isStreamResponse } from './lib/proxy/proxy-retry.js';
15
16
  import { buildRecord, appendRecord, dailyFilePath, todayStr, emitProxyStatsUpdate } from './lib/proxy/proxy-stats.js';
16
17
  import { reportSwallowed } from './lib/error-report.js';
17
18
  import { LOG_DIR } from '../findcc.js';
@@ -231,7 +232,6 @@ export function startProxy() {
231
232
  res.writeHead(response.status, responseHeaders);
232
233
 
233
234
  if (response.body) {
234
- const { Readable, pipeline } = await import('node:stream');
235
235
  // @ts-ignore
236
236
  const nodeStream = Readable.fromWeb(response.body);
237
237
  // 持久 error handler 兜底:防止 pipeline 清理后延迟到达的 error 事件导致进程崩溃
@@ -388,15 +388,59 @@ async function handleLlmApiRequest(req, res, fullUrl, fetchOptions, body, proxyD
388
388
  res.writeHead(finalStatus, responseHeaders);
389
389
 
390
390
  if (response.body) {
391
- const { Readable, pipeline } = await import('node:stream');
392
391
  // @ts-ignore
393
392
  const nodeStream = Readable.fromWeb(response.body);
394
393
  nodeStream.on('error', () => {});
395
- pipeline(nodeStream, res, (err) => {
396
- if (err && process.env.CCV_DEBUG) {
397
- console.error('[CC-Viewer Proxy] Stream pipeline error:', err.message);
398
- }
399
- });
394
+ const streamIdleMs = retryConfig && retryConfig.mode === 'off'
395
+ ? (retryConfig.streamIdleTimeoutMs ?? 0)
396
+ : 0;
397
+ // Only gate SSE (streaming) responses: a non-stream body legitimately arrives as one
398
+ // whole payload after a long upstream think, and must not be truncated mid-wait.
399
+ if (streamIdleMs > 0 && isStreamResponse(response)) {
400
+ // Off-mode-only idle watchdog: retry modes already attach the equivalent
401
+ // guard on the Web-stream side upstream (applyStreamIdleWatchdog). That
402
+ // variant cannot run for off mode — its pipeThrough races the
403
+ // interceptor's getReader() body reconstruction (spurious `fetch failed`).
404
+ // This Node-side Transform sits after Readable.fromWeb, so it is immune
405
+ // to that race. The timer arms at construction, BEFORE the first chunk,
406
+ // so a stream that never sends its first byte is still bounded.
407
+ let watchdog = null;
408
+ let idleTimer = null;
409
+ const disarmIdle = () => {
410
+ if (idleTimer) {
411
+ clearTimeout(idleTimer);
412
+ idleTimer = null;
413
+ }
414
+ };
415
+ const armIdle = () => {
416
+ disarmIdle();
417
+ idleTimer = setTimeout(() => {
418
+ idleTimer = null;
419
+ // Log unconditionally: a destroyed stream surfaces downstream as an unexplained
420
+ // `fetch failed`; without this line there is no way to attribute it.
421
+ console.warn(`[CC-Viewer Proxy] off-mode stream idle timeout (${streamIdleMs}ms) — destroying upstream stream`);
422
+ watchdog.destroy(new Error(`proxy stream idle timeout (${streamIdleMs}ms)`));
423
+ }, streamIdleMs);
424
+ if (typeof idleTimer.unref === 'function') idleTimer.unref();
425
+ };
426
+ watchdog = new Transform({
427
+ transform(chunk, _enc, cb) { armIdle(); cb(null, chunk); },
428
+ flush(cb) { disarmIdle(); cb(); },
429
+ destroy(err, cb) { disarmIdle(); cb(err); },
430
+ });
431
+ armIdle();
432
+ pipeline(nodeStream, watchdog, res, (err) => {
433
+ if (err && process.env.CCV_DEBUG) {
434
+ console.error('[CC-Viewer Proxy] Stream pipeline error:', err.message);
435
+ }
436
+ });
437
+ } else {
438
+ pipeline(nodeStream, res, (err) => {
439
+ if (err && process.env.CCV_DEBUG) {
440
+ console.error('[CC-Viewer Proxy] Stream pipeline error:', err.message);
441
+ }
442
+ });
443
+ }
400
444
  } else if (response.text) {
401
445
  // 竞速失败兜底 response:用 text() 取 body
402
446
  const text = await response.text();
@@ -1,10 +1,8 @@
1
1
  // Read-oriented file-access routes (moved verbatim from server.js handleRequest).
2
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';
5
- import { isReadAllowed, reasonToStatus } from '../lib/file-access-policy.js';
3
+ import { join, basename, dirname, resolve, sep } from 'node:path';
4
+ import { isReadAllowed, isWriteAllowed, reasonToStatus } from '../lib/file-access-policy.js';
6
5
  import { ERROR_STATUS_MAP, isPathContained } from '../lib/file-api.js';
7
- import { listSkills } from '../lib/skills-api.js';
8
6
  import { viewRootOrReply, resolveViewRoot } from '../lib/view-root.js';
9
7
  import { findSdkSessionByInstance } from '../lib/sdk-session-index.js';
10
8
  import { projectKeyForCwd } from '../lib/system-prompt-snapshots.js';
@@ -16,6 +14,10 @@ import { getClaudeConfigDir } from '../../findcc.js';
16
14
  import { _projectName } from '../interceptor.js';
17
15
  import { isAdminReq } from '../lib/is-admin.js';
18
16
  import { isSameOriginBrowserRequest } from '../lib/same-origin.js';
17
+ import {
18
+ targetError, errnoToStatus, realDirectory, checkedDirectory, insidePath,
19
+ targetRelPath, resolveTargetRoot,
20
+ } from '../lib/target-scope.js';
19
21
 
20
22
  // Multi-project file operations use the same viewed root for reads and writes.
21
23
  // Every file-browser mutation enforces realpath containment in that root;
@@ -546,6 +548,29 @@ function fileContentPost(req, res, parsedUrl, isLocal) {
546
548
  // encoding. Normalize only this mutation's containment comparisons.
547
549
  let writeRoot;
548
550
  const isInsideWriteRoot = (path) => isPathContained(path, writeRoot);
551
+ // isPathContained realpaths its target, so it returns false for a not-yet-existing
552
+ // file — which wrongly 403s every new-file save in target mode. For a missing leaf,
553
+ // realpath the nearest existing ancestor, re-append the descent, then prefix-compare
554
+ // against the (already-real) write root; an existing path keeps the strict realpath.
555
+ const isInsideWriteRootAllowNew = (path) => {
556
+ if (isInsideWriteRoot(path)) return true;
557
+ if (existsSync(path)) return false; // exists but resolves outside → strict deny
558
+ const root = writeRoot.endsWith(sep) ? writeRoot : writeRoot + sep;
559
+ let cursor = resolve(path, '..');
560
+ const descent = [basename(path)];
561
+ for (let depth = 0; depth < 32; depth++) {
562
+ if (existsSync(cursor)) {
563
+ let rebuilt;
564
+ try { rebuilt = join(realpathSync(cursor), ...descent); } catch { return false; }
565
+ return rebuilt === writeRoot || rebuilt.startsWith(root);
566
+ }
567
+ const parent = resolve(cursor, '..');
568
+ if (parent === cursor) return false;
569
+ descent.unshift(basename(cursor));
570
+ cursor = parent;
571
+ }
572
+ return false;
573
+ };
549
574
  const MAX_BODY = 5 * 1024 * 1024; // 5MB,与 GET 路由限制对齐
550
575
  let body = '';
551
576
  let overflow = false;
@@ -563,7 +588,36 @@ function fileContentPost(req, res, parsedUrl, isLocal) {
563
588
  const { path: reqPath, content, editorSession } = JSON.parse(body);
564
589
  const cwd = _viewRootOrReply(req, res, parsedUrl);
565
590
  if (!cwd) return;
566
- try { writeRoot = realpathSync(cwd); } catch { writeRoot = cwd; }
591
+ // Target mode (the CLAUDE.md / skill browser modal) edits files that live in
592
+ // the target's NAVIGATION ROOT, which is frequently OUTSIDE the viewed project
593
+ // (~/.claude, $HOME). When the request carries a target identity
594
+ // (kind + id / source + targetPath, same as /api/target-files), resolve the
595
+ // write root to that navigation root instead of the project view root, so
596
+ // isInsideWriteRoot no longer 403s the file the user is explicitly editing.
597
+ // The target root is re-derived server-side (never trusted from the client),
598
+ // and the isWriteAllowed policy below still runs unchanged.
599
+ // SECURITY / INTENTIONAL DESIGN (do NOT tighten in review without product
600
+ // sign-off): in target mode the write policy is the WHOLE navigation root,
601
+ // and writes go through isWriteAllowed (NOT isReadAllowed) — so the LOG_DIR
602
+ // tree, ~/.claude credentials/settings, ~/.ssh, and the config-backup tree
603
+ // stay unwritable even though the wider surface is editable. This path is
604
+ // gated to admin + same-origin browser requests, the same bar as
605
+ // /api/target-files, because it can reach the user's home tree. A relative
606
+ // path in target mode is anchored at the navigation root, not the project.
607
+ const targetKind = parsedUrl.searchParams.get('kind');
608
+ let targetNavRoot = null;
609
+ if (targetKind) {
610
+ if (!isAdminReq(req, isLocal) || !isSameOriginBrowserRequest(req, parsedUrl)) {
611
+ res.writeHead(403, { 'Content-Type': 'application/json' });
612
+ res.end(JSON.stringify({ error: 'forbidden' }));
613
+ return;
614
+ }
615
+ const scope = resolveTargetRoot(parsedUrl, cwd);
616
+ writeRoot = scope.rootReal;
617
+ targetNavRoot = scope.rootReal;
618
+ } else {
619
+ try { writeRoot = realpathSync(cwd); } catch { writeRoot = cwd; }
620
+ }
567
621
  if (!reqPath) {
568
622
  res.writeHead(400, { 'Content-Type': 'application/json' });
569
623
  res.end(JSON.stringify({ error: 'Invalid path' }));
@@ -575,12 +629,18 @@ function fileContentPost(req, res, parsedUrl, isLocal) {
575
629
  return;
576
630
  }
577
631
  const isAbs = /^([a-zA-Z]:[\\/]|[\\/])/.test(reqPath);
578
- const absPath = isAbs ? reqPath : resolve(cwd, reqPath);
632
+ // In target mode a relative path is anchored at the navigation root; in
633
+ // project mode at the project view root (unchanged legacy behavior).
634
+ const absPath = isAbs ? reqPath : resolve(targetNavRoot || cwd, reqPath);
579
635
  // 写路径同样走 policy(收敛 editorSession 后门):允许覆盖现有文件;
580
636
  // 新文件场景递归向上找最近存在的祖先目录,只要它在 allowlist 内即放行。
581
637
  // (旧实现只查 immediate parent,父目录也不存在时误拒嵌套新建。)
638
+ // Target mode uses the WRITE policy (isWriteAllowed) so the LOG_DIR tree,
639
+ // ~/.claude credentials and other sensitive targets stay unwritable even
640
+ // though the navigation root is editable; project mode keeps isReadAllowed.
641
+ const policyFn = targetNavRoot ? isWriteAllowed : isReadAllowed;
582
642
  let targetReal;
583
- const policy = isReadAllowed(absPath);
643
+ const policy = policyFn(absPath);
584
644
  if (policy.ok) {
585
645
  targetReal = policy.real;
586
646
  } else if (policy.reason === 'realpath-failed') {
@@ -644,7 +704,16 @@ function fileContentPost(req, res, parsedUrl, isLocal) {
644
704
  res.end(JSON.stringify({ error: 'Forbidden', reason: 'editor-session-requires-admin' }));
645
705
  return;
646
706
  }
647
- if (!editorSession && existsSync(targetReal) && !isInsideWriteRoot(targetReal)) {
707
+ // Target mode: containment in the navigation root must hold whether the file
708
+ // exists OR is being created — otherwise a crafted relative path could write
709
+ // outside the navigation root (isWriteAllowed only bounds by the allowlist).
710
+ // Project mode keeps the legacy existing-file-only check.
711
+ if (!editorSession && targetNavRoot && !isInsideWriteRootAllowNew(targetReal)) {
712
+ res.writeHead(403, { 'Content-Type': 'application/json' });
713
+ res.end(JSON.stringify({ error: 'Forbidden', reason: 'outside-view-root' }));
714
+ return;
715
+ }
716
+ if (!editorSession && !targetNavRoot && existsSync(targetReal) && !isInsideWriteRoot(targetReal)) {
648
717
  res.writeHead(403, { 'Content-Type': 'application/json' });
649
718
  res.end(JSON.stringify({ error: 'Forbidden', reason: 'outside-view-root' }));
650
719
  return;
@@ -654,7 +723,9 @@ function fileContentPost(req, res, parsedUrl, isLocal) {
654
723
  res.writeHead(200, { 'Content-Type': 'application/json' });
655
724
  res.end(JSON.stringify({ ok: true, size: stat.size }));
656
725
  } catch (err) {
657
- const status = ERROR_STATUS_MAP[err.code] || 500;
726
+ // targetError carries `.status` (400/403/404); fs errnos carry `.code`.
727
+ // Honor both so an invalid/stale target identity is not reported as 500.
728
+ const status = err.status || ERROR_STATUS_MAP[err.code] || 500;
658
729
  const message = status === 500 ? `Cannot save file: ${err.message}` : err.message;
659
730
  res.writeHead(status, { 'Content-Type': 'application/json' });
660
731
  res.end(JSON.stringify({ error: message }));
@@ -662,130 +733,6 @@ function fileContentPost(req, res, parsedUrl, isLocal) {
662
733
  });
663
734
  }
664
735
 
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
736
  function listTargetDirectory(rootReal, segments, deps, requireReadable = true) {
790
737
  const dirReal = requireReadable
791
738
  ? checkedDirectory(segments.length ? join(rootReal, ...segments) : rootReal, 'Directory')
@@ -5,11 +5,12 @@ import {
5
5
  } from 'node:fs';
6
6
  import { join, dirname, basename, resolve, sep } from 'node:path';
7
7
  import { homedir, tmpdir } from 'node:os';
8
- import { execFile, spawn } from 'node:child_process';
8
+ import { execFile } from 'node:child_process';
9
9
  import { bumpWorkspacesVersion, isReadAllowed } from '../lib/file-access-policy.js';
10
10
  import { isPathContained, validateImportDir, renameSyncWithRetry } from '../lib/file-api.js';
11
11
  import { reportSwallowed } from '../lib/error-report.js';
12
12
  import { viewRootOrReply } from '../lib/view-root.js';
13
+ import { revealInOs, openTerminalAt } from '../lib/os-open.js';
13
14
  import { loadWorkspaces } from '../workspace-registry.js';
14
15
  import { listLivePtys } from '../pty-manager.js';
15
16
  import { PROFILE_PATH, _projectName, _logDir } from '../interceptor.js';
@@ -645,17 +646,12 @@ function revealFile(req, res, parsedUrl, isLocal, deps) {
645
646
  return;
646
647
  }
647
648
  const plat = process.platform;
648
- if (plat === 'darwin') {
649
- execFile('open', ['-R', fullPath], () => {});
650
- } else if (plat === 'win32') {
651
- // explorer /select 的规范形式是 /select,"<path>"(仅路径部分加引号、整体一个 arg)。
652
- // 必须 windowsVerbatimArguments 透传:否则 Node 对含空格 arg 整体加引号生成
653
- // explorer.exe "/select,C:\My Proj\x.txt",explorer 解析不了 → 功能失效。
654
- // Windows 文件名不允许 ",且不经 cmd.exe,无元字符注入面。
655
- const child = spawn('explorer.exe', [`/select,"${fullPath}"`], { windowsVerbatimArguments: true, windowsHide: true });
656
- child.on('error', () => {}); // 防 async ENOENT 变 uncaughtException 砸进程
649
+ if (plat === 'darwin' || plat === 'win32') {
650
+ revealInOs(fullPath);
657
651
  } else {
658
- execFile('xdg-open', [dirname(fullPath)], () => {});
652
+ // Linux: a directory reveals itself; a file reveals its parent (xdg-open on
653
+ // a file would launch the default app). Shared helper, see lib/os-open.js.
654
+ revealInOs(fullPath, { isDir: statSync(fullPath).isDirectory() });
659
655
  }
660
656
  res.writeHead(200, { 'Content-Type': 'application/json' });
661
657
  res.end(JSON.stringify({ ok: true, fullPath }));
@@ -919,49 +915,7 @@ function openTerminal(req, res, parsedUrl, isLocal, deps) {
919
915
  res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
920
916
  return;
921
917
  }
922
- const plat = process.platform;
923
- if (plat === 'darwin') {
924
- spawn('open', ['-a', 'Terminal', fullDir], { stdio: 'ignore', detached: true }).unref();
925
- } else if (plat === 'win32') {
926
- spawn('cmd.exe', ['/c', 'start', 'cmd.exe'], { cwd: fullDir, stdio: 'ignore', detached: true, windowsHide: true }).unref();
927
- } else {
928
- // Linux: try common terminal emulators
929
- const terminals = ['gnome-terminal', 'konsole', 'xfce4-terminal', 'xterm'];
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;
942
- try {
943
- if (term === 'gnome-terminal') {
944
- child = spawn(term, ['--working-directory=' + fullDir], { stdio: 'pipe' });
945
- } else if (term === 'konsole') {
946
- child = spawn(term, ['--workdir', fullDir], { stdio: 'pipe' });
947
- } else {
948
- child = spawn(term, [], { cwd: fullDir, stdio: 'pipe' });
949
- }
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);
964
- }
918
+ openTerminalAt(fullDir);
965
919
  res.writeHead(200, { 'Content-Type': 'application/json' });
966
920
  res.end(JSON.stringify({ ok: true }));
967
921
  } catch (err) {