cc-viewer 1.6.263 → 1.6.265
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 +13 -0
- package/cli.js +4 -0
- package/dist/assets/App-Dn8sM_4p.js +1 -0
- package/dist/assets/{MdxEditorPanel--reKHew0.js → MdxEditorPanel-CVaK7mOf.js} +1 -1
- package/dist/assets/Mobile-HEclS2va.js +1 -0
- package/dist/assets/{_baseUniq-DiLy7vi3.js → _baseUniq-Dgkw4IXM.js} +1 -1
- package/dist/assets/{arc-CAB2oIHx.js → arc-AiHQLijx.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-Cijl_JpW.js → architectureDiagram-Q4EWVU46-CPRvAIHK.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-Bk4yWCPQ.js → blockDiagram-DXYQGD6D-CK2cwrfX.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-Vz4JKuzi.js → c4Diagram-AHTNJAMY-BP-UBbgv.js} +1 -1
- package/dist/assets/{channel-BnYKz_zI.js → channel-Ny3Nm_-t.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-DM3ZjqKX.js → chunk-4BX2VUAB-DdsULqPZ.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-BTiJOoNa.js → chunk-4TB4RGXK-BDSjQHh0.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-B4fMQcTE.js → chunk-55IACEB6-DrKr3wBa.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-B_WylnyS.js → chunk-EDXVE4YY-o_0SUbAB.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-Cx2lqZi9.js → chunk-FMBD7UC4-Ca_AgqWi.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-CPZm7o6V.js → chunk-OYMX7WX6-CyWWbq5o.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-DuYVzv7E.js → chunk-QZHKN3VN-5rXHErSL.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-Bk3OysLK.js → chunk-YZCP3GAM-DznXBadU.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CLYcbnwx.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-CLYcbnwx.js +1 -0
- package/dist/assets/clone-5GFhU8Pv.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-CQuaqKHt.js → cose-bilkent-S5V4N54A-BhGyix0v.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-BFdoRcuo.js → dagre-KV5264BT-CzzHxIvc.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-ByFdSFIu.js → diagram-5BDNPKRD-tu3BXl0c.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-C1TcKWp0.js → diagram-G4DWMVQ6-C6WkK7sj.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-B5N1Sn5F.js → diagram-MMDJMWI5-DBeD_WW-.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-B-payI0e.js → diagram-TYMM5635-BXUyHHJ4.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-zziefklH.js → erDiagram-SMLLAGMA-Bye5tnW2.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-BOSomu1b.js → flowDiagram-DWJPFMVM-C3pYOs38.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-DILUsv0T.js → ganttDiagram-T4ZO3ILL-DxXkI_FW.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-BKp2DE69.js → gitGraphDiagram-UUTBAWPF-nsxsXsGX.js} +1 -1
- package/dist/assets/{graph-NObGxitU.js → graph-Da-Z9hB7.js} +1 -1
- package/dist/assets/{index-Bq9Sic2n.js → index-4gmR7Eun.js} +1 -1
- package/dist/assets/{index-DHUf_c1w.js → index-BOxeMjZZ.js} +2 -2
- package/dist/assets/{index-BI-0Lyyt.js → index-Brh2V8V0.js} +1 -1
- package/dist/assets/{index-0aPBVZuP.js → index-C0PhJcXG.js} +1 -1
- package/dist/assets/{index-BbXZgnby.js → index-C8w5Sxw3.js} +1 -1
- package/dist/assets/{index-VqFARC4A.js → index-CA8JGh5J.js} +1 -1
- package/dist/assets/{index-PsZiLKrC.js → index-CsuhosSl.js} +1 -1
- package/dist/assets/{index-C88BDuL0.js → index-D7XF7UJ8.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-D409o-BL.js → infoDiagram-42DDH7IO-Ca9j90t5.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-CMVPOGr3.js → ishikawaDiagram-UXIWVN3A-DhjV0XPD.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-Bl_5WlaZ.js → journeyDiagram-VCZTEJTY-CRSHLZPV.js} +1 -1
- package/dist/assets/{jszip.min-C2654z9i.js → jszip.min-CcCCdMNW.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-BfCyUP29.js → kanban-definition-6JOO6SKY-Bg0CUwgc.js} +1 -1
- package/dist/assets/{layout-CgAMa0xE.js → layout-CWNu13XT.js} +1 -1
- package/dist/assets/{linear-J1N1npGr.js → linear-Dcmw1639.js} +1 -1
- package/dist/assets/{mermaid.core-YnqOkuoS.js → mermaid.core-1heNIJ5f.js} +2 -2
- package/dist/assets/{min-CowkZam8.js → min-CC9CkAxn.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-D7yMfot2.js → mindmap-definition-QFDTVHPH-Gr0ex_Ny.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-DKUHBCwB.js → pieDiagram-DEJITSTG-D7P3sUJY.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-DhRqBNfT.js → quadrantDiagram-34T5L4WZ-Bov3lcpV.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-DVE3wKT7.js → requirementDiagram-MS252O5E-BcLptaOU.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-Rn_9b5V_.js → sankeyDiagram-XADWPNL6-B2qAUsON.js} +1 -1
- package/dist/assets/seqResourceLoaders-BGxPc8Yp.js +2 -0
- package/dist/assets/{seqResourceLoaders-DZvMjXCl.css → seqResourceLoaders-DmvKKyh9.css} +3 -3
- package/dist/assets/{sequenceDiagram-FGHM5R23-CemLRaXC.js → sequenceDiagram-FGHM5R23-Do62Uz-a.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-DNT1gAty.js → stateDiagram-FHFEXIEX-Wu8aqa8C.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-DPlMhu-M.js → stateDiagram-v2-QKLJ7IA2-BfYq7Jgo.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-B-F8vgZN.js → timeline-definition-GMOUNBTQ-DYfz5xD6.js} +1 -1
- package/dist/assets/{vendor-antd-Dq3DHFa-.js → vendor-antd-BG1SvzuN.js} +2 -2
- package/dist/assets/{vendor-codemirror-DjMkT0sn.js → vendor-codemirror-8NDhydlF.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-CrZ9SWce.js → vendor-mdxeditor-BB4hhpxM.js} +2 -2
- package/dist/assets/{vendor-qrcode-C_77dtHg.js → vendor-qrcode-DMsNGQ10.js} +1 -1
- package/dist/assets/{vendor-virtuoso-aMZPf2fi.js → vendor-virtuoso-BUT96ALa.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-K67JHnnN.js → vennDiagram-DHZGUBPP-CGr-cc7e.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-CCis01LD.js → wardley-RL74JXVD-BN899vMf.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-BjqRIOpN.js → wardleyDiagram-NUSXRM2D-xUsI1E7h.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CaXETYTI.js → xychartDiagram-5P7HB3ND-woQrslzB.js} +1 -1
- package/dist/index.html +4 -4
- package/dist/voice-packs/default/askQuestion.wav +0 -0
- package/dist/voice-packs/default/pack.json +34 -0
- package/dist/voice-packs/default/planApproval.wav +0 -0
- package/dist/voice-packs/default/timeoutWarning5min.wav +0 -0
- package/dist/voice-packs/default/timeoutWarning60s.wav +0 -0
- package/dist/voice-packs/default/turnEnd.wav +0 -0
- package/interceptor.js +18 -5
- package/lib/approval-modal-prefs.js +71 -0
- package/lib/ensure-hooks.js +48 -4
- package/lib/git-diff.js +4 -0
- package/lib/sdk-manager.js +12 -1
- package/lib/turn-end-bridge.js +117 -0
- package/lib/voice-pack-events.js +32 -0
- package/lib/voice-pack-manager.js +246 -0
- package/package.json +1 -1
- package/pty-manager.js +8 -1
- package/server.js +304 -2
- package/dist/assets/App-CX6bF6ke.js +0 -1
- package/dist/assets/Mobile-YwIGAQWc.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CeAAXpgl.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-CeAAXpgl.js +0 -1
- package/dist/assets/clone-BcGHaFBY.js +0 -1
- package/dist/assets/seqResourceLoaders-B9D4RGth.js +0 -2
package/server.js
CHANGED
|
@@ -52,6 +52,21 @@ import { awaitDrainOrClose } from './lib/sse-backpressure.js';
|
|
|
52
52
|
import { enrichRawIfNeeded } from './lib/enrich-plan-input.js';
|
|
53
53
|
import { buildTeamStatusResponse } from './lib/team-runtime.js';
|
|
54
54
|
import { discoverClaudeMdCandidates, readCandidateById } from './lib/claude-md-discovery.js';
|
|
55
|
+
import {
|
|
56
|
+
saveAudio as vpSaveAudio,
|
|
57
|
+
listUserAudio as vpListUserAudio,
|
|
58
|
+
deleteUserAudio as vpDeleteUserAudio,
|
|
59
|
+
getUserAudioPath as vpGetUserAudioPath,
|
|
60
|
+
getDefaultPackPath as vpGetDefaultPackPath,
|
|
61
|
+
listDefaultPack as vpListDefaultPack,
|
|
62
|
+
isDefaultPackPlaceholder as vpIsDefaultPackPlaceholder,
|
|
63
|
+
reconcileVoicePackPrefs as vpReconcile,
|
|
64
|
+
mimeForFormat as vpMime,
|
|
65
|
+
isValidId as vpIsValidId,
|
|
66
|
+
EVENT_KEYS as VP_EVENT_KEYS,
|
|
67
|
+
MAX_AUDIO_BYTES as VP_MAX_BYTES,
|
|
68
|
+
} from './lib/voice-pack-manager.js';
|
|
69
|
+
import { mergeApprovalModalPrefs as vpMergeAM } from './lib/approval-modal-prefs.js';
|
|
55
70
|
|
|
56
71
|
|
|
57
72
|
// 动态获取 getPrefsFile()(LOG_DIR 可能在运行时被 setLogDir 修改)
|
|
@@ -221,6 +236,10 @@ const HOST = '0.0.0.0';
|
|
|
221
236
|
|
|
222
237
|
// 局域网访问 token(本地 127.0.0.1 免验证)
|
|
223
238
|
const ACCESS_TOKEN = randomBytes(16).toString('hex');
|
|
239
|
+
// Internal token used ONLY for bridge → server calls (env-leaked to the spawned
|
|
240
|
+
// claude process via pty-manager). Separate from ACCESS_TOKEN so the LAN URL
|
|
241
|
+
// token can't double as a bridge auth bypass for same-host CSRF (round-3 P1).
|
|
242
|
+
const INTERNAL_TOKEN = randomBytes(16).toString('hex');
|
|
224
243
|
|
|
225
244
|
let clients = [];
|
|
226
245
|
let server;
|
|
@@ -649,6 +668,11 @@ async function handleRequest(req, res) {
|
|
|
649
668
|
// join() 而非字符串拼接,避免 Windows 分隔符不匹配导致比较失败
|
|
650
669
|
const _cDir = getClaudeConfigDir();
|
|
651
670
|
prefs.claudeConfigDir = _cDir === join(homedir(), '.claude') ? '~/.claude' : _cDir;
|
|
671
|
+
// voice-pack id reconcile — strip references to audio files that no longer exist
|
|
672
|
+
// so the client never tries to play a 404. Read-only here; client save path also runs this.
|
|
673
|
+
if (prefs.approvalModal?.voicePack) {
|
|
674
|
+
prefs.approvalModal.voicePack = vpReconcile(LOG_DIR, prefs.approvalModal.voicePack);
|
|
675
|
+
}
|
|
652
676
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
653
677
|
res.end(JSON.stringify(prefs));
|
|
654
678
|
return;
|
|
@@ -666,7 +690,15 @@ async function handleRequest(req, res) {
|
|
|
666
690
|
}
|
|
667
691
|
let prefs = {};
|
|
668
692
|
try { if (existsSync(getPrefsFile())) prefs = JSON.parse(readFileSync(getPrefsFile(), 'utf-8')); } catch { }
|
|
669
|
-
|
|
693
|
+
// Deep-merge approvalModal so partial updates (e.g. `{ voicePack: { events: { askQuestion: id } } }`)
|
|
694
|
+
// don't blow away unrelated approval prefs. Shallow Object.assign for everything else.
|
|
695
|
+
const { approvalModal: incAM, ...incRest } = incoming;
|
|
696
|
+
Object.assign(prefs, incRest);
|
|
697
|
+
if (incAM && typeof incAM === 'object') {
|
|
698
|
+
prefs.approvalModal = vpMergeAM(prefs.approvalModal, incAM, {
|
|
699
|
+
reconcile: (vp) => vpReconcile(LOG_DIR, vp),
|
|
700
|
+
});
|
|
701
|
+
}
|
|
670
702
|
// 确保目录存在
|
|
671
703
|
const prefsFile = getPrefsFile();
|
|
672
704
|
const prefsDir = dirname(prefsFile);
|
|
@@ -707,6 +739,249 @@ async function handleRequest(req, res) {
|
|
|
707
739
|
return;
|
|
708
740
|
}
|
|
709
741
|
|
|
742
|
+
// ── Voice Pack API ────────────────────────────────────────────────────────
|
|
743
|
+
// Manages user-uploaded audio + serves the bundled "皇上系列" default pack.
|
|
744
|
+
// Uploads are loopback-only — LAN clients can play but not write.
|
|
745
|
+
if (url === '/api/voice-pack/list' && method === 'GET') {
|
|
746
|
+
try {
|
|
747
|
+
const userAudio = vpListUserAudio(LOG_DIR);
|
|
748
|
+
const defaultPack = vpListDefaultPack();
|
|
749
|
+
// defaultPackPlaceholder lets Settings UI label the Default option as
|
|
750
|
+
// "(placeholder)" until real recordings replace the bundled sine-wave WAVs.
|
|
751
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
752
|
+
res.end(JSON.stringify({
|
|
753
|
+
userAudio,
|
|
754
|
+
defaultPack,
|
|
755
|
+
defaultPackPlaceholder: vpIsDefaultPackPlaceholder(),
|
|
756
|
+
eventKeys: VP_EVENT_KEYS,
|
|
757
|
+
maxBytes: VP_MAX_BYTES,
|
|
758
|
+
}));
|
|
759
|
+
} catch (err) {
|
|
760
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
761
|
+
res.end(JSON.stringify({ error: 'list failed', detail: err?.message }));
|
|
762
|
+
}
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (url === '/api/voice-pack/upload' && method === 'POST') {
|
|
767
|
+
// Loopback-only — refuse LAN clients even if they hold a valid token.
|
|
768
|
+
// The token already gates LAN access but voice-pack writes touch the local FS
|
|
769
|
+
// and end up reachable from every client; keep the write side strictly local.
|
|
770
|
+
if (!isLocal) {
|
|
771
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
772
|
+
res.end(JSON.stringify({ error: 'Upload allowed from loopback only' }));
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const contentType = req.headers['content-type'] || '';
|
|
776
|
+
const boundaryMatch = contentType.match(/boundary=(.+)/);
|
|
777
|
+
if (!boundaryMatch) {
|
|
778
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
779
|
+
res.end(JSON.stringify({ error: 'Missing boundary' }));
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
|
|
783
|
+
if (contentLength > VP_MAX_BYTES + 4096) {
|
|
784
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
785
|
+
res.end(JSON.stringify({ error: `File too large (max ${VP_MAX_BYTES} bytes)` }));
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const boundary = boundaryMatch[1];
|
|
789
|
+
const chunks = [];
|
|
790
|
+
let totalSize = 0;
|
|
791
|
+
let aborted = false;
|
|
792
|
+
req.on('data', chunk => {
|
|
793
|
+
totalSize += chunk.length;
|
|
794
|
+
if (totalSize > VP_MAX_BYTES + 4096) {
|
|
795
|
+
aborted = true;
|
|
796
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
797
|
+
res.end(JSON.stringify({ error: `File too large (max ${VP_MAX_BYTES} bytes)` }));
|
|
798
|
+
req.destroy();
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
chunks.push(chunk);
|
|
802
|
+
});
|
|
803
|
+
req.on('end', () => {
|
|
804
|
+
if (aborted) return;
|
|
805
|
+
try {
|
|
806
|
+
const buf = Buffer.concat(chunks);
|
|
807
|
+
const headerEnd = buf.indexOf('\r\n\r\n');
|
|
808
|
+
if (headerEnd === -1) throw new Error('Malformed multipart');
|
|
809
|
+
const headerStr = buf.slice(0, headerEnd).toString();
|
|
810
|
+
const nameMatch = headerStr.match(/filename="([^"]+)"/);
|
|
811
|
+
const originalName = nameMatch ? nameMatch[1].replace(/[\x00-\x1f/\\]/g, '_') : 'upload';
|
|
812
|
+
const bodyStart = headerEnd + 4;
|
|
813
|
+
const closingBoundary = Buffer.from('\r\n--' + boundary);
|
|
814
|
+
const bodyEnd = buf.indexOf(closingBoundary, bodyStart);
|
|
815
|
+
const fileData = bodyEnd !== -1 ? buf.slice(bodyStart, bodyEnd) : buf.slice(bodyStart);
|
|
816
|
+
const result = vpSaveAudio(LOG_DIR, originalName, fileData, { isLoopback: true });
|
|
817
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
818
|
+
res.end(JSON.stringify({ ok: true, ...result }));
|
|
819
|
+
} catch (err) {
|
|
820
|
+
const status = err?.code === 'TOO_LARGE' ? 413 : err?.code === 'BAD_FORMAT' ? 415 : 400;
|
|
821
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
822
|
+
res.end(JSON.stringify({ error: err?.message || 'Upload failed' }));
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (url.startsWith('/api/voice-pack/delete/') && method === 'DELETE') {
|
|
829
|
+
if (!isLocal) {
|
|
830
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
831
|
+
res.end(JSON.stringify({ error: 'Delete allowed from loopback only' }));
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
const id = url.slice('/api/voice-pack/delete/'.length);
|
|
835
|
+
if (!vpIsValidId(id)) {
|
|
836
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
837
|
+
res.end(JSON.stringify({ error: 'Invalid id' }));
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const ok = vpDeleteUserAudio(LOG_DIR, id);
|
|
841
|
+
res.writeHead(ok ? 200 : 404, { 'Content-Type': 'application/json' });
|
|
842
|
+
res.end(JSON.stringify({ ok }));
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Serve audio — supports HTTP Range so iOS Safari / mobile players can seek mp3
|
|
847
|
+
// (Safari refuses to start playback when the server returns 200 without Accept-Ranges).
|
|
848
|
+
// Path forms:
|
|
849
|
+
// /api/voice-pack/audio/default/<eventKey> — bundled default pack
|
|
850
|
+
// /api/voice-pack/audio/<uuid> — user-uploaded file
|
|
851
|
+
if (url.startsWith('/api/voice-pack/audio/') && method === 'GET') {
|
|
852
|
+
const tail = url.slice('/api/voice-pack/audio/'.length);
|
|
853
|
+
let resolved = null;
|
|
854
|
+
let isDefault = false;
|
|
855
|
+
if (tail.startsWith('default/')) {
|
|
856
|
+
const eventKey = tail.slice('default/'.length);
|
|
857
|
+
resolved = vpGetDefaultPackPath(eventKey);
|
|
858
|
+
isDefault = true;
|
|
859
|
+
} else {
|
|
860
|
+
resolved = vpGetUserAudioPath(LOG_DIR, tail);
|
|
861
|
+
}
|
|
862
|
+
if (!resolved) {
|
|
863
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
864
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
// Cache strategy:
|
|
868
|
+
// - default-pack: short max-age + must-revalidate, no `immutable` — the on-disk
|
|
869
|
+
// file *can* change when a placeholder is replaced by a real recording at the
|
|
870
|
+
// same path. `must-revalidate` keeps the file out of the stale bucket once
|
|
871
|
+
// max-age expires. Paired with the ETag below, this lets browsers
|
|
872
|
+
// conditional-request and pick up regenerated audio after a `gen-default-voicepack`
|
|
873
|
+
// run — without an ETag they'd silently serve cached stale content.
|
|
874
|
+
// - user audio: content-addressed by UUID (delete + re-upload always mints a
|
|
875
|
+
// new id), so safe to mark immutable for a full day. Loopback-only writes,
|
|
876
|
+
// so the LAN audience cannot mutate.
|
|
877
|
+
const cacheControl = isDefault
|
|
878
|
+
? 'public, max-age=300, must-revalidate'
|
|
879
|
+
: 'private, max-age=86400, immutable';
|
|
880
|
+
try {
|
|
881
|
+
// Symlink hardening: refuse to serve symlinks even though the routing layer
|
|
882
|
+
// already enforces the id whitelist. A local attacker who can write to
|
|
883
|
+
// LOG_DIR/voice-packs/ could otherwise drop `<uuid>.mp3 → /etc/passwd` and
|
|
884
|
+
// have it streamed over LAN. Same family as the file-access-policy realpath
|
|
885
|
+
// check used elsewhere in server.js for /api/read-file.
|
|
886
|
+
const ls = lstatSync(resolved.path);
|
|
887
|
+
if (ls.isSymbolicLink()) {
|
|
888
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
889
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
const stat = statSync(resolved.path);
|
|
893
|
+
const fileSize = stat.size;
|
|
894
|
+
// ETag = "<size>-<mtime ms>" — cheap, stable across restarts, changes whenever
|
|
895
|
+
// the file is rewritten. Honors If-None-Match → 304 so a regenerated default
|
|
896
|
+
// pack actually reaches the browser instead of being silently served stale.
|
|
897
|
+
const etag = `"${fileSize.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`;
|
|
898
|
+
if (req.headers['if-none-match'] === etag) {
|
|
899
|
+
res.writeHead(304, { ETag: etag, 'Cache-Control': cacheControl });
|
|
900
|
+
res.end();
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
const mime = vpMime(resolved.format);
|
|
904
|
+
const range = req.headers.range;
|
|
905
|
+
if (range) {
|
|
906
|
+
const m = range.match(/bytes=(\d+)-(\d*)/);
|
|
907
|
+
if (m) {
|
|
908
|
+
const start = parseInt(m[1], 10);
|
|
909
|
+
const end = m[2] ? parseInt(m[2], 10) : fileSize - 1;
|
|
910
|
+
if (Number.isFinite(start) && Number.isFinite(end) && start >= 0 && end < fileSize && start <= end) {
|
|
911
|
+
res.writeHead(206, {
|
|
912
|
+
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
|
913
|
+
'Accept-Ranges': 'bytes',
|
|
914
|
+
'Content-Length': end - start + 1,
|
|
915
|
+
'Content-Type': mime,
|
|
916
|
+
'Cache-Control': cacheControl,
|
|
917
|
+
ETag: etag,
|
|
918
|
+
});
|
|
919
|
+
createReadStream(resolved.path, { start, end }).pipe(res);
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
res.writeHead(416, { 'Content-Range': `bytes */${fileSize}` });
|
|
924
|
+
res.end();
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
res.writeHead(200, {
|
|
928
|
+
'Content-Length': fileSize,
|
|
929
|
+
'Content-Type': mime,
|
|
930
|
+
'Accept-Ranges': 'bytes',
|
|
931
|
+
'Cache-Control': cacheControl,
|
|
932
|
+
ETag: etag,
|
|
933
|
+
});
|
|
934
|
+
createReadStream(resolved.path).pipe(res);
|
|
935
|
+
} catch (err) {
|
|
936
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
937
|
+
res.end(JSON.stringify({ error: 'Read failed', detail: err?.message }));
|
|
938
|
+
}
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
942
|
+
|
|
943
|
+
// Turn-end notification — POSTed by lib/turn-end-bridge.js when Claude Code's
|
|
944
|
+
// Stop hook fires (CLI/PTY mode) AND directly by lib/sdk-manager.js's
|
|
945
|
+
// onTurnEnd callback (SDK mode — Stop hooks don't exist there, so cli.js
|
|
946
|
+
// wires the SDK 'result' event to broadcastTurnEnd() instead). Broadcasts a
|
|
947
|
+
// `turn_end` SSE so all connected tabs play voice-pack turnEnd at the real
|
|
948
|
+
// end of a user-prompt turn.
|
|
949
|
+
//
|
|
950
|
+
// Auth: loopback IP + X-CCViewer-Internal header matching INTERNAL_TOKEN.
|
|
951
|
+
// Defense-in-depth against a same-host malicious page POSTing fake turn_end
|
|
952
|
+
// events (round-3 P1). Internal-only POST → in-process SDK callback skips
|
|
953
|
+
// this endpoint and calls `_broadcastTurnEnd` directly below.
|
|
954
|
+
if (url === '/api/turn-end-notify' && method === 'POST') {
|
|
955
|
+
if (!isLocal) {
|
|
956
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
957
|
+
res.end(JSON.stringify({ error: 'Loopback only' }));
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
if (req.headers['x-ccviewer-internal'] !== INTERNAL_TOKEN) {
|
|
961
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
962
|
+
res.end(JSON.stringify({ error: 'Invalid bridge token' }));
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
let body = '';
|
|
966
|
+
let truncated = false;
|
|
967
|
+
req.on('data', chunk => {
|
|
968
|
+
body += chunk;
|
|
969
|
+
if (body.length > 16384) { truncated = true; req.destroy(); }
|
|
970
|
+
});
|
|
971
|
+
req.on('end', () => {
|
|
972
|
+
if (truncated) {
|
|
973
|
+
console.warn('[turn-end-notify] body exceeded 16KB cap — request destroyed');
|
|
974
|
+
return; // socket already closed by destroy()
|
|
975
|
+
}
|
|
976
|
+
let payload = {};
|
|
977
|
+
try { payload = JSON.parse(body); } catch { /* tolerate empty / malformed */ }
|
|
978
|
+
_broadcastTurnEnd(payload.sessionId || null, payload.ts || Date.now());
|
|
979
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
980
|
+
res.end(JSON.stringify({ ok: true }));
|
|
981
|
+
});
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
|
|
710
985
|
// 注册新的日志文件进行 watch(供新进程复用旧服务时调用)
|
|
711
986
|
if (url === '/api/register-log' && method === 'POST') {
|
|
712
987
|
let body = '';
|
|
@@ -870,7 +1145,7 @@ async function handleRequest(req, res) {
|
|
|
870
1145
|
if (proxyPort) {
|
|
871
1146
|
const { spawnClaude } = await import('./pty-manager.js');
|
|
872
1147
|
const mergedArgs = [..._workspaceClaudeArgs, ...(Array.isArray(launchExtraArgs) ? launchExtraArgs : [])];
|
|
873
|
-
await spawnClaude(parseInt(proxyPort), wsPath, mergedArgs, _workspaceClaudePath, _workspaceIsNpmVersion, actualPort, serverProtocol);
|
|
1148
|
+
await spawnClaude(parseInt(proxyPort), wsPath, mergedArgs, _workspaceClaudePath, _workspaceIsNpmVersion, actualPort, serverProtocol, INTERNAL_TOKEN);
|
|
874
1149
|
}
|
|
875
1150
|
|
|
876
1151
|
_workspaceLaunched = true;
|
|
@@ -4460,6 +4735,33 @@ export function getAccessToken() {
|
|
|
4460
4735
|
return ACCESS_TOKEN;
|
|
4461
4736
|
}
|
|
4462
4737
|
|
|
4738
|
+
export function getInternalToken() {
|
|
4739
|
+
return INTERNAL_TOKEN;
|
|
4740
|
+
}
|
|
4741
|
+
|
|
4742
|
+
// In-process broadcast helper for the `turn_end` SSE event. Two callers:
|
|
4743
|
+
// 1. /api/turn-end-notify POST handler (PTY/CLI mode via Stop hook bridge)
|
|
4744
|
+
// 2. cli.js runSdkMode → sdkManager.initSdkSession({ onTurnEnd }) (SDK mode —
|
|
4745
|
+
// ensureHooks() isn't called so Stop hook isn't installed; emit the event
|
|
4746
|
+
// directly when SDK's 'result' message fires).
|
|
4747
|
+
// Same SSE payload shape regardless of source so the frontend listener doesn't
|
|
4748
|
+
// care which path produced it.
|
|
4749
|
+
function _broadcastTurnEnd(sessionId, ts) {
|
|
4750
|
+
try {
|
|
4751
|
+
if (clients.length > 0 && sendEventToClients) {
|
|
4752
|
+
sendEventToClients(clients, 'turn_end', {
|
|
4753
|
+
sessionId: sessionId || null,
|
|
4754
|
+
ts: ts || Date.now(),
|
|
4755
|
+
});
|
|
4756
|
+
}
|
|
4757
|
+
} catch (err) {
|
|
4758
|
+
console.warn('[turn-end] broadcast failed:', err.message);
|
|
4759
|
+
}
|
|
4760
|
+
}
|
|
4761
|
+
export function broadcastTurnEnd(sessionId = null, ts = Date.now()) {
|
|
4762
|
+
_broadcastTurnEnd(sessionId, ts);
|
|
4763
|
+
}
|
|
4764
|
+
|
|
4463
4765
|
// 流式状态 SSE 推送定时器:检测 streamingState 变化并广播给所有客户端
|
|
4464
4766
|
let _streamingStatusTimer = null;
|
|
4465
4767
|
let _lastStreamingActive = false;
|