cc-viewer 1.8.19 → 1.8.21
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/cli.js +33 -4
- package/dist/assets/App-BmZyzd9Q.js +2 -0
- package/dist/assets/App-DOZI5HME.css +1 -0
- package/dist/assets/{MdxEditorPanel-DU_5CRtW.js → MdxEditorPanel-BHvoYOF-.js} +1 -1
- package/dist/assets/{MdxEditorPanel-Dpn-4ydO.css → MdxEditorPanel-DTfdj6GS.css} +1 -1
- package/dist/assets/Mobile-C10tOIQo.js +1 -0
- package/dist/assets/ProxyStatsModal-Do4qrHT7.js +1 -0
- package/dist/assets/{_baseUniq-B3e4JaL0.js → _baseUniq-CwQykUuc.js} +1 -1
- package/dist/assets/{arc-DnoPn_qY.js → arc-EdUdBxtG.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-DrPKTbxH.js → architectureDiagram-Q4EWVU46-DtBIdElY.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-CEaO0Vcw.js → blockDiagram-DXYQGD6D-guNZIKkZ.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-BLaawFeg.js → c4Diagram-AHTNJAMY-xV5vB-Z9.js} +1 -1
- package/dist/assets/{channel-DnYR1fF4.js → channel-DYEktNap.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-Bfd4edKZ.js → chunk-4BX2VUAB-ZTkItpvj.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-t2nMQmY7.js → chunk-4TB4RGXK-dYIewyyR.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-C6tLOjGX.js → chunk-55IACEB6-CSsPNgPX.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-dWCvxbCb.js → chunk-EDXVE4YY-CnMiuqzQ.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-BHELZLPK.js → chunk-FMBD7UC4-CVpATPZ3.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-C8XTkH1S.js → chunk-OYMX7WX6-Dpq4s8dW.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-CD0j6SAs.js → chunk-QZHKN3VN-Bnojh6X6.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-D4dKfXJJ.js → chunk-YZCP3GAM-BYqK4Dsa.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +1 -0
- package/dist/assets/clone-CFj55h49.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BF2HsiSD.js → cose-bilkent-S5V4N54A-OJ7mmWyV.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-f3h967yx.js → dagre-KV5264BT-B1Kwd_Rk.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-B9PzHQfh.js → diagram-5BDNPKRD-D8qrw8sp.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-kR953Bqm.js → diagram-G4DWMVQ6-ip-N4ho8.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-DAewa2eg.js → diagram-MMDJMWI5-2PZaskUF.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-C8HNTH6I.js → diagram-TYMM5635-CeAOQUZP.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-BQ5P8wWK.js → erDiagram-SMLLAGMA-BCBaEYEb.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-BIpEkrMR.js → flowDiagram-DWJPFMVM-CsGyIqcX.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CWqSfB5v.js → ganttDiagram-T4ZO3ILL-COsj3FZ-.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Ba-9yEbQ.js → gitGraphDiagram-UUTBAWPF-DvQVQGJG.js} +1 -1
- package/dist/assets/{graph-D3zXKQ6P.js → graph-BSB9fDSP.js} +1 -1
- package/dist/assets/{index-G3xd-gLo.js → index-BRw4Iz2O.js} +1 -1
- package/dist/assets/{index-DClD_EkN.js → index-Bo-12bug.js} +1 -1
- package/dist/assets/{index-DSd5P3WQ.js → index-BuVComcY.js} +1 -1
- package/dist/assets/{index-HLrnYsI1.js → index-CNNAYjkF.js} +1 -1
- package/dist/assets/{index-Bm3A5VLb.js → index-DKufsAzx.js} +1 -1
- package/dist/assets/{index-C9N0rZ7Z.js → index-DXBfwPYD.js} +1 -1
- package/dist/assets/index-DyVKoGOX.js +2 -0
- package/dist/assets/{index-Nvk8r3tO.css → index-Dz36p_bA.css} +1 -1
- package/dist/assets/{index-Q8wp4Nof.js → index-eM2ih5n7.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-Cxm79ZJR.js → infoDiagram-42DDH7IO-eNucS4iW.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-Cd0vZP1B.js → ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-B2b57HXk.js → journeyDiagram-VCZTEJTY-yJJzVEBb.js} +1 -1
- package/dist/assets/{jszip.min-Bj5uiEb2.js → jszip.min-ByI0ds5u.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-YCERiUCO.js → kanban-definition-6JOO6SKY-2rfTi2zw.js} +1 -1
- package/dist/assets/{layout-Do5TlO2U.js → layout-DTmHj0Wm.js} +1 -1
- package/dist/assets/{linear-BiJNTDgN.js → linear-BOA3a9FE.js} +1 -1
- package/dist/assets/{mermaid.core-Dg0McRZt.js → mermaid.core-5H-NR79H.js} +3 -3
- package/dist/assets/{min-T51iefht.js → min-CpjFTEec.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-Cu_GJxY3.js → mindmap-definition-QFDTVHPH-B8KCjFgB.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-DldHHZXi.js → pieDiagram-DEJITSTG-BARy96g5.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-BzyTPNAJ.js → quadrantDiagram-34T5L4WZ-DYoTapuI.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-C5xxFkvG.js → requirementDiagram-MS252O5E-EeANLLK2.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-OxdjoaE1.js → sankeyDiagram-XADWPNL6-DmQ-YVPj.js} +1 -1
- package/dist/assets/seqResourceLoaders-B-wmkeam.js +2 -0
- package/dist/assets/seqResourceLoaders-BdWMUSrC.css +41 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-C7c89uIS.js → sequenceDiagram-FGHM5R23-SZtW0rYC.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-ssdJnDrO.js → stateDiagram-FHFEXIEX-CEygsIld.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BP-0WClK.js → stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-sWScEpSM.js → timeline-definition-GMOUNBTQ-DhPBaQBt.js} +1 -1
- package/dist/assets/{vendor-antd-DKIytXho.js → vendor-antd-CS_S2mBN.js} +1 -1
- package/dist/assets/{vendor-codemirror-4qgXq03k.js → vendor-codemirror-B5l-VbBO.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-D5Z5D9fq.js → vendor-mdxeditor-Cz09a1_I.js} +2 -2
- package/dist/assets/{vendor-qrcode-B7lb5qk1.js → vendor-qrcode-qbbntD6-.js} +1 -1
- package/dist/assets/{vendor-virtuoso-BqoXlbhH.js → vendor-virtuoso-Sk8fQ4Hx.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-DNfXR0ge.js → vennDiagram-DHZGUBPP-NniTHxDw.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-JXd--CRD.js → wardley-RL74JXVD-D9SNOrDw.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-DHmNyVPM.js → wardleyDiagram-NUSXRM2D-DvHXKNza.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CTb0gltZ.js → xychartDiagram-5P7HB3ND-CaiwPTQB.js} +1 -1
- package/dist/index.html +5 -5
- package/package.json +1 -1
- package/server/interceptor.js +192 -24
- 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/chat-queue.js +67 -4
- 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/same-origin.js +22 -0
- package/server/lib/session-pin-store.js +7 -14
- package/server/lib/v2/convert.js +1 -1
- package/server/lib/v2/layout.js +28 -3
- package/server/lib/v2/live-feed.js +41 -4
- package/server/lib/v2/resume-list.js +150 -0
- package/server/lib/v2/session-select.js +63 -14
- package/server/lib/v2/v2-writer.js +172 -34
- package/server/lib/v2/view-router.js +83 -0
- package/server/lib/view-root.js +209 -0
- package/server/pty-manager.js +800 -163
- package/server/routes/ask-perm.js +21 -1
- package/server/routes/auth.js +21 -1
- package/server/routes/events.js +123 -7
- package/server/routes/expert.js +46 -14
- package/server/routes/files-content.js +72 -7
- package/server/routes/files-fs.js +28 -3
- package/server/routes/git.js +31 -5
- package/server/routes/preferences.js +59 -46
- package/server/routes/project-meta.js +32 -3
- package/server/routes/proxy-stats.js +33 -5
- package/server/routes/resume.js +302 -0
- package/server/routes/search.js +23 -1
- package/server/routes/skills.js +18 -2
- package/server/routes/v2.js +6 -1
- package/server/routes/workspaces.js +10 -3
- package/server/server.js +199 -25
- package/server/system-prompt-templates/presets/kimi-k3.md +1 -1
- package/server/workspace-registry.js +41 -35
- package/dist/assets/App-C9Tx6Iqw.js +0 -2
- package/dist/assets/App-M61pqQEx.css +0 -1
- package/dist/assets/Mobile--EECC49-.js +0 -1
- package/dist/assets/ProxyStatsModal-DJu0xq3Z.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BecPAAuk.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-BecPAAuk.js +0 -1
- package/dist/assets/clone-B0_ghF3F.js +0 -1
- package/dist/assets/index-CJdRSHTP.js +0 -2
- package/dist/assets/seqResourceLoaders-BumAjQDW.css +0 -41
- package/dist/assets/seqResourceLoaders-C5UX6ecI.js +0 -2
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Credential vault — real at-rest encryption for cc-viewer's secrets.
|
|
2
|
+
//
|
|
3
|
+
// The legacy encodings were obfuscation, not security: profile.json held apiKeys in plaintext,
|
|
4
|
+
// and the LAN password / IM app secrets were base64 inside preferences.json (trivially reversible;
|
|
5
|
+
// the code comments said so themselves). This module upgrades them to AES-256-GCM with a
|
|
6
|
+
// per-record random IV and a machine-local master key.
|
|
7
|
+
//
|
|
8
|
+
// Key model: a single 256-bit `master.key` (0600) generated on first use and stored next to the
|
|
9
|
+
// data root. This is the standard trade-off for a headless background server (no interactive
|
|
10
|
+
// passphrase prompt, no OS-keychain native dep). At-rest on a single machine it is inherently
|
|
11
|
+
// bounded — anyone who reads BOTH master.key and the ciphertext can decrypt — but it raises the
|
|
12
|
+
// bar from "plaintext / base64 in a world-readable-shape file" to "needs the key file too", and it
|
|
13
|
+
// is the prerequisite for any cloud handling (only ciphertext, never plaintext, may leave the
|
|
14
|
+
// machine; the master key never does).
|
|
15
|
+
//
|
|
16
|
+
// Boundary: L1-lib. Path-injected (no LOG_DIR import) so it stays a pure primitive.
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs';
|
|
18
|
+
import { dirname } from 'node:path';
|
|
19
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
20
|
+
|
|
21
|
+
const KEY_MODE = 0o600;
|
|
22
|
+
const IV_BYTES = 12; // GCM standard
|
|
23
|
+
const TAG_BYTES = 16; // GCM auth tag
|
|
24
|
+
const KEY_BYTES = 32; // AES-256
|
|
25
|
+
|
|
26
|
+
// Process-local cache so we don't re-read/re-stat the key file on every encrypt. Keyed by the
|
|
27
|
+
// master.key path so tests that redirect LOG_DIR get a fresh key per path.
|
|
28
|
+
const _keyCache = new Map();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Load (or lazily create) the master key at `keyPath`. Created with randomBytes(32) + 0600 on
|
|
32
|
+
* first use. Throws if the key file exists but is malformed (wrong length) — better to fail loud
|
|
33
|
+
* than silently re-derive and orphan existing ciphertext.
|
|
34
|
+
*/
|
|
35
|
+
export function loadMasterKey(keyPath) {
|
|
36
|
+
// Cache is only valid while the on-disk key still exists. If the key file was removed out
|
|
37
|
+
// from under us (a wipe of the data root, an OS cleanup), a stale cached key would let a
|
|
38
|
+
// writer re-encrypt with a key that no longer exists on disk — leaving ciphertext that a
|
|
39
|
+
// later cold process cannot read, and splitting state from _keyUsable()'s disk check.
|
|
40
|
+
if (_keyCache.has(keyPath) && existsSync(keyPath)) return _keyCache.get(keyPath);
|
|
41
|
+
_keyCache.delete(keyPath);
|
|
42
|
+
let key;
|
|
43
|
+
if (existsSync(keyPath)) {
|
|
44
|
+
const raw = readFileSync(keyPath);
|
|
45
|
+
// Stored raw (32 bytes). Tolerate a trailing newline for hand-inspectability.
|
|
46
|
+
const trimmed = raw.subarray(0, KEY_BYTES);
|
|
47
|
+
if (trimmed.length !== KEY_BYTES) {
|
|
48
|
+
throw new Error(`credential-vault: master key at ${keyPath} has ${trimmed.length} bytes, expected ${KEY_BYTES}`);
|
|
49
|
+
}
|
|
50
|
+
key = Buffer.from(trimmed);
|
|
51
|
+
} else {
|
|
52
|
+
key = randomBytes(KEY_BYTES);
|
|
53
|
+
mkdirSync(dirname(keyPath), { recursive: true, mode: 0o700 });
|
|
54
|
+
writeFileSync(keyPath, key, { mode: KEY_MODE });
|
|
55
|
+
try { chmodSync(keyPath, KEY_MODE); } catch { /* best-effort; non-POSIX */ }
|
|
56
|
+
}
|
|
57
|
+
_keyCache.set(keyPath, key);
|
|
58
|
+
return key;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Encrypt a UTF-8 secret → base64(iv | tag | ciphertext). Returns '' for empty input so callers
|
|
63
|
+
* can distinguish "no secret" from "a secret that decrypts to empty".
|
|
64
|
+
*/
|
|
65
|
+
export function encryptSecret(keyPath, plain) {
|
|
66
|
+
if (!plain) return '';
|
|
67
|
+
const key = loadMasterKey(keyPath);
|
|
68
|
+
const iv = randomBytes(IV_BYTES);
|
|
69
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
70
|
+
const ct = Buffer.concat([cipher.update(String(plain), 'utf-8'), cipher.final()]);
|
|
71
|
+
const tag = cipher.getAuthTag();
|
|
72
|
+
return Buffer.concat([iv, tag, ct]).toString('base64');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Decrypt a value produced by encryptSecret. Returns '' for empty input. Throws on malformed
|
|
77
|
+
* input or auth-tag mismatch (tampered / wrong key) — callers decide whether to fall back to a
|
|
78
|
+
* legacy encoding or surface the error.
|
|
79
|
+
*/
|
|
80
|
+
export function decryptSecret(keyPath, stored) {
|
|
81
|
+
if (!stored) return '';
|
|
82
|
+
const key = loadMasterKey(keyPath);
|
|
83
|
+
const buf = Buffer.from(String(stored), 'base64');
|
|
84
|
+
if (buf.length < IV_BYTES + TAG_BYTES) {
|
|
85
|
+
throw new Error('credential-vault: ciphertext too short');
|
|
86
|
+
}
|
|
87
|
+
const iv = buf.subarray(0, IV_BYTES);
|
|
88
|
+
const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
|
|
89
|
+
const ct = buf.subarray(IV_BYTES + TAG_BYTES);
|
|
90
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
91
|
+
decipher.setAuthTag(tag);
|
|
92
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf-8');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Heuristic: does `stored` look like vault ciphertext (vs a legacy plaintext/base64 value)?
|
|
97
|
+
* Used by the one-time migration to detect already-migrated values. Not a security boundary —
|
|
98
|
+
* just a shape check (base64 that decodes to ≥ IV+TAG bytes).
|
|
99
|
+
*/
|
|
100
|
+
export function looksEncrypted(stored) {
|
|
101
|
+
if (!stored || typeof stored !== 'string') return false;
|
|
102
|
+
if (!/^[A-Za-z0-9+/=]+$/.test(stored)) return false;
|
|
103
|
+
let buf;
|
|
104
|
+
try { buf = Buffer.from(stored, 'base64'); } catch { return false; }
|
|
105
|
+
return buf.length >= IV_BYTES + TAG_BYTES + 1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Test hook: drop the cached key for `keyPath` (or all) so a fresh one is (re)generated. */
|
|
109
|
+
export function _resetKeyCache(keyPath) {
|
|
110
|
+
if (keyPath === undefined) _keyCache.clear();
|
|
111
|
+
else _keyCache.delete(keyPath);
|
|
112
|
+
}
|
|
@@ -15,6 +15,7 @@ import { resolve, basename, sep, join } from 'node:path';
|
|
|
15
15
|
import { homedir, platform, tmpdir } from 'node:os';
|
|
16
16
|
import { getClaudeConfigDir, onLogDirChange } from '../../findcc.js';
|
|
17
17
|
import { loadWorkspaces } from '../workspace-registry.js';
|
|
18
|
+
import { getBackupRoot } from './config-backup.js';
|
|
18
19
|
|
|
19
20
|
const osPlatform = platform();
|
|
20
21
|
const isWin = osPlatform === 'win32';
|
|
@@ -91,6 +92,10 @@ const SENSITIVE_CLAUDE_FILES = new Set([
|
|
|
91
92
|
'.credentials.json',
|
|
92
93
|
'settings.json',
|
|
93
94
|
'settings.local.json',
|
|
95
|
+
// cc-viewer 凭证 vault(credentials.json 密文)与主密钥(master.key):即使 ~/.claude 在 allowlist,
|
|
96
|
+
// 也绝不跨网读(master.key 同时被下方 .key 文件名规则覆盖,这里双保险)。
|
|
97
|
+
'credentials.json',
|
|
98
|
+
'master.key',
|
|
94
99
|
]);
|
|
95
100
|
|
|
96
101
|
// ─── allowlist roots 缓存 ──────────────────────────────────────────────────────
|
|
@@ -205,6 +210,17 @@ export function isReadAllowed(absPath) {
|
|
|
205
210
|
}
|
|
206
211
|
}
|
|
207
212
|
|
|
213
|
+
// 2b) Deny the cc-viewer config-backup dir tree — every rolling backup holds credentials.json
|
|
214
|
+
// ciphertext + master.key, which together are the full decryption kit. The dir defaults to
|
|
215
|
+
// ~/.claude/cc-viewer-config-backups/ (a sibling of LOG_DIR), but it must be denied whenever it
|
|
216
|
+
// lands inside an allowlist root, regardless of whether LOG_DIR was moved.
|
|
217
|
+
try {
|
|
218
|
+
const backupReal = realpathSync(getBackupRoot());
|
|
219
|
+
if (isInsideRoot(real, backupReal)) {
|
|
220
|
+
return { ok: false, reason: 'sensitive-config-backup' };
|
|
221
|
+
}
|
|
222
|
+
} catch { /* backup root missing / not realpath-able → no extra deny */ }
|
|
223
|
+
|
|
208
224
|
// 3) 项目内文件豁免 sensitive 文件名(允许 fixtures/test-cert.pem 等合法 fixture)
|
|
209
225
|
const projectRoot = getProjectRoot();
|
|
210
226
|
const isInProj = isInsideRoot(real, projectRoot);
|
|
@@ -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
|
+
}
|