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
|
@@ -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
|
|
15
|
-
import { join
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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(
|
|
184
|
-
switch (type) {
|
|
185
|
-
case '
|
|
186
|
-
|
|
187
|
-
|
|
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,
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
244
|
-
* secret is
|
|
245
|
-
*
|
|
246
|
-
* (plaintext)
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
259
|
-
|
|
307
|
+
mutateJsonSync(getPrefsPath(), (prefs) => {
|
|
308
|
+
prefs[desc.prefKey] = encodeForDisk(id, normalized, cleared);
|
|
309
|
+
}, { mode: 0o600 });
|
|
260
310
|
return normalized;
|
|
261
311
|
}
|
package/server/lib/im-deny.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
47
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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,
|
|
11
|
+
import { existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
-
import {
|
|
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
|
|
36
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/server/pty-manager.js
CHANGED
|
@@ -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)
|
|
357
|
-
// credential store (preferences.json),
|
|
358
|
-
//
|
|
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
|
}
|