cc-viewer 1.8.9 → 1.8.10

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/dist/index.html CHANGED
@@ -21,7 +21,7 @@
21
21
  // 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
22
22
  // electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
23
23
  </script>
24
- <script type="module" crossorigin src="./assets/index-BbF89uTe.js"></script>
24
+ <script type="module" crossorigin src="./assets/index-C4Jei4gi.js"></script>
25
25
  <link rel="modulepreload" crossorigin href="./assets/vendor-antd-FhK5cQx7.js">
26
26
  <link rel="modulepreload" crossorigin href="./assets/vendor-virtuoso-B_7lc2Ot.js">
27
27
  <link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-DL3yyKRQ.js">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.8.9",
3
+ "version": "1.8.10",
4
4
  "description": "Claude Code logging, visualization, and management toolkit — launch a web viewer alongside Claude Code with full request/response tracing, proxy, and mobile support",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -218,10 +218,18 @@ export function parseCookies(header) {
218
218
  * urlToken, cookieToken, accessToken,
219
219
  * enabled, password, wantsHtml
220
220
  * }
221
- * → { action: 'allow' | 'login-page' | 'unauthorized' | 'forbidden' }
221
+ * → { action: 'allow' | 'login-page' | 'unauthorized' | 'forbidden', isAdmin: boolean }
222
222
  *
223
223
  * Caller invariants (see plan): 'login-page' / 'unauthorized' / 'forbidden' MUST
224
224
  * terminate the request; only 'allow' falls through to the Host allowlist + routing.
225
+ *
226
+ * `isAdmin` = loopback OR an authenticated remote (valid urlToken / cookieToken). It is computed
227
+ * from the credential-bearing allow branches, so the two can never drift apart — server.js uses
228
+ * it to elevate management routes (see lib/is-admin.js). Static-asset, /api/auth/login, AND the
229
+ * empty-password branch are allow-exempt but NOT admin: empty-password mode means "no read
230
+ * protection", but destructive/admin actions (delete skills, change the password, adjust
231
+ * retry/proxy config that multiplies the host's paid upstream spend) must NOT be opened to every
232
+ * unauthenticated LAN/internet client — those stay credential-or-loopback gated even in open mode.
225
233
  */
226
234
  export function decideAuth(ctx) {
227
235
  const {
@@ -230,6 +238,14 @@ export function decideAuth(ctx) {
230
238
  enabled, password, wantsHtml,
231
239
  } = ctx;
232
240
 
241
+ // Admin = the credential-bearing / trusted-peer allow branches. Deliberately EXCLUDES the
242
+ // empty-password allow branch: "no read protection" must not become "everyone is admin".
243
+ // Single source of truth for both gating and remote-admin elevation.
244
+ const isAdmin =
245
+ isLocal === true ||
246
+ urlToken === accessToken ||
247
+ cookieToken === accessToken;
248
+
233
249
  if (
234
250
  isStaticAsset ||
235
251
  pathname === '/api/auth/login' || // login endpoint must be reachable unauthenticated
@@ -238,16 +254,16 @@ export function decideAuth(ctx) {
238
254
  cookieToken === accessToken ||
239
255
  (enabled && password === '') // empty password = explicitly no protection
240
256
  ) {
241
- return { action: 'allow' };
257
+ return { action: 'allow', isAdmin };
242
258
  }
243
259
 
244
260
  if (enabled) {
245
261
  // Password auth on but no valid credential: HTML navigations get the login page,
246
262
  // XHR/asset/WS get a plain 401.
247
- return { action: wantsHtml ? 'login-page' : 'unauthorized' };
263
+ return { action: wantsHtml ? 'login-page' : 'unauthorized', isAdmin };
248
264
  }
249
265
  // Password auth off → preserve original token-only behaviour (403).
250
- return { action: 'forbidden' };
266
+ return { action: 'forbidden', isAdmin };
251
267
  }
252
268
 
253
269
  function escapeHtml(s) {
@@ -0,0 +1,27 @@
1
+ // Remote-admin elevation for management routes (container / cloud deployment).
2
+ //
3
+ // cc-viewer's sensitive management endpoints are gated on `isLocal` (loopback socket
4
+ // peer) — a model that assumes the admin sits on the host machine. In a single-container
5
+ // or cloud deployment the admin's browser is remote, so `isLocal` is false and every
6
+ // management call 403s even after the caller has authenticated (password-login cookie or
7
+ // `?token=` URL).
8
+ //
9
+ // `server.js handleRequest` therefore computes `isAdmin = isLocal || authenticated-remote`
10
+ // (mirroring decideAuth's remote-allow branches) and attaches it as `req.ccvIsAdmin`
11
+ // before dispatch. Management handlers call `isAdminReq(req, isLocal)` instead of a bare
12
+ // `isLocal` check so an authenticated remote admin is treated like the loopback admin.
13
+ //
14
+ // IMPORTANT: this must NOT be used for the internal machine bridges (events.js hook
15
+ // notifies, ask-perm.js streamChunk) — those carry INTERNAL_TOKEN from the local claude
16
+ // child process and must stay strictly loopback. Only human-facing management routes use
17
+ // isAdminReq.
18
+
19
+ /**
20
+ * Is this request from an admin — loopback, or an authenticated remote client?
21
+ * @param {object} req the incoming request (server.js sets req.ccvIsAdmin after auth)
22
+ * @param {boolean} isLocal loopback socket peer (the dispatch-threaded flag)
23
+ * @returns {boolean}
24
+ */
25
+ export function isAdminReq(req, isLocal) {
26
+ return isLocal === true || req?.ccvIsAdmin === true;
27
+ }
@@ -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 (isLocal).
5
- // POST /api/auth/config — admin-only (!isLocal → 403); enable/disable + set password.
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
- // Passwords (effective + global) are only revealed to the admin (local).
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: isLocal,
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, the local (admin)
5
- // caller additionally gets the plaintext appSecret to view/copy.
6
- // POST /api/dingtalk/config — loopback-only (!isLocal → 403); save creds, reload bridge.
7
- // POST /api/dingtalk/test — loopback-only; validate creds (fetch an access token).
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
- // connectionState passes through as-is; lastError stays loopback-only (mirrors routes/im.js).
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
- // Loopback-only: an app_secret must never be settable over the LAN even with a valid token.
59
- if (!isLocal) {
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;
@@ -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
- // the local (admin) caller additionally gets plaintext secrets + process info.
5
- // POST /api/im/:platform/config — loopback-only; save creds, then drive the process manager
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 — loopback-only; validate creds (fetch an access token).
10
- // POST /api/im/:platform/process — loopback-only; {action:start|stop|restart} the detached worker.
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
- // Loopback gate: a token-authorized LAN client sees only what the header chip needs.
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
- // loopback-only:姓名/头像属个人信息,不向局域网暴露(与 config/test/process 同级)。
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
- // 「模型性格定义」= 该 IM worker 工作目录下的 CC_APPEND_SYSTEM.md(启动 claude 时注入为
286
- // --append-system-prompt-file)。loopback-only:本地文件内容、admin-only。
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
- // 「${IM} SKILL 管理」= 该 IM worker 工作目录下的 .claude/skills/。loopback-only(本地文件操作、admin-only)。
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 (!isLocal || !isSameOriginBrowserRequest(req, parsedUrl)) {
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 = isLocal ? listForks(prefs) : null; // 计算需早于 delete prefsByProject
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
- // 非本机显示"启动项目独立配置"开关、本机在有 fork 时显示"配置管理"入口。
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 (isLocal) prefs._projectPrefsKeys = _forkKeys;
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 the loopback admin,
123
- // never by a remote/project-scoped preferences client.
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
- if (!isLocal || !isSameOriginBrowserRequest(req, parsedUrl)) {
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: isLocal ? 'Same-origin request required' : 'Loopback only' }));
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
- // Local-only, mirroring ccswitchImportPost: retry config is written to a
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 — a LAN
412
- // client must not be able to amplify the host's API spend machine-wide.
413
- if (!isLocal) {
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. Loopback admin only.
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 loopback management modal).
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();
@@ -3,6 +3,7 @@ import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
3
3
  import { join, resolve, sep, dirname } from 'node:path';
4
4
  import { getClaudeConfigDir } from '../../findcc.js';
5
5
  import { listSkills, moveSkill, deleteSkill, validateSkillName } from '../lib/skills-api.js';
6
+ import { isAdminReq } from '../lib/is-admin.js';
6
7
 
7
8
  async function skillsList(req, res) {
8
9
  try {
@@ -44,10 +45,12 @@ function skillsToggle(req, res) {
44
45
  });
45
46
  }
46
47
 
47
- // Skill 永久删除 —— 把单个 skill 文件夹从 skills/ 或 skills-skip/ 彻底删除(不可恢复)
48
- // loopback-only:不可逆 rmSync 不向局域网暴露(toggle 是可逆 move,这里更严)。
48
+ // Permanently delete a skill — rmSync the skill folder from skills/ or skills-skip/ (irreversible).
49
+ // admin-only: an irreversible rmSync is not exposed to unauthenticated LAN clients (toggle is a
50
+ // reversible move — this is stricter); an authenticated remote admin (container/cloud) is allowed
51
+ // the same as the loopback admin.
49
52
  function skillsDelete(req, res, parsedUrl, isLocal) {
50
- if (!isLocal) {
53
+ if (!isAdminReq(req, isLocal)) {
51
54
  res.writeHead(403, { 'Content-Type': 'application/json' });
52
55
  res.end(JSON.stringify({ error: 'Loopback only' }));
53
56
  return;
@@ -1,6 +1,6 @@
1
1
  // Voice-pack routes (moved verbatim from server.js handleRequest).
2
2
  // Manages user-uploaded audio + serves the bundled "皇上系列" default pack.
3
- // Uploads are loopback-only — LAN clients can play but not write.
3
+ // Uploads/deletes are admin-only (loopback, or authenticated remote admin) — other clients can play but not write.
4
4
  import { lstatSync, statSync, createReadStream } from 'node:fs';
5
5
  import { LOG_DIR } from '../../findcc.js';
6
6
  import {
@@ -18,6 +18,7 @@ import {
18
18
  EVENT_KEYS as VP_EVENT_KEYS,
19
19
  MAX_AUDIO_BYTES as VP_MAX_BYTES,
20
20
  } from '../lib/voice-pack-manager.js';
21
+ import { isAdminReq } from '../lib/is-admin.js';
21
22
 
22
23
  function voicePackList(req, res) {
23
24
  try {
@@ -45,10 +46,10 @@ function voicePackList(req, res) {
45
46
  }
46
47
 
47
48
  function voicePackUpload(req, res, parsedUrl, isLocal) {
48
- // Loopback-only — refuse LAN clients even if they hold a valid token.
49
- // The token already gates LAN access but voice-pack writes touch the local FS
50
- // and end up reachable from every client; keep the write side strictly local.
51
- if (!isLocal) {
49
+ // Admin-only — refuse unauthenticated LAN clients even if they hold a valid token.
50
+ // The token already gates LAN access but voice-pack writes touch the local FS and end up
51
+ // reachable from every client; an authenticated remote admin (container/cloud) is allowed.
52
+ if (!isAdminReq(req, isLocal)) {
52
53
  res.writeHead(403, { 'Content-Type': 'application/json' });
53
54
  res.end(JSON.stringify({ error: 'Upload allowed from loopback only' }));
54
55
  return;
@@ -106,7 +107,7 @@ function voicePackUpload(req, res, parsedUrl, isLocal) {
106
107
  }
107
108
 
108
109
  function voicePackDelete(req, res, parsedUrl, isLocal) {
109
- if (!isLocal) {
110
+ if (!isAdminReq(req, isLocal)) {
110
111
  res.writeHead(403, { 'Content-Type': 'application/json' });
111
112
  res.end(JSON.stringify({ error: 'Delete allowed from loopback only' }));
112
113
  return;
package/server/server.js CHANGED
@@ -757,17 +757,27 @@ async function handleRequest(req, res) {
757
757
  const isLocal = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1';
758
758
  const isStaticAsset = url.startsWith('/assets/') || url === '/favicon.ico';
759
759
  const wantsHtml = method === 'GET' && ((req.headers.accept || '').includes('text/html') || url === '/');
760
+ // Hoisted so decideAuth and the isAdmin computation share the SAME credential values.
761
+ const urlToken = parsedUrl.searchParams.get('token');
762
+ const cookieToken = parseCookies(req.headers.cookie).ccv_auth;
760
763
  const authDecision = decideAuth({
761
764
  isStaticAsset,
762
765
  pathname: url,
763
766
  isLocal,
764
- urlToken: parsedUrl.searchParams.get('token'),
765
- cookieToken: parseCookies(req.headers.cookie).ccv_auth,
767
+ urlToken,
768
+ cookieToken,
766
769
  accessToken: ACCESS_TOKEN,
767
770
  enabled: authConfig.enabled,
768
771
  password: authConfig.password,
769
772
  wantsHtml,
770
773
  });
774
+ // Remote-admin elevation (container/cloud deploy): an authenticated remote caller is
775
+ // treated as admin for management routes (isAdminReq in lib/is-admin.js). isAdmin comes
776
+ // straight from decideAuth (single source of truth — it mirrors the remote-allow branches
777
+ // and can never drift from them). Loopback is always admin. This MUST NOT relax the
778
+ // internal machine bridges (events.js hook notifies, ask-perm.js streamChunk) — those
779
+ // stay strictly loopback + INTERNAL_TOKEN.
780
+ req.ccvIsAdmin = authDecision.isAdmin === true;
771
781
  if (authDecision.action === 'login-page') {
772
782
  const lang = localeFromAcceptLanguage(req.headers['accept-language']) || getLang();
773
783
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });