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/README.md +3 -1
- package/dist/assets/{App-C9Tx6Iqw.js → App-CesjoFSv.js} +2 -2
- package/dist/assets/{MdxEditorPanel-DU_5CRtW.js → MdxEditorPanel-Dsnv2wIw.js} +1 -1
- package/dist/assets/{Mobile--EECC49-.js → Mobile-bvGZ392d.js} +1 -1
- package/dist/assets/{ProxyStatsModal-DJu0xq3Z.js → ProxyStatsModal-HFk51cI8.js} +1 -1
- package/dist/assets/{index-CJdRSHTP.js → index-DanpP6qP.js} +2 -2
- package/dist/assets/{seqResourceLoaders-C5UX6ecI.js → seqResourceLoaders-ByHQMNIi.js} +2 -2
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/interceptor.js +73 -11
- package/server/lib/ask/ask-store.js +7 -3
- package/server/lib/async-file-lock.js +29 -5
- package/server/lib/auth.js +71 -54
- package/server/lib/config-backup.js +8 -5
- package/server/lib/credential-access.js +233 -0
- package/server/lib/credential-migrate.js +147 -0
- package/server/lib/credential-store.js +120 -0
- package/server/lib/credential-vault.js +112 -0
- package/server/lib/file-access-policy.js +16 -0
- package/server/lib/im/im-config.js +81 -31
- package/server/lib/im-deny.js +22 -8
- package/server/lib/json-store.js +264 -0
- package/server/lib/prefs-store.js +8 -33
- package/server/lib/session-pin-store.js +7 -14
- package/server/pty-manager.js +10 -4
- package/server/routes/auth.js +21 -1
- package/server/routes/preferences.js +58 -30
- package/server/server.js +8 -0
- package/server/workspace-registry.js +27 -35
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-
|
|
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.
|
|
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",
|
package/server/interceptor.js
CHANGED
|
@@ -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
|
-
|
|
193
|
-
|
|
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 =
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
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 {
|
package/server/lib/auth.js
CHANGED
|
@@ -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
|
|
13
|
-
import { join,
|
|
14
|
-
import {
|
|
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)
|
|
83
|
-
//
|
|
84
|
-
//
|
|
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
|
|
96
|
-
// authByProject: { "<projectDir>": { enabled
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
|
121
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
3
|
-
// 是无法从日志/会话重建的"静态设置"
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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}$/;
|