cc-viewer 1.8.9 → 1.8.11
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/dist/assets/App-vpm6omRY.js +2 -0
- package/dist/assets/{MdxEditorPanel-BznPWiig.js → MdxEditorPanel-CpknU7SR.js} +1 -1
- package/dist/assets/{Mobile-ph7aKFZy.js → Mobile-BHIBdqHs.js} +1 -1
- package/dist/assets/{ProxyStatsModal-B7iew2Tv.js → ProxyStatsModal-DdV7wN2V.js} +1 -1
- package/dist/assets/{_baseUniq-D2aO8mv4.js → _baseUniq-C2FCldN4.js} +1 -1
- package/dist/assets/{arc-DOjcnBTh.js → arc-CSzd_bdT.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-Drea2H7X.js → architectureDiagram-Q4EWVU46-CjEbBe2R.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-Bc9eijBD.js → blockDiagram-DXYQGD6D-BYzl2ukW.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-D-umzFnM.js → c4Diagram-AHTNJAMY-0x0_8O_X.js} +1 -1
- package/dist/assets/{channel-DUff3FBt.js → channel-xMmQtLq9.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-CFnsROfz.js → chunk-4BX2VUAB-DnFoMzQG.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-C95jMPjB.js → chunk-4TB4RGXK-Dc5reuwv.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-CeWf2Y2b.js → chunk-55IACEB6-DTKTLKcJ.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-ClUoTJxv.js → chunk-EDXVE4YY-BNqgeN9r.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-Dk62kqb8.js → chunk-FMBD7UC4-DzJVZ11t.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-CZuykSsu.js → chunk-OYMX7WX6-CjYwzMor.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-DYtx68Um.js → chunk-QZHKN3VN-C0KVKecr.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-rLSq0tSC.js → chunk-YZCP3GAM-3VhNeNVm.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DdUtH789.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-DdUtH789.js +1 -0
- package/dist/assets/clone-C6d2XDvR.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-B5H6ewon.js → cose-bilkent-S5V4N54A-DGE02sJS.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-BO2R2WP7.js → dagre-KV5264BT-CY4eCcwS.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-Y3GNif-g.js → diagram-5BDNPKRD-DHq2THYn.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-Du3_TylQ.js → diagram-G4DWMVQ6-DD_76oJg.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-DB_Ynyjc.js → diagram-MMDJMWI5-BvQxk1jl.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-_Rfjm82W.js → diagram-TYMM5635-B_tMXPJl.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-D5XhBiiV.js → erDiagram-SMLLAGMA-DpuGbN9e.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-CxWJ7sAQ.js → flowDiagram-DWJPFMVM-BW0xGFBf.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-D-a8fh00.js → ganttDiagram-T4ZO3ILL-BSzTiT93.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-q5WMk0Ce.js → gitGraphDiagram-UUTBAWPF-1tsgBlcn.js} +1 -1
- package/dist/assets/{graph-Bi8xMAuo.js → graph-CwiXMdRR.js} +1 -1
- package/dist/assets/{index-BRtbpMRz.js → index-9e6BGquk.js} +1 -1
- package/dist/assets/{index-BWPHHS6P.js → index-BmpXfVQ8.js} +1 -1
- package/dist/assets/{index-O2Mx9uLd.js → index-CZWNaOo8.js} +1 -1
- package/dist/assets/{index-BBEH6Ppx.js → index-Cixs7aRD.js} +1 -1
- package/dist/assets/{index-C3wsDB-y.js → index-Ct6Fi103.js} +1 -1
- package/dist/assets/index-D1yGak8I.css +1 -0
- package/dist/assets/{index-3Z3ucdlB.js → index-DIAMN9QO.js} +1 -1
- package/dist/assets/{index-BB66PLo6.js → index-DhjwHRxk.js} +1 -1
- package/dist/assets/{index-BbF89uTe.js → index-tnWldUm3.js} +2 -2
- package/dist/assets/{infoDiagram-42DDH7IO-CE2mUuLF.js → infoDiagram-42DDH7IO-Dk1ESZFk.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-C5sNQIVL.js → ishikawaDiagram-UXIWVN3A-DcPyNwkI.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-g7HXOeyX.js → journeyDiagram-VCZTEJTY-CZWieu1U.js} +1 -1
- package/dist/assets/{jszip.min-x3xOZxY_.js → jszip.min-Bq1JxqcN.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-D3Mus6hc.js → kanban-definition-6JOO6SKY-S1sSSTKx.js} +1 -1
- package/dist/assets/{layout-05qDuweB.js → layout-BQFiWq71.js} +1 -1
- package/dist/assets/{linear-DQEG_N-F.js → linear-BNV4po81.js} +1 -1
- package/dist/assets/{mermaid.core-DcaXqkk5.js → mermaid.core-DvwEdatr.js} +2 -2
- package/dist/assets/{min-DSpvw5zG.js → min-DFik8k4L.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-DomUIop7.js → mindmap-definition-QFDTVHPH-CebeX76u.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-COTLcC5G.js → pieDiagram-DEJITSTG-DMB5JbMP.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-DMg8C3Up.js → quadrantDiagram-34T5L4WZ-BAYzoLVo.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-DF9beYd7.js → requirementDiagram-MS252O5E-CUQoAuOc.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-MvERK2UP.js → sankeyDiagram-XADWPNL6-Dt0AQuLW.js} +1 -1
- package/dist/assets/seqResourceLoaders-C-QHRkUq.js +2 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-B8k_nyzc.js → sequenceDiagram-FGHM5R23-HyZFUlMU.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-D-9fTYas.js → stateDiagram-FHFEXIEX-B8x24fuD.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-2qtHlnwO.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-CbK1g9CO.js → timeline-definition-GMOUNBTQ-TnbjDVtB.js} +1 -1
- package/dist/assets/{vendor-antd-FhK5cQx7.js → vendor-antd-BTBRwbax.js} +1 -1
- package/dist/assets/{vendor-codemirror-DL3yyKRQ.js → vendor-codemirror-CPWlhuQ1.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-DgyrvHzS.js → vendor-mdxeditor-Cy5Xg_90.js} +2 -2
- package/dist/assets/{vendor-qrcode-BAwwP1Nd.js → vendor-qrcode-E9ld0lSz.js} +1 -1
- package/dist/assets/{vendor-virtuoso-B_7lc2Ot.js → vendor-virtuoso-Bq6QMhgG.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-DYjGtMA5.js → vennDiagram-DHZGUBPP-BqgIjkBO.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-Djh5alV9.js → wardley-RL74JXVD-BFcIn2-Q.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-al50Jlxp.js → wardleyDiagram-NUSXRM2D-CYDWQLb6.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-Bl969-3U.js → xychartDiagram-5P7HB3ND-zfusE3BC.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +1 -1
- package/server/lib/auth.js +20 -4
- package/server/lib/is-admin.js +27 -0
- package/server/lib/skills-api.js +48 -26
- package/server/routes/auth.js +11 -9
- package/server/routes/dingtalk.js +23 -8
- package/server/routes/files-fs.js +76 -1
- package/server/routes/im.js +36 -20
- package/server/routes/preferences.js +27 -15
- package/server/routes/project-prefs.js +6 -5
- package/server/routes/skills.js +50 -29
- package/server/routes/voice-pack.js +7 -6
- package/server/server.js +12 -2
- package/dist/assets/App-DuJsCzTH.js +0 -2
- package/dist/assets/classDiagram-6PBFFD2Q-DT8RQXlO.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-DT8RQXlO.js +0 -1
- package/dist/assets/clone-_dhVVwEt.js +0 -1
- package/dist/assets/index-D3GEPgde.css +0 -1
- package/dist/assets/seqResourceLoaders-76uKbVqf.js +0 -2
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-CO0tv8rD.js +0 -1
package/server/lib/skills-api.js
CHANGED
|
@@ -24,43 +24,65 @@ export function validateSkillName(name) {
|
|
|
24
24
|
return true;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
let descIdx = -1, firstVal = '';
|
|
43
|
-
for (let i = 0; i < lines.length; i++) {
|
|
44
|
-
const lm = /^description\s*:\s*(.*)$/.exec(lines[i]);
|
|
45
|
-
if (lm) { descIdx = i; firstVal = lm[1].trim(); break; }
|
|
46
|
-
}
|
|
47
|
-
if (descIdx < 0) return null;
|
|
27
|
+
// Parse SKILL.md frontmatter text → { name, description } (each string|null),
|
|
28
|
+
// or null when no parseable `---` frontmatter block exists.
|
|
29
|
+
// name: single-line `name: value` (surrounding quotes stripped).
|
|
30
|
+
// description supports 4 YAML forms:
|
|
31
|
+
// 1) description: plain text ← single line
|
|
32
|
+
// 2) description: "quoted text" ← quoted
|
|
33
|
+
// 3) description: | ← block scalar (keeps newlines)
|
|
34
|
+
// 4) description: > ← folded scalar (newlines → spaces)
|
|
35
|
+
// Values that are empty after trimming are returned as null (treated as absent).
|
|
36
|
+
export function parseSkillFrontmatter(text) {
|
|
37
|
+
// Tolerate CRLF line endings (Windows-authored SKILL.md) — otherwise the whole
|
|
38
|
+
// frontmatter block fails to match and valid skills are rejected as INVALID_FRONTMATTER.
|
|
39
|
+
const m = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
40
|
+
if (!m) return null;
|
|
41
|
+
const lines = m[1].split('\n').map((l) => l.replace(/\r$/, ''));
|
|
48
42
|
|
|
49
|
-
|
|
43
|
+
let name = null;
|
|
44
|
+
const nm = /^name\s*:\s*(.*)$/m.exec(m[1]);
|
|
45
|
+
if (nm) {
|
|
46
|
+
const v = nm[1].trim().replace(/\r$/, '').replace(/^["']|["']$/g, '');
|
|
47
|
+
name = v || null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let description = null;
|
|
51
|
+
let descIdx = -1, firstVal = '';
|
|
52
|
+
for (let i = 0; i < lines.length; i++) {
|
|
53
|
+
const lm = /^description\s*:\s*(.*)$/.exec(lines[i]);
|
|
54
|
+
if (lm) { descIdx = i; firstVal = lm[1].trim(); break; }
|
|
55
|
+
}
|
|
56
|
+
if (descIdx >= 0) {
|
|
50
57
|
if (/^[|>][-+]?$/.test(firstVal)) {
|
|
58
|
+
// block scalar: | / |- / > / >-
|
|
51
59
|
const fold = firstVal.startsWith('>');
|
|
52
60
|
const collected = [];
|
|
53
61
|
for (let i = descIdx + 1; i < lines.length; i++) {
|
|
54
62
|
const line = lines[i];
|
|
55
|
-
//
|
|
63
|
+
// Block ends at a non-empty line without leading whitespace (next top-level key).
|
|
56
64
|
if (line !== '' && !/^\s/.test(line)) break;
|
|
57
65
|
collected.push(line.replace(/^\s+/, ''));
|
|
58
66
|
}
|
|
59
67
|
while (collected.length && collected[collected.length - 1] === '') collected.pop();
|
|
60
|
-
|
|
68
|
+
description = fold ? collected.join(' ') : collected.join('\n');
|
|
69
|
+
} else {
|
|
70
|
+
description = firstVal.replace(/^["']|["']$/g, '');
|
|
61
71
|
}
|
|
62
|
-
|
|
63
|
-
|
|
72
|
+
if (!description || !description.trim()) description = null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { name, description };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Read <skillDir>/SKILL.md and return the frontmatter description (delegates to
|
|
79
|
+
// parseSkillFrontmatter). Returns null on any failure — the UI falls back to "no description".
|
|
80
|
+
function parseSkillMdFrontmatter(skillDir) {
|
|
81
|
+
const mdPath = join(skillDir, 'SKILL.md');
|
|
82
|
+
if (!existsSync(mdPath)) return null;
|
|
83
|
+
try {
|
|
84
|
+
const fm = parseSkillFrontmatter(readFileSync(mdPath, 'utf8'));
|
|
85
|
+
return fm ? fm.description : null;
|
|
64
86
|
} catch { return null; }
|
|
65
87
|
}
|
|
66
88
|
|
package/server/routes/auth.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// Password-auth API routes. See server/lib/auth.js for the underlying logic and
|
|
2
2
|
// the plan for the overall design.
|
|
3
3
|
//
|
|
4
|
-
// GET /api/auth/state — public; reveals the plaintext password only to admin
|
|
5
|
-
// POST /api/auth/config — admin-only (
|
|
4
|
+
// GET /api/auth/state — public; reveals the plaintext password only to a loopback admin.
|
|
5
|
+
// POST /api/auth/config — admin-only (loopback, or authenticated remote admin; else 403); enable/disable + set password.
|
|
6
6
|
// POST /api/auth/login — public + IP rate-limited; on success Set-Cookie ccv_auth=ACCESS_TOKEN.
|
|
7
7
|
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
8
8
|
import { generatePassword } from '../lib/auth.js';
|
|
9
|
+
import { isAdminReq } from '../lib/is-admin.js';
|
|
9
10
|
|
|
10
11
|
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
|
11
12
|
|
|
@@ -28,14 +29,15 @@ function readBody(req, deps, cb) {
|
|
|
28
29
|
req.on('end', () => cb(body));
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
function buildState(deps, isLocal) {
|
|
32
|
+
function buildState(deps, isLocal, isAdmin) {
|
|
32
33
|
// Reports the EFFECTIVE config (gate enforces this) plus enough scope info for the
|
|
33
34
|
// admin UI to manage both the global default and a per-project override.
|
|
34
|
-
//
|
|
35
|
+
// Plaintext passwords stay loopback-only even for an authenticated remote admin
|
|
36
|
+
// (mirrors the IM status secret policy); `isAdmin` reflects loopback OR remote-auth.
|
|
35
37
|
const s = deps.getAuthState();
|
|
36
38
|
return {
|
|
37
39
|
enabled: s.effective.enabled,
|
|
38
|
-
isAdmin
|
|
40
|
+
isAdmin,
|
|
39
41
|
password: isLocal ? s.effective.password : null,
|
|
40
42
|
scope: s.scope, // 'project' | 'global' — which one is in effect
|
|
41
43
|
hasProjectOverride: s.hasProjectOverride,
|
|
@@ -81,11 +83,11 @@ function recordFailedAttempt(ip) {
|
|
|
81
83
|
|
|
82
84
|
function authState(req, res, parsedUrl, isLocal, deps) {
|
|
83
85
|
res.writeHead(200, JSON_HEADERS);
|
|
84
|
-
res.end(JSON.stringify(buildState(deps, isLocal)));
|
|
86
|
+
res.end(JSON.stringify(buildState(deps, isLocal, isAdminReq(req, isLocal))));
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
function authConfigPost(req, res, parsedUrl, isLocal, deps) {
|
|
88
|
-
if (!isLocal) {
|
|
90
|
+
if (!isAdminReq(req, isLocal)) {
|
|
89
91
|
res.writeHead(403, JSON_HEADERS);
|
|
90
92
|
res.end(JSON.stringify({ error: 'admin-only' }));
|
|
91
93
|
return;
|
|
@@ -104,7 +106,7 @@ function authConfigPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
104
106
|
if (incoming.clearOverride === true) {
|
|
105
107
|
deps.clearAuthOverride();
|
|
106
108
|
res.writeHead(200, JSON_HEADERS);
|
|
107
|
-
res.end(JSON.stringify(buildState(deps, isLocal)));
|
|
109
|
+
res.end(JSON.stringify(buildState(deps, isLocal, isAdminReq(req, isLocal))));
|
|
108
110
|
return;
|
|
109
111
|
}
|
|
110
112
|
// Target scope: explicit 'global' → global; otherwise project when this server has a
|
|
@@ -126,7 +128,7 @@ function authConfigPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
126
128
|
}
|
|
127
129
|
deps.setAuthConfig(next, scope);
|
|
128
130
|
res.writeHead(200, JSON_HEADERS);
|
|
129
|
-
res.end(JSON.stringify(buildState(deps, isLocal)));
|
|
131
|
+
res.end(JSON.stringify(buildState(deps, isLocal, isAdminReq(req, isLocal))));
|
|
130
132
|
});
|
|
131
133
|
}
|
|
132
134
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// DingTalk bridge config API. See server/lib/im/dingtalk-config.js (storage) and
|
|
2
2
|
// server/lib/im/dingtalk-bridge.js (the Stream client) for the underlying logic.
|
|
3
3
|
//
|
|
4
|
-
// GET /api/dingtalk/status — public; remote callers get only hasSecret
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// POST /api/dingtalk/
|
|
4
|
+
// GET /api/dingtalk/status — public; remote callers get only enabled+hasSecret+connection; an
|
|
5
|
+
// authenticated remote admin additionally gets process+pid and lastError
|
|
6
|
+
// (error chip); only the plaintext appSecret stays loopback-only.
|
|
7
|
+
// POST /api/dingtalk/config — admin-only (loopback, or authenticated remote admin); save creds, reload bridge.
|
|
8
|
+
// POST /api/dingtalk/test — admin-only; validate creds (fetch an access token).
|
|
8
9
|
import { loadDingTalkState, saveDingTalkConfig, loadDingTalkConfig } from '../lib/im/dingtalk-config.js';
|
|
10
|
+
import { isAdminReq } from '../lib/is-admin.js';
|
|
9
11
|
|
|
10
12
|
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
|
11
13
|
|
|
@@ -36,7 +38,19 @@ async function dingtalkStatus(req, res, parsedUrl, isLocal, deps) {
|
|
|
36
38
|
}
|
|
37
39
|
res.writeHead(200, JSON_HEADERS);
|
|
38
40
|
if (!isLocal) {
|
|
39
|
-
//
|
|
41
|
+
// Authenticated remote admin (container/cloud): full process/connection state (incl.
|
|
42
|
+
// `lastError` so the header chip can render the real error) — only the plaintext appSecret
|
|
43
|
+
// stays loopback-only (the frontend edit form only reads hasSecret).
|
|
44
|
+
if (isAdminReq(req, isLocal)) {
|
|
45
|
+
res.end(JSON.stringify({
|
|
46
|
+
...loadDingTalkState(),
|
|
47
|
+
connection: conn,
|
|
48
|
+
process: processInfo,
|
|
49
|
+
pid: deps.dingtalk.isWorker ? process.pid : (processInfo?.pid ?? null),
|
|
50
|
+
}));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
// connectionState passes through as-is; lastError stays loopback-only (non-admin remote).
|
|
40
54
|
res.end(JSON.stringify({
|
|
41
55
|
enabled: loadDingTalkState().enabled,
|
|
42
56
|
hasSecret: loadDingTalkState().hasSecret,
|
|
@@ -55,8 +69,9 @@ async function dingtalkStatus(req, res, parsedUrl, isLocal, deps) {
|
|
|
55
69
|
}
|
|
56
70
|
|
|
57
71
|
function dingtalkConfigPost(req, res, parsedUrl, isLocal, deps) {
|
|
58
|
-
//
|
|
59
|
-
|
|
72
|
+
// Admin-only (loopback, or an authenticated remote admin): an app_secret must never be
|
|
73
|
+
// settable by an unauthenticated LAN client even with a valid token.
|
|
74
|
+
if (!isAdminReq(req, isLocal)) {
|
|
60
75
|
res.writeHead(403, JSON_HEADERS);
|
|
61
76
|
res.end(JSON.stringify({ error: 'Loopback only' }));
|
|
62
77
|
return;
|
|
@@ -104,7 +119,7 @@ function dingtalkConfigPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
104
119
|
}
|
|
105
120
|
|
|
106
121
|
function dingtalkTestPost(req, res, parsedUrl, isLocal, deps) {
|
|
107
|
-
if (!isLocal) {
|
|
122
|
+
if (!isAdminReq(req, isLocal)) {
|
|
108
123
|
res.writeHead(403, JSON_HEADERS);
|
|
109
124
|
res.end(JSON.stringify({ error: 'Loopback only' }));
|
|
110
125
|
return;
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
import { join, dirname, basename, resolve, sep } from 'node:path';
|
|
7
7
|
import { homedir, tmpdir } from 'node:os';
|
|
8
8
|
import { execFile, spawn } from 'node:child_process';
|
|
9
|
-
import { bumpWorkspacesVersion } from '../lib/file-access-policy.js';
|
|
9
|
+
import { bumpWorkspacesVersion, isReadAllowed } from '../lib/file-access-policy.js';
|
|
10
10
|
import { validateImportDir } from '../lib/file-api.js';
|
|
11
11
|
import { PROFILE_PATH, _projectName, _logDir } from '../interceptor.js';
|
|
12
12
|
import { LOG_DIR, getClaudeConfigDir } from '../../findcc.js';
|
|
@@ -659,6 +659,80 @@ function resolvePath(req, res, parsedUrl, isLocal, deps) {
|
|
|
659
659
|
});
|
|
660
660
|
}
|
|
661
661
|
|
|
662
|
+
// Lightweight existence probe for the chat markdown code-span feature: the web
|
|
663
|
+
// client batches path-looking inline code spans here and only renders them
|
|
664
|
+
// clickable when the file provably exists. Uniform {exists:false} for every
|
|
665
|
+
// not-readable-as-regular-file outcome (missing / outside allowlist / denied /
|
|
666
|
+
// directory) — never echoes policy reasons, so this is a strictly weaker oracle
|
|
667
|
+
// than /api/file-content's 404/403 split.
|
|
668
|
+
const FILES_EXISTS_MAX_BATCH = 50;
|
|
669
|
+
const FILES_EXISTS_MAX_PATH = 1024;
|
|
670
|
+
const FILES_EXISTS_MAX_BODY = 256 * 1024; // 50 × 1024 + JSON overhead ≪ this
|
|
671
|
+
|
|
672
|
+
function filesExists(req, res, parsedUrl, isLocal, deps) {
|
|
673
|
+
let body = '';
|
|
674
|
+
let aborted = false;
|
|
675
|
+
req.on('data', chunk => {
|
|
676
|
+
body += chunk;
|
|
677
|
+
if (body.length > FILES_EXISTS_MAX_BODY && !aborted) {
|
|
678
|
+
aborted = true;
|
|
679
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
680
|
+
res.end(JSON.stringify({ error: 'Request too large' }));
|
|
681
|
+
req.destroy();
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
req.on('end', () => {
|
|
685
|
+
if (aborted) return;
|
|
686
|
+
let parsed;
|
|
687
|
+
try { parsed = JSON.parse(body); } catch {
|
|
688
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
689
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const paths = parsed && parsed.paths;
|
|
693
|
+
if (!Array.isArray(paths)) {
|
|
694
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
695
|
+
res.end(JSON.stringify({ error: 'Invalid paths' }));
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (paths.length > FILES_EXISTS_MAX_BATCH) {
|
|
699
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
700
|
+
res.end(JSON.stringify({ error: 'Too many paths' }));
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
for (const p of paths) {
|
|
704
|
+
if (typeof p !== 'string' || p.length > FILES_EXISTS_MAX_PATH) {
|
|
705
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
706
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
|
|
711
|
+
const results = paths.map(p => ({ path: p, exists: probeFileExists(p, cwd) }));
|
|
712
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
713
|
+
res.end(JSON.stringify({ results }));
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function probeFileExists(p, cwd) {
|
|
718
|
+
try {
|
|
719
|
+
const isAbs = /^([a-zA-Z]:[\\/]|[\\/])/.test(p);
|
|
720
|
+
// Mirrors /api/file-content: a relative `..` segment can never be opened by
|
|
721
|
+
// the click path either, so report it as non-existent rather than 400.
|
|
722
|
+
// Segment-wise check — a legit filename like `foo..bar.md` must not match.
|
|
723
|
+
if (!isAbs && p.split(/[\\/]/).includes('..')) return false;
|
|
724
|
+
// Resolve BEFORE isReadAllowed: realpathSync inside the policy resolves
|
|
725
|
+
// relative input against process.cwd(), which is the app dir (not the
|
|
726
|
+
// project) in packaged Electron runs.
|
|
727
|
+
const absPath = isAbs ? p : resolve(cwd, p);
|
|
728
|
+
const policy = isReadAllowed(absPath);
|
|
729
|
+
if (!policy.ok) return false;
|
|
730
|
+
return statSync(policy.real).isFile();
|
|
731
|
+
} catch {
|
|
732
|
+
return false; // one bad path must not sink the whole batch
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
662
736
|
function createFile(req, res, parsedUrl, isLocal, deps) {
|
|
663
737
|
let body = '';
|
|
664
738
|
req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
|
|
@@ -964,6 +1038,7 @@ export const filesFsRoutes = [
|
|
|
964
1038
|
{ method: 'POST', match: 'exact', path: '/api/reveal-file', handler: revealFile },
|
|
965
1039
|
{ method: 'POST', match: 'exact', path: '/api/open-file', handler: openFile },
|
|
966
1040
|
{ method: 'POST', match: 'exact', path: '/api/resolve-path', handler: resolvePath },
|
|
1041
|
+
{ method: 'POST', match: 'exact', path: '/api/files-exists', handler: filesExists },
|
|
967
1042
|
{ method: 'POST', match: 'exact', path: '/api/create-file', handler: createFile },
|
|
968
1043
|
{ method: 'POST', match: 'exact', path: '/api/open-terminal', handler: openTerminal },
|
|
969
1044
|
{ method: 'POST', match: 'exact', path: '/api/create-dir', handler: createDir },
|
package/server/routes/im.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// Generic multi-IM bridge config + process-control API. Platform-parametric (keyed by descriptor id).
|
|
2
2
|
//
|
|
3
|
-
// GET /api/im/:platform/status — public; remote callers get only enabled+hasSecret+connection
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// GET /api/im/:platform/status — public; remote callers get only enabled+hasSecret+connection;
|
|
4
|
+
// an authenticated remote admin additionally gets process+pid and
|
|
5
|
+
// connection/process lastError (for the error chip); only the
|
|
6
|
+
// plaintext secrets stay loopback-only; the loopback admin gets all.
|
|
7
|
+
// POST /api/im/:platform/config — admin-only (loopback, or authenticated remote admin); save creds, then drive the process manager
|
|
6
8
|
// (enable→stop+spawn worker, disable→stop). Allowlist is optional:
|
|
7
9
|
// enabling with an empty one warns (not blocked) since the worker
|
|
8
10
|
// runs with --dangerously-skip-permissions.
|
|
9
|
-
// POST /api/im/:platform/test —
|
|
10
|
-
// POST /api/im/:platform/process —
|
|
11
|
+
// POST /api/im/:platform/test — admin-only; validate creds (fetch an access token).
|
|
12
|
+
// POST /api/im/:platform/process — admin-only; {action:start|stop|restart} the detached worker.
|
|
11
13
|
// 'start' requires stored creds (400 `missing …` otherwise) and
|
|
12
14
|
// also persists enabled:true (merged into the stored config):
|
|
13
15
|
// a worker spawned while the config says disabled no-ops in
|
|
@@ -27,6 +29,7 @@ import { readImAppendSystem, writeImAppendSystem, buildImAppendSystemPreset, MAX
|
|
|
27
29
|
import { imDir } from '../lib/im/im-lock.js';
|
|
28
30
|
import { resolvePrefLang } from '../lib/im/im-lang.js';
|
|
29
31
|
import { listSkills, moveSkill, deleteSkill } from '../lib/skills-api.js';
|
|
32
|
+
import { isAdminReq } from '../lib/is-admin.js';
|
|
30
33
|
import { importSkillTo } from './skills.js';
|
|
31
34
|
import { LOG_DIR } from '../../findcc.js';
|
|
32
35
|
import { join, basename } from 'node:path';
|
|
@@ -116,7 +119,20 @@ async function imStatus(req, res, parsedUrl, isLocal, deps) {
|
|
|
116
119
|
|
|
117
120
|
res.writeHead(200, JSON_HEADERS);
|
|
118
121
|
if (!isLocal) {
|
|
119
|
-
//
|
|
122
|
+
// Authenticated remote admin (container/cloud): full process/connection state so the settings
|
|
123
|
+
// UI start() poll sees `process.state === 'ready'` AND the header chip can render the real
|
|
124
|
+
// error (`connection.lastError` — the worker's own report, not a stored secret). Only the
|
|
125
|
+
// plaintext SECRETS stay loopback-only (the frontend edit form only ever reads hasSecret).
|
|
126
|
+
if (isAdminReq(req, isLocal)) {
|
|
127
|
+
res.end(JSON.stringify({
|
|
128
|
+
...state,
|
|
129
|
+
connection,
|
|
130
|
+
process: processInfo,
|
|
131
|
+
pid: deps.im.isWorker ? process.pid : (processInfo?.pid ?? null),
|
|
132
|
+
}));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// Unauthenticated/non-admin remote: only what the header chip needs.
|
|
120
136
|
// connectionState is passed through as-is (no defaulting); lastError stays loopback-only.
|
|
121
137
|
res.end(JSON.stringify({
|
|
122
138
|
enabled: state.enabled,
|
|
@@ -141,7 +157,7 @@ async function imStatus(req, res, parsedUrl, isLocal, deps) {
|
|
|
141
157
|
function imConfigPost(req, res, parsedUrl, isLocal, deps) {
|
|
142
158
|
const id = platformOf(parsedUrl.pathname);
|
|
143
159
|
if (!id) { notFound(res); return; }
|
|
144
|
-
if (!isLocal) { loopbackOnly(res); return; }
|
|
160
|
+
if (!isAdminReq(req, isLocal)) { loopbackOnly(res); return; }
|
|
145
161
|
readBody(req, deps, async (body) => {
|
|
146
162
|
let incoming;
|
|
147
163
|
try { incoming = JSON.parse(body); }
|
|
@@ -182,7 +198,7 @@ function imConfigPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
182
198
|
function imTestPost(req, res, parsedUrl, isLocal, deps) {
|
|
183
199
|
const id = platformOf(parsedUrl.pathname);
|
|
184
200
|
if (!id) { notFound(res); return; }
|
|
185
|
-
if (!isLocal) { loopbackOnly(res); return; }
|
|
201
|
+
if (!isAdminReq(req, isLocal)) { loopbackOnly(res); return; }
|
|
186
202
|
readBody(req, deps, async (body) => {
|
|
187
203
|
let incoming = {};
|
|
188
204
|
try { incoming = body ? JSON.parse(body) : {}; } catch { /* fall back to stored */ }
|
|
@@ -204,7 +220,7 @@ function imTestPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
204
220
|
function imProcessPost(req, res, parsedUrl, isLocal, deps) {
|
|
205
221
|
const id = platformOf(parsedUrl.pathname);
|
|
206
222
|
if (!id) { notFound(res); return; }
|
|
207
|
-
if (!isLocal) { loopbackOnly(res); return; }
|
|
223
|
+
if (!isAdminReq(req, isLocal)) { loopbackOnly(res); return; }
|
|
208
224
|
// 只有主进程负责管理 worker;worker 自身不应被要求 spawn/stop(避免嵌套)。
|
|
209
225
|
if (deps.im.isWorker) {
|
|
210
226
|
res.writeHead(409, JSON_HEADERS);
|
|
@@ -273,23 +289,23 @@ function imLogs(req, res, parsedUrl, isLocal, deps) {
|
|
|
273
289
|
}
|
|
274
290
|
|
|
275
291
|
// 发送者身份映射(senderId → {name, avatar, ts}):供「对话记录」按 senderId 显示真实姓名+头像。
|
|
276
|
-
//
|
|
292
|
+
// admin-only: names/avatars are personal info, not exposed to unauthenticated LAN clients (same tier as config/test/process).
|
|
277
293
|
function imSenders(req, res, parsedUrl, isLocal, deps) {
|
|
278
294
|
const id = platformOf(parsedUrl.pathname);
|
|
279
295
|
if (!id) { notFound(res); return; }
|
|
280
|
-
if (!isLocal) { loopbackOnly(res); return; }
|
|
296
|
+
if (!isAdminReq(req, isLocal)) { loopbackOnly(res); return; }
|
|
281
297
|
res.writeHead(200, JSON_HEADERS);
|
|
282
298
|
res.end(JSON.stringify({ platform: id, senders: readSenders(id) }));
|
|
283
299
|
}
|
|
284
300
|
|
|
285
|
-
//
|
|
286
|
-
// --append-system-prompt-file
|
|
301
|
+
// "Model persona definition" = CC_APPEND_SYSTEM.md in that IM worker's working dir (injected at claude launch as
|
|
302
|
+
// --append-system-prompt-file). admin-only: local file content (loopback, or an authenticated remote admin).
|
|
287
303
|
// 该文件仅在 worker 启动时读取一次,故保存后需重启该 IM worker 才生效(前端据此提示用户)。
|
|
288
304
|
// ?default=1:返回当前语言的预置文本(绕过磁盘文件),供编辑器「恢复默认」按钮加载——不落盘,由用户保存才生效。
|
|
289
305
|
function imAppendSystemGet(req, res, parsedUrl, isLocal, deps) {
|
|
290
306
|
const id = platformOf(parsedUrl.pathname);
|
|
291
307
|
if (!id) { notFound(res); return; }
|
|
292
|
-
if (!isLocal) { loopbackOnly(res); return; }
|
|
308
|
+
if (!isAdminReq(req, isLocal)) { loopbackOnly(res); return; }
|
|
293
309
|
try {
|
|
294
310
|
const def = parsedUrl?.searchParams?.get('default');
|
|
295
311
|
const lang = resolvePrefLang();
|
|
@@ -305,7 +321,7 @@ function imAppendSystemGet(req, res, parsedUrl, isLocal, deps) {
|
|
|
305
321
|
function imAppendSystemPost(req, res, parsedUrl, isLocal, deps) {
|
|
306
322
|
const id = platformOf(parsedUrl.pathname);
|
|
307
323
|
if (!id) { notFound(res); return; }
|
|
308
|
-
if (!isLocal) { loopbackOnly(res); return; }
|
|
324
|
+
if (!isAdminReq(req, isLocal)) { loopbackOnly(res); return; }
|
|
309
325
|
readBody(req, deps, (body) => {
|
|
310
326
|
let incoming;
|
|
311
327
|
try { incoming = JSON.parse(body); }
|
|
@@ -327,13 +343,13 @@ function imAppendSystemPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
327
343
|
});
|
|
328
344
|
}
|
|
329
345
|
|
|
330
|
-
//
|
|
346
|
+
// "${IM} SKILL management" = the .claude/skills/ under that IM worker's working dir. admin-only (local file ops; loopback, or an authenticated remote admin).
|
|
331
347
|
// 复用 skills-api 的 listSkills/moveSkill(按 projectDir 参数化)+ skills.js 的 importSkillTo(按 skillsRoot 参数化)。
|
|
332
348
|
// IM worker 仅在启动时读取 skills,故增删/启停后需重启该 IM worker 才生效(前端提示用户)。
|
|
333
349
|
function imSkills(req, res, parsedUrl, isLocal, deps) {
|
|
334
350
|
const id = platformOf(parsedUrl.pathname);
|
|
335
351
|
if (!id) { notFound(res); return; }
|
|
336
|
-
if (!isLocal) { loopbackOnly(res); return; }
|
|
352
|
+
if (!isAdminReq(req, isLocal)) { loopbackOnly(res); return; }
|
|
337
353
|
try {
|
|
338
354
|
const dir = imDir(id);
|
|
339
355
|
// projectDir 与 homeDir 都指向 IM 目录:两次扫描命中同一 .claude/skills(user+project 重复),
|
|
@@ -350,7 +366,7 @@ function imSkills(req, res, parsedUrl, isLocal, deps) {
|
|
|
350
366
|
function imSkillsToggle(req, res, parsedUrl, isLocal, deps) {
|
|
351
367
|
const id = platformOf(parsedUrl.pathname);
|
|
352
368
|
if (!id) { notFound(res); return; }
|
|
353
|
-
if (!isLocal) { loopbackOnly(res); return; }
|
|
369
|
+
if (!isAdminReq(req, isLocal)) { loopbackOnly(res); return; }
|
|
354
370
|
readBody(req, deps, (body) => {
|
|
355
371
|
let incoming;
|
|
356
372
|
try { incoming = JSON.parse(body); }
|
|
@@ -371,7 +387,7 @@ function imSkillsToggle(req, res, parsedUrl, isLocal, deps) {
|
|
|
371
387
|
function imSkillsDelete(req, res, parsedUrl, isLocal, deps) {
|
|
372
388
|
const id = platformOf(parsedUrl.pathname);
|
|
373
389
|
if (!id) { notFound(res); return; }
|
|
374
|
-
if (!isLocal) { loopbackOnly(res); return; }
|
|
390
|
+
if (!isAdminReq(req, isLocal)) { loopbackOnly(res); return; }
|
|
375
391
|
readBody(req, deps, (body) => {
|
|
376
392
|
let incoming;
|
|
377
393
|
try { incoming = JSON.parse(body); }
|
|
@@ -392,7 +408,7 @@ function imSkillsDelete(req, res, parsedUrl, isLocal, deps) {
|
|
|
392
408
|
function imSkillsImport(req, res, parsedUrl, isLocal, deps) {
|
|
393
409
|
const id = platformOf(parsedUrl.pathname);
|
|
394
410
|
if (!id) { notFound(res); return; }
|
|
395
|
-
if (!isLocal) { loopbackOnly(res); return; }
|
|
411
|
+
if (!isAdminReq(req, isLocal)) { loopbackOnly(res); return; }
|
|
396
412
|
importSkillTo(req, res, { skillsRoot: join(imDir(id), '.claude', 'skills'), windowsReserved: deps.WINDOWS_RESERVED_NAMES });
|
|
397
413
|
}
|
|
398
414
|
|
|
@@ -14,6 +14,7 @@ import { readClaudeProjectModel } from '../lib/context-watcher.js';
|
|
|
14
14
|
import { sendEventToClients } from '../lib/log-watcher.js';
|
|
15
15
|
import { listPlatforms } from '../lib/im/im-config.js';
|
|
16
16
|
import { mutatePrefs, applyPrefsPatch, readPrefsRaw } from '../lib/prefs-store.js';
|
|
17
|
+
import { isAdminReq } from '../lib/is-admin.js';
|
|
17
18
|
import {
|
|
18
19
|
getCurrentProjectKey, getCurrentProjectName, hasFork, listForks, resolveScoped,
|
|
19
20
|
} from '../lib/project-prefs.js';
|
|
@@ -52,12 +53,16 @@ export function _resetThemeSyncForTests() { _themeSyncInFlight = false; }
|
|
|
52
53
|
function preferencesGet(req, res, parsedUrl, isLocal, deps) {
|
|
53
54
|
let prefs = {};
|
|
54
55
|
try { if (existsSync(deps.getPrefsFile())) prefs = JSON.parse(readFileSync(deps.getPrefsFile(), 'utf-8')); } catch { }
|
|
56
|
+
// isAdmin = loopback OR authenticated remote (container/cloud admin); _isLocal stays the
|
|
57
|
+
// physical loopback fact. Computed once up-front so every admin gate below (fork keys,
|
|
58
|
+
// executable key, _isAdmin echo) uses the SAME value.
|
|
59
|
+
const isAdmin = isAdminReq(req, isLocal);
|
|
55
60
|
// auth 配置(含明文密码)与偏好同存于 preferences.json,但绝不能从这里下发 ——
|
|
56
61
|
// 密码仅由 admin-only 的 /api/auth/state 暴露给本机。否则远程密码登录用户也能读到明文。
|
|
57
62
|
// 全局 auth 与每个项目的 authByProject 覆盖都要剥离(后者同样含明文密码)。
|
|
58
63
|
delete prefs.auth;
|
|
59
64
|
delete prefs.authByProject;
|
|
60
|
-
if (!
|
|
65
|
+
if (!isAdmin || !isSameOriginBrowserRequest(req, parsedUrl)) {
|
|
61
66
|
delete prefs[CLAUDE_EXECUTABLE_PREF_KEY];
|
|
62
67
|
}
|
|
63
68
|
stripImConfigs(prefs); // dingtalk / feishu / … — admin-only, never to a LAN client
|
|
@@ -65,8 +70,10 @@ function preferencesGet(req, res, parsedUrl, isLocal, deps) {
|
|
|
65
70
|
// 解析出该项目的有效偏好;本机(admin)始终看全局。forks blob 绝不下发,仅以 _projectPrefsKeys
|
|
66
71
|
// 元信息告知本机管理入口该不该出现。元字段以 _ 前缀标记,POST 侧会剥离不落盘。
|
|
67
72
|
const _projectKey = getCurrentProjectKey();
|
|
73
|
+
// _scoped keys on isLocal (physical loopback): a remote admin still gets the scoped project
|
|
74
|
+
// view like other remote users — only the MANAGE capability (_projectPrefsKeys) is admin-gated.
|
|
68
75
|
const _scoped = !isLocal && hasFork(prefs, _projectKey);
|
|
69
|
-
const _forkKeys =
|
|
76
|
+
const _forkKeys = isAdmin ? listForks(prefs) : null; // 计算需早于 delete prefsByProject; admin(本机或远程)可见 fork 列表
|
|
70
77
|
const _fork = _scoped ? prefs.prefsByProject[_projectKey] : null;
|
|
71
78
|
delete prefs.prefsByProject;
|
|
72
79
|
if (_scoped) {
|
|
@@ -90,11 +97,14 @@ function preferencesGet(req, res, parsedUrl, isLocal, deps) {
|
|
|
90
97
|
prefs.approvalModal.voicePack = vpReconcile(LOG_DIR, prefs.approvalModal.voicePack);
|
|
91
98
|
}
|
|
92
99
|
// 项目独立配置元信息(_ 前缀 = 仅回包、不落盘;POST 侧统一剥离):前端据此决定
|
|
93
|
-
// 非本机显示"启动项目独立配置"
|
|
100
|
+
// 非本机显示"启动项目独立配置"开关、admin(本机或已鉴权远程)在有 fork 时显示"配置管理"入口。
|
|
101
|
+
// _isAdmin = loopback OR authenticated remote (container/cloud admin); _isLocal stays the
|
|
102
|
+
// physical loopback fact (drives remote-only affordances like "Download to local").
|
|
94
103
|
prefs._isLocal = isLocal;
|
|
104
|
+
prefs._isAdmin = isAdmin;
|
|
95
105
|
prefs._projectName = getCurrentProjectName();
|
|
96
106
|
prefs._projectScoped = _scoped;
|
|
97
|
-
if (
|
|
107
|
+
if (isAdmin) prefs._projectPrefsKeys = _forkKeys;
|
|
98
108
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
99
109
|
res.end(JSON.stringify(prefs));
|
|
100
110
|
}
|
|
@@ -119,9 +129,9 @@ function preferencesPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
119
129
|
delete incoming.auth;
|
|
120
130
|
delete incoming.authByProject;
|
|
121
131
|
// Claude executable selection controls which local program CCV executes.
|
|
122
|
-
// It is machine-global and therefore writable only by
|
|
123
|
-
// never by
|
|
124
|
-
if (!isLocal) {
|
|
132
|
+
// It is machine-global and therefore writable only by an admin (loopback, or an
|
|
133
|
+
// authenticated remote admin) — never by an unauthenticated remote/project client.
|
|
134
|
+
if (!isAdminReq(req, isLocal)) {
|
|
125
135
|
delete incoming[CLAUDE_EXECUTABLE_PREF_KEY];
|
|
126
136
|
} else if (Object.prototype.hasOwnProperty.call(incoming, CLAUDE_EXECUTABLE_PREF_KEY)) {
|
|
127
137
|
const value = incoming[CLAUDE_EXECUTABLE_PREF_KEY];
|
|
@@ -211,7 +221,7 @@ function preferencesPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
211
221
|
delete prefs.auth;
|
|
212
222
|
delete prefs.authByProject;
|
|
213
223
|
delete prefs.prefsByProject; // fork blob 绝不回显(与 GET 一致)
|
|
214
|
-
if (!isLocal || !isSameOriginBrowserRequest(req, parsedUrl)) {
|
|
224
|
+
if (!isAdminReq(req, isLocal) || !isSameOriginBrowserRequest(req, parsedUrl)) {
|
|
215
225
|
delete prefs[CLAUDE_EXECUTABLE_PREF_KEY];
|
|
216
226
|
}
|
|
217
227
|
stripImConfigs(prefs);
|
|
@@ -228,9 +238,10 @@ function preferencesPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
228
238
|
}
|
|
229
239
|
|
|
230
240
|
function claudeExecutablesGet(req, res, parsedUrl, isLocal, deps) {
|
|
231
|
-
|
|
241
|
+
const isAdmin = isAdminReq(req, isLocal);
|
|
242
|
+
if (!isAdmin || !isSameOriginBrowserRequest(req, parsedUrl)) {
|
|
232
243
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
233
|
-
res.end(JSON.stringify({ error:
|
|
244
|
+
res.end(JSON.stringify({ error: isAdmin ? 'Same-origin request required' : 'Loopback only' }));
|
|
234
245
|
return;
|
|
235
246
|
}
|
|
236
247
|
let configuredPath = null;
|
|
@@ -406,11 +417,12 @@ function retryConfigGet(req, res, _parsedUrl, _isLocal, _deps) {
|
|
|
406
417
|
}
|
|
407
418
|
|
|
408
419
|
function retryConfigPost(req, res, _parsedUrl, isLocal, deps) {
|
|
409
|
-
//
|
|
420
|
+
// Admin-only, mirroring ccswitchImportPost: retry config is written to a
|
|
410
421
|
// cross-process file every ccv instance hot-reloads, and race/stagger with
|
|
411
|
-
// high concurrency multiplies the HOST's paid upstream requests —
|
|
412
|
-
// client must not be able to amplify the host's API spend machine-wide.
|
|
413
|
-
|
|
422
|
+
// high concurrency multiplies the HOST's paid upstream requests — an unauthenticated
|
|
423
|
+
// LAN client must not be able to amplify the host's API spend machine-wide.
|
|
424
|
+
// An authenticated remote admin (container/cloud) is trusted like the loopback admin.
|
|
425
|
+
if (!isAdminReq(req, isLocal)) {
|
|
414
426
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
415
427
|
res.end(JSON.stringify({ ok: false, error: 'retry config is local-only' }));
|
|
416
428
|
return;
|
|
@@ -474,7 +486,7 @@ async function ccswitchProvidersGet(req, res, _parsedUrl, isLocal, _deps) {
|
|
|
474
486
|
// POST body 可选 { setActive: true } —— 是否把 cc-switch 的 current 设为 cc-viewer active。
|
|
475
487
|
// 默认不设(避免覆盖用户当前选择)。导入只 merge 列表,凭证刷新幂等。
|
|
476
488
|
async function ccswitchImportPost(req, res, _parsedUrl, isLocal, deps) {
|
|
477
|
-
if (!isLocal) {
|
|
489
|
+
if (!isAdminReq(req, isLocal)) {
|
|
478
490
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
479
491
|
res.end(JSON.stringify({ ok: false, error: 'cc-switch import is local-only' }));
|
|
480
492
|
return;
|
|
@@ -14,6 +14,7 @@ import { basename } from 'node:path';
|
|
|
14
14
|
import { LOG_DIR } from '../../findcc.js';
|
|
15
15
|
import { reconcileVoicePackPrefs as vpReconcile } from '../lib/voice-pack-manager.js';
|
|
16
16
|
import { mutatePrefs, applyPrefsPatch, readPrefsRaw } from '../lib/prefs-store.js';
|
|
17
|
+
import { isAdminReq } from '../lib/is-admin.js';
|
|
17
18
|
import {
|
|
18
19
|
getCurrentProjectKey, hasActiveProject, snapshotForFork, stripSensitive,
|
|
19
20
|
} from '../lib/project-prefs.js';
|
|
@@ -73,7 +74,7 @@ async function updateHandler(req, res, parsedUrl, isLocal, deps) {
|
|
|
73
74
|
const hasProject = typeof body.project === 'string' && body.project;
|
|
74
75
|
let key;
|
|
75
76
|
if (hasProject) {
|
|
76
|
-
if (!isLocal) return sendJson(res, 403, { error: 'forbidden' });
|
|
77
|
+
if (!isAdminReq(req, isLocal)) return sendJson(res, 403, { error: 'forbidden' });
|
|
77
78
|
key = body.project;
|
|
78
79
|
} else {
|
|
79
80
|
if (!hasActiveProject()) return sendJson(res, 409, { error: 'no-active-project' });
|
|
@@ -91,9 +92,9 @@ async function updateHandler(req, res, parsedUrl, isLocal, deps) {
|
|
|
91
92
|
sendJson(res, 200, { ok: true });
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
// POST /api/project-prefs/delete — remove a fork.
|
|
95
|
+
// POST /api/project-prefs/delete — remove a fork. Admin only (loopback or authenticated remote).
|
|
95
96
|
async function deleteHandler(req, res, parsedUrl, isLocal, deps) {
|
|
96
|
-
if (!isLocal) return sendJson(res, 403, { error: 'forbidden' });
|
|
97
|
+
if (!isAdminReq(req, isLocal)) return sendJson(res, 403, { error: 'forbidden' });
|
|
97
98
|
let body;
|
|
98
99
|
try { body = await readBody(req, deps); } catch { return sendJson(res, 400, { error: 'Invalid JSON' }); }
|
|
99
100
|
const key = body.project;
|
|
@@ -107,9 +108,9 @@ async function deleteHandler(req, res, parsedUrl, isLocal, deps) {
|
|
|
107
108
|
sendJson(res, 200, { ok: true });
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
// GET /api/project-prefs — every fork + contents (for the
|
|
111
|
+
// GET /api/project-prefs — every fork + contents (for the admin management modal).
|
|
111
112
|
function listHandler(req, res, parsedUrl, isLocal, deps) {
|
|
112
|
-
if (!isLocal) return sendJson(res, 403, { error: 'forbidden' });
|
|
113
|
+
if (!isAdminReq(req, isLocal)) return sendJson(res, 403, { error: 'forbidden' });
|
|
113
114
|
const prefs = readPrefsRaw();
|
|
114
115
|
const forks = (prefs && prefs.prefsByProject && typeof prefs.prefsByProject === 'object') ? prefs.prefsByProject : {};
|
|
115
116
|
const currentKey = getCurrentProjectKey();
|