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.
- package/cli.js +17 -1
- package/dist/assets/{App-CYdOioAz.js → App-COyZwmVt.js} +2 -2
- package/dist/assets/{MdxEditorPanel-CuCAe1OG.js → MdxEditorPanel-BsHDgZCY.js} +1 -1
- package/dist/assets/Mobile-B1H_PrNl.js +1 -0
- package/dist/assets/{ProxyStatsModal-Dhj4EVnh.js → ProxyStatsModal-CSQNNwpU.js} +1 -1
- package/dist/assets/index-CPn0gvr6.js +2 -0
- package/dist/assets/seqResourceLoaders-Cm1JnNsu.js +2 -0
- package/dist/assets/{seqResourceLoaders-B-5xGs9q.css → seqResourceLoaders-DZA-JHK0.css} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/lib/file-access-policy.js +108 -3
- package/server/lib/os-open.js +66 -0
- package/server/lib/plugin-loader.js +34 -2
- package/server/lib/sdk-manager.js +450 -45
- package/server/lib/target-scope.js +174 -0
- package/server/proxy.js +52 -8
- package/server/routes/files-content.js +80 -133
- package/server/routes/files-fs.js +8 -54
- package/server/routes/target-files-fs.js +332 -0
- package/server/server.js +59 -12
- package/dist/assets/Mobile-CfNYOgRa.js +0 -1
- package/dist/assets/index-sgW8MlD3.js +0 -2
- package/dist/assets/seqResourceLoaders-Cj0pdz-l.js +0 -2
|
@@ -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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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,
|
|
4
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|