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.
@@ -11,8 +11,10 @@
11
11
  // (`secret`: appSecret) are both base64-encoded on disk so preferences.json never
12
12
  // shows them in literal plaintext. This is light obfuscation, NOT encryption. The
13
13
  // admin API masks secret fields entirely (→ hasSecret).
14
- import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs';
15
- import { join, dirname } from 'node:path';
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { mutateJsonSync } from '../json-store.js';
17
+ import { readSecretOr, writeSecret } from '../credential-access.js';
16
18
  import { LOG_DIR } from '../../../findcc.js';
17
19
 
18
20
  const MIN_CHUNK = 500;
@@ -130,16 +132,10 @@ function readPrefs() {
130
132
  }
131
133
  }
132
134
 
133
- function writePrefs(prefs) {
134
- const p = getPrefsPath();
135
- const dir = dirname(p);
136
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
137
- writeFileSync(p, JSON.stringify(prefs, null, 2), { mode: 0o600 });
138
- // writeFileSync's mode only applies on creation; re-assert 0600 — the file now carries
139
- // the (base64) secrets.
140
- try { chmodSync(p, 0o600); } catch { /* best-effort; non-POSIX or race */ }
141
- }
142
-
135
+ // Credential fields (`cred`: appKey/appId/botId, LOW sensitivity) stay base64 in preferences.json.
136
+ // Secret fields (`secret`: appSecret/botToken) live in the encrypted credential vault
137
+ // (credentials.json, AES-256-GCM) — never in preferences.json. The legacy base64 secret fields
138
+ // are migrated out at startup and cleared. Secrets are keyed `<platform>.<field>`.
143
139
  export function encodeSecret(plain) {
144
140
  return plain ? Buffer.from(plain, 'utf-8').toString('base64') : '';
145
141
  }
@@ -148,6 +144,11 @@ export function decodeSecret(stored) {
148
144
  try { return Buffer.from(stored, 'base64').toString('utf-8'); } catch { return ''; }
149
145
  }
150
146
 
147
+ // Vault ref for an IM platform secret field. Exported so the one-time migration
148
+ // (credential-migrate.js) derives refs from the SAME source as the runtime readers — a divergence
149
+ // would silently orphan every migrated secret.
150
+ export function secretRef(id, fieldKey) { return `${id}.${fieldKey}`; }
151
+
151
152
  function clampChunk(n, dflt = DEFAULT_CHUNK) {
152
153
  const v = Number(n);
153
154
  if (!Number.isFinite(v)) return dflt; // missing/invalid → the field's default (per-platform, e.g. Discord 1900)
@@ -180,13 +181,19 @@ function normField(type, v, dflt) {
180
181
  }
181
182
  }
182
183
 
183
- function decodeField(type, v, dflt) {
184
- switch (type) {
185
- case 'cred':
186
- case 'secret': return decodeSecret(v);
187
- case 'bool': return v !== undefined && v !== null ? !!v : (dflt !== undefined ? !!dflt : false);
184
+ function decodeField(id, f, v) {
185
+ switch (f.type) {
186
+ case 'secret': {
187
+ // Vault-first, legacy base64 as the one-version read-fallback. readSecretOr distinguishes
188
+ // "unreadable" (lost key / tampered) from "absent"; for a bridge secret, unreadable and
189
+ // absent both degrade to "bridge won't start" (hasCreds false) — a down bot, never a leak.
190
+ const legacyPlain = decodeSecret(v);
191
+ return readSecretOr('im-secret', secretRef(id, f.key), legacyPlain).value;
192
+ }
193
+ case 'cred': return decodeSecret(v);
194
+ case 'bool': return v !== undefined && v !== null ? !!v : (f.default !== undefined ? !!f.default : false);
188
195
  case 'idlist': return normalizeIdList(v);
189
- case 'chunk': return clampChunk(v, dflt);
196
+ case 'chunk': return clampChunk(v, f.default);
190
197
  case 'region': return v === 'lark' ? 'lark' : 'feishu';
191
198
  default: return typeof v === 'string' ? v : '';
192
199
  }
@@ -204,15 +211,30 @@ export function normalize(id, cfg) {
204
211
  function decodeStored(id, stored) {
205
212
  const desc = DESCRIPTORS[id];
206
213
  const out = {};
207
- for (const f of desc.fields) out[f.key] = decodeField(f.type, stored ? stored[f.key] : undefined, f.default);
214
+ for (const f of desc.fields) {
215
+ // No on-disk entry for this field → its default. For a secret that ALSO means "don't
216
+ // consult the vault": a stray vault entry must not resurrect a secret for a platform the
217
+ // prefs say nothing about (e.g. after preferences.json was wiped while credentials.json
218
+ // survived). Only a field actually present on disk resolves through the vault.
219
+ if (!stored || typeof stored !== 'object' || !(f.key in stored)) {
220
+ out[f.key] = normField(f.type, undefined, f.default);
221
+ continue;
222
+ }
223
+ out[f.key] = decodeField(id, f, stored[f.key]);
224
+ }
208
225
  return out;
209
226
  }
210
227
 
211
- function encodeForDisk(id, n) {
228
+ // On-disk shape for preferences.json[platform]: cred fields stay base64; a secret field is
229
+ // CLEARED (empty string) when the vault write succeeded, or kept as base64 when it failed (so
230
+ // the secret is not lost). `cleared` maps fieldKey → bool per secret field.
231
+ function encodeForDisk(id, n, cleared = {}) {
212
232
  const desc = DESCRIPTORS[id];
213
233
  const out = {};
214
234
  for (const f of desc.fields) {
215
- out[f.key] = (f.type === 'cred' || f.type === 'secret') ? encodeSecret(n[f.key]) : n[f.key];
235
+ if (f.type === 'secret') out[f.key] = cleared[f.key] ? '' : encodeSecret(n[f.key]);
236
+ else if (f.type === 'cred') out[f.key] = encodeSecret(n[f.key]);
237
+ else out[f.key] = n[f.key];
216
238
  }
217
239
  return out;
218
240
  }
@@ -240,22 +262,50 @@ export function loadState(id) {
240
262
 
241
263
  /**
242
264
  * Persist a platform's config (read-merge-write, preserving all other prefs and other
243
- * platforms). If a secret field is empty AND a secret is already stored, the existing
244
- * secret is PRESERVED (lets the admin edit other fields without re-typing the secret).
245
- * To remove the secret, disable the bridge. Stored base64; returns the in-memory
246
- * (plaintext) normalized shape.
265
+ * platforms). Secret fields are written to the credential vault; if a secret field is empty
266
+ * AND a secret is already stored (in the vault), the existing secret is PRESERVED (lets the
267
+ * admin edit other fields without re-typing the secret). To remove the secret, disable the
268
+ * bridge. cred fields stay base64 in preferences.json. Returns the in-memory (plaintext)
269
+ * normalized shape.
247
270
  */
248
271
  export function saveConfig(id, cfg) {
249
272
  const desc = DESCRIPTORS[id];
250
- const prefs = readPrefs();
251
273
  const normalized = normalize(id, cfg);
274
+ // Resolve each secret field's EFFECTIVE plaintext BEFORE writing. An empty field means "keep
275
+ // the stored one". The stored value may live in the VAULT and/or (pre-migration) in the legacy
276
+ // base64 preferences field — try the vault first, then the legacy field, so an unreadable vault
277
+ // (lost key / corrupt) never resolves a still-present legacy secret to empty and wipes it.
278
+ const legacyPlain = {};
279
+ const vaultUnreadableNoFallback = new Set();
280
+ mutateJsonSync(getPrefsPath(), (prefs) => {
281
+ const storedCfg = prefs[desc.prefKey];
282
+ for (const f of desc.fields) {
283
+ if (f.type !== 'secret') continue;
284
+ const ref = secretRef(id, f.key);
285
+ legacyPlain[f.key] = decodeSecret(storedCfg && storedCfg[f.key]);
286
+ if (!normalized[f.key]) {
287
+ const { value, unreadable } = readSecretOr('im-secret', ref, legacyPlain[f.key]);
288
+ if (value) normalized[f.key] = value;
289
+ else if (legacyPlain[f.key]) normalized[f.key] = legacyPlain[f.key];
290
+ else if (unreadable) vaultUnreadableNoFallback.add(f.key); // vault unreadable & nothing on disk
291
+ }
292
+ }
293
+ }, { mode: 0o600 });
294
+ // Write each resolved secret to the vault. A field whose vault write failed, or whose vault is
295
+ // unreadable with no legacy fallback, KEEPS its legacy base64 in preferences.json (secret not
296
+ // lost); only a field safely written to the vault is cleared from preferences.json.
297
+ const cleared = {};
252
298
  for (const f of desc.fields) {
253
- if (f.type === 'secret' && !normalized[f.key]) {
254
- const existing = decodeSecret(prefs[desc.prefKey] && prefs[desc.prefKey][f.key]);
255
- if (existing) normalized[f.key] = existing;
299
+ if (f.type !== 'secret') continue;
300
+ const ref = secretRef(id, f.key);
301
+ if (vaultUnreadableNoFallback.has(f.key)) {
302
+ cleared[f.key] = false; // vault unreadable & no legacy copy → keep whatever is on disk
303
+ } else {
304
+ cleared[f.key] = normalized[f.key] ? writeSecret('im-secret', ref, normalized[f.key]) : true;
256
305
  }
257
306
  }
258
- prefs[desc.prefKey] = encodeForDisk(id, normalized);
259
- writePrefs(prefs);
307
+ mutateJsonSync(getPrefsPath(), (prefs) => {
308
+ prefs[desc.prefKey] = encodeForDisk(id, normalized, cleared);
309
+ }, { mode: 0o600 });
260
310
  return normalized;
261
311
  }
@@ -8,16 +8,25 @@
8
8
  // 注意:worker 的工作目录在 ~/.claude/cc-viewer/IM_<id>/ 下,因此**不能**整体封禁 ~/.claude,
9
9
  // 只精确保护其中的全局 settings/hooks 与 preferences.json(IM 密钥),其余留给 worker 正常读写。
10
10
  import os from 'node:os';
11
- import { resolve } from 'node:path';
11
+ import { resolve, basename } from 'node:path';
12
12
 
13
13
  // 凭证目录:读 + 写都拒(含密钥/令牌)。
14
- const CRED_DIRS = ['.ssh', '.aws', '.gnupg', '.kube', '.docker', '.config/gcloud'];
14
+ const CRED_DIRS = ['.ssh', '.aws', '.gnupg', '.kube', '.docker', '.config/gcloud', '.claude/cc-viewer-config-backups'];
15
15
  // 家目录下的 shell 启动文件:写拒(被改写可植入持久化)。
16
16
  const WRITE_HOME_FILES = ['.bashrc', '.zshrc', '.bash_profile', '.zprofile', '.zshenv', '.profile', '.npmrc', '.netrc'];
17
- // 精确文件:写拒(保护 deny 机制本身与 IM 密钥)。相对家目录。
18
- const WRITE_REL_PATHS = ['.claude/settings.json', '.claude/settings.local.json', '.claude/cc-viewer/preferences.json'];
19
- // 精确文件:读拒(含令牌/密钥)。相对家目录。
20
- const READ_REL_PATHS = ['.npmrc', '.netrc', '.claude/cc-viewer/preferences.json'];
17
+ // Global settings/hooks: write-deny (protects the deny mechanism itself). Resolved against ~.
18
+ const WRITE_REL_PATHS = ['.claude/settings.json', '.claude/settings.local.json', '.claude/cc-viewer/preferences.json', '.claude/cc-viewer/credentials.json', '.claude/cc-viewer/master.key'];
19
+ // Token/secret files under home: read-deny. Resolved against ~.
20
+ const READ_REL_PATHS = ['.npmrc', '.netrc', '.claude/cc-viewer/preferences.json', '.claude/cc-viewer/credentials.json', '.claude/cc-viewer/master.key'];
21
+
22
+ // Vault file names (bare-name match, any directory): LOG_DIR can be moved by --log-dir /
23
+ // POST /api/preferences {logDir}, so hardcoding ~/.claude/cc-viewer would lose the vault whenever
24
+ // the root moves. Match by file name so it stays denied regardless of root. Case-insensitive and
25
+ // EXACT (does NOT match .bak/.old/.txt suffix variants — those are covered by the Bash-layer /i
26
+ // regex and the backup-dir rule); basename grabs the last segment across POSIX/win32 (pathOf's
27
+ // resolve would make lastIndexOf('/') miss on win32 backslashes).
28
+ const VAULT_FILE_RE = /^(credentials\.json|master\.key)$/i;
29
+ function isVaultFile(abs) { return VAULT_FILE_RE.test(basename(abs)); }
21
30
 
22
31
  // Bash 命令硬拦截规则。每条 { re, reason }。
23
32
  const BASH_DENY_RULES = [
@@ -43,8 +52,11 @@ const BASH_DENY_RULES = [
43
52
  { re: /\b(curl|wget)\b[^\n]*\s-{1,2}(d|data|data-binary|data-raw|post-file|F|form|T|upload-file)\b/i, reason: 'outbound data upload (exfil risk)' },
44
53
  { re: /\b(curl|wget)\b[^\n]*@\//i, reason: 'outbound file upload (exfil risk)' },
45
54
  // 凭证 / 密钥文件访问(Bash 层;与下面 Read/Write 路径层互为补充——cat 等会绕过路径层)。
46
- // 覆盖 SSH/AWS/GnuPG/k8s/docker/gcloud/gh/npm/netrc + cc-viewer 自身的 IM 密钥库 preferences.json + 全局 settings。
47
- { re: /(id_rsa|id_ed25519|id_ecdsa|authorized_keys|\.ssh\/|\.aws\/|\.gnupg\/|\.kube\/|\.docker\/|\.config\/(gcloud|gh)\/|\.netrc|\.npmrc|cc-viewer\/preferences\.json|\.claude\/settings(\.local)?\.json)\b/i, reason: 'access to credential / secret files' },
55
+ // 覆盖 SSH/AWS/GnuPG/k8s/docker/gcloud/gh/npm/netrc + cc-viewer 自身的 IM 密钥库 preferences.json
56
+ // + 凭证 vault(credentials.json/master.key,含 cc-viewer-config-backups 备份目录) + 全局 settings。
57
+ // credentials.json/master.key 用裸文件名匹配(不限 cc-viewer/ 前缀),并含 cred*/master* glob 形式,
58
+ // 防止经备份目录、相对路径或 shell glob(cred*.json master*)绕过——IM 通道宁可拦多。
59
+ { re: /(id_rsa|id_ed25519|id_ecdsa|authorized_keys|\.ssh\/|\.aws\/|\.gnupg\/|\.kube\/|\.docker\/|\.config\/(gcloud|gh)\/|\.netrc|\.npmrc|cc-viewer\/preferences\.json|credentials\.json|master\.key|cc-viewer-config-backups|\.claude\/settings(\.local)?\.json)\b|(cred\*|master\*)/i, reason: 'access to credential / secret files' },
48
60
  ];
49
61
 
50
62
  function underAny(absPath, roots) {
@@ -82,6 +94,7 @@ export function evaluateImDeny(toolName, toolInput = {}, opts = {}) {
82
94
  if (toolName === 'Read') {
83
95
  const abs = pathOf(toolInput, home);
84
96
  if (!abs) return { deny: false };
97
+ if (isVaultFile(abs)) return { deny: true, reason: 'read of the credential vault / master key' };
85
98
  if (underAny(abs, credRoots)) return { deny: true, reason: 'read of a credential directory' };
86
99
  if (READ_REL_PATHS.some((rel) => abs === resolve(home, rel))) return { deny: true, reason: 'read of a secret/credential file' };
87
100
  return { deny: false };
@@ -90,6 +103,7 @@ export function evaluateImDeny(toolName, toolInput = {}, opts = {}) {
90
103
  if (toolName === 'Edit' || toolName === 'Write' || toolName === 'NotebookEdit') {
91
104
  const abs = pathOf(toolInput, home);
92
105
  if (!abs) return { deny: false };
106
+ if (isVaultFile(abs)) return { deny: true, reason: 'write to the credential vault / master key' };
93
107
  if (underAny(abs, credRoots)) return { deny: true, reason: 'write to a credential directory' };
94
108
  if (WRITE_HOME_FILES.some((f) => abs === resolve(home, f))) return { deny: true, reason: 'write to a shell startup / credential file' };
95
109
  if (WRITE_REL_PATHS.some((rel) => abs === resolve(home, rel))) return { deny: true, reason: 'write to protected global config (settings/hooks or IM secrets)' };
@@ -0,0 +1,264 @@
1
+ // Unified JSON store kernel — one locked + atomic read/write path per config file.
2
+ //
3
+ // cc-viewer historically copy-pasted the same "read → mutate → tmp→rename" block across
4
+ // prefs-store / auth / im-config / workspace-registry / ask-store / session-pin-store, with
5
+ // divergent lock and permission handling (and a few writers that were neither locked nor
6
+ // atomic — see lib/im/im-config.js). This module extracts that pattern ONCE so the lock and
7
+ // the 0600 policy live in a single place. It is the prerequisite for any consistent local
8
+ // state (and, later, for cloud sync): a file cannot be projected or compared unless every
9
+ // writer goes through the same locked, atomic path.
10
+ //
11
+ // Boundary: L1-lib. Imports only node builtins + the two L0-leaf primitives
12
+ // (file-api.renameSyncWithRetry, async-file-lock.withFileLockAsync) + @ccv/core/error-report.
13
+ // It deliberately does NOT import LOG_DIR — callers inject the file path so the kernel stays
14
+ // a pure, reusable primitive with no data-root coupling.
15
+ //
16
+ // Two lock flavors are provided because callers split into two camps:
17
+ // - mutateJson — async lock (withFileLockAsync), for async mutators.
18
+ // - mutateJsonSync — synchronous spin lock (openSync('wx')), for the many existing SYNC
19
+ // writers (auth.js, im-config.js) that cannot become async without
20
+ // cascading signature changes through routes and server startup.
21
+ // Both serialize same-process callers and mutex cross-process on the same `${file}.lock`.
22
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync, openSync, closeSync, statSync } from 'node:fs';
23
+ import { dirname } from 'node:path';
24
+ import { randomBytes } from 'node:crypto';
25
+ import { renameSyncWithRetry } from './file-api.js';
26
+ import { withFileLockAsync, hasLiveDiskHolder } from './async-file-lock.js';
27
+ import { reportSwallowed } from '@ccv/core/error-report';
28
+
29
+ /** Lock file lives next to the data file, derived from ITS name (not a fixed basename), so two
30
+ * different files in the same directory never share a lock by accident. */
31
+ export function lockPathFor(file) { return `${file}.lock`; }
32
+
33
+ /**
34
+ * Tolerant JSON read. Missing / corrupt / non-object (when fallback is a plain object) → fallback.
35
+ * Arrays are accepted when the fallback is an array; a scalar never satisfies an object fallback.
36
+ */
37
+ export function readJsonSafe(file, fallback = {}) {
38
+ try {
39
+ if (!existsSync(file)) return fallback;
40
+ const obj = JSON.parse(readFileSync(file, 'utf-8'));
41
+ if (obj === null || typeof obj !== 'object') return fallback;
42
+ // Shape guard: an array fallback accepts only an array; a plain-object fallback rejects one.
43
+ if (Array.isArray(fallback) !== Array.isArray(obj)) return fallback;
44
+ return obj;
45
+ } catch {
46
+ return fallback;
47
+ }
48
+ }
49
+
50
+ // Sentinel for "file exists but cannot be read/parsed". A MUTATE path can choose how to treat it:
51
+ // - DEFAULT (tolerant): collapse to `fallback` and write, so a corrupt preferences.json stays
52
+ // self-healing (the long-standing L66 behavior — a user re-saving recovers the file).
53
+ // - strictCorrupt:true: refuse to write, preserving the corrupt bytes — for files whose loss is
54
+ // unacceptable (e.g. profile.json, where a {} overwrite would drop every profile).
55
+ const CORRUPT = Symbol('json-store.corrupt');
56
+ function _readForMutation(file, fallback) {
57
+ if (!existsSync(file)) return fallback; // genuinely absent → seeding is fine
58
+ try {
59
+ const obj = JSON.parse(readFileSync(file, 'utf-8'));
60
+ if (obj === null || typeof obj !== 'object') return CORRUPT;
61
+ if (Array.isArray(fallback) !== Array.isArray(obj)) return CORRUPT;
62
+ return obj;
63
+ } catch {
64
+ return CORRUPT;
65
+ }
66
+ }
67
+
68
+ function _resolveForMutation(data, file, strictCorrupt) {
69
+ if (data !== CORRUPT) return data;
70
+ if (strictCorrupt) {
71
+ const err = new Error(`json-store: refusing to overwrite corrupt/unreadable file ${file}`);
72
+ err.code = 'JSON_STORE_CORRUPT';
73
+ reportSwallowed('json-store.corrupt-guard', err);
74
+ throw err;
75
+ }
76
+ return undefined; // tolerant: signal "use the fallback"
77
+ }
78
+
79
+ /**
80
+ * Atomic write (tmp + renameSyncWithRetry). `mode` defaults to 0600 (the file may carry secrets);
81
+ * pass `mode: false` to skip the permission bit entirely (non-secret stores under umask).
82
+ * Serialization: `pretty` (2-space, the prefs convention) or compact.
83
+ */
84
+ export function writeJsonAtomic(file, data, { mode = 0o600, pretty = true } = {}) {
85
+ mkdirSync(dirname(file), { recursive: true });
86
+ const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
87
+ try {
88
+ writeFileSync(tmp, pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data), mode ? { mode } : undefined);
89
+ renameSyncWithRetry(tmp, file);
90
+ if (mode) {
91
+ // writeFileSync's mode only applies on creation; re-assert on a pre-existing file.
92
+ try { chmodSync(file, mode); } catch { /* best-effort; non-POSIX or race */ }
93
+ }
94
+ } catch (err) {
95
+ try { unlinkSync(tmp); } catch {}
96
+ throw err;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Locked read-modify-write (async). Reads inside the lock, runs `mutator(data)` (mutate in
102
+ * place; may be async), atomically writes, returns the mutator's value when defined else data.
103
+ */
104
+ export async function mutateJson(file, mutator, { mode = 0o600, pretty = true, fallback = {}, ensureDir, strictCorrupt = false } = {}) {
105
+ return withFileLockAsync(lockPathFor(file), async () => {
106
+ const resolved = _resolveForMutation(_readForMutation(file, fallback), file, strictCorrupt);
107
+ const data = resolved === undefined ? fallback : resolved;
108
+ const result = await mutator(data);
109
+ writeJsonAtomic(file, data, { mode, pretty });
110
+ return result !== undefined ? result : data;
111
+ }, { ensureDir: ensureDir ?? dirname(file) });
112
+ }
113
+
114
+ /**
115
+ * Lock-only primitive: holds the file's `${file}.lock` around `fn()` WITHOUT doing any read
116
+ * or write itself. For stores whose on-disk shape differs from their in-memory shape (e.g.
117
+ * ask-store's {version, entries} wrap) and therefore need to run their own domain read/save
118
+ * inside the mutex rather than the kernel's read→mutate→write. Shares the same lock as
119
+ * mutateJson / mutateJsonSync for the file.
120
+ */
121
+ export async function withJsonLock(file, fn, { ensureDir } = {}) {
122
+ return withFileLockAsync(lockPathFor(file), fn, { ensureDir: ensureDir ?? dirname(file) });
123
+ }
124
+
125
+ // ─── Synchronous spin lock ───
126
+ // Mirrors async-file-lock's two-tier stale detection (dead PID or aged mtime) but with
127
+ // openSync('wx') + a blocking Atomics.wait so sync callers can hold the SAME `${file}.lock`.
128
+ // Same-process serialization is inherent (JS is single-threaded; a sync critical section has
129
+ // no interleaving). Cross-process mutual exclusion comes from the lock file.
130
+
131
+ function _isPidAlive(pid) {
132
+ if (!Number.isInteger(pid) || pid <= 0) return false;
133
+ try { process.kill(pid, 0); return true; } catch (err) { return err && err.code === 'EPERM'; }
134
+ }
135
+
136
+ function _readLockOwnerPid(path) {
137
+ try {
138
+ const raw = readFileSync(path, 'utf-8');
139
+ if (!raw) return null;
140
+ const obj = JSON.parse(raw);
141
+ if (obj && Number.isInteger(obj.pid)) return obj.pid;
142
+ } catch {}
143
+ return null;
144
+ }
145
+
146
+ function _isLockStale(path, mtimeFallbackMs) {
147
+ const pid = _readLockOwnerPid(path);
148
+ if (pid !== null) {
149
+ if (pid === process.pid) {
150
+ // Own pid is a crash leftover ONLY when this process does not currently hold the disk lock
151
+ // via the async flavor. An async holder (mutateJson / withJsonLock / mutatePrefs) is alive
152
+ // and mid-critical-section right now — it yields the JS thread at every await, so this sync
153
+ // caller can observe its lock; stealing it would let both writers commit and lose an update.
154
+ if (hasLiveDiskHolder(path)) return false;
155
+ return true;
156
+ }
157
+ return !_isPidAlive(pid);
158
+ }
159
+ try {
160
+ const stats = statSync(path);
161
+ return Date.now() - stats.mtimeMs > mtimeFallbackMs;
162
+ } catch {
163
+ return false; // conservative: cannot stat → assume held
164
+ }
165
+ }
166
+
167
+ function _blockingSleep(ms) {
168
+ try {
169
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
170
+ } catch { /* Atomics.wait unavailable on the main thread in some embedders — best-effort */ }
171
+ }
172
+
173
+ // Returns true when the lock was acquired, false on timeout (caller decides how to degrade).
174
+ // When the lock is held by THIS process's async flavor (mutateJson / withJsonLock / mutatePrefs),
175
+ // the sync caller must NOT spin-then-degrade: the async holder is alive and mid-critical-section,
176
+ // and a sync write issued from inside that critical section would alias the SAME in-memory object
177
+ // the async holder is about to write back — so the sync update is silently lost (the exact failure
178
+ // this kernel exists to prevent). That reentrancy is a programming error, not a race to ride out:
179
+ // throw so the caller fails loudly instead of corrupting state. Foreign-process contention still
180
+ // degrades per the caller's timeout policy.
181
+ function _acquireLockSync(lockPath, { deadline = 2000, retryMs = 25, staleThresholdMs = 5000 } = {}) {
182
+ if (hasLiveDiskHolder(lockPath)) {
183
+ const err = new Error(`mutateJsonSync re-entered a lock held by this process's async flavor: ${lockPath}`);
184
+ err.code = 'JSON_STORE_REENTRANT';
185
+ reportSwallowed('json-store.reentrant-sync', err);
186
+ throw err;
187
+ }
188
+ const deadlineAt = Date.now() + deadline;
189
+ while (true) {
190
+ let fd;
191
+ try {
192
+ fd = openSync(lockPath, 'wx');
193
+ try { writeFileSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() })); } finally { closeSync(fd); }
194
+ return true;
195
+ } catch (err) {
196
+ if (err?.code === 'EEXIST') {
197
+ if (Date.now() < deadlineAt) {
198
+ if (_isLockStale(lockPath, staleThresholdMs)) {
199
+ try { unlinkSync(lockPath); } catch {}
200
+ continue;
201
+ }
202
+ _blockingSleep(retryMs);
203
+ continue;
204
+ }
205
+ if (_isLockStale(lockPath, staleThresholdMs)) {
206
+ try { unlinkSync(lockPath); } catch {}
207
+ continue;
208
+ }
209
+ return false; // timeout — caller degrades instead of crashing
210
+ }
211
+ throw err;
212
+ }
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Synchronous locked read-modify-write, for the existing SYNC writers (auth.js, im-config.js).
218
+ * Holds the SAME `${file}.lock` as mutateJson so a sync writer and an async writer of the same
219
+ * file still mutex cross-process. `mutator` must be synchronous (no await).
220
+ *
221
+ * Timeout / reentrancy policy:
222
+ * - Foreign process holds the lock and it cannot be acquired in time: DEGRADE to an unlocked
223
+ * atomic write rather than throwing — a 2s freeze plus an uncaught throw out of a route
224
+ * callback would kill the whole server. The atomic write still can't tear the file; the
225
+ * residual lost-update window is the pre-kernel status quo and is reported so it's visible.
226
+ * Pass { strict: true } to throw instead.
227
+ * - THIS process's async flavor holds the lock (reentrant sync call from inside an async
228
+ * critical section): THROW (JSON_STORE_REENTRANT). That write would alias the async holder's
229
+ * in-memory object and be silently overwritten — a fail-closed throw is the only safe answer.
230
+ */
231
+ export function mutateJsonSync(file, mutator, { mode = 0o600, pretty = true, fallback = {}, ensureDir, deadline, strict = false, strictCorrupt = false } = {}) {
232
+ const dir = ensureDir ?? dirname(file);
233
+ try { mkdirSync(dir, { recursive: true }); } catch {}
234
+ const lockPath = lockPathFor(file);
235
+ const acquired = _acquireLockSync(lockPath, deadline ? { deadline } : {});
236
+ if (!acquired && strict) {
237
+ throw new Error(`Lock acquisition timeout: ${lockPath} (held by live process)`);
238
+ }
239
+ if (!acquired) {
240
+ reportSwallowed('json-store.lock-timeout-degraded', new Error(`mutateJsonSync degraded to unlocked write: ${file}`));
241
+ }
242
+ try {
243
+ const resolved = _resolveForMutation(_readForMutation(file, fallback), file, strictCorrupt);
244
+ const data = resolved === undefined ? fallback : resolved;
245
+ const result = mutator(data);
246
+ writeJsonAtomic(file, data, { mode, pretty });
247
+ return result !== undefined ? result : data;
248
+ } finally {
249
+ if (acquired) {
250
+ try { unlinkSync(lockPath); } catch (err) { reportSwallowed('json-store.lock-release', err); }
251
+ }
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Generic shallow merge of a patch onto `target` IN PLACE. Domain-specific merges (e.g.
257
+ * approvalModal reconciliation in prefs-store) stay in the per-store layer — the kernel only
258
+ * provides the plain-object assign so every store doesn't re-hand-roll it.
259
+ */
260
+ export function applyJsonPatch(target, patch) {
261
+ if (!patch || typeof patch !== 'object') return target;
262
+ Object.assign(target, patch);
263
+ return target;
264
+ }
@@ -7,11 +7,8 @@
7
7
  // callers serialize via withFileLockAsync's per-lockPath Promise chain; cross-process
8
8
  // callers mutex on the lock file. This prevents a concurrent writer from clobbering the
9
9
  // password-bearing file or losing a fork update.
10
- import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync } from 'node:fs';
11
10
  import { join, dirname } from 'node:path';
12
- import { randomBytes } from 'node:crypto';
13
- import { renameSyncWithRetry } from './file-api.js';
14
- import { withFileLockAsync } from './async-file-lock.js';
11
+ import { mutateJson, readJsonSafe, writeJsonAtomic } from './json-store.js';
15
12
  import { mergeApprovalModalPrefs } from '@ccv/core/approval-modal-prefs';
16
13
  import { reconcileVoicePackPrefs } from './voice-pack-manager.js';
17
14
  import { LOG_DIR } from '../../findcc.js';
@@ -22,46 +19,24 @@ import { LOG_DIR } from '../../findcc.js';
22
19
  // override on the helpers below exists only so the preferences route can forward its
23
20
  // deps.getPrefsFile() seam (used by branch tests) and stay symmetric with the GET read.
24
21
  export function getPrefsFile() { return join(LOG_DIR, 'preferences.json'); }
25
- // Lock lives next to the file so every writer of the SAME preferences.json shares one lock
26
- // (global POST, fork ops, …). In prod all paths are canonical ⇒ one lock ⇒ serialized.
27
- function getPrefsLock(file) { return join(dirname(file), 'preferences.lock'); }
28
22
 
29
23
  /** Read the raw on-disk prefs object (no stripping, no virtual defaults). {} on miss/corrupt. */
30
24
  export function readPrefsRaw(file = getPrefsFile()) {
31
- try {
32
- if (!existsSync(file)) return {};
33
- const obj = JSON.parse(readFileSync(file, 'utf-8'));
34
- return obj && typeof obj === 'object' ? obj : {};
35
- } catch { return {}; }
36
- }
37
-
38
- /** Atomic write (tmp + rename) with 0600 — the file may carry the base64 password. */
39
- function writePrefsAtomic(prefs, file) {
40
- mkdirSync(dirname(file), { recursive: true });
41
- const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
42
- try {
43
- writeFileSync(tmp, JSON.stringify(prefs, null, 2), { mode: 0o600 });
44
- renameSyncWithRetry(tmp, file);
45
- // writeFileSync's mode only applies on creation; re-assert 0600 on a pre-existing file.
46
- try { chmodSync(file, 0o600); } catch { /* best-effort; non-POSIX or race */ }
47
- } catch (err) {
48
- try { unlinkSync(tmp); } catch {}
49
- throw err;
50
- }
25
+ return readJsonSafe(file, {});
51
26
  }
52
27
 
53
28
  /**
54
29
  * Locked read-modify-write. Reads the raw prefs inside the lock, runs mutator(prefs)
55
30
  * (mutate in place; may be async), atomically writes, and returns the mutator's return
56
31
  * value when defined, else the mutated prefs object. `file` defaults to the canonical path.
32
+ *
33
+ * Delegates to the unified json-store kernel (mutateJson): one async file lock derived from
34
+ * the file name + atomic tmp→rename at 0600. The lock is shared with the SYNC writers of the
35
+ * same file (auth.js / im-config.js via mutateJsonSync), so all preferences.json writers now
36
+ * mutex on the same `preferences.json.lock`.
57
37
  */
58
38
  export async function mutatePrefs(mutator, file = getPrefsFile()) {
59
- return withFileLockAsync(getPrefsLock(file), async () => {
60
- const prefs = readPrefsRaw(file);
61
- const result = await mutator(prefs);
62
- writePrefsAtomic(prefs, file);
63
- return result !== undefined ? result : prefs;
64
- }, { ensureDir: dirname(file) });
39
+ return mutateJson(file, mutator, { mode: 0o600, fallback: {}, ensureDir: dirname(file) });
65
40
  }
66
41
 
67
42
  /**
@@ -8,10 +8,9 @@
8
8
  // '' means no active project (workspace mode not yet launched) → read = null / write = no-op.
9
9
  // Writes are atomic (tmp + rename) so a torn read can't happen when several writers race
10
10
  // (mirrors server/lib/prefs-store.js).
11
- import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
11
+ import { existsSync, readFileSync, unlinkSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
- import { randomBytes } from 'node:crypto';
14
- import { renameSyncWithRetry } from './file-api.js';
13
+ import { writeJsonAtomic } from './json-store.js';
15
14
 
16
15
  /** Pin file path inside the project dir: `.session-pin.json`. */
17
16
  export function pinFilePath(logDir) {
@@ -32,8 +31,10 @@ export function readPin(logDir) {
32
31
 
33
32
  /**
34
33
  * Write (or clear) the pinned session id. No project (logDir = '') → no-op, returns false.
35
- * A null/empty pinnedSessionId deletes the file (back to "show latest"). Atomic tmp+rename.
36
- * Returns true on success, false on no-project / write failure (view state is best-effort).
34
+ * A null/empty pinnedSessionId deletes the file (back to "show latest"). Atomic via the
35
+ * json-store kernel (tmp + rename). Non-secret → mode:false (umask); the pin is a view
36
+ * preference, not a credential. Returns true on success, false on no-project / write
37
+ * failure (view state is best-effort).
37
38
  */
38
39
  export function writePin(logDir, pinnedSessionId) {
39
40
  if (!logDir) return false;
@@ -44,15 +45,7 @@ export function writePin(logDir, pinnedSessionId) {
44
45
  try { unlinkSync(file); } catch { /* already absent */ }
45
46
  return true;
46
47
  }
47
- mkdirSync(logDir, { recursive: true });
48
- const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
49
- try {
50
- writeFileSync(tmp, JSON.stringify({ pinnedSessionId: id }));
51
- renameSyncWithRetry(tmp, file);
52
- } catch (err) {
53
- try { unlinkSync(tmp); } catch {}
54
- throw err;
55
- }
48
+ writeJsonAtomic(file, { pinnedSessionId: id }, { mode: false, pretty: false });
56
49
  return true;
57
50
  } catch { return false; }
58
51
  }
@@ -353,13 +353,19 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
353
353
  'Bash(git push:*)', 'Bash(npm publish:*)', 'Bash(ssh:*)', 'Bash(scp:*)',
354
354
  `Read(${home}/.ssh/**)`, `Edit(${home}/.ssh/**)`, `Write(${home}/.ssh/**)`,
355
355
  `Read(${home}/.aws/**)`, `Edit(${home}/.aws/**)`, `Write(${home}/.aws/**)`,
356
- // File-precise: protect the deny mechanism itself (settings/hooks) and the IM
357
- // credential store (preferences.json), but do not block all of ~/.claude — the
358
- // worker's working directory sits under ~/.claude/cc-viewer/IM_<id>/ and must stay
359
- // writable.
356
+ // File-precise: protect the deny mechanism itself (settings/hooks), the IM
357
+ // credential store (preferences.json), and the credential vault (credentials.json +
358
+ // master.key), but do not block all of ~/.claude — the worker's working directory
359
+ // sits under ~/.claude/cc-viewer/IM_<id>/ and must stay writable.
360
360
  `Edit(${home}/.claude/settings.json)`, `Write(${home}/.claude/settings.json)`,
361
361
  `Edit(${home}/.claude/settings.local.json)`, `Write(${home}/.claude/settings.local.json)`,
362
362
  `Edit(${home}/.claude/cc-viewer/preferences.json)`, `Write(${home}/.claude/cc-viewer/preferences.json)`,
363
+ `Read(${home}/.claude/cc-viewer/credentials.json)`, `Edit(${home}/.claude/cc-viewer/credentials.json)`, `Write(${home}/.claude/cc-viewer/credentials.json)`,
364
+ `Read(${home}/.claude/cc-viewer/master.key)`, `Edit(${home}/.claude/cc-viewer/master.key)`, `Write(${home}/.claude/cc-viewer/master.key)`,
365
+ // Backup copies of the vault (cc-viewer-config-backups/<ts>/) hold the same decryption
366
+ // kit (credentials.json + master.key) — deny the whole subtree or an IM worker could read
367
+ // a rolled backup and decrypt every secret.
368
+ `Read(${home}/.claude/cc-viewer-config-backups/**)`, `Edit(${home}/.claude/cc-viewer-config-backups/**)`, `Write(${home}/.claude/cc-viewer-config-backups/**)`,
363
369
  ],
364
370
  };
365
371
  }