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,332 @@
|
|
|
1
|
+
// Target-mode filesystem mutation + OS-open routes for the file browser modal
|
|
2
|
+
// (CLAUDE.md / skill folder targets). These mirror the project-scoped routes in
|
|
3
|
+
// files-fs.js, but resolve the writable root from the TARGET identity (kind+id /
|
|
4
|
+
// source+targetPath) via lib/target-scope.js instead of the viewed project —
|
|
5
|
+
// because a target frequently lives OUTSIDE any project root (~/.claude, $HOME),
|
|
6
|
+
// where no ?project= value could resolve it.
|
|
7
|
+
//
|
|
8
|
+
// Path contract: `path` / `dirPath` / `oldPath` in the request body are relative
|
|
9
|
+
// to the target's NAVIGATION ROOT (the same root the read route /api/target-files
|
|
10
|
+
// returns entries against — that is what the browser displays in its breadcrumb).
|
|
11
|
+
// This differs from files-fs.js, where paths are relative to the project root.
|
|
12
|
+
// Do NOT "fix" callers to send project-relative paths.
|
|
13
|
+
//
|
|
14
|
+
// SECURITY / INTENTIONAL DESIGN (do NOT weaken in review without product sign-off):
|
|
15
|
+
// - The writable surface is the WHOLE navigation root (see
|
|
16
|
+
// lib/target-scope.js resolveTargetWriteScope), a deliberate product decision
|
|
17
|
+
// so the context menu keeps working after the user navigates to ancestors.
|
|
18
|
+
// - Sensitive files/dirs inside that wider surface are the backstop of
|
|
19
|
+
// isWriteAllowed (lib/file-access-policy.js): ~/.claude credentials/settings/
|
|
20
|
+
// history, ~/.ssh, the LOG_DIR tree, the config-backup tree, private keys, etc.
|
|
21
|
+
// - rename validates BOTH source and destination with isWriteAllowed (renaming a
|
|
22
|
+
// denylisted name to a benign one must not make it readable); recursive delete
|
|
23
|
+
// scans the subtree and refuses if any descendant is write-denied.
|
|
24
|
+
// - Every handler is gated by isAdminReq + isSameOriginBrowserRequest BEFORE the
|
|
25
|
+
// body is read (same bar as the read route), because these endpoints can reach
|
|
26
|
+
// the user's home tree.
|
|
27
|
+
// - Symlinks are rejected as mutation targets (lstat on the spelled path);
|
|
28
|
+
// isWriteAllowed additionally refuses a dangling-symlink leaf so create/save
|
|
29
|
+
// cannot be redirected outside the root. Delete re-realpaths before rmdir
|
|
30
|
+
// (TOCTOU defense).
|
|
31
|
+
import {
|
|
32
|
+
existsSync, statSync, lstatSync, mkdirSync, writeFileSync,
|
|
33
|
+
realpathSync, rmSync, unlinkSync, readdirSync,
|
|
34
|
+
} from 'node:fs';
|
|
35
|
+
import { join, dirname, basename } from 'node:path';
|
|
36
|
+
import { isWriteAllowed, isReadAllowed } from '../lib/file-access-policy.js';
|
|
37
|
+
import { renameSyncWithRetry } from '../lib/file-api.js';
|
|
38
|
+
import { reportSwallowed } from '../lib/error-report.js';
|
|
39
|
+
import { revealInOs, openTerminalAt } from '../lib/os-open.js';
|
|
40
|
+
import { viewRootOrReply } from '../lib/view-root.js';
|
|
41
|
+
import { isAdminReq } from '../lib/is-admin.js';
|
|
42
|
+
import { isSameOriginBrowserRequest } from '../lib/same-origin.js';
|
|
43
|
+
import { loadWorkspaces } from '../workspace-registry.js';
|
|
44
|
+
import { listLivePtys } from '../pty-manager.js';
|
|
45
|
+
import {
|
|
46
|
+
targetError, errnoToStatus, insidePath, requiredTargetRelPath, resolveTargetWriteScope,
|
|
47
|
+
} from '../lib/target-scope.js';
|
|
48
|
+
|
|
49
|
+
function forbidden(res) {
|
|
50
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
51
|
+
res.end(JSON.stringify({ error: 'forbidden' }));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function send(res, status, obj) {
|
|
55
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
56
|
+
res.end(JSON.stringify(obj));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function replyTargetError(res, err) {
|
|
60
|
+
const status = err?.status || 500;
|
|
61
|
+
if (status === 500) console.error('[api/target-*]', err);
|
|
62
|
+
send(res, status, { error: status === 500 ? 'Internal error' : err.message });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Resolve the viewed project cwd (query project/instance win over POST body).
|
|
66
|
+
function cwdFor(req, res, parsedUrl, body) {
|
|
67
|
+
return viewRootOrReply(req, res, parsedUrl, {
|
|
68
|
+
boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
|
|
69
|
+
loadWorkspaces,
|
|
70
|
+
listLivePtys,
|
|
71
|
+
bodyProject: body?.project,
|
|
72
|
+
bodyInstance: body?.instance,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Read + JSON-parse the POST body, then run `run(body, scope)`. The admin +
|
|
77
|
+
// same-origin gate runs BEFORE the body is consumed so a cross-site / non-admin
|
|
78
|
+
// caller cannot even reach the parser. `scope` is re-derived server-side from
|
|
79
|
+
// the target identity in the query string (never from a client-sent root).
|
|
80
|
+
function withBody(req, res, parsedUrl, isLocal, deps, run) {
|
|
81
|
+
if (!isAdminReq(req, isLocal) || !isSameOriginBrowserRequest(req, parsedUrl)) {
|
|
82
|
+
return forbidden(res);
|
|
83
|
+
}
|
|
84
|
+
let body = '';
|
|
85
|
+
req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
|
|
86
|
+
req.on('end', () => {
|
|
87
|
+
let parsed;
|
|
88
|
+
try { parsed = JSON.parse(body); } catch { return send(res, 400, { error: 'Invalid request body' }); }
|
|
89
|
+
try {
|
|
90
|
+
const cwd = cwdFor(req, res, parsedUrl, parsed);
|
|
91
|
+
if (!cwd) return;
|
|
92
|
+
const scope = resolveTargetWriteScope(parsedUrl, cwd);
|
|
93
|
+
run(parsed, scope);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
replyTargetError(res, err);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Reject symlink mutation targets: an attacker could swap a symlink between
|
|
101
|
+
// validation and the mutation so the operation lands outside the navigation
|
|
102
|
+
// root. lstat does not follow the link, so an isSymbolicLink() hit refuses.
|
|
103
|
+
function lstatNoSymlink(fullPath) {
|
|
104
|
+
let st;
|
|
105
|
+
try { st = lstatSync(fullPath); } catch (err) { throw targetError(errnoToStatus(err), 'Path not found'); }
|
|
106
|
+
if (st.isSymbolicLink()) throw targetError(400, 'Cannot operate on symbolic links via this endpoint');
|
|
107
|
+
return st;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Walk a directory subtree and throw 403 if any descendant fails the write
|
|
111
|
+
// policy. Used before a recursive rmdir so deleting a directory cannot bypass
|
|
112
|
+
// the per-file denylist (credentials, settings, transcripts). Symlinks are
|
|
113
|
+
// skipped (their targets are evaluated by the caller's own containment checks).
|
|
114
|
+
function assertSubtreeWritable(dirReal) {
|
|
115
|
+
let dirents;
|
|
116
|
+
try { dirents = readdirSync(dirReal, { withFileTypes: true }); } catch { return; }
|
|
117
|
+
for (const entry of dirents) {
|
|
118
|
+
const child = join(dirReal, entry.name);
|
|
119
|
+
let lst;
|
|
120
|
+
try { lst = lstatSync(child); } catch { continue; }
|
|
121
|
+
if (lst.isSymbolicLink()) continue;
|
|
122
|
+
const policy = isWriteAllowed(child);
|
|
123
|
+
if (!policy.ok) throw targetError(403, `Refusing to delete: contains a protected path (${entry.name})`);
|
|
124
|
+
if (lst.isDirectory()) assertSubtreeWritable(child);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function validateName(name, kind) {
|
|
129
|
+
if (!name) throw targetError(400, 'Missing name');
|
|
130
|
+
if (name.includes('/') || name.includes('\\') || name.includes('..') || /[\x00-\x1f]/.test(name)) {
|
|
131
|
+
throw targetError(400, `Invalid ${kind} name`);
|
|
132
|
+
}
|
|
133
|
+
// A `.git` segment must never be created/renamed into place from the browser.
|
|
134
|
+
if (name.toLowerCase() === '.git') throw targetError(400, `Invalid ${kind} name`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Resolve a navigation-root-relative path to its real absolute path, enforcing
|
|
138
|
+
// containment in the navigation root. Returns the REAL path (symlinks resolved).
|
|
139
|
+
function resolveContained(scope, segments) {
|
|
140
|
+
const full = segments.length ? join(scope.rootReal, ...segments) : scope.rootReal;
|
|
141
|
+
let real;
|
|
142
|
+
try { real = realpathSync(full); } catch (err) { throw targetError(errnoToStatus(err), 'Path not found'); }
|
|
143
|
+
if (!insidePath(real, scope.rootReal)) throw targetError(403, 'Path outside navigation root');
|
|
144
|
+
return { full, real };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── reveal in OS file manager ──────────────────────────────────────────────
|
|
148
|
+
function targetRevealFile(req, res, parsedUrl, isLocal, deps) {
|
|
149
|
+
withBody(req, res, parsedUrl, isLocal, deps, (parsed, scope) => {
|
|
150
|
+
// '' resolves to the navigation root itself (the browser's container menu).
|
|
151
|
+
const segments = requiredTargetRelPath(parsed.path ?? '');
|
|
152
|
+
const { full, real } = resolveContained(scope, segments);
|
|
153
|
+
// Reveal is a READ-side action (open the OS file manager), so gate it with the
|
|
154
|
+
// read policy, not the write policy — a read-only-but-unwritable entry (e.g.
|
|
155
|
+
// inside LOG_DIR) must still be revealable.
|
|
156
|
+
if (parsed.path) {
|
|
157
|
+
const policy = isReadAllowed(real);
|
|
158
|
+
if (!policy.ok) throw targetError(403, 'Path not allowed');
|
|
159
|
+
}
|
|
160
|
+
// A directory reveals itself; a file reveals its parent (Linux xdg-open on a
|
|
161
|
+
// file would launch the default app instead of showing it).
|
|
162
|
+
revealInOs(full, { isDir: statSync(real).isDirectory() });
|
|
163
|
+
send(res, 200, { ok: true, fullPath: full });
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── open in OS terminal ────────────────────────────────────────────────────
|
|
168
|
+
function targetOpenTerminal(req, res, parsedUrl, isLocal, deps) {
|
|
169
|
+
withBody(req, res, parsedUrl, isLocal, deps, (parsed, scope) => {
|
|
170
|
+
const segments = requiredTargetRelPath(parsed.path ?? '');
|
|
171
|
+
const { real } = resolveContained(scope, segments);
|
|
172
|
+
const st = statSync(real);
|
|
173
|
+
// Opening a terminal on a FILE opens it in the file's directory instead.
|
|
174
|
+
const dir = st.isDirectory() ? real : dirname(real);
|
|
175
|
+
openTerminalAt(dir);
|
|
176
|
+
send(res, 200, { ok: true });
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── resolve to absolute path (for copy-path / attach-to-chat) ──────────────
|
|
181
|
+
function targetResolvePath(req, res, parsedUrl, isLocal, deps) {
|
|
182
|
+
withBody(req, res, parsedUrl, isLocal, deps, (parsed, scope) => {
|
|
183
|
+
const segments = parsed.path ? requiredTargetRelPath(parsed.path) : [];
|
|
184
|
+
const full = segments.length ? join(scope.rootReal, ...segments) : scope.rootReal;
|
|
185
|
+
send(res, 200, { ok: true, fullPath: full });
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── create file ────────────────────────────────────────────────────────────
|
|
190
|
+
function targetCreateFile(req, res, parsedUrl, isLocal, deps) {
|
|
191
|
+
withBody(req, res, parsedUrl, isLocal, deps, (parsed, scope) => {
|
|
192
|
+
validateName(parsed.name, 'file');
|
|
193
|
+
const dirSegs = parsed.dirPath ? requiredTargetRelPath(parsed.dirPath) : [];
|
|
194
|
+
const { real: dirReal } = resolveContained(scope, dirSegs);
|
|
195
|
+
if (!statSync(dirReal).isDirectory()) throw targetError(400, 'Directory not found');
|
|
196
|
+
const full = join(dirReal, parsed.name);
|
|
197
|
+
if (existsSync(full)) throw targetError(409, 'File already exists');
|
|
198
|
+
const policy = isWriteAllowed(full);
|
|
199
|
+
if (!policy.ok) throw targetError(403, 'Path not allowed');
|
|
200
|
+
writeFileSync(policy.real, '');
|
|
201
|
+
const relPath = parsed.dirPath ? `${parsed.dirPath}/${parsed.name}` : parsed.name;
|
|
202
|
+
send(res, 200, { ok: true, path: relPath });
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ─── create directory ───────────────────────────────────────────────────────
|
|
207
|
+
function targetCreateDir(req, res, parsedUrl, isLocal, deps) {
|
|
208
|
+
withBody(req, res, parsedUrl, isLocal, deps, (parsed, scope) => {
|
|
209
|
+
validateName(parsed.name, 'folder');
|
|
210
|
+
const dirSegs = parsed.dirPath ? requiredTargetRelPath(parsed.dirPath) : [];
|
|
211
|
+
const { real: dirReal } = resolveContained(scope, dirSegs);
|
|
212
|
+
if (!statSync(dirReal).isDirectory()) throw targetError(400, 'Directory not found');
|
|
213
|
+
const full = join(dirReal, parsed.name);
|
|
214
|
+
if (existsSync(full)) throw targetError(409, 'Already exists');
|
|
215
|
+
const policy = isWriteAllowed(full);
|
|
216
|
+
if (!policy.ok) throw targetError(403, 'Path not allowed');
|
|
217
|
+
mkdirSync(policy.real);
|
|
218
|
+
const relPath = parsed.dirPath ? `${parsed.dirPath}/${parsed.name}` : parsed.name;
|
|
219
|
+
send(res, 200, { ok: true, path: relPath });
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── rename (basename only; never moves across directories) ────────────────
|
|
224
|
+
function targetRenameFile(req, res, parsedUrl, isLocal, deps) {
|
|
225
|
+
withBody(req, res, parsedUrl, isLocal, deps, (parsed, scope) => {
|
|
226
|
+
const { oldPath, newName } = parsed;
|
|
227
|
+
if (!oldPath || !newName) throw targetError(400, 'Missing oldPath or newName');
|
|
228
|
+
validateName(newName, 'file');
|
|
229
|
+
const segs = requiredTargetRelPath(oldPath);
|
|
230
|
+
// Renaming the navigation root itself would break the browser's own anchor.
|
|
231
|
+
if (segs.length === 0) throw targetError(403, 'Cannot rename the navigation root');
|
|
232
|
+
const { full: oldFull, real: oldReal } = resolveContained(scope, segs);
|
|
233
|
+
// Symlink check must run on the SPELLED path (oldFull), not the realpath'd
|
|
234
|
+
// one — lstat on oldReal can never see a symlink, so checking it would
|
|
235
|
+
// silently rename the link's target instead of refusing the link.
|
|
236
|
+
lstatNoSymlink(oldFull);
|
|
237
|
+
// SECURITY: validate the SOURCE with the write policy too, not just the
|
|
238
|
+
// destination. Renaming a denylisted name (settings.json / credentials.json /
|
|
239
|
+
// master.key) or subtree (projects/ todos/ shell-snapshots/ …) to a benign
|
|
240
|
+
// name would otherwise make it readable via /api/file-content — a denylist
|
|
241
|
+
// bypass. Do NOT drop this check in review.
|
|
242
|
+
const srcPolicy = isWriteAllowed(oldReal);
|
|
243
|
+
if (!srcPolicy.ok) throw targetError(403, 'Path not allowed');
|
|
244
|
+
// SECURITY: renaming a DIRECTORY moves its whole subtree, so apply the same
|
|
245
|
+
// per-descendant write scan as delete. Without this, renaming ~/.claude (or a
|
|
246
|
+
// sensitive-prefix dir like ~/.ssh) to a benign name would sidestep every
|
|
247
|
+
// path-based denylist (the read policy keys off the live config-dir path) and
|
|
248
|
+
// expose credentials/transcripts. Do NOT drop this scan in review.
|
|
249
|
+
if (statSync(oldReal).isDirectory()) assertSubtreeWritable(oldReal);
|
|
250
|
+
// Parent must also stay inside the navigation root (it does, but the rename
|
|
251
|
+
// destination is derived from it, so assert before composing).
|
|
252
|
+
const parentDir = dirname(oldReal);
|
|
253
|
+
if (!insidePath(parentDir, scope.rootReal)) throw targetError(403, 'Path outside navigation root');
|
|
254
|
+
const newFull = join(parentDir, newName);
|
|
255
|
+
const policy = isWriteAllowed(newFull);
|
|
256
|
+
if (!policy.ok) throw targetError(403, 'Path not allowed');
|
|
257
|
+
const parentRel = segs.slice(0, -1).join('/');
|
|
258
|
+
const newRelPath = parentRel ? `${parentRel}/${newName}` : newName;
|
|
259
|
+
|
|
260
|
+
// Case-only rename on a case-insensitive FS needs a temp hop (same dance as
|
|
261
|
+
// files-fs.js renameFile) so NTFS/APFS do not see the destination as the source.
|
|
262
|
+
const oldName = basename(oldReal);
|
|
263
|
+
if (oldName !== newName && oldName.toLowerCase() === newName.toLowerCase()) {
|
|
264
|
+
try {
|
|
265
|
+
if (realpathSync.native(newFull) === realpathSync.native(oldReal)) {
|
|
266
|
+
const tempPath = join(parentDir, `.ccv-rename-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
267
|
+
renameSyncWithRetry(oldReal, tempPath);
|
|
268
|
+
try { renameSyncWithRetry(tempPath, newFull); }
|
|
269
|
+
catch (err) {
|
|
270
|
+
try { renameSyncWithRetry(tempPath, oldReal); }
|
|
271
|
+
catch (rollbackErr) { reportSwallowed('targetFs.renameRollback', rollbackErr, { tempPath }); }
|
|
272
|
+
throw err;
|
|
273
|
+
}
|
|
274
|
+
return send(res, 200, { ok: true, newPath: newRelPath });
|
|
275
|
+
}
|
|
276
|
+
} catch (err) {
|
|
277
|
+
if (err.code !== 'ENOENT') throw err;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (existsSync(newFull)) throw targetError(409, 'Target already exists');
|
|
281
|
+
renameSyncWithRetry(oldReal, newFull);
|
|
282
|
+
send(res, 200, { ok: true, newPath: newRelPath });
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─── delete ─────────────────────────────────────────────────────────────────
|
|
287
|
+
function targetDeleteFile(req, res, parsedUrl, isLocal, deps) {
|
|
288
|
+
withBody(req, res, parsedUrl, isLocal, deps, (parsed, scope) => {
|
|
289
|
+
const filePath = parsed.path;
|
|
290
|
+
if (!filePath) throw targetError(400, 'Missing path');
|
|
291
|
+
const segs = requiredTargetRelPath(filePath);
|
|
292
|
+
if (segs.length === 0) throw targetError(403, 'Cannot delete the navigation root');
|
|
293
|
+
const { full, real } = resolveContained(scope, segs);
|
|
294
|
+
const policy = isWriteAllowed(real);
|
|
295
|
+
if (!policy.ok) throw targetError(403, 'Path not allowed');
|
|
296
|
+
const st = lstatNoSymlink(full);
|
|
297
|
+
if (st.isDirectory()) {
|
|
298
|
+
// protectedDirs guard must defeat Win backslash + NTFS case-insensitive
|
|
299
|
+
// spellings, so normalize both separators and case before the check.
|
|
300
|
+
const protectedDirs = new Set(['node_modules', '.git', '.svn', '.hg']);
|
|
301
|
+
const normalizedSegs = filePath.split(/[\\/]/).map(s => s.toLowerCase());
|
|
302
|
+
if (normalizedSegs.some(part => protectedDirs.has(part))) {
|
|
303
|
+
throw targetError(400, 'Cannot delete protected directory');
|
|
304
|
+
}
|
|
305
|
+
// Defense-in-depth: re-realpath in the window between lstat and rmdir.
|
|
306
|
+
const real2 = realpathSync(full);
|
|
307
|
+
if (!insidePath(real2, scope.rootReal)) throw targetError(403, 'Path escaped navigation root');
|
|
308
|
+
// SECURITY: a recursive rmdir must not bypass the per-file write policy.
|
|
309
|
+
// Scan the subtree and refuse the whole delete if ANY descendant is
|
|
310
|
+
// write-denied (settings.json / credentials / projects transcripts / …),
|
|
311
|
+
// otherwise deleting ~/.claude would wipe the very files the policy
|
|
312
|
+
// protects one-by-one. Do NOT remove this scan to "speed up" deletes.
|
|
313
|
+
assertSubtreeWritable(real2);
|
|
314
|
+
rmSync(full, { recursive: true, force: true });
|
|
315
|
+
} else if (st.isFile()) {
|
|
316
|
+
unlinkSync(full);
|
|
317
|
+
} else {
|
|
318
|
+
throw targetError(400, 'Unsupported path type');
|
|
319
|
+
}
|
|
320
|
+
send(res, 200, { ok: true });
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export const targetFilesFsRoutes = [
|
|
325
|
+
{ method: 'POST', match: 'exact', path: '/api/target-reveal-file', handler: targetRevealFile },
|
|
326
|
+
{ method: 'POST', match: 'exact', path: '/api/target-open-terminal', handler: targetOpenTerminal },
|
|
327
|
+
{ method: 'POST', match: 'exact', path: '/api/target-resolve-path', handler: targetResolvePath },
|
|
328
|
+
{ method: 'POST', match: 'exact', path: '/api/target-create-file', handler: targetCreateFile },
|
|
329
|
+
{ method: 'POST', match: 'exact', path: '/api/target-create-dir', handler: targetCreateDir },
|
|
330
|
+
{ method: 'POST', match: 'exact', path: '/api/target-rename-file', handler: targetRenameFile },
|
|
331
|
+
{ method: 'POST', match: 'exact', path: '/api/target-delete-file', handler: targetDeleteFile },
|
|
332
|
+
];
|
package/server/server.js
CHANGED
|
@@ -32,6 +32,7 @@ import { ultraAgentsRoutes } from './routes/ultra-agents.js';
|
|
|
32
32
|
import { filesContentRoutes } from './routes/files-content.js';
|
|
33
33
|
import { workflowJournalRoutes } from './routes/workflow-journal.js';
|
|
34
34
|
import { filesFsRoutes } from './routes/files-fs.js';
|
|
35
|
+
import { targetFilesFsRoutes } from './routes/target-files-fs.js';
|
|
35
36
|
import { searchRoutes } from './routes/search.js';
|
|
36
37
|
import { workspacesRoutes } from './routes/workspaces.js';
|
|
37
38
|
import { expertRoutes } from './routes/expert.js';
|
|
@@ -294,9 +295,10 @@ const gitRestoreLocks = new Map(); // Map<absLockKey, Promise<void>>
|
|
|
294
295
|
|
|
295
296
|
// Notify the parent process (Electron main, when forked under tab-worker) about pending state changes.
|
|
296
297
|
// No-op outside Electron (process.send is undefined when run as a standalone Node server).
|
|
297
|
-
// Only ask-hook-* / sdk-ask-* are translated. Permission
|
|
298
|
-
// drive global modal / flashFrame / Notification (per UX direction).
|
|
299
|
-
// renderer and reported via window.tabBridge directly
|
|
298
|
+
// Only ask-hook-* / sdk-ask-* are translated. Permission stays inline-only and does not
|
|
299
|
+
// drive global modal / flashFrame / Notification (per UX direction). Plan approvals — PTY and
|
|
300
|
+
// SDK — are parsed in the renderer and reported via window.tabBridge directly (SDK ExitPlanMode
|
|
301
|
+
// reuses the PTY plan card/modal channel), not through this server-side hook.
|
|
300
302
|
function _notifyParentPending(msg) {
|
|
301
303
|
if (!process.send || !msg || typeof msg !== 'object' || !msg.type) return;
|
|
302
304
|
let event = null;
|
|
@@ -867,6 +869,7 @@ const _routes = [
|
|
|
867
869
|
...filesContentRoutes,
|
|
868
870
|
...workflowJournalRoutes,
|
|
869
871
|
...filesFsRoutes,
|
|
872
|
+
...targetFilesFsRoutes,
|
|
870
873
|
...searchRoutes,
|
|
871
874
|
...workspacesRoutes,
|
|
872
875
|
...expertRoutes,
|
|
@@ -2213,11 +2216,10 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2213
2216
|
const fproj = (typeof frame.cwd === 'string' && frame.cwd) ? (projectKeyForCwd(frame.cwd) || null) : null;
|
|
2214
2217
|
return !fproj || fproj === viewProject; // no-cwd frame → replay; else must match
|
|
2215
2218
|
};
|
|
2216
|
-
const replaySdkPendingApprovals = (
|
|
2219
|
+
const replaySdkPendingApprovals = () => {
|
|
2217
2220
|
if (!_sdkGetPendingApprovals) return;
|
|
2218
2221
|
for (const p of _sdkGetPendingApprovals() || []) {
|
|
2219
2222
|
if (!p || !p.replay || !p.id) continue;
|
|
2220
|
-
if (planOnly && p.kind !== 'plan') continue;
|
|
2221
2223
|
const payload = { ...p.replay };
|
|
2222
2224
|
if (!_sdkFrameForView(payload)) continue;
|
|
2223
2225
|
// The generic replay filter is project-scoped. When an exact instance
|
|
@@ -2456,9 +2458,24 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2456
2458
|
}
|
|
2457
2459
|
}
|
|
2458
2460
|
// View changes use attach rather than a new connection: restore that view's
|
|
2459
|
-
// pending approvals too, so a plan cleared on switch-away can be
|
|
2460
|
-
// after switching back instead of waiting for the server timeout.
|
|
2461
|
-
|
|
2461
|
+
// pending approvals too, so a plan/ask/perm cleared on switch-away can be
|
|
2462
|
+
// answered after switching back instead of waiting for the server timeout.
|
|
2463
|
+
// (Was plan-only; ask/perm were orphaned into a timeout deny on re-attach.
|
|
2464
|
+
// The replay helpers already filter by the just-stamped view project+instance,
|
|
2465
|
+
// and the frontend dedupes by id, so a full replay never double-opens a modal.)
|
|
2466
|
+
replaySdkPendingApprovals();
|
|
2467
|
+
// Re-announce the just-viewed workspace's turn state so the spinner / Stop
|
|
2468
|
+
// button reflects it immediately (otherwise a long-running turn shows idle
|
|
2469
|
+
// until the next state change or heartbeat).
|
|
2470
|
+
if (_sdkGetSessionStateSnapshot) {
|
|
2471
|
+
const stateAnchor = ws._ccvViewInstance
|
|
2472
|
+
? { instanceKey: ws._ccvViewInstance }
|
|
2473
|
+
: (ws._ccvViewProject ? { project: ws._ccvViewProject } : null);
|
|
2474
|
+
const stateSnap = stateAnchor ? _sdkGetSessionStateSnapshot(stateAnchor) : _sdkGetSessionStateSnapshot();
|
|
2475
|
+
if (stateSnap && (!ws._ccvViewInstance || stateSnap.instanceKey === ws._ccvViewInstance)) {
|
|
2476
|
+
try { ws.send(JSON.stringify(stateSnap)); } catch (err) { reportSwallowed('sdk.session-state-replay', err); }
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2462
2479
|
replayQueueSnapshot();
|
|
2463
2480
|
return;
|
|
2464
2481
|
}
|
|
@@ -2876,7 +2893,11 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2876
2893
|
if (_sdkSendUserMessage && msg.text) {
|
|
2877
2894
|
const anchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined;
|
|
2878
2895
|
_sdkSendUserMessage(msg.text, anchor).catch(err => {
|
|
2879
|
-
|
|
2896
|
+
reportSwallowed('server.sdk-send', err);
|
|
2897
|
+
// Directed back to the initiator ONLY (a global broadcast would show every
|
|
2898
|
+
// viewer a send failure for a message only one of them typed).
|
|
2899
|
+
try { ws.send(JSON.stringify({ type: 'sdk-send-failed', reason: err?.code === 'not-delivered' ? 'not-delivered' : 'error', detail: String(err?.message || err), ts: Date.now() })); }
|
|
2900
|
+
catch (e2) { reportSwallowed('server.sdk-send-failed-send', e2); }
|
|
2880
2901
|
});
|
|
2881
2902
|
}
|
|
2882
2903
|
} else if (msg.type === 'sdk-interrupt') {
|
|
@@ -2894,6 +2915,25 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2894
2915
|
// 切非运行态并等 4s 兜底才翻回,输出仍在流 —— 打日志让这类错配可观测。
|
|
2895
2916
|
console.warn('[SDK] sdk-interrupt ignored: isSdkMode=%s, handler=%s', isSdkMode, !!_sdkInterruptTurn);
|
|
2896
2917
|
}
|
|
2918
|
+
} else if (msg.type === 'sdk-retry-turn') {
|
|
2919
|
+
// Manual [retry] of the last failed (retryable) turn: pushes a synthetic
|
|
2920
|
+
// continue instruction instead of resending the user's original message.
|
|
2921
|
+
// Refusals go back ONLY to the initiating ws (a global broadcast would show
|
|
2922
|
+
// every viewer a reject for an action only one of them took).
|
|
2923
|
+
if (isSdkMode && _sdkRetryTurn) {
|
|
2924
|
+
const anchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined;
|
|
2925
|
+
_sdkRetryTurn(typeof msg.turnId === 'string' ? msg.turnId : null, anchor).then((r) => {
|
|
2926
|
+
if (!r || r.ok) return;
|
|
2927
|
+
// Echo the view stamps so a user who switched views before the reply
|
|
2928
|
+
// lands doesn't see the rejection (and its sdkRetry clear) in the new view.
|
|
2929
|
+
try { ws.send(JSON.stringify({ type: 'sdk-retry-state', phase: 'rejected', reason: r.reason || 'unavailable', turnId: msg.turnId || null, project: msg.project, instanceKey: msg.instanceKey, ts: Date.now() })); }
|
|
2930
|
+
catch (err) { reportSwallowed('server.sdk-retry-reject', err); }
|
|
2931
|
+
}).catch((err) => {
|
|
2932
|
+
reportSwallowed('server.sdk-retry-turn', err);
|
|
2933
|
+
try { ws.send(JSON.stringify({ type: 'sdk-retry-state', phase: 'rejected', reason: 'unavailable', turnId: msg.turnId || null, project: msg.project, instanceKey: msg.instanceKey, ts: Date.now() })); }
|
|
2934
|
+
catch (e2) { reportSwallowed('server.sdk-retry-reject', e2); }
|
|
2935
|
+
});
|
|
2936
|
+
}
|
|
2897
2937
|
} else if (msg.type === 'queue-message') {
|
|
2898
2938
|
// Chat-composer busy-queue enqueue (terminal-hidden PTY mode). These queue-* types
|
|
2899
2939
|
// are deliberately NOT in the SDK no-op gate above: each branch routes by mode.
|
|
@@ -2904,7 +2944,9 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2904
2944
|
if (_sdkSendUserMessage) {
|
|
2905
2945
|
const anchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined;
|
|
2906
2946
|
_sdkSendUserMessage(msg.text, anchor).catch(err => {
|
|
2907
|
-
|
|
2947
|
+
reportSwallowed('server.sdk-queue-relay', err);
|
|
2948
|
+
try { ws.send(JSON.stringify({ type: 'sdk-send-failed', reason: err?.code === 'not-delivered' ? 'not-delivered' : 'error', detail: String(err?.message || err), ts: Date.now() })); }
|
|
2949
|
+
catch (e2) { reportSwallowed('server.sdk-send-failed-send', e2); }
|
|
2908
2950
|
});
|
|
2909
2951
|
}
|
|
2910
2952
|
} else {
|
|
@@ -3004,7 +3046,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
3004
3046
|
_armRedrawRetries();
|
|
3005
3047
|
}
|
|
3006
3048
|
}
|
|
3007
|
-
} catch {}
|
|
3049
|
+
} catch (err) { reportSwallowed('server.ws-dispatch', err); }
|
|
3008
3050
|
});
|
|
3009
3051
|
|
|
3010
3052
|
ws.on('close', () => {
|
|
@@ -3724,7 +3766,8 @@ export function broadcastWsMessage(msg) { if (terminalWss) {
|
|
|
3724
3766
|
try { c.send(str); } catch {}
|
|
3725
3767
|
});
|
|
3726
3768
|
}
|
|
3727
|
-
// 仅对 ask 类型转译给主进程;perm-hook-*
|
|
3769
|
+
// 仅对 ask 类型转译给主进程;perm-hook-* 维持 inline-only(红线)。sdk-plan-* 不经此 server
|
|
3770
|
+
// 侧钩子——SDK plan 在渲染端复用 PTY plan 卡片/modal 通道,由 window.tabBridge 上报。
|
|
3728
3771
|
// 显式调用 _notifyParentPending 的分支(ask-hook-resolved 等)走 ws.send 不进这里,无重复触发。
|
|
3729
3772
|
if (msg && typeof msg === 'object' && typeof msg.type === 'string'
|
|
3730
3773
|
&& (msg.type === 'sdk-ask-pending' || msg.type === 'sdk-ask-resolved' || msg.type === 'sdk-ask-timeout'
|
|
@@ -3801,6 +3844,10 @@ export function setSdkSendUserMessage(fn) { _sdkSendUserMessage = fn; }
|
|
|
3801
3844
|
let _sdkInterruptTurn = null;
|
|
3802
3845
|
export function setSdkInterruptTurn(fn) { _sdkInterruptTurn = fn; }
|
|
3803
3846
|
|
|
3847
|
+
/** Reference to sdk-manager's retryInterruptedTurn (set by cli.js after import). */
|
|
3848
|
+
let _sdkRetryTurn = null;
|
|
3849
|
+
export function setSdkRetryTurn(fn) { _sdkRetryTurn = fn; }
|
|
3850
|
+
|
|
3804
3851
|
/** Reference to sdk-manager's getPendingApprovals (set by cli.js after import). */
|
|
3805
3852
|
let _sdkGetPendingApprovals = null;
|
|
3806
3853
|
export function setSdkGetPendingApprovals(fn) { _sdkGetPendingApprovals = fn; }
|