cc-viewer 1.8.19 → 1.8.21
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/README.md +3 -1
- package/cli.js +33 -4
- package/dist/assets/App-BmZyzd9Q.js +2 -0
- package/dist/assets/App-DOZI5HME.css +1 -0
- package/dist/assets/{MdxEditorPanel-DU_5CRtW.js → MdxEditorPanel-BHvoYOF-.js} +1 -1
- package/dist/assets/{MdxEditorPanel-Dpn-4ydO.css → MdxEditorPanel-DTfdj6GS.css} +1 -1
- package/dist/assets/Mobile-C10tOIQo.js +1 -0
- package/dist/assets/ProxyStatsModal-Do4qrHT7.js +1 -0
- package/dist/assets/{_baseUniq-B3e4JaL0.js → _baseUniq-CwQykUuc.js} +1 -1
- package/dist/assets/{arc-DnoPn_qY.js → arc-EdUdBxtG.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-DrPKTbxH.js → architectureDiagram-Q4EWVU46-DtBIdElY.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-CEaO0Vcw.js → blockDiagram-DXYQGD6D-guNZIKkZ.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-BLaawFeg.js → c4Diagram-AHTNJAMY-xV5vB-Z9.js} +1 -1
- package/dist/assets/{channel-DnYR1fF4.js → channel-DYEktNap.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-Bfd4edKZ.js → chunk-4BX2VUAB-ZTkItpvj.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-t2nMQmY7.js → chunk-4TB4RGXK-dYIewyyR.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-C6tLOjGX.js → chunk-55IACEB6-CSsPNgPX.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-dWCvxbCb.js → chunk-EDXVE4YY-CnMiuqzQ.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-BHELZLPK.js → chunk-FMBD7UC4-CVpATPZ3.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-C8XTkH1S.js → chunk-OYMX7WX6-Dpq4s8dW.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-CD0j6SAs.js → chunk-QZHKN3VN-Bnojh6X6.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-D4dKfXJJ.js → chunk-YZCP3GAM-BYqK4Dsa.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +1 -0
- package/dist/assets/clone-CFj55h49.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BF2HsiSD.js → cose-bilkent-S5V4N54A-OJ7mmWyV.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-f3h967yx.js → dagre-KV5264BT-B1Kwd_Rk.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-B9PzHQfh.js → diagram-5BDNPKRD-D8qrw8sp.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-kR953Bqm.js → diagram-G4DWMVQ6-ip-N4ho8.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-DAewa2eg.js → diagram-MMDJMWI5-2PZaskUF.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-C8HNTH6I.js → diagram-TYMM5635-CeAOQUZP.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-BQ5P8wWK.js → erDiagram-SMLLAGMA-BCBaEYEb.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-BIpEkrMR.js → flowDiagram-DWJPFMVM-CsGyIqcX.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CWqSfB5v.js → ganttDiagram-T4ZO3ILL-COsj3FZ-.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Ba-9yEbQ.js → gitGraphDiagram-UUTBAWPF-DvQVQGJG.js} +1 -1
- package/dist/assets/{graph-D3zXKQ6P.js → graph-BSB9fDSP.js} +1 -1
- package/dist/assets/{index-G3xd-gLo.js → index-BRw4Iz2O.js} +1 -1
- package/dist/assets/{index-DClD_EkN.js → index-Bo-12bug.js} +1 -1
- package/dist/assets/{index-DSd5P3WQ.js → index-BuVComcY.js} +1 -1
- package/dist/assets/{index-HLrnYsI1.js → index-CNNAYjkF.js} +1 -1
- package/dist/assets/{index-Bm3A5VLb.js → index-DKufsAzx.js} +1 -1
- package/dist/assets/{index-C9N0rZ7Z.js → index-DXBfwPYD.js} +1 -1
- package/dist/assets/index-DyVKoGOX.js +2 -0
- package/dist/assets/{index-Nvk8r3tO.css → index-Dz36p_bA.css} +1 -1
- package/dist/assets/{index-Q8wp4Nof.js → index-eM2ih5n7.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-Cxm79ZJR.js → infoDiagram-42DDH7IO-eNucS4iW.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-Cd0vZP1B.js → ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-B2b57HXk.js → journeyDiagram-VCZTEJTY-yJJzVEBb.js} +1 -1
- package/dist/assets/{jszip.min-Bj5uiEb2.js → jszip.min-ByI0ds5u.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-YCERiUCO.js → kanban-definition-6JOO6SKY-2rfTi2zw.js} +1 -1
- package/dist/assets/{layout-Do5TlO2U.js → layout-DTmHj0Wm.js} +1 -1
- package/dist/assets/{linear-BiJNTDgN.js → linear-BOA3a9FE.js} +1 -1
- package/dist/assets/{mermaid.core-Dg0McRZt.js → mermaid.core-5H-NR79H.js} +3 -3
- package/dist/assets/{min-T51iefht.js → min-CpjFTEec.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-Cu_GJxY3.js → mindmap-definition-QFDTVHPH-B8KCjFgB.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-DldHHZXi.js → pieDiagram-DEJITSTG-BARy96g5.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-BzyTPNAJ.js → quadrantDiagram-34T5L4WZ-DYoTapuI.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-C5xxFkvG.js → requirementDiagram-MS252O5E-EeANLLK2.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-OxdjoaE1.js → sankeyDiagram-XADWPNL6-DmQ-YVPj.js} +1 -1
- package/dist/assets/seqResourceLoaders-B-wmkeam.js +2 -0
- package/dist/assets/seqResourceLoaders-BdWMUSrC.css +41 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-C7c89uIS.js → sequenceDiagram-FGHM5R23-SZtW0rYC.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-ssdJnDrO.js → stateDiagram-FHFEXIEX-CEygsIld.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BP-0WClK.js → stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-sWScEpSM.js → timeline-definition-GMOUNBTQ-DhPBaQBt.js} +1 -1
- package/dist/assets/{vendor-antd-DKIytXho.js → vendor-antd-CS_S2mBN.js} +1 -1
- package/dist/assets/{vendor-codemirror-4qgXq03k.js → vendor-codemirror-B5l-VbBO.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-D5Z5D9fq.js → vendor-mdxeditor-Cz09a1_I.js} +2 -2
- package/dist/assets/{vendor-qrcode-B7lb5qk1.js → vendor-qrcode-qbbntD6-.js} +1 -1
- package/dist/assets/{vendor-virtuoso-BqoXlbhH.js → vendor-virtuoso-Sk8fQ4Hx.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-DNfXR0ge.js → vennDiagram-DHZGUBPP-NniTHxDw.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-JXd--CRD.js → wardley-RL74JXVD-D9SNOrDw.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-DHmNyVPM.js → wardleyDiagram-NUSXRM2D-DvHXKNza.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CTb0gltZ.js → xychartDiagram-5P7HB3ND-CaiwPTQB.js} +1 -1
- package/dist/index.html +5 -5
- package/package.json +1 -1
- package/server/interceptor.js +192 -24
- package/server/lib/ask/ask-store.js +7 -3
- package/server/lib/async-file-lock.js +29 -5
- package/server/lib/auth.js +71 -54
- package/server/lib/chat-queue.js +67 -4
- package/server/lib/config-backup.js +8 -5
- package/server/lib/credential-access.js +233 -0
- package/server/lib/credential-migrate.js +147 -0
- package/server/lib/credential-store.js +120 -0
- package/server/lib/credential-vault.js +112 -0
- package/server/lib/file-access-policy.js +16 -0
- package/server/lib/im/im-config.js +81 -31
- package/server/lib/im-deny.js +22 -8
- package/server/lib/json-store.js +264 -0
- package/server/lib/prefs-store.js +8 -33
- package/server/lib/same-origin.js +22 -0
- package/server/lib/session-pin-store.js +7 -14
- package/server/lib/v2/convert.js +1 -1
- package/server/lib/v2/layout.js +28 -3
- package/server/lib/v2/live-feed.js +41 -4
- package/server/lib/v2/resume-list.js +150 -0
- package/server/lib/v2/session-select.js +63 -14
- package/server/lib/v2/v2-writer.js +172 -34
- package/server/lib/v2/view-router.js +83 -0
- package/server/lib/view-root.js +209 -0
- package/server/pty-manager.js +800 -163
- package/server/routes/ask-perm.js +21 -1
- package/server/routes/auth.js +21 -1
- package/server/routes/events.js +123 -7
- package/server/routes/expert.js +46 -14
- package/server/routes/files-content.js +72 -7
- package/server/routes/files-fs.js +28 -3
- package/server/routes/git.js +31 -5
- package/server/routes/preferences.js +59 -46
- package/server/routes/project-meta.js +32 -3
- package/server/routes/proxy-stats.js +33 -5
- package/server/routes/resume.js +302 -0
- package/server/routes/search.js +23 -1
- package/server/routes/skills.js +18 -2
- package/server/routes/v2.js +6 -1
- package/server/routes/workspaces.js +10 -3
- package/server/server.js +199 -25
- package/server/system-prompt-templates/presets/kimi-k3.md +1 -1
- package/server/workspace-registry.js +41 -35
- package/dist/assets/App-C9Tx6Iqw.js +0 -2
- package/dist/assets/App-M61pqQEx.css +0 -1
- package/dist/assets/Mobile--EECC49-.js +0 -1
- package/dist/assets/ProxyStatsModal-DJu0xq3Z.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BecPAAuk.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-BecPAAuk.js +0 -1
- package/dist/assets/clone-B0_ghF3F.js +0 -1
- package/dist/assets/index-CJdRSHTP.js +0 -2
- package/dist/assets/seqResourceLoaders-BumAjQDW.css +0 -41
- package/dist/assets/seqResourceLoaders-C5UX6ecI.js +0 -2
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { loadAskStore, consumeIfFinal as askStoreConsumeIfFinal, markCancelled as askStoreMarkCancelled } from '../lib/ask/ask-store.js';
|
|
3
3
|
import { runWaterfallHook, runParallelHook } from '../lib/plugin-loader.js';
|
|
4
4
|
import { sendEventToClients } from '../lib/log-watcher.js';
|
|
5
|
+
import { filterClientsByViewProject } from '../lib/v2/view-router.js';
|
|
6
|
+
import { _projectName } from '../interceptor.js';
|
|
5
7
|
|
|
6
8
|
function pendingAsks(req, res, parsedUrl, isLocal, deps) {
|
|
7
9
|
try {
|
|
@@ -512,13 +514,31 @@ function streamChunk(req, res, parsedUrl, isLocal, deps) {
|
|
|
512
514
|
}
|
|
513
515
|
// 用 named event 'stream-progress' 避免混入 data: 流与 dedup 冲突
|
|
514
516
|
// 精简 payload:前端只需要 timestamp/url/content 渲染 Live overlay
|
|
517
|
+
// Multi-project (2026-10): stamp the SOURCE project so the broadcast can
|
|
518
|
+
// be routed only to clients VIEWING that project — an unfiltered
|
|
519
|
+
// broadcast let project A's typewriter render inside project B's view
|
|
520
|
+
// (the SSE cross-project bleed). `entry._resumeProject` is the emitting
|
|
521
|
+
// claude's own project (self-reported via x-ccv-project-dir); absent ⇒
|
|
522
|
+
// the bound project (legacy no-header producer).
|
|
523
|
+
const sourceProject = (typeof entry._resumeProject === 'string' && entry._resumeProject)
|
|
524
|
+
? entry._resumeProject
|
|
525
|
+
: (_projectName || '');
|
|
515
526
|
const _streamChunkPayload = {
|
|
516
527
|
timestamp: entry.timestamp,
|
|
517
528
|
url: entry.url,
|
|
518
529
|
content: entry.response?.body?.content || [],
|
|
519
530
|
model: entry.body?.model,
|
|
531
|
+
// B3 (multi-session view-attach): relay the session uuid so an attached
|
|
532
|
+
// client can drop the other session's typewriter. Additive — older clients
|
|
533
|
+
// ignore it.
|
|
534
|
+
sessionId: typeof entry.sessionId === 'string' ? entry.sessionId : null,
|
|
535
|
+
project: sourceProject,
|
|
520
536
|
};
|
|
521
|
-
sendEventToClients(
|
|
537
|
+
sendEventToClients(
|
|
538
|
+
filterClientsByViewProject(deps.clients, sourceProject, _projectName || ''),
|
|
539
|
+
'stream-progress',
|
|
540
|
+
_streamChunkPayload,
|
|
541
|
+
);
|
|
522
542
|
runParallelHook('onStreamChunk', _streamChunkPayload);
|
|
523
543
|
} catch {}
|
|
524
544
|
try { res.writeHead(204); res.end(); } catch {}
|
package/server/routes/auth.js
CHANGED
|
@@ -39,10 +39,13 @@ function buildState(deps, isLocal, isAdmin) {
|
|
|
39
39
|
enabled: s.effective.enabled,
|
|
40
40
|
isAdmin,
|
|
41
41
|
password: isLocal ? s.effective.password : null,
|
|
42
|
+
// Surface the fail-closed signal so the admin UI can disable the password field and explain
|
|
43
|
+
// why a save would be refused (see the 409 PASSWORD_UNREADABLE branch in authConfigPost).
|
|
44
|
+
passwordUnreadable: !!s.effective.passwordUnreadable,
|
|
42
45
|
scope: s.scope, // 'project' | 'global' — which one is in effect
|
|
43
46
|
hasProjectOverride: s.hasProjectOverride,
|
|
44
47
|
projectDir: s.projectDir, // null when not project-scoped (non-CLI mode)
|
|
45
|
-
global: { enabled: s.global.enabled, password: isLocal ? s.global.password : null },
|
|
48
|
+
global: { enabled: s.global.enabled, password: isLocal ? s.global.password : null, passwordUnreadable: !!s.global.passwordUnreadable },
|
|
46
49
|
};
|
|
47
50
|
}
|
|
48
51
|
|
|
@@ -117,6 +120,23 @@ function authConfigPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
117
120
|
const cur = scope === 'global'
|
|
118
121
|
? state.global
|
|
119
122
|
: (state.hasProjectOverride ? state.effective : { enabled: false, password: '' });
|
|
123
|
+
// Fail-closed: the current scope's vault password exists but is UNREADABLE (lost master.key /
|
|
124
|
+
// tampered ciphertext). cur.password has collapsed to '' for the gate, but the real ciphertext
|
|
125
|
+
// must NOT be destroyed. Refuse any save that would overwrite/clear it — including the
|
|
126
|
+
// auto-generate-on-enable path (next.password is '' here) and any edit-and-save (which would
|
|
127
|
+
// delete/replace the unreadable entry). The admin recovers by restoring master.key, not by a
|
|
128
|
+
// save that silently wipes the previous secret.
|
|
129
|
+
// Read the flag from the SAME scope object as `cur` — a global-unreadable password must NOT
|
|
130
|
+
// refuse a save whose target is a fresh project override (a different vault ref).
|
|
131
|
+
const curUnreadable = !!cur.passwordUnreadable;
|
|
132
|
+
if (curUnreadable) {
|
|
133
|
+
res.writeHead(409, JSON_HEADERS);
|
|
134
|
+
res.end(JSON.stringify({
|
|
135
|
+
error: 'LAN password is stored in the credential vault but is currently unreadable (lost master.key or corrupt credentials.json). Refusing to overwrite it. Restore master.key to recover, or clear the vault entry first.',
|
|
136
|
+
code: 'PASSWORD_UNREADABLE',
|
|
137
|
+
}));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
120
140
|
const next = { enabled: cur.enabled, password: cur.password };
|
|
121
141
|
if (typeof incoming.enabled === 'boolean') next.enabled = incoming.enabled;
|
|
122
142
|
const passwordProvided = typeof incoming.password === 'string';
|
package/server/routes/events.js
CHANGED
|
@@ -9,6 +9,9 @@ import { reportSwallowed } from '@ccv/core/error-report';
|
|
|
9
9
|
import { sseHead, sseWrite, needsDrain, wireEnd, awaitWireDrain, isWireV3Enabled } from '../lib/wire-compress.js';
|
|
10
10
|
import { readV2ColdBundle } from '../lib/v2/meta-rows.js';
|
|
11
11
|
import { readV2SingleEntry } from '../lib/v2/adapter.js';
|
|
12
|
+
import { resolveSessionDirName, latestMainSessionDir } from '../lib/v2/session-select.js';
|
|
13
|
+
import { sanitizePathComponent, normalizeSessionCwd } from '../lib/v2/layout.js';
|
|
14
|
+
import { listLivePtys } from '../pty-manager.js';
|
|
12
15
|
import { enrichRawIfNeeded } from '../lib/enrich-plan-input.js';
|
|
13
16
|
import { validateLogPath } from '../lib/log-management.js';
|
|
14
17
|
import { isMainAgentEntry, extractCachedContent } from '../lib/kv-cache-analyzer.js';
|
|
@@ -188,12 +191,15 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
188
191
|
const updFrame = sseUpdateBadgeFrame(deps.pendingMajorUpdate);
|
|
189
192
|
if (updFrame) sseWrite(res, updFrame);
|
|
190
193
|
|
|
191
|
-
// 1.7.0 迁移引导(P2
|
|
194
|
+
// 1.7.0 迁移引导(P2):「正在被查看的项目」仍有未转换的 v1 日志 → 连接即推 migrate_prompt。
|
|
192
195
|
// 是否弹窗由客户端决定(「不再提醒」偏好在客户端;continued=true 时无视 dismissed
|
|
193
196
|
// 再提醒一次——`-c` 续接的对话前半段在旧格式里,不迁移就看不到)。工作区切换后的
|
|
194
|
-
// 提示由 workspaces launch 路由对存量连接广播(本帧只覆盖新连接)。
|
|
197
|
+
// 提示由 workspaces launch 路由对存量连接广播(本帧只覆盖新连接)。viewedProject
|
|
198
|
+
// (declared below from ?project=) scopes the check to the project the client is
|
|
199
|
+
// actually viewing, not always the bound one.
|
|
195
200
|
try {
|
|
196
|
-
const
|
|
201
|
+
const migProject = parsedUrl.searchParams.get('project') || _projectName || '';
|
|
202
|
+
const mig = migrationStatus(LOG_DIR, migProject);
|
|
197
203
|
if (mig.pending) {
|
|
198
204
|
sseWrite(res, `event: migrate_prompt\ndata: ${JSON.stringify({ ...mig, continued: isContinuedLaunch() })}\n\n`);
|
|
199
205
|
}
|
|
@@ -203,8 +209,15 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
203
209
|
const sinceParam = parsedUrl.searchParams.get('since');
|
|
204
210
|
const ccParam = parseInt(parsedUrl.searchParams.get('cc'), 10) || 0;
|
|
205
211
|
const projectParam = parsedUrl.searchParams.get('project');
|
|
206
|
-
|
|
207
|
-
|
|
212
|
+
// Multi-project (2026-10): a foreign `?project=` (a parallel-project VIEW)
|
|
213
|
+
// may ALSO resume incrementally — the view-state cache keeps the departing
|
|
214
|
+
// project's lastTs and the client returns with `?project=<p>&since=<lastTs>`.
|
|
215
|
+
// The previous `projectMatch` gate forced a full cold reload for any
|
|
216
|
+
// non-bound project (the "slow switch" half of the bug). `?project` already
|
|
217
|
+
// self-declares the view scope, and the cold-source resolution below serves
|
|
218
|
+
// that project's current session read-only without re-binding the server —
|
|
219
|
+
// so the bound-match condition is dropped (cc>0 + a valid date still guard).
|
|
220
|
+
const useIncremental = !!(sinceParam && ccParam > 0 && !isNaN(new Date(sinceParam).getTime()));
|
|
208
221
|
|
|
209
222
|
// 分页参数:
|
|
210
223
|
// - mobile 首次加载传 ?limit=200
|
|
@@ -223,6 +236,102 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
223
236
|
}
|
|
224
237
|
const useLimit = effectiveLimit > 0;
|
|
225
238
|
|
|
239
|
+
// Cold-load source resolution. Two view scopes, both read-only and NEITHER
|
|
240
|
+
// re-binds the server (the bound project `_projectName` is untouched):
|
|
241
|
+
// - `?sid=<uuid>`: THAT session's full transcript (incl. pre-attach history)
|
|
242
|
+
// instead of the single "current" session dir. `?project=<p>` scopes the
|
|
243
|
+
// sid lookup to project <p>'s dir; without it the lookup scans the bound
|
|
244
|
+
// project and a foreign sid resolves to null.
|
|
245
|
+
// - `?project=<p>` (no sid): project <p>'s CURRENT session — the newest
|
|
246
|
+
// readable, non-teammate session with a main turn — so a parallel-project
|
|
247
|
+
// chip can be VIEWED without respawning or killing its process.
|
|
248
|
+
const sidParamRaw = parsedUrl.searchParams.get('sid');
|
|
249
|
+
const sidParam = (typeof sidParamRaw === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sidParamRaw)) ? sidParamRaw : null;
|
|
250
|
+
// Multi-instance (2026-10-06): `?instance=<instanceKey>` narrows the view to ONE concurrent
|
|
251
|
+
// process of a same-cwd project (which otherwise share a basename session dir pool). It
|
|
252
|
+
// scopes the no-sid cold-load pick (each instance resolves to ITS OWN latest session) and is
|
|
253
|
+
// stamped on the SSE response so the per-project live feed can route to exactly the clients
|
|
254
|
+
// viewing that instance. Validated to the minted `ccv-<hex>` shape.
|
|
255
|
+
const instanceParamRaw = parsedUrl.searchParams.get('instance');
|
|
256
|
+
const instanceParam = (typeof instanceParamRaw === 'string' && /^ccv-[0-9a-f]+$/.test(instanceParamRaw)) ? instanceParamRaw : null;
|
|
257
|
+
// `projectParam` (declared above for the incremental guard) doubles as the view
|
|
258
|
+
// scope. The project this client is VIEWING: the explicit ?project= override,
|
|
259
|
+
// else the bound project. Stamped onto the SSE response so per-project feeds can
|
|
260
|
+
// route live entries to exactly the clients viewing that project.
|
|
261
|
+
const viewedProject = projectParam || (_projectName || '');
|
|
262
|
+
const resolveColdSource = () => {
|
|
263
|
+
if (sidParam) {
|
|
264
|
+
const project = projectParam || _projectName;
|
|
265
|
+
if (!project) return null;
|
|
266
|
+
try {
|
|
267
|
+
const projectDir = join(LOG_DIR, sanitizePathComponent(project));
|
|
268
|
+
const dirName = resolveSessionDirName(projectDir, sidParam);
|
|
269
|
+
return dirName ? join(projectDir, 'sessions', dirName) : null;
|
|
270
|
+
} catch (err) {
|
|
271
|
+
reportSwallowed('events.sid-resolve', err, { sid: sidParam });
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// No sid: a foreign ?project= resolves to that project's current session
|
|
276
|
+
// (skipForeignLive honors multi-window isolation, same as getLiveLogSource's
|
|
277
|
+
// fallback). When it matches the bound project (or is absent) the standard
|
|
278
|
+
// live source already serves exactly this — return null to take that path.
|
|
279
|
+
// Multi-instance (2026-10-06): an explicit `?instance=` ALSO drives a cold-source pick even
|
|
280
|
+
// when the project IS the bound one — a second same-cwd instance must cold-load ITS OWN
|
|
281
|
+
// latest session, not the bound project's `_currentSid` (which the most-recent instance
|
|
282
|
+
// overwrites). Without this branch the instance filter was unreachable for the bound
|
|
283
|
+
// project and the first paint showed the OTHER instance's conversation.
|
|
284
|
+
if (projectParam && (projectParam !== (_projectName || '') || instanceParam)) {
|
|
285
|
+
try {
|
|
286
|
+
// Same-name isolation (2026-10-07): resolve the TARGET instance's owning cwd so the
|
|
287
|
+
// cold-load fallback can be scoped to it (two cross-dir same-basename projects share
|
|
288
|
+
// this basename pool). The live PTY record's cwd is the same spawn cwd the writer
|
|
289
|
+
// stamped into meta.cwd (its `s.cwd`) — so compare after the same lexical normalize.
|
|
290
|
+
// Instance dead / no live record / lookup failure → '' → no cwd filter
|
|
291
|
+
// (conservative, identical to today's whole-pool fallback; never mis-excludes history).
|
|
292
|
+
let targetCwd = '';
|
|
293
|
+
if (instanceParam) {
|
|
294
|
+
try {
|
|
295
|
+
const rec = listLivePtys().find((p) => p && p.instanceKey === instanceParam);
|
|
296
|
+
targetCwd = rec && rec.cwd ? normalizeSessionCwd(rec.cwd) : '';
|
|
297
|
+
} catch (err) { targetCwd = ''; reportSwallowed('events.target-cwd', err); }
|
|
298
|
+
}
|
|
299
|
+
return latestMainSessionDir(join(LOG_DIR, sanitizePathComponent(projectParam)), { skipForeignLive: true, instanceKey: instanceParam || '', targetCwd }) || null;
|
|
300
|
+
} catch (err) {
|
|
301
|
+
reportSwallowed('events.project-resolve', err, { project: projectParam });
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
};
|
|
307
|
+
const viewColdDir = resolveColdSource();
|
|
308
|
+
// Silent-fallback guard (design: never silently fall back). A `?sid=<uuid>`
|
|
309
|
+
// that resolves to null (the session dir was /clear-pruned or quota-cleaned)
|
|
310
|
+
// must NOT fall through to the live current session while the client believes
|
|
311
|
+
// it attached — tell the client explicitly so it can toast + auto-detach.
|
|
312
|
+
const sidNotFound = !!(sidParam && !viewColdDir);
|
|
313
|
+
if (sidNotFound) {
|
|
314
|
+
try { sseWrite(res, `event: sid-not-found\ndata: ${JSON.stringify({ sid: sidParam, ts: Date.now() })}\n\n`); } catch { }
|
|
315
|
+
}
|
|
316
|
+
// Multi-instance (2026-10-06): an explicit `?instance=` that resolves to NO session dir must
|
|
317
|
+
// likewise NOT silently fall through to the bound project's current session (the instance
|
|
318
|
+
// filter excludes legacy/other-instance dirs, so an empty result would otherwise show the
|
|
319
|
+
// WRONG project's/instance's conversation). Tell the client explicitly (same posture as
|
|
320
|
+
// sid-not-found) so it can surface an empty state instead of cross-project bleed.
|
|
321
|
+
const instanceNotFound = !!(instanceParam && !sidParam && !viewColdDir);
|
|
322
|
+
if (instanceNotFound) {
|
|
323
|
+
try { sseWrite(res, `event: sid-not-found\ndata: ${JSON.stringify({ sid: null, instance: instanceParam, reason: 'instance-no-session', ts: Date.now() })}\n\n`); } catch { }
|
|
324
|
+
}
|
|
325
|
+
// When a view-scoped dir was requested AND resolved, it replaces the live-source
|
|
326
|
+
// cold load; otherwise fall back to the standard single-current-session source.
|
|
327
|
+
const coldSource = viewColdDir || null;
|
|
328
|
+
// A client viewing a non-bound project needs that project's live feed running
|
|
329
|
+
// (its PTY is alive but its feed is lazy). Start it idempotently; the feed's
|
|
330
|
+
// per-project client filter keeps its entries scoped to this project's viewers.
|
|
331
|
+
if (viewedProject && viewedProject !== (_projectName || '') && typeof deps.ensureProjectFeed === 'function') {
|
|
332
|
+
try { deps.ensureProjectFeed(viewedProject); } catch (err) { reportSwallowed('events.ensure-feed', err, { project: viewedProject }); }
|
|
333
|
+
}
|
|
334
|
+
|
|
226
335
|
// KV-Cache / context_window 追踪(扫描全量条目,不受 since 过滤影响)
|
|
227
336
|
let latestKvCache = null;
|
|
228
337
|
let latestContextWindow = null;
|
|
@@ -243,7 +352,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
243
352
|
// Wire v3 (V3.S5): flagged + v2 source ⇒ the legacy full-entry cold stream
|
|
244
353
|
// is REPLACED by rows + native lines (the byte win); the client assembler
|
|
245
354
|
// rebuilds entries locally. v1 legacy files keep the entry pipeline.
|
|
246
|
-
const _v3Src = deps.wireV3 ? getLiveLogSource() : null;
|
|
355
|
+
const _v3Src = deps.wireV3 ? (coldSource || getLiveLogSource()) : null;
|
|
247
356
|
const v3Cold = !!(_v3Src && existsSync(join(_v3Src, 'journal.jsonl')));
|
|
248
357
|
|
|
249
358
|
// S6b: the cold-load source is the current v2 session dir when the v2
|
|
@@ -252,7 +361,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
252
361
|
// in-flight placeholder, which the client batch gate still blocks — the
|
|
253
362
|
// previous conversation is the better cold load there (interceptor.js
|
|
254
363
|
// getLiveLogSource header comment).
|
|
255
|
-
const coldLoadResult = v3Cold ? null : await streamRawEntriesAsync(getLiveLogSource({ serveInFlight: !!deps.wireV3 }), async (raw) => {
|
|
364
|
+
const coldLoadResult = v3Cold ? null : await streamRawEntriesAsync(coldSource || getLiveLogSource({ serveInFlight: !!deps.wireV3 }), async (raw) => {
|
|
256
365
|
// 直接发送原始 JSON 字符串,不做 parse/reconstruct/stringify
|
|
257
366
|
// ExitPlanMode V2 空 input 的条目按需补全 plan / planFilePath,其它原样透传
|
|
258
367
|
if (res.destroyed || !res.writable) return;
|
|
@@ -412,6 +521,13 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
412
521
|
|
|
413
522
|
// 历史数据 + KV-Cache + context_window 全部发送完毕后,才将客户端加入广播列表。
|
|
414
523
|
// 这样 watcher 的 sendToClients 不会在 load 阶段向该客户端推送 live entry。
|
|
524
|
+
// Stamp the viewed project so per-project feeds route live entries to exactly
|
|
525
|
+
// the clients viewing that project (unset ⇒ the bound project, the default).
|
|
526
|
+
if (viewedProject) res._ccvViewProject = viewedProject;
|
|
527
|
+
// Multi-instance: stamp the viewed instance too, so a same-cwd project's live feed can
|
|
528
|
+
// route each instance's entries to exactly the clients viewing THAT instance (unset ⇒
|
|
529
|
+
// instance-agnostic, the pre-multi-instance default).
|
|
530
|
+
if (instanceParam) res._ccvViewInstance = instanceParam;
|
|
415
531
|
deps.clients.push(res);
|
|
416
532
|
|
|
417
533
|
// 任务清单快照补推:内存态不在冷加载数据里,页面刷新/重连后横条要等下一次
|
package/server/routes/expert.js
CHANGED
|
@@ -38,6 +38,46 @@ async function resolveDir(deps) {
|
|
|
38
38
|
return deps.isWorkspaceMode ? null : process.cwd();
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Multi-project (2026-10): expert GET routes resolve the viewed project ONLY
|
|
42
|
+
// when ?project= is present (resolveViewRoot). Absent it they fall back to the
|
|
43
|
+
// legacy resolveDir chain (live cwd > CCV_PROJECT_DIR > null-in-workspace-mode)
|
|
44
|
+
// — the workspaceMode "no active workspace ⇒ null" contract is preserved
|
|
45
|
+
// verbatim. POST routes keep the bound resolveDir (writes stay bound).
|
|
46
|
+
async function _viewRootOrReply(req, res, parsedUrl, deps) {
|
|
47
|
+
const projectParam = parsedUrl?.searchParams?.get('project');
|
|
48
|
+
const instanceParam = parsedUrl?.searchParams?.get('instance');
|
|
49
|
+
if (!projectParam && !instanceParam) return resolveDir(deps);
|
|
50
|
+
const { resolveViewRoot } = await import('../lib/view-root.js');
|
|
51
|
+
const { loadWorkspaces } = await import('../workspace-registry.js');
|
|
52
|
+
const { listLivePtys } = await import('../pty-manager.js');
|
|
53
|
+
const r = resolveViewRoot({
|
|
54
|
+
projectParam,
|
|
55
|
+
instanceParam,
|
|
56
|
+
boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
|
|
57
|
+
loadWorkspaces,
|
|
58
|
+
listLivePtys,
|
|
59
|
+
});
|
|
60
|
+
if (!r.ok) {
|
|
61
|
+
sendJson(res, r.status, { error: r.error });
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return r.root;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function getSystemText(req, res, parsedUrl, isLocal, deps) {
|
|
68
|
+
try {
|
|
69
|
+
const dir = await _viewRootOrReply(req, res, parsedUrl, deps);
|
|
70
|
+
// Legacy no-active-workspace surface: dir=null → readWorkspaceSystemText
|
|
71
|
+
// returns the empty append surface (active=false) — not an error.
|
|
72
|
+
const { mode, text } = readWorkspaceSystemText(dir);
|
|
73
|
+
sendJson(res, 200, { dir: dir || null, active: !!dir, mode, text });
|
|
74
|
+
} catch (e) {
|
|
75
|
+
// 原始 fs 错误只落服务端日志,对外返回通用错误码(不外泄绝对路径/系统细节)。
|
|
76
|
+
console.error('[CC Viewer] expert system-text GET failed:', e.message);
|
|
77
|
+
sendJson(res, 500, { error: 'read_failed' });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
41
81
|
function sendJson(res, code, obj) {
|
|
42
82
|
if (res.headersSent) return;
|
|
43
83
|
try {
|
|
@@ -102,18 +142,6 @@ function computeSystemPromptStatus(dir) {
|
|
|
102
142
|
return out;
|
|
103
143
|
}
|
|
104
144
|
|
|
105
|
-
async function getSystemText(req, res, parsedUrl, isLocal, deps) {
|
|
106
|
-
try {
|
|
107
|
-
const dir = await resolveDir(deps);
|
|
108
|
-
const { mode, text } = readWorkspaceSystemText(dir);
|
|
109
|
-
sendJson(res, 200, { dir: dir || null, active: !!dir, mode, text });
|
|
110
|
-
} catch (e) {
|
|
111
|
-
// 原始 fs 错误只落服务端日志,对外返回通用错误码(不外泄绝对路径/系统细节)。
|
|
112
|
-
console.error('[CC Viewer] expert system-text GET failed:', e.message);
|
|
113
|
-
sendJson(res, 500, { error: 'read_failed' });
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
145
|
function postSystemText(req, res, parsedUrl, isLocal, deps) {
|
|
118
146
|
let body = '';
|
|
119
147
|
let truncated = false;
|
|
@@ -158,7 +186,9 @@ function collectModelEntries(dir) {
|
|
|
158
186
|
|
|
159
187
|
async function getModelPrompts(req, res, parsedUrl, isLocal, deps) {
|
|
160
188
|
try {
|
|
161
|
-
const dir = await
|
|
189
|
+
const dir = await _viewRootOrReply(req, res, parsedUrl, deps);
|
|
190
|
+
// dir=null(无活动工作区)不是错误:照常渲染 workspaceActive:false + 全局条目
|
|
191
|
+
//(与改动前 resolveDir 返回 null 的路径逐字节一致)。
|
|
162
192
|
const globalDir = join(LOG_DIR, MODEL_PROMPT_DIR);
|
|
163
193
|
const status = computeSystemPromptStatus(dir);
|
|
164
194
|
// 内置 preset 条目(默认生效层):text 为 renderPresetTemplate 输出(边界已剥离),
|
|
@@ -195,7 +225,9 @@ async function getModelPrompts(req, res, parsedUrl, isLocal, deps) {
|
|
|
195
225
|
// 轻量状态查询(不内联提示词文本):头部工具栏据此决定是否自动露出「系统提示词修改」入口。
|
|
196
226
|
async function getSystemPromptStatus(req, res, parsedUrl, isLocal, deps) {
|
|
197
227
|
try {
|
|
198
|
-
const dir = await
|
|
228
|
+
const dir = await _viewRootOrReply(req, res, parsedUrl, deps);
|
|
229
|
+
// dir=null(无活动工作区)不是错误:computeSystemPromptStatus 容忍 null
|
|
230
|
+
//(旧 resolveDir 返回 null 的路径照常渲染全局命中)。
|
|
199
231
|
sendJson(res, 200, computeSystemPromptStatus(dir));
|
|
200
232
|
} catch (e) {
|
|
201
233
|
console.error('[CC Viewer] expert system-prompt-status GET failed:', e.message);
|
|
@@ -3,11 +3,27 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, realpathS
|
|
|
3
3
|
import { join, basename, dirname, resolve, sep } from 'node:path';
|
|
4
4
|
import { isReadAllowed, reasonToStatus } from '../lib/file-access-policy.js';
|
|
5
5
|
import { ERROR_STATUS_MAP } from '../lib/file-api.js';
|
|
6
|
+
import { viewRootOrReply } from '../lib/view-root.js';
|
|
7
|
+
import { loadWorkspaces } from '../workspace-registry.js';
|
|
8
|
+
import { listLivePtys } from '../pty-manager.js';
|
|
6
9
|
import { reportSwallowed } from '@ccv/core/error-report';
|
|
7
10
|
import { discoverClaudeMdCandidates, readCandidateById } from '../lib/claude-md-discovery.js';
|
|
8
11
|
import { getClaudeConfigDir } from '../../findcc.js';
|
|
9
12
|
import { _projectName } from '../interceptor.js';
|
|
10
13
|
|
|
14
|
+
// Multi-project (2026-10): project-scoped READ routes resolve the viewed
|
|
15
|
+
// project via the shared viewRootOrReply (?project=). WRITE routes keep the
|
|
16
|
+
// bound root. SYNCHRONOUS: these handlers were sync before multi-project, and
|
|
17
|
+
// their tests (and the dispatcher's sync-call convention) assert on res
|
|
18
|
+
// immediately after the call — the override must not async-ify them.
|
|
19
|
+
function _viewRootOrReply(req, res, parsedUrl) {
|
|
20
|
+
return viewRootOrReply(req, res, parsedUrl, {
|
|
21
|
+
boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
|
|
22
|
+
loadWorkspaces,
|
|
23
|
+
listLivePtys,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
11
27
|
// file-raw 扩展名 → MIME 映射。图片走 <img> 预览;html 走 iframe 预览(带下方 CSP sandbox);
|
|
12
28
|
// 其余为 HTML 预览的同目录子资源(c8/nyc/lcov-genhtml 等静态报告):CSP sandbox 使文档
|
|
13
29
|
// 处于 opaque origin,对一切子资源都是跨源——跨源样式表受浏览器严格 MIME 校验,
|
|
@@ -84,7 +100,12 @@ function planFile(req, res, parsedUrl) {
|
|
|
84
100
|
|
|
85
101
|
function fileContentGet(req, res, parsedUrl) {
|
|
86
102
|
const reqPath = parsedUrl.searchParams.get('path');
|
|
87
|
-
const cwd =
|
|
103
|
+
const cwd = _viewRootOrReply(req, res, parsedUrl);
|
|
104
|
+
if (!cwd) return;
|
|
105
|
+
fileContentGetWithRoot(req, res, parsedUrl, reqPath, cwd);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function fileContentGetWithRoot(req, res, parsedUrl, reqPath, cwd) {
|
|
88
109
|
try {
|
|
89
110
|
if (!reqPath) {
|
|
90
111
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
@@ -143,10 +164,30 @@ function projectMemory(req, res, parsedUrl) {
|
|
|
143
164
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
144
165
|
res.end(JSON.stringify(body));
|
|
145
166
|
};
|
|
167
|
+
projectMemoryEntry(req, res, parsedUrl, respondJson);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The entry wraps BOTH the view-root resolution and the read in the endpoint's
|
|
171
|
+
// single error surface (the searchParams-throw defense test asserts a 500 from
|
|
172
|
+
// THIS catch, regardless of which stage throws).
|
|
173
|
+
function projectMemoryEntry(req, res, parsedUrl, respondJson) {
|
|
146
174
|
try {
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
175
|
+
const cwd = _viewRootOrReply(req, res, parsedUrl);
|
|
176
|
+
if (!cwd) return;
|
|
177
|
+
projectMemoryWithRoot(req, res, parsedUrl, respondJson, cwd);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
// 与 /api/file-content 一致:已知 errno(ENOENT/EACCES 等)走 ERROR_STATUS_MAP 映射;
|
|
180
|
+
// 500 时不回显 err.message —— 可能含 ~/.claude/projects/<encoded>/memory/ 路径片段。
|
|
181
|
+
const status = ERROR_STATUS_MAP[err.code] || 500;
|
|
182
|
+
const message = status === 500 ? 'Internal error' : err.message;
|
|
183
|
+
respondJson(status, { error: message });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function projectMemoryWithRoot(req, res, parsedUrl, respondJson, cwd) {
|
|
188
|
+
try {
|
|
189
|
+
const cwdClean = cwd.replace(/[/\\]+$/, '');
|
|
190
|
+
const encoded = cwdClean.replace(/[^a-zA-Z0-9-]/g, '-');
|
|
150
191
|
const dir = join(getClaudeConfigDir(), 'projects', encoded, 'memory');
|
|
151
192
|
const fileParam = parsedUrl.searchParams.get('file');
|
|
152
193
|
const MAX_BYTES = 512 * 1024;
|
|
@@ -205,8 +246,22 @@ function claudeMd(req, res, parsedUrl) {
|
|
|
205
246
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
206
247
|
res.end(JSON.stringify(body));
|
|
207
248
|
};
|
|
249
|
+
// The entry wraps BOTH the view-root resolution and the read in the
|
|
250
|
+
// endpoint's single error surface (the searchParams-throw defense test
|
|
251
|
+
// asserts a 500 from THIS catch, regardless of which stage throws).
|
|
252
|
+
try {
|
|
253
|
+
const cwd = _viewRootOrReply(req, res, parsedUrl);
|
|
254
|
+
if (!cwd) return;
|
|
255
|
+
claudeMdWithRoot(req, res, parsedUrl, respondJson, cwd);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
const status = ERROR_STATUS_MAP[err.code] || 500;
|
|
258
|
+
const message = status === 500 ? 'Internal error' : err.message;
|
|
259
|
+
respondJson(status, { error: message });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function claudeMdWithRoot(req, res, parsedUrl, respondJson, cwd) {
|
|
208
264
|
try {
|
|
209
|
-
const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
|
|
210
265
|
const claudeConfigDir = getClaudeConfigDir();
|
|
211
266
|
// 注入 isReadAllowed 做发现阶段预过滤: 不在 allowlist 内的祖先候选不进列表 ——
|
|
212
267
|
// 否则 UI 会渲染出点击必 403 的"看得见点不开"chip。
|
|
@@ -262,7 +317,12 @@ function fileRaw(req, res, parsedUrl) {
|
|
|
262
317
|
} else {
|
|
263
318
|
reqPath = parsedUrl.searchParams.get('path');
|
|
264
319
|
}
|
|
265
|
-
const cwd =
|
|
320
|
+
const cwd = _viewRootOrReply(req, res, parsedUrl);
|
|
321
|
+
if (!cwd) return;
|
|
322
|
+
fileRawWithRoot(req, res, parsedUrl, reqPath, cwd, method);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function fileRawWithRoot(req, res, parsedUrl, reqPath, cwd, method) {
|
|
266
326
|
try {
|
|
267
327
|
if (!reqPath) {
|
|
268
328
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
@@ -336,7 +396,12 @@ function fileRaw(req, res, parsedUrl) {
|
|
|
336
396
|
// No size cap — a download is precisely the path for files too large to preview.
|
|
337
397
|
function fileDownload(req, res, parsedUrl) {
|
|
338
398
|
const reqPath = parsedUrl.searchParams.get('path');
|
|
339
|
-
const cwd =
|
|
399
|
+
const cwd = _viewRootOrReply(req, res, parsedUrl);
|
|
400
|
+
if (!cwd) return;
|
|
401
|
+
fileDownloadWithRoot(req, res, parsedUrl, reqPath, cwd);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function fileDownloadWithRoot(req, res, parsedUrl, reqPath, cwd) {
|
|
340
405
|
try {
|
|
341
406
|
if (!reqPath) {
|
|
342
407
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
@@ -8,9 +8,27 @@ import { homedir, tmpdir } from 'node:os';
|
|
|
8
8
|
import { execFile, spawn } from 'node:child_process';
|
|
9
9
|
import { bumpWorkspacesVersion, isReadAllowed } from '../lib/file-access-policy.js';
|
|
10
10
|
import { validateImportDir } from '../lib/file-api.js';
|
|
11
|
+
import { viewRootOrReply } from '../lib/view-root.js';
|
|
12
|
+
import { loadWorkspaces } from '../workspace-registry.js';
|
|
13
|
+
import { listLivePtys } from '../pty-manager.js';
|
|
11
14
|
import { PROFILE_PATH, _projectName, _logDir } from '../interceptor.js';
|
|
12
15
|
import { LOG_DIR, getClaudeConfigDir } from '../../findcc.js';
|
|
13
16
|
|
|
17
|
+
// Multi-project (2026-10): project-scoped READ routes resolve the viewed
|
|
18
|
+
// project via the shared viewRootOrReply (?project=, or POST body {project}
|
|
19
|
+
// for the body-carrying read probes). WRITE routes keep the bound root.
|
|
20
|
+
// SYNCHRONOUS: the affected handlers were sync before multi-project and their
|
|
21
|
+
// tests assert on res immediately after the call.
|
|
22
|
+
function _viewRootOrReply(req, res, parsedUrl, bodyProject, bodyInstance) {
|
|
23
|
+
return viewRootOrReply(req, res, parsedUrl, {
|
|
24
|
+
boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
|
|
25
|
+
loadWorkspaces,
|
|
26
|
+
listLivePtys,
|
|
27
|
+
bodyProject,
|
|
28
|
+
bodyInstance,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
14
32
|
function upload(req, res, parsedUrl, isLocal, deps) {
|
|
15
33
|
const contentType = req.headers['content-type'] || '';
|
|
16
34
|
const boundaryMatch = contentType.match(/boundary=(.+)/);
|
|
@@ -245,7 +263,8 @@ async function files(req, res, parsedUrl, isLocal, deps) {
|
|
|
245
263
|
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
246
264
|
return;
|
|
247
265
|
}
|
|
248
|
-
const cwd =
|
|
266
|
+
const cwd = _viewRootOrReply(req, res, parsedUrl);
|
|
267
|
+
if (!cwd) return;
|
|
249
268
|
const targetDir = join(cwd, reqPath);
|
|
250
269
|
try {
|
|
251
270
|
const entries = readdirSync(targetDir, { withFileTypes: true });
|
|
@@ -636,7 +655,7 @@ function openFile(req, res, parsedUrl, isLocal, deps) {
|
|
|
636
655
|
function resolvePath(req, res, parsedUrl, isLocal, deps) {
|
|
637
656
|
let body = '';
|
|
638
657
|
req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
|
|
639
|
-
req.on('end', () => {
|
|
658
|
+
req.on('end', async () => {
|
|
640
659
|
let parsed;
|
|
641
660
|
try { parsed = JSON.parse(body); } catch {
|
|
642
661
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
@@ -650,6 +669,11 @@ function resolvePath(req, res, parsedUrl, isLocal, deps) {
|
|
|
650
669
|
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
651
670
|
return;
|
|
652
671
|
}
|
|
672
|
+
// resolve-path is an internal UI helper (resolve a relative path against
|
|
673
|
+
// the project root for reveal/open actions). It stays BOUND-only (review
|
|
674
|
+
// P2-5): resolving a foreign project's root and echoing its absolute path
|
|
675
|
+
// back would make this endpoint a weak path-leak oracle for projects the
|
|
676
|
+
// caller is not viewing.
|
|
653
677
|
const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
|
|
654
678
|
const fullPath = relPath ? join(cwd, relPath) : cwd;
|
|
655
679
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -709,7 +733,8 @@ function filesExists(req, res, parsedUrl, isLocal, deps) {
|
|
|
709
733
|
return;
|
|
710
734
|
}
|
|
711
735
|
}
|
|
712
|
-
const cwd =
|
|
736
|
+
const cwd = _viewRootOrReply(req, res, parsedUrl, parsed.project, parsed.instance);
|
|
737
|
+
if (!cwd) return;
|
|
713
738
|
const results = paths.map(p => ({ path: p, exists: probeFileExists(p, cwd) }));
|
|
714
739
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
715
740
|
res.end(JSON.stringify({ results }));
|
package/server/routes/git.js
CHANGED
|
@@ -2,12 +2,32 @@
|
|
|
2
2
|
import { existsSync, readdirSync, realpathSync } from 'node:fs';
|
|
3
3
|
import { join, basename, resolve, sep } from 'node:path';
|
|
4
4
|
import { getGitDiffs, countUntrackedLines, getUnpushedCommits, isValidCommitHash } from '../lib/git-diff.js';
|
|
5
|
+
import { viewRootOrReply } from '../lib/view-root.js';
|
|
6
|
+
import { loadWorkspaces } from '../workspace-registry.js';
|
|
7
|
+
import { listLivePtys } from '../pty-manager.js';
|
|
5
8
|
|
|
6
9
|
const GIT_RESTORE_LOCK_CLEANUP_MS = 30000;
|
|
7
10
|
|
|
8
|
-
|
|
11
|
+
// Multi-project (2026-10): git READ routes resolve the viewed project via the
|
|
12
|
+
// shared viewRootOrReply (?project=) and pass it as resolveRepoCwd's baseDir.
|
|
13
|
+
// git-restore (destructive) keeps the bound root. SYNCHRONOUS: gitRepos was
|
|
14
|
+
// sync before multi-project and its tests assert on res immediately.
|
|
15
|
+
function _viewRootOrReply(req, res, parsedUrl) {
|
|
16
|
+
return viewRootOrReply(req, res, parsedUrl, {
|
|
17
|
+
boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
|
|
18
|
+
loadWorkspaces,
|
|
19
|
+
listLivePtys,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function gitRepos(req, res, parsedUrl) {
|
|
24
|
+
const projectDir = _viewRootOrReply(req, res, parsedUrl || new URL(req.url, 'http://localhost'));
|
|
25
|
+
if (!projectDir) return;
|
|
26
|
+
gitReposWithRoot(req, res, projectDir);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function gitReposWithRoot(req, res, projectDir) {
|
|
9
30
|
try {
|
|
10
|
-
const projectDir = process.env.CCV_PROJECT_DIR || process.cwd();
|
|
11
31
|
const repos = [];
|
|
12
32
|
if (existsSync(join(projectDir, '.git'))) {
|
|
13
33
|
repos.push({ name: basename(projectDir), path: '.', isRoot: true });
|
|
@@ -102,7 +122,9 @@ function gitRestore(req, res, parsedUrl, isLocal, deps) {
|
|
|
102
122
|
async function gitStatus(req, res, parsedUrl, isLocal, deps) {
|
|
103
123
|
try {
|
|
104
124
|
const repoParam = parsedUrl.searchParams.get('repo');
|
|
105
|
-
const
|
|
125
|
+
const viewRoot = _viewRootOrReply(req, res, parsedUrl);
|
|
126
|
+
if (!viewRoot) return;
|
|
127
|
+
const cwd = deps.resolveRepoCwd(repoParam, viewRoot);
|
|
106
128
|
if (!cwd) {
|
|
107
129
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
108
130
|
res.end(JSON.stringify({ error: 'Invalid repo parameter', changes: [] }));
|
|
@@ -173,7 +195,9 @@ async function gitStatus(req, res, parsedUrl, isLocal, deps) {
|
|
|
173
195
|
async function gitDiff(req, res, parsedUrl, isLocal, deps) {
|
|
174
196
|
try {
|
|
175
197
|
const repoParam = parsedUrl.searchParams.get('repo');
|
|
176
|
-
const
|
|
198
|
+
const viewRoot = _viewRootOrReply(req, res, parsedUrl);
|
|
199
|
+
if (!viewRoot) return;
|
|
200
|
+
const cwd = deps.resolveRepoCwd(repoParam, viewRoot);
|
|
177
201
|
if (!cwd) {
|
|
178
202
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
179
203
|
res.end(JSON.stringify({ error: 'Invalid repo parameter', diffs: [] }));
|
|
@@ -204,7 +228,9 @@ async function gitDiff(req, res, parsedUrl, isLocal, deps) {
|
|
|
204
228
|
async function gitLogUnpushed(req, res, parsedUrl, isLocal, deps) {
|
|
205
229
|
try {
|
|
206
230
|
const repoParam = parsedUrl.searchParams.get('repo');
|
|
207
|
-
const
|
|
231
|
+
const viewRoot = _viewRootOrReply(req, res, parsedUrl);
|
|
232
|
+
if (!viewRoot) return;
|
|
233
|
+
const cwd = deps.resolveRepoCwd(repoParam, viewRoot);
|
|
208
234
|
if (!cwd) {
|
|
209
235
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
210
236
|
res.end(JSON.stringify({ error: 'Invalid repo parameter', commits: [], hasUpstream: false }));
|