cc-viewer 1.8.19 → 1.8.20

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-CJdRSHTP.js"></script>
24
+ <script type="module" crossorigin src="./assets/index-DanpP6qP.js"></script>
25
25
  <link rel="modulepreload" crossorigin href="./assets/vendor-antd-DKIytXho.js">
26
26
  <link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-4qgXq03k.js">
27
27
  <link rel="modulepreload" crossorigin href="./assets/vendor-mdxeditor-D5Z5D9fq.js">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.8.19",
3
+ "version": "1.8.20",
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",
@@ -22,6 +22,8 @@ import { sanitizePathComponent } from './lib/v2/layout.js';
22
22
  import { parseAgentId, findHeader } from './lib/v2/agent-id.js';
23
23
  import { parseUserId } from './lib/session-id.js';
24
24
  import { setRetryConfigPath, loadRetryConfig, DEFAULT_RETRY_CONFIG } from './lib/proxy/proxy-retry.js';
25
+ import { writeJsonAtomic, mutateJsonSync } from './lib/json-store.js';
26
+ import { readSecretOr, writeSecret, removeSecret } from './lib/credential-access.js';
25
27
  import { setProjectName } from './lib/project-state.js';
26
28
  import { consumePendingForResume, writeSnapshot, projectKeyForCwd } from './lib/system-prompt-snapshots.js';
27
29
  import { liveSystemPromptEnabled, getLaunchSystemPromptInfo, getLiveEntry, putLiveEntry, selectEntriesForModel, applyLiveSystem, knownInjectedTexts } from './lib/system-prompt-live.js';
@@ -186,16 +188,72 @@ function _writeWorkspaceActive(activeId, roles) {
186
188
  }
187
189
  }
188
190
 
191
+ // Resolve a profile's effective apiKey for request injection. The key lives in the credential
192
+ // vault (kind=profile-apiKey, ref=profile.id); a plaintext apiKey still present in profile.json
193
+ // is the one-version read-fallback (pre-migration). FAIL-CLOSED: a profile with a baseURL whose
194
+ // key cannot be decrypted (lost master.key / tampered ciphertext) must be excluded from routing —
195
+ // otherwise proxy.js would forward the request to the third-party baseURL while the auth rewrite
196
+ // is skipped, transmitting the user's default Anthropic credential to that host.
197
+ function _resolveProfileApiKey(profile) {
198
+ if (!profile || profile.id === 'max') return { apiKey: '', unusable: false, reason: '' };
199
+ const legacyPlain = typeof profile.apiKey === 'string' ? profile.apiKey : '';
200
+ const { value, unreadable } = readSecretOr('profile-apiKey', profile.id, legacyPlain);
201
+ if (unreadable) return { apiKey: '', unusable: true, reason: 'unreadable' };
202
+ // FAIL-CLOSED: a profile that rewrites the URL to a third-party baseURL but resolves to NO key
203
+ // (no vault entry, no legacy plaintext) must also be excluded from routing. The URL rewrite runs
204
+ // unconditionally while the auth rewrite is guarded by `if (_effProfile.apiKey && ...)` — with an
205
+ // empty key the request would reach the third-party host carrying the user's own Anthropic
206
+ // credential (the exact outcome the unreadable branch above exists to prevent).
207
+ if (typeof profile.baseURL === 'string' && profile.baseURL && !value) {
208
+ return { apiKey: '', unusable: true, reason: 'keyless-baseurl' };
209
+ }
210
+ return { apiKey: value, unusable: false, reason: '' };
211
+ }
212
+
213
+ /**
214
+ * Decrypt-backfill apiKeys onto a raw profile.json list for READERS that consume the file
215
+ * directly (e.g. GET /api/proxy-profiles) rather than the in-memory _profilesById. Profiles
216
+ * whose key is unreadable are returned with an EMPTY apiKey (the caller masks/serves as-is);
217
+ * routing itself is fail-closed via _loadProxyProfile, which DROPS such profiles.
218
+ */
219
+ export function hydrateProfilesWithApiKeys(profiles) {
220
+ return (Array.isArray(profiles) ? profiles : []).map((p) => {
221
+ if (!p || typeof p.id !== 'string') return p;
222
+ const { apiKey } = _resolveProfileApiKey(p);
223
+ return { ...p, apiKey };
224
+ });
225
+ }
226
+
227
+ // Throttle the per-profile "key unusable" diagnostic to once per profile id per process —
228
+ // _loadProxyProfile re-runs on every 1.5s watchFile tick and would otherwise spam the log.
229
+ const _reportedUnusableProfiles = new Set();
230
+
189
231
  function _loadProxyProfile() {
190
232
  try {
191
233
  const data = JSON.parse(readFileSync(PROFILE_PATH, 'utf-8'));
192
- _profilesById = new Map((Array.isArray(data.profiles) ? data.profiles : [])
193
- .filter(p => p && typeof p.id === 'string').map(p => [p.id, p]));
234
+ const rawProfiles = (Array.isArray(data.profiles) ? data.profiles : []).filter(p => p && typeof p.id === 'string');
235
+ // Decrypt-backfill apiKeys; drop profiles whose key is unreadable (fail-closed).
236
+ const profiles = [];
237
+ for (const p of rawProfiles) {
238
+ const { apiKey, unusable, reason } = _resolveProfileApiKey(p);
239
+ if (unusable) {
240
+ if (!_reportedUnusableProfiles.has(p.id)) {
241
+ _reportedUnusableProfiles.add(p.id);
242
+ const why = reason === 'keyless-baseurl'
243
+ ? 'has a baseURL but no apiKey — refusing to forward your default credential to a third-party host'
244
+ : 'apiKey unreadable (lost master.key or tampered credentials.json)';
245
+ reportSwallowed('proxy-profile.key-unusable', new Error(`profile "${p.id}" dropped: ${why}`));
246
+ }
247
+ continue;
248
+ }
249
+ profiles.push({ ...p, apiKey });
250
+ }
251
+ _profilesById = new Map(profiles.map(p => [p.id, p]));
194
252
  // active 解析优先级:workspace override > profile.json.active (兼容老数据 / 全局回退) > null
195
253
  const ws = _readWorkspaceActive();
196
254
  _roleIds = ws.roles;
197
255
  const activeId = ws.activeId || data.active;
198
- const active = data.profiles?.find(p => p.id === activeId);
256
+ const active = profiles.find(p => p.id === activeId);
199
257
  _activeProfile = (active && active.id !== 'max') ? active : null;
200
258
  } catch (err) {
201
259
  _activeProfile = null;
@@ -226,14 +284,18 @@ function setActiveProfileForWorkspace(activeId, roles) {
226
284
  if (activeId !== undefined) {
227
285
  const normalizedId = (activeId && typeof activeId === 'string') ? activeId : 'max';
228
286
  try {
229
- const data = existsSync(PROFILE_PATH)
230
- ? JSON.parse(readFileSync(PROFILE_PATH, 'utf-8'))
231
- : { profiles: [{ id: 'max', name: 'Default' }] };
232
- if (data.active !== normalizedId) {
233
- data.active = normalizedId;
234
- mkdirSync(dirname(PROFILE_PATH), { recursive: true });
235
- writeFileSync(PROFILE_PATH, JSON.stringify(data, null, 2), { mode: 0o600 });
236
- }
287
+ // Read-merge-write INSIDE the shared profile.json lock (mutateJsonSync), so this whole-file
288
+ // update mutexes with the startup credential-strip and proxyProfilesPost — an unlocked
289
+ // snapshot could otherwise resurrect a stripped apiKey field or drop a concurrent list edit.
290
+ mutateJsonSync(PROFILE_PATH, (data) => {
291
+ const base = (data && typeof data === 'object' && !Array.isArray(data))
292
+ ? data
293
+ : { profiles: [{ id: 'max', name: 'Default' }] };
294
+ if (base.active !== normalizedId) base.active = normalizedId;
295
+ return base;
296
+ }, { mode: 0o600, strictCorrupt: true, fallback: { profiles: [{ id: 'max', name: 'Default' }] } });
297
+ // strictCorrupt: profile.json corrupt 时抛错(下方 catch 兜住、跳过本次写),绝不用 fallback
298
+ // 的 {max}-only 覆盖掉磁盘上虽损坏但可能仍可人工恢复的全部 profile。
237
299
  result.profile = true;
238
300
  } catch { /* 双失败场景下 result 全 false,由调用方自行兜底 */ }
239
301
  }
@@ -13,7 +13,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from '
13
13
  import { join } from 'node:path';
14
14
  import { randomBytes } from 'node:crypto';
15
15
  import { renameSyncWithRetry } from '../file-api.js';
16
- import { withFileLockAsync } from '../async-file-lock.js';
16
+ import { withJsonLock } from '../json-store.js';
17
17
  import { LOG_DIR } from '../../../findcc.js';
18
18
 
19
19
  const SCHEMA_VERSION = 1;
@@ -23,10 +23,14 @@ const SCHEMA_VERSION = 1;
23
23
  let _loggedPersistError = false;
24
24
 
25
25
  function getStoreFile() { return join(LOG_DIR, 'ask-store.json'); }
26
- function getLockFile() { return join(LOG_DIR, 'ask-store.lock'); }
27
26
 
27
+ // Serialize every read-modify-write through the unified json-store kernel's async lock
28
+ // (derived from the file name → ask-store.json.lock). The domain read/save (schema filter,
29
+ // version wrap, entry cleaning) stays below in loadAskStore/saveAskStore; the kernel only
30
+ // supplies the mutex via withJsonLock (lock-only, no read/write) because the store's on-disk
31
+ // shape ({version,entries}) differs from its in-memory shape (plain entries map).
28
32
  function withLock(fn) {
29
- return withFileLockAsync(getLockFile(), fn, { ensureDir: LOG_DIR });
33
+ return withJsonLock(getStoreFile(), fn, { ensureDir: LOG_DIR });
30
34
  }
31
35
 
32
36
  /**
@@ -8,6 +8,17 @@ import { mkdirSync } from 'node:fs';
8
8
  // 同进程内的 Promise 互斥锁(按 lockPath 分组)
9
9
  const _inProcessLocks = new Map();
10
10
 
11
+ // Disk lock files this process currently holds via the async path (between _acquireFileLock
12
+ // success and the matching unlink). Consulted by the SYNC lock flavor in json-store.js so it
13
+ // never mistakes a live async holder (which yields the JS thread at every await inside its
14
+ // critical section) for a stale own-pid lock and steals it.
15
+ const _heldDiskLocks = new Set();
16
+
17
+ /** True when this process currently holds `lockPath`'s disk lock via the async flavor. */
18
+ export function hasLiveDiskHolder(lockPath) {
19
+ return _heldDiskLocks.has(lockPath);
20
+ }
21
+
11
22
  function isPidAlive(pid) {
12
23
  if (!Number.isInteger(pid) || pid <= 0) return false;
13
24
  try {
@@ -56,11 +67,23 @@ async function _acquireFileLock(lockPath, opts) {
56
67
  while (true) {
57
68
  try {
58
69
  const fh = await open(lockPath, 'wx');
59
- if (writePid) {
60
- await fh.writeFile(JSON.stringify({ pid: process.pid, ts: Date.now() }));
70
+ // Register the hold IMMEDIATELY on acquisition, before the pid-write await. A SYNC caller
71
+ // (json-store._acquireLockSync) that runs at that await boundary would otherwise see the lock
72
+ // file with our own pid but hasLiveDiskHolder===false, judge it a stale crash-leftover, steal
73
+ // and unlink it — clobbering this async holder's critical section (the exact lost update the
74
+ // sync flavor's reentrancy guard exists to prevent).
75
+ _heldDiskLocks.add(lockPath);
76
+ try {
77
+ if (writePid) {
78
+ await fh.writeFile(JSON.stringify({ pid: process.pid, ts: Date.now() }));
79
+ }
80
+ await fh.close();
81
+ return;
82
+ } catch (err) {
83
+ _heldDiskLocks.delete(lockPath); // never fully acquired → drop the registration
84
+ try { await fh.close(); } catch {}
85
+ throw err;
61
86
  }
62
- await fh.close();
63
- return;
64
87
  } catch (err) {
65
88
  if (err?.code === 'EEXIST') {
66
89
  if (Date.now() < deadline) {
@@ -108,10 +131,11 @@ export async function withFileLockAsync(lockPath, fn, opts = {}) {
108
131
  await prev;
109
132
 
110
133
  try {
111
- await _acquireFileLock(lockPath, opts);
134
+ await _acquireFileLock(lockPath, opts); // registers in _heldDiskLocks at the acquisition point
112
135
  try {
113
136
  return await fn();
114
137
  } finally {
138
+ _heldDiskLocks.delete(lockPath);
115
139
  try { await unlink(lockPath); } catch {}
116
140
  }
117
141
  } finally {
@@ -9,9 +9,10 @@
9
9
  // unit-tested directly (loopback HTTP tests always look local, so they cannot
10
10
  // exercise the remote branch — `decideAuth` covers it as a pure function).
11
11
  import { randomBytes } from 'node:crypto';
12
- import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync } from 'node:fs';
13
- import { join, dirname } from 'node:path';
14
- import { renameSyncWithRetry } from './file-api.js';
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { join, resolve } from 'node:path';
14
+ import { mutateJsonSync } from './json-store.js';
15
+ import { readSecretOr, writeSecret, removeSecret } from './credential-access.js';
15
16
  import { LOG_DIR } from '../../findcc.js';
16
17
  import { tFor, localeFromAcceptLanguage } from '../i18n.js';
17
18
 
@@ -79,9 +80,14 @@ function normalizeAuth(cfg) {
79
80
  }
80
81
 
81
82
  // The password is kept in memory & over the admin API as plaintext (admin must be able
82
- // to view/copy it), but is base64-encoded on disk so preferences.json never shows the
83
- // raw password. This is light obfuscation, NOT real security — base64 is trivially
84
- // reversible; it only avoids the password sitting in literal plaintext in the file.
83
+ // to view/copy it). On disk it lives in the encrypted credential vault (credentials.json)
84
+ // as AES-256-GCM ciphertext — NOT in preferences.json. The legacy encoding was base64 inside
85
+ // preferences.json (obfuscation only); those fields are migrated out at startup and cleared.
86
+ //
87
+ // CRITICAL fail-closed rule: a password that exists but cannot be decrypted (lost master.key,
88
+ // tampered ciphertext) is "unreadable", NOT "empty". decideAuth treats an EMPTY password as
89
+ // allow-all, so collapsing an unreadable secret to '' would open the LAN gate. We therefore
90
+ // thread a `passwordUnreadable` flag through and deny on it before the empty-password branch.
85
91
  function encodePassword(plain) {
86
92
  return plain ? Buffer.from(plain, 'utf-8').toString('base64') : '';
87
93
  }
@@ -91,18 +97,32 @@ function decodePassword(stored) {
91
97
  }
92
98
 
93
99
  // ─── Scoped persistence (global default + optional per-project override) ───
94
- // preferences.json:
95
- // auth: { enabled, password(b64) } ← global default
96
- // authByProject: { "<projectDir>": { enabled, password(b64) } } ← optional overrides
100
+ // preferences.json (after migration):
101
+ // auth: { enabled } ← global default (password in vault)
102
+ // authByProject: { "<projectDir>": { enabled } } ← optional overrides (password in vault)
103
+ // Vault refs: 'lan-password:global' and 'lan-password:proj:<resolvedDir>'.
97
104
  // A project "has an override" iff a key exists for it (even if disabled). The gate
98
105
  // resolves: project override (if the key exists) else global. To inherit global again,
99
106
  // the override must be REMOVED (clearProjectOverride), not merely disabled.
100
107
 
101
- function decodeStored(a) {
102
- return { enabled: !!(a && a.enabled), password: decodePassword(a && a.password) };
108
+ // Vault ref for the LAN password of a scope. Exported so the one-time migration
109
+ // (credential-migrate.js) derives refs from the SAME source as the runtime readers — a divergence
110
+ // would silently orphan every migrated secret (the writer and reader would use different keys).
111
+ export function refFor(projectDir) {
112
+ return projectDir ? `proj:${resolve(projectDir)}` : 'global';
103
113
  }
104
- function encodeForDisk(normalized) {
105
- return { enabled: normalized.enabled, password: encodePassword(normalized.password) };
114
+
115
+ function decodeStored(a, projectDir = null) {
116
+ // No auth entry at all → the default "disabled, no password" shape; do NOT consult the vault
117
+ // (a stray vault entry must not resurrect a password for a scope the prefs say nothing about,
118
+ // e.g. after preferences.json was wiped/corrupt while credentials.json survived).
119
+ if (!a || typeof a !== 'object') return { enabled: false, password: '', passwordUnreadable: false };
120
+ const enabled = !!a.enabled;
121
+ // Legacy on-disk value (base64) is the read-fallback for the one-version transition window;
122
+ // the vault is authoritative when it holds the entry.
123
+ const legacyPlain = decodePassword(a.password);
124
+ const { value, unreadable } = readSecretOr('lan-password', refFor(projectDir), legacyPlain);
125
+ return { enabled, password: value, passwordUnreadable: unreadable };
106
126
  }
107
127
 
108
128
  /** Does this project have its own auth override? (key present, regardless of enabled) */
@@ -117,8 +137,9 @@ function hasOverride(prefs, projectDir) {
117
137
  */
118
138
  export function loadAuthConfig(projectDir = null) {
119
139
  const prefs = readPrefs();
120
- const src = hasOverride(prefs, projectDir) ? prefs.authByProject[projectDir] : prefs.auth;
121
- return decodeStored(src);
140
+ const overridden = hasOverride(prefs, projectDir);
141
+ const src = overridden ? prefs.authByProject[projectDir] : prefs.auth;
142
+ return decodeStored(src, overridden ? projectDir : null);
122
143
  }
123
144
 
124
145
  /**
@@ -130,38 +151,14 @@ export function loadAuthState(projectDir = null) {
130
151
  const prefs = readPrefs();
131
152
  const overridden = hasOverride(prefs, projectDir);
132
153
  return {
133
- effective: decodeStored(overridden ? prefs.authByProject[projectDir] : prefs.auth),
134
- global: decodeStored(prefs.auth),
154
+ effective: decodeStored(overridden ? prefs.authByProject[projectDir] : prefs.auth, overridden ? projectDir : null),
155
+ global: decodeStored(prefs.auth, null),
135
156
  scope: overridden ? 'project' : 'global',
136
157
  hasProjectOverride: overridden,
137
158
  projectDir: projectDir || null,
138
159
  };
139
160
  }
140
161
 
141
- function writePrefs(prefs) {
142
- const p = getPrefsPath();
143
- const dir = dirname(p);
144
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
145
- // Atomic tmp→rename so a crash mid-write can't truncate the password-bearing file
146
- // (preferences.json also holds UI prefs + per-project forks). Mirrors prefs-store.js.
147
- // TODO(prefs-lock): this path does NOT take the prefs-store lock (writePrefs is sync, its
148
- // callers are sync) — auth writes are rare/admin-initiated, so the residual lost-update
149
- // window vs a concurrent locked prefs write is accepted; atomicity rules out corruption.
150
- // Dormant only while every mutatePrefs mutator stays synchronous (no await between its
151
- // locked read and write); route auth writes through prefs-store.mutatePrefs if that changes.
152
- const tmp = `${p}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
153
- try {
154
- writeFileSync(tmp, JSON.stringify(prefs, null, 2), { mode: 0o600 });
155
- renameSyncWithRetry(tmp, p);
156
- // writeFileSync's mode only applies on creation; a pre-existing file keeps its old
157
- // perms. Re-assert 0600 — preferences.json now carries the (base64) password.
158
- try { chmodSync(p, 0o600); } catch { /* best-effort; non-POSIX or race */ }
159
- } catch (err) {
160
- try { unlinkSync(tmp); } catch {}
161
- throw err;
162
- }
163
- }
164
-
165
162
  /**
166
163
  * Persist auth config (read-merge-write, preserving all other prefs). `opts.scope`
167
164
  * 'global' → the `auth` key; 'project' → authByProject[opts.projectDir] (requires
@@ -170,26 +167,37 @@ function writePrefs(prefs) {
170
167
  */
171
168
  export function saveAuthConfig(cfg, opts = {}) {
172
169
  const normalized = normalizeAuth(cfg);
173
- const prefs = readPrefs();
174
170
  const scope = opts.scope === 'project' && opts.projectDir ? 'project' : 'global';
175
- if (scope === 'project') {
176
- if (!prefs.authByProject || typeof prefs.authByProject !== 'object') prefs.authByProject = {};
177
- prefs.authByProject[opts.projectDir] = encodeForDisk(normalized);
178
- } else {
179
- prefs.auth = encodeForDisk(normalized);
180
- }
181
- writePrefs(prefs);
171
+ const projectDir = scope === 'project' ? opts.projectDir : null;
172
+ // Write the password to the vault FIRST. Only on success do we persist { enabled } (sans
173
+ // password) to preferences.json — otherwise the legacy field must be preserved so the secret
174
+ // is not lost. (writeSecret verifies the key is usable and reports+false on failure.)
175
+ const wrote = writeSecret('lan-password', refFor(projectDir), normalized.password);
176
+ // Read-merge-write INSIDE the kernel's sync lock so a concurrent prefs/IM save can't be
177
+ // lost between our read and write.
178
+ mutateJsonSync(getPrefsPath(), (prefs) => {
179
+ // persisted shape: { enabled } plus the legacy base64 password ONLY when the vault write
180
+ // failed (preserves the secret for a later migration) — removed once vault-backed.
181
+ const legacyField = wrote ? {} : { password: encodePassword(normalized.password) };
182
+ if (scope === 'project') {
183
+ if (!prefs.authByProject || typeof prefs.authByProject !== 'object') prefs.authByProject = {};
184
+ prefs.authByProject[opts.projectDir] = { enabled: normalized.enabled, ...legacyField };
185
+ } else {
186
+ prefs.auth = { enabled: normalized.enabled, ...legacyField };
187
+ }
188
+ }, { mode: 0o600 });
182
189
  return normalized;
183
190
  }
184
191
 
185
192
  /** Remove a project's override so it inherits the global default again. No-op if absent. */
186
193
  export function clearProjectOverride(projectDir) {
187
194
  if (!projectDir) return;
188
- const prefs = readPrefs();
189
- if (prefs.authByProject && Object.prototype.hasOwnProperty.call(prefs.authByProject, projectDir)) {
190
- delete prefs.authByProject[projectDir];
191
- writePrefs(prefs);
192
- }
195
+ removeSecret('lan-password', refFor(projectDir));
196
+ mutateJsonSync(getPrefsPath(), (prefs) => {
197
+ if (prefs.authByProject && Object.prototype.hasOwnProperty.call(prefs.authByProject, projectDir)) {
198
+ delete prefs.authByProject[projectDir];
199
+ }
200
+ }, { mode: 0o600 });
193
201
  }
194
202
 
195
203
  /**
@@ -236,6 +244,7 @@ export function decideAuth(ctx) {
236
244
  isStaticAsset, pathname, isLocal,
237
245
  urlToken, cookieToken, accessToken,
238
246
  enabled, password, wantsHtml,
247
+ passwordUnreadable = false,
239
248
  } = ctx;
240
249
 
241
250
  // Admin = the credential-bearing / trusted-peer allow branches. Deliberately EXCLUDES the
@@ -246,6 +255,14 @@ export function decideAuth(ctx) {
246
255
  urlToken === accessToken ||
247
256
  cookieToken === accessToken;
248
257
 
258
+ // Fail-closed: a password that EXISTS but could not be decrypted (lost master.key / tampered
259
+ // ciphertext) must deny BEFORE the empty-password allow branch below — otherwise an unreadable
260
+ // secret collapses to '' and opens the LAN gate. Login page stays reachable so the admin can
261
+ // still authenticate via token (which does not depend on the vault).
262
+ if (passwordUnreadable && !isAdmin) {
263
+ return { action: wantsHtml ? 'login-page' : 'unauthorized', isAdmin };
264
+ }
265
+
249
266
  if (
250
267
  isStaticAsset ||
251
268
  pathname === '/api/auth/login' || // login endpoint must be reachable unauthenticated
@@ -1,13 +1,16 @@
1
1
  // 启动期配置备份 — 2026-06-06 LOG_DIR 整树删除事故防再犯。
2
- // preferences.json(auth/IM token/偏好)、profile.json(代理热切换)、workspaces.json(工作区注册表)
3
- // 是无法从日志/会话重建的"静态设置";本模块在 server 启动时把它们备份到 LOG_DIR **之外**
4
- // 的兄弟目录(默认 ~/.claude/cc-viewer-config-backups/<时间戳>/),滚动保留最近 KEEP 份。
5
- // 全程 best-effort:任何失败只返回 {ok:false},绝不抛错阻塞启动。
2
+ // preferences.json(auth/IM/偏好)、profile.json(代理热切换)、workspaces.json(工作区注册表)、
3
+ // credentials.json + master.key(凭证 vault 密文与主密钥) 是无法从日志/会话重建的"静态设置";
4
+ // 本模块在 server 启动时把它们备份到 LOG_DIR **之外**的兄弟目录
5
+ // (默认 ~/.claude/cc-viewer-config-backups/<时间戳>/),滚动保留最近 KEEP 份。
6
+ // master.key 一并备份:credentials.json 是密文,没有 key 就是废数据;二者同地 0600,恢复路径才完整。
7
+ // 历史明文备份(迁移前含 base64/明文密钥的 preferences/profile)不自动删除——它们是唯一的老密钥
8
+ // 恢复来源,在确认 vault 可读前绝不清理。全程 best-effort:任何失败只返回 {ok:false},绝不抛错阻塞启动。
6
9
  import { existsSync, mkdirSync, copyFileSync, readdirSync, rmSync, chmodSync } from 'node:fs';
7
10
  import { join, dirname } from 'node:path';
8
11
  import { LOG_DIR } from '../../findcc.js';
9
12
 
10
- const CONFIG_FILES = ['preferences.json', 'profile.json', 'workspaces.json'];
13
+ const CONFIG_FILES = ['preferences.json', 'profile.json', 'workspaces.json', 'credentials.json', 'master.key'];
11
14
  const KEEP = 10;
12
15
  // 备份子目录名:严格时间戳形态。prune 只删匹配此形态的目录,绝不波及其它内容。
13
16
  const STAMP_RE = /^\d{8}_\d{6}$/;