cc-viewer 1.9.0 → 1.9.2

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.
Files changed (160) hide show
  1. package/README.md +3 -1
  2. package/cli.js +33 -4
  3. package/concepts/ar/UltraPlan.md +1 -1
  4. package/concepts/da/UltraPlan.md +1 -1
  5. package/concepts/de/UltraPlan.md +1 -1
  6. package/concepts/en/UltraPlan.md +1 -1
  7. package/concepts/es/UltraPlan.md +1 -1
  8. package/concepts/fr/UltraPlan.md +1 -1
  9. package/concepts/it/UltraPlan.md +1 -1
  10. package/concepts/ja/UltraPlan.md +1 -1
  11. package/concepts/ko/UltraPlan.md +1 -1
  12. package/concepts/no/UltraPlan.md +1 -1
  13. package/concepts/pl/UltraPlan.md +1 -1
  14. package/concepts/pt-BR/UltraPlan.md +1 -1
  15. package/concepts/ru/UltraPlan.md +1 -1
  16. package/concepts/th/UltraPlan.md +1 -1
  17. package/concepts/tr/UltraPlan.md +1 -1
  18. package/concepts/uk/UltraPlan.md +1 -1
  19. package/concepts/zh/UltraPlan.md +1 -1
  20. package/concepts/zh-TW/UltraPlan.md +1 -1
  21. package/dist/assets/App-DJ4yNvwz.css +1 -0
  22. package/dist/assets/App-DPRn6oGn.js +2 -0
  23. package/dist/assets/{MdxEditorPanel-3j8rS3IT.js → MdxEditorPanel-Ci4ldhd7.js} +1 -1
  24. package/dist/assets/{MdxEditorPanel-Dpn-4ydO.css → MdxEditorPanel-DTfdj6GS.css} +1 -1
  25. package/dist/assets/Mobile-Dq-KcO1s.js +1 -0
  26. package/dist/assets/ProxyStatsModal-CUFpqt1s.js +1 -0
  27. package/dist/assets/{_baseUniq-B3e4JaL0.js → _baseUniq-CwQykUuc.js} +1 -1
  28. package/dist/assets/{arc-DnoPn_qY.js → arc-EdUdBxtG.js} +1 -1
  29. package/dist/assets/{architectureDiagram-Q4EWVU46-DrPKTbxH.js → architectureDiagram-Q4EWVU46-DtBIdElY.js} +1 -1
  30. package/dist/assets/{blockDiagram-DXYQGD6D-CEaO0Vcw.js → blockDiagram-DXYQGD6D-guNZIKkZ.js} +1 -1
  31. package/dist/assets/{c4Diagram-AHTNJAMY-BLaawFeg.js → c4Diagram-AHTNJAMY-xV5vB-Z9.js} +1 -1
  32. package/dist/assets/{channel-DnYR1fF4.js → channel-DYEktNap.js} +1 -1
  33. package/dist/assets/{chunk-4BX2VUAB-Bfd4edKZ.js → chunk-4BX2VUAB-ZTkItpvj.js} +1 -1
  34. package/dist/assets/{chunk-4TB4RGXK-t2nMQmY7.js → chunk-4TB4RGXK-dYIewyyR.js} +1 -1
  35. package/dist/assets/{chunk-55IACEB6-C6tLOjGX.js → chunk-55IACEB6-CSsPNgPX.js} +1 -1
  36. package/dist/assets/{chunk-EDXVE4YY-dWCvxbCb.js → chunk-EDXVE4YY-CnMiuqzQ.js} +1 -1
  37. package/dist/assets/{chunk-FMBD7UC4-BHELZLPK.js → chunk-FMBD7UC4-CVpATPZ3.js} +1 -1
  38. package/dist/assets/{chunk-OYMX7WX6-C8XTkH1S.js → chunk-OYMX7WX6-Dpq4s8dW.js} +1 -1
  39. package/dist/assets/{chunk-QZHKN3VN-CD0j6SAs.js → chunk-QZHKN3VN-Bnojh6X6.js} +1 -1
  40. package/dist/assets/{chunk-YZCP3GAM-D4dKfXJJ.js → chunk-YZCP3GAM-BYqK4Dsa.js} +1 -1
  41. package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +1 -0
  42. package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +1 -0
  43. package/dist/assets/clone-CFj55h49.js +1 -0
  44. package/dist/assets/{cose-bilkent-S5V4N54A-BF2HsiSD.js → cose-bilkent-S5V4N54A-OJ7mmWyV.js} +1 -1
  45. package/dist/assets/{dagre-KV5264BT-f3h967yx.js → dagre-KV5264BT-B1Kwd_Rk.js} +1 -1
  46. package/dist/assets/{diagram-5BDNPKRD-B9PzHQfh.js → diagram-5BDNPKRD-D8qrw8sp.js} +1 -1
  47. package/dist/assets/{diagram-G4DWMVQ6-kR953Bqm.js → diagram-G4DWMVQ6-ip-N4ho8.js} +1 -1
  48. package/dist/assets/{diagram-MMDJMWI5-DAewa2eg.js → diagram-MMDJMWI5-2PZaskUF.js} +1 -1
  49. package/dist/assets/{diagram-TYMM5635-C8HNTH6I.js → diagram-TYMM5635-CeAOQUZP.js} +1 -1
  50. package/dist/assets/{erDiagram-SMLLAGMA-BQ5P8wWK.js → erDiagram-SMLLAGMA-BCBaEYEb.js} +1 -1
  51. package/dist/assets/{flowDiagram-DWJPFMVM-BIpEkrMR.js → flowDiagram-DWJPFMVM-CsGyIqcX.js} +1 -1
  52. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWqSfB5v.js → ganttDiagram-T4ZO3ILL-COsj3FZ-.js} +1 -1
  53. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Ba-9yEbQ.js → gitGraphDiagram-UUTBAWPF-DvQVQGJG.js} +1 -1
  54. package/dist/assets/{graph-D3zXKQ6P.js → graph-BSB9fDSP.js} +1 -1
  55. package/dist/assets/index-BLzca-ta.css +1 -0
  56. package/dist/assets/{index-G3xd-gLo.js → index-BRw4Iz2O.js} +1 -1
  57. package/dist/assets/{index-DClD_EkN.js → index-Bo-12bug.js} +1 -1
  58. package/dist/assets/{index-DSd5P3WQ.js → index-BuVComcY.js} +1 -1
  59. package/dist/assets/{index-HLrnYsI1.js → index-CNNAYjkF.js} +1 -1
  60. package/dist/assets/index-Ci38xZiX.js +2 -0
  61. package/dist/assets/{index-Bm3A5VLb.js → index-DKufsAzx.js} +1 -1
  62. package/dist/assets/{index-C9N0rZ7Z.js → index-DXBfwPYD.js} +1 -1
  63. package/dist/assets/{index-Q8wp4Nof.js → index-eM2ih5n7.js} +1 -1
  64. package/dist/assets/{infoDiagram-42DDH7IO-Cxm79ZJR.js → infoDiagram-42DDH7IO-eNucS4iW.js} +1 -1
  65. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Cd0vZP1B.js → ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js} +1 -1
  66. package/dist/assets/{journeyDiagram-VCZTEJTY-B2b57HXk.js → journeyDiagram-VCZTEJTY-yJJzVEBb.js} +1 -1
  67. package/dist/assets/{jszip.min-Bj5uiEb2.js → jszip.min-ByI0ds5u.js} +1 -1
  68. package/dist/assets/{kanban-definition-6JOO6SKY-YCERiUCO.js → kanban-definition-6JOO6SKY-2rfTi2zw.js} +1 -1
  69. package/dist/assets/{layout-Do5TlO2U.js → layout-DTmHj0Wm.js} +1 -1
  70. package/dist/assets/{linear-BiJNTDgN.js → linear-BOA3a9FE.js} +1 -1
  71. package/dist/assets/{mermaid.core-Dg0McRZt.js → mermaid.core-5H-NR79H.js} +3 -3
  72. package/dist/assets/{min-T51iefht.js → min-CpjFTEec.js} +1 -1
  73. package/dist/assets/{mindmap-definition-QFDTVHPH-Cu_GJxY3.js → mindmap-definition-QFDTVHPH-B8KCjFgB.js} +1 -1
  74. package/dist/assets/{pieDiagram-DEJITSTG-DldHHZXi.js → pieDiagram-DEJITSTG-BARy96g5.js} +1 -1
  75. package/dist/assets/{quadrantDiagram-34T5L4WZ-BzyTPNAJ.js → quadrantDiagram-34T5L4WZ-DYoTapuI.js} +1 -1
  76. package/dist/assets/{requirementDiagram-MS252O5E-C5xxFkvG.js → requirementDiagram-MS252O5E-EeANLLK2.js} +1 -1
  77. package/dist/assets/{sankeyDiagram-XADWPNL6-OxdjoaE1.js → sankeyDiagram-XADWPNL6-DmQ-YVPj.js} +1 -1
  78. package/dist/assets/seqResourceLoaders-B0p7e_lM.js +2 -0
  79. package/dist/assets/seqResourceLoaders-BsMFNfvR.css +41 -0
  80. package/dist/assets/{sequenceDiagram-FGHM5R23-C7c89uIS.js → sequenceDiagram-FGHM5R23-SZtW0rYC.js} +1 -1
  81. package/dist/assets/{stateDiagram-FHFEXIEX-ssdJnDrO.js → stateDiagram-FHFEXIEX-CEygsIld.js} +1 -1
  82. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BP-0WClK.js → stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js} +1 -1
  83. package/dist/assets/{timeline-definition-GMOUNBTQ-sWScEpSM.js → timeline-definition-GMOUNBTQ-DhPBaQBt.js} +1 -1
  84. package/dist/assets/{vendor-antd-DKIytXho.js → vendor-antd-CS_S2mBN.js} +1 -1
  85. package/dist/assets/{vendor-codemirror-4qgXq03k.js → vendor-codemirror-B5l-VbBO.js} +1 -1
  86. package/dist/assets/{vendor-mdxeditor-D5Z5D9fq.js → vendor-mdxeditor-Cz09a1_I.js} +2 -2
  87. package/dist/assets/{vendor-qrcode-B7lb5qk1.js → vendor-qrcode-qbbntD6-.js} +1 -1
  88. package/dist/assets/{vendor-virtuoso-BqoXlbhH.js → vendor-virtuoso-Sk8fQ4Hx.js} +1 -1
  89. package/dist/assets/{vennDiagram-DHZGUBPP-DNfXR0ge.js → vennDiagram-DHZGUBPP-NniTHxDw.js} +1 -1
  90. package/dist/assets/{wardley-RL74JXVD-JXd--CRD.js → wardley-RL74JXVD-D9SNOrDw.js} +1 -1
  91. package/dist/assets/{wardleyDiagram-NUSXRM2D-DHmNyVPM.js → wardleyDiagram-NUSXRM2D-DvHXKNza.js} +1 -1
  92. package/dist/assets/{xychartDiagram-5P7HB3ND-CTb0gltZ.js → xychartDiagram-5P7HB3ND-CaiwPTQB.js} +1 -1
  93. package/dist/index.html +5 -5
  94. package/package.json +1 -1
  95. package/server/interceptor.js +192 -24
  96. package/server/lib/ask/ask-store.js +7 -3
  97. package/server/lib/async-file-lock.js +29 -5
  98. package/server/lib/auth.js +71 -54
  99. package/server/lib/chat-queue.js +67 -4
  100. package/server/lib/config-backup.js +8 -5
  101. package/server/lib/credential-access.js +233 -0
  102. package/server/lib/credential-migrate.js +147 -0
  103. package/server/lib/credential-store.js +120 -0
  104. package/server/lib/credential-vault.js +112 -0
  105. package/server/lib/file-access-policy.js +16 -0
  106. package/server/lib/git-diff.js +49 -20
  107. package/server/lib/im/im-config.js +81 -31
  108. package/server/lib/im-deny.js +22 -8
  109. package/server/lib/json-store.js +264 -0
  110. package/server/lib/log-watcher.js +9 -1
  111. package/server/lib/prefs-store.js +8 -33
  112. package/server/lib/same-origin.js +22 -0
  113. package/server/lib/session-pin-store.js +7 -14
  114. package/server/lib/v2/convert.js +1 -1
  115. package/server/lib/v2/layout.js +53 -3
  116. package/server/lib/v2/live-feed.js +41 -4
  117. package/server/lib/v2/resume-list.js +150 -0
  118. package/server/lib/v2/session-select.js +63 -14
  119. package/server/lib/v2/v2-writer.js +172 -34
  120. package/server/lib/v2/view-router.js +83 -0
  121. package/server/lib/view-root.js +209 -0
  122. package/server/pty-manager.js +809 -163
  123. package/server/routes/ask-perm.js +21 -1
  124. package/server/routes/auth.js +21 -1
  125. package/server/routes/events.js +123 -7
  126. package/server/routes/expert.js +46 -14
  127. package/server/routes/files-content.js +72 -7
  128. package/server/routes/files-fs.js +28 -3
  129. package/server/routes/git.js +31 -5
  130. package/server/routes/im.js +15 -2
  131. package/server/routes/preferences.js +59 -46
  132. package/server/routes/project-meta.js +32 -3
  133. package/server/routes/proxy-stats.js +33 -5
  134. package/server/routes/resume.js +457 -0
  135. package/server/routes/search.js +23 -1
  136. package/server/routes/skills.js +18 -2
  137. package/server/routes/v2.js +6 -1
  138. package/server/routes/workspaces.js +18 -5
  139. package/server/server.js +199 -25
  140. package/server/system-prompt-templates/presets/GLM-5.2.md +0 -1
  141. package/server/system-prompt-templates/presets/GLM-5.3.md +0 -1
  142. package/server/system-prompt-templates/presets/Qwen-3.md +0 -1
  143. package/server/system-prompt-templates/presets/deepseek-v4-flash.md +0 -1
  144. package/server/system-prompt-templates/presets/deepseek-v4-pro.md +0 -1
  145. package/server/system-prompt-templates/presets/kimi-k2.7-code.md +0 -1
  146. package/server/system-prompt-templates/presets/kimi-k3.md +1 -1
  147. package/server/system-prompt-templates/systemPromptModel.md +0 -1
  148. package/server/workspace-registry.js +150 -75
  149. package/ultraAgents/code-expert.json +1 -1
  150. package/dist/assets/App-M61pqQEx.css +0 -1
  151. package/dist/assets/App-_HfxMJvv.js +0 -2
  152. package/dist/assets/Mobile-DaRLB7Gh.js +0 -1
  153. package/dist/assets/ProxyStatsModal-dlPIDlx-.js +0 -1
  154. package/dist/assets/classDiagram-6PBFFD2Q-BecPAAuk.js +0 -1
  155. package/dist/assets/classDiagram-v2-HSJHXN6E-BecPAAuk.js +0 -1
  156. package/dist/assets/clone-B0_ghF3F.js +0 -1
  157. package/dist/assets/index-CjbBUDGP.js +0 -2
  158. package/dist/assets/index-Nvk8r3tO.css +0 -1
  159. package/dist/assets/seqResourceLoaders-B_FSQh3U.css +0 -41
  160. package/dist/assets/seqResourceLoaders-hqLhsiGO.js +0 -2
@@ -22,6 +22,8 @@ import { sanitizePathComponent } from './lib/v2/layout.js';
22
22
  import { parseAgentId, findHeader } from './lib/v2/agent-id.js';
23
23
  import { parseUserId } from './lib/session-id.js';
24
24
  import { setRetryConfigPath, loadRetryConfig, DEFAULT_RETRY_CONFIG } from './lib/proxy/proxy-retry.js';
25
+ import { writeJsonAtomic, mutateJsonSync } from './lib/json-store.js';
26
+ import { readSecretOr, writeSecret, removeSecret } from './lib/credential-access.js';
25
27
  import { setProjectName } from './lib/project-state.js';
26
28
  import { consumePendingForResume, writeSnapshot, projectKeyForCwd } from './lib/system-prompt-snapshots.js';
27
29
  import { liveSystemPromptEnabled, getLaunchSystemPromptInfo, getLiveEntry, putLiveEntry, selectEntriesForModel, applyLiveSystem, knownInjectedTexts } from './lib/system-prompt-live.js';
@@ -186,16 +188,72 @@ function _writeWorkspaceActive(activeId, roles) {
186
188
  }
187
189
  }
188
190
 
191
+ // Resolve a profile's effective apiKey for request injection. The key lives in the credential
192
+ // vault (kind=profile-apiKey, ref=profile.id); a plaintext apiKey still present in profile.json
193
+ // is the one-version read-fallback (pre-migration). FAIL-CLOSED: a profile with a baseURL whose
194
+ // key cannot be decrypted (lost master.key / tampered ciphertext) must be excluded from routing —
195
+ // otherwise proxy.js would forward the request to the third-party baseURL while the auth rewrite
196
+ // is skipped, transmitting the user's default Anthropic credential to that host.
197
+ function _resolveProfileApiKey(profile) {
198
+ if (!profile || profile.id === 'max') return { apiKey: '', unusable: false, reason: '' };
199
+ const legacyPlain = typeof profile.apiKey === 'string' ? profile.apiKey : '';
200
+ const { value, unreadable } = readSecretOr('profile-apiKey', profile.id, legacyPlain);
201
+ if (unreadable) return { apiKey: '', unusable: true, reason: 'unreadable' };
202
+ // FAIL-CLOSED: a profile that rewrites the URL to a third-party baseURL but resolves to NO key
203
+ // (no vault entry, no legacy plaintext) must also be excluded from routing. The URL rewrite runs
204
+ // unconditionally while the auth rewrite is guarded by `if (_effProfile.apiKey && ...)` — with an
205
+ // empty key the request would reach the third-party host carrying the user's own Anthropic
206
+ // credential (the exact outcome the unreadable branch above exists to prevent).
207
+ if (typeof profile.baseURL === 'string' && profile.baseURL && !value) {
208
+ return { apiKey: '', unusable: true, reason: 'keyless-baseurl' };
209
+ }
210
+ return { apiKey: value, unusable: false, reason: '' };
211
+ }
212
+
213
+ /**
214
+ * Decrypt-backfill apiKeys onto a raw profile.json list for READERS that consume the file
215
+ * directly (e.g. GET /api/proxy-profiles) rather than the in-memory _profilesById. Profiles
216
+ * whose key is unreadable are returned with an EMPTY apiKey (the caller masks/serves as-is);
217
+ * routing itself is fail-closed via _loadProxyProfile, which DROPS such profiles.
218
+ */
219
+ export function hydrateProfilesWithApiKeys(profiles) {
220
+ return (Array.isArray(profiles) ? profiles : []).map((p) => {
221
+ if (!p || typeof p.id !== 'string') return p;
222
+ const { apiKey } = _resolveProfileApiKey(p);
223
+ return { ...p, apiKey };
224
+ });
225
+ }
226
+
227
+ // Throttle the per-profile "key unusable" diagnostic to once per profile id per process —
228
+ // _loadProxyProfile re-runs on every 1.5s watchFile tick and would otherwise spam the log.
229
+ const _reportedUnusableProfiles = new Set();
230
+
189
231
  function _loadProxyProfile() {
190
232
  try {
191
233
  const data = JSON.parse(readFileSync(PROFILE_PATH, 'utf-8'));
192
- _profilesById = new Map((Array.isArray(data.profiles) ? data.profiles : [])
193
- .filter(p => p && typeof p.id === 'string').map(p => [p.id, p]));
234
+ const rawProfiles = (Array.isArray(data.profiles) ? data.profiles : []).filter(p => p && typeof p.id === 'string');
235
+ // Decrypt-backfill apiKeys; drop profiles whose key is unreadable (fail-closed).
236
+ const profiles = [];
237
+ for (const p of rawProfiles) {
238
+ const { apiKey, unusable, reason } = _resolveProfileApiKey(p);
239
+ if (unusable) {
240
+ if (!_reportedUnusableProfiles.has(p.id)) {
241
+ _reportedUnusableProfiles.add(p.id);
242
+ const why = reason === 'keyless-baseurl'
243
+ ? 'has a baseURL but no apiKey — refusing to forward your default credential to a third-party host'
244
+ : 'apiKey unreadable (lost master.key or tampered credentials.json)';
245
+ reportSwallowed('proxy-profile.key-unusable', new Error(`profile "${p.id}" dropped: ${why}`));
246
+ }
247
+ continue;
248
+ }
249
+ profiles.push({ ...p, apiKey });
250
+ }
251
+ _profilesById = new Map(profiles.map(p => [p.id, p]));
194
252
  // active 解析优先级:workspace override > profile.json.active (兼容老数据 / 全局回退) > null
195
253
  const ws = _readWorkspaceActive();
196
254
  _roleIds = ws.roles;
197
255
  const activeId = ws.activeId || data.active;
198
- const active = data.profiles?.find(p => p.id === activeId);
256
+ const active = profiles.find(p => p.id === activeId);
199
257
  _activeProfile = (active && active.id !== 'max') ? active : null;
200
258
  } catch (err) {
201
259
  _activeProfile = null;
@@ -226,14 +284,18 @@ function setActiveProfileForWorkspace(activeId, roles) {
226
284
  if (activeId !== undefined) {
227
285
  const normalizedId = (activeId && typeof activeId === 'string') ? activeId : 'max';
228
286
  try {
229
- const data = existsSync(PROFILE_PATH)
230
- ? JSON.parse(readFileSync(PROFILE_PATH, 'utf-8'))
231
- : { profiles: [{ id: 'max', name: 'Default' }] };
232
- if (data.active !== normalizedId) {
233
- data.active = normalizedId;
234
- mkdirSync(dirname(PROFILE_PATH), { recursive: true });
235
- writeFileSync(PROFILE_PATH, JSON.stringify(data, null, 2), { mode: 0o600 });
236
- }
287
+ // Read-merge-write INSIDE the shared profile.json lock (mutateJsonSync), so this whole-file
288
+ // update mutexes with the startup credential-strip and proxyProfilesPost — an unlocked
289
+ // snapshot could otherwise resurrect a stripped apiKey field or drop a concurrent list edit.
290
+ mutateJsonSync(PROFILE_PATH, (data) => {
291
+ const base = (data && typeof data === 'object' && !Array.isArray(data))
292
+ ? data
293
+ : { profiles: [{ id: 'max', name: 'Default' }] };
294
+ if (base.active !== normalizedId) base.active = normalizedId;
295
+ return base;
296
+ }, { mode: 0o600, strictCorrupt: true, fallback: { profiles: [{ id: 'max', name: 'Default' }] } });
297
+ // strictCorrupt: profile.json corrupt 时抛错(下方 catch 兜住、跳过本次写),绝不用 fallback
298
+ // 的 {max}-only 覆盖掉磁盘上虽损坏但可能仍可人工恢复的全部 profile。
237
299
  result.profile = true;
238
300
  } catch { /* 双失败场景下 result 全 false,由调用方自行兜底 */ }
239
301
  }
@@ -504,23 +566,49 @@ export function markSessionStart(payload) {
504
566
  console.warn('[ccv session-start] resume signal without transcript_path — ignored');
505
567
  return;
506
568
  }
507
- // Soft cross-project guard: a foreign project's claude that somehow
508
- // carries our CCVIEWER_PORT must not re-bind THIS project's writer. Only
509
- // enforced when both sides are known (workspace mode may leave
510
- // _projectName empty until bound).
511
- if (cwd && typeof cwd === 'string' && _projectName) {
569
+ // Cross-project guard (2026-10-06, relaxed for true-/resume): a foreign project's
570
+ // claude that somehow carries our CCVIEWER_PORT must not re-bind THIS project's
571
+ // writer — BUT a resume in a PARALLEL project this server also manages (a live ccv
572
+ // PTY whose cwd maps to that project) is legitimate and must re-bind keyed by THAT
573
+ // project. Resolve the target project; drop only when neither bound nor a known
574
+ // live ccv project. This is what lets /api/resume-session switch a parallel
575
+ // viewed project and still have its writer follow.
576
+ let resumeProject = null; // null = bound project (default)
577
+ if (cwd && typeof cwd === 'string') {
512
578
  const cwdProject = projectKeyForCwd(cwd);
513
- if (cwdProject !== _projectName) {
514
- console.warn(`[ccv session-start] resume signal from project "${cwdProject}" ignored (bound to "${_projectName}")`);
515
- return;
579
+ if (_projectName && cwdProject === _projectName) {
580
+ resumeProject = null; // bound project — default key
581
+ } else {
582
+ // Synchronously resolvable? No — isLiveClaudeProject lives in pty-manager
583
+ // (L2), imported lazily below. Compute the candidate key here and let the
584
+ // async gate decide whether to arm the switch.
585
+ resumeProject = cwdProject || null;
516
586
  }
517
587
  }
518
588
  const transcriptUuid = basename(transcriptPath, '.jsonl');
519
589
  if (source === 'resume') {
520
- _v2Writer.beginResumeSwitch({
521
- transcriptUuid,
522
- hookSid: (typeof sessionId === 'string' && sessionId) ? sessionId : null,
523
- });
590
+ const armSwitch = () => {
591
+ _v2Writer.beginResumeSwitch({
592
+ transcriptUuid,
593
+ hookSid: (typeof sessionId === 'string' && sessionId) ? sessionId : null,
594
+ // Keyed by the resumed conversation's project (bound = undefined default).
595
+ project: resumeProject || undefined,
596
+ });
597
+ };
598
+ if (!resumeProject || (_projectName && resumeProject === _projectName)) {
599
+ armSwitch();
600
+ } else {
601
+ // Parallel project: only arm when it's a live ccv-managed claude PTY.
602
+ import('./pty-manager.js')
603
+ .then((pm) => {
604
+ if (pm && typeof pm.isLiveClaudeProject === 'function' && pm.isLiveClaudeProject(resumeProject)) {
605
+ armSwitch();
606
+ } else {
607
+ console.warn(`[ccv session-start] resume signal from unmanaged project "${resumeProject}" ignored`);
608
+ }
609
+ })
610
+ .catch((err) => reportSwallowed('session-start.resume-project-gate', err));
611
+ }
524
612
  }
525
613
  // System-prompt snapshot, Bind B (system-prompt-snapshots.js): a process-level
526
614
  // -c/-r launch queued a resumeExpected pending at spawn (pty-manager). The
@@ -620,13 +708,20 @@ export function initForWorkspace(projectPath, { forceNew = false } = {}) { // es
620
708
  const dir = join(LOG_DIR, projectName);
621
709
  try { mkdirSync(dir, { recursive: true }); } catch {}
622
710
 
711
+ // Capture the project being switched AWAY from BEFORE rebinding: a scoped
712
+ // resetSessions(below) drops only THAT project's cached session bindings, so a
713
+ // concurrently-running BACKGROUND project (a cross-project /resume takeover's
714
+ // old project, still writing via entry._resumeProject) keeps its bindings and
715
+ // continues uninterrupted. Same-project re-init passes the same name (a no-op
716
+ // scoped clear of just that project, matching the old full clear for it).
717
+ const previousProject = _projectName;
623
718
  _projectName = projectName;
624
719
  setProjectName(projectName);
625
720
  _logDir = dir;
626
721
  // Fresh workspace context — no carried names apply; future requests must
627
722
  // create session dirs under the NEW project.
628
723
  _agentSpawnRegistry.clear();
629
- _v2Writer.resetSessions(); // also clears the per-process `-c` adoption latch
724
+ _v2Writer.resetSessions(previousProject || null); // also clears the per-process `-c` adoption latch
630
725
  // A `--fork-session` / `-r` launch marks fork/resume intent; neither must
631
726
  // leak into a LATER workspace's `-c` (in a long-lived server that would
632
727
  // permanently suppress adoption). Clear both marks per launch and re-sync —
@@ -801,6 +896,9 @@ export function setupInterceptor() {
801
896
  if (isProxyTrace && options?.headers) {
802
897
  delete options.headers['x-cc-viewer-trace'];
803
898
  }
899
+ // NOTE: `x-ccv-project-dir` is stripped LATER (right before _originalFetch,
900
+ // after requestEntry has extracted `_resumeProject` from it) — stripping it
901
+ // here would delete it before the conversion copy below could read it.
804
902
 
805
903
  const timestamp = new Date().toISOString();
806
904
  let body = null;
@@ -864,10 +962,47 @@ export function setupInterceptor() {
864
962
  safeHeaders['authorization'] = '****';
865
963
  }
866
964
  }
965
+ // ccv-internal self-report headers (x-ccv-project-dir / x-ccv-instance) are routing
966
+ // metadata, not request content — drop them from the logged/forwarded `safeHeaders`
967
+ // copy so they never land in the on-disk journal or reach the frontend wire (they are
968
+ // extracted onto `_resumeProject`/`_ccvInstance` separately). Case-insensitive.
969
+ for (const k of Object.keys(safeHeaders)) {
970
+ const lk = k.toLowerCase();
971
+ if (lk === 'x-ccv-project-dir' || lk === 'x-ccv-instance') delete safeHeaders[k];
972
+ }
867
973
 
868
974
  requestEntry = {
869
975
  timestamp,
870
976
  project: (() => { try { return basename(process.cwd()); } catch { return 'unknown'; } })(),
977
+ // /resume global sessions: a child claude spawned for resume carries
978
+ // `x-ccv-project-dir` = its own cwd, so its traffic is logged under THAT
979
+ // project rather than this ccv process's cwd. Set ONLY the dedicated
980
+ // `_resumeProject` override (the writer prefers it over the bound
981
+ // project); `entry.project` keeps its original process-cwd meaning for
982
+ // every other consumer. projectKeyForCwd takes only the basename and
983
+ // sanitizes it, so the header cannot path-traverse.
984
+ ...(() => {
985
+ const dir = findHeader(headers, 'x-ccv-project-dir');
986
+ const proj = (typeof dir === 'string' && dir) ? projectKeyForCwd(dir) : '';
987
+ if (!proj) return {};
988
+ // Same-name session isolation (2026-10-07): also surface the RAW full cwd
989
+ // (`dir`, the findHeader output — NOT the projectKeyForCwd basename above)
990
+ // as `_resumeProjectCwd`, so the writer can stamp `meta.cwd` on the session
991
+ // dir. The read-side fallback then tightens by full-path cwd, telling two
992
+ // cross-dir same-basename projects apart (basename routing cannot). Using the
993
+ // basename here would never equal the read side's full-path targetCwd.
994
+ return { _resumeProject: proj, _resumeProjectCwd: dir };
995
+ })(),
996
+ // Multi-instance (2026-10-06): the spawned claude also self-reports its PTY
997
+ // instanceKey via `x-ccv-instance` (spawnClaude injects it). Surface it as
998
+ // `_ccvInstance` so the writer can pin the resolved sessionId to THIS exact process
999
+ // — basename routing cannot tell two same-cwd instances apart. Validated to the
1000
+ // minted `ccv-<hex>` shape before use (it becomes a Map key downstream).
1001
+ ...(() => {
1002
+ const inst = findHeader(headers, 'x-ccv-instance');
1003
+ const key = (typeof inst === 'string' && /^ccv-[0-9a-f]+$/.test(inst)) ? inst : '';
1004
+ return key ? { _ccvInstance: key } : {};
1005
+ })(),
871
1006
  url: urlStr,
872
1007
  method: options?.method || 'GET',
873
1008
  headers: safeHeaders,
@@ -1169,6 +1304,32 @@ export function setupInterceptor() {
1169
1304
  catch (err) { reportSwallowed('interceptor.ingest-request', err); }
1170
1305
  }
1171
1306
 
1307
+ // Strip the ccv-internal project self-report header right before forwarding
1308
+ // upstream — AFTER requestEntry already read `_resumeProject` from it. Strip
1309
+ // case-insensitively (the claude client applies it via ANTHROPIC_CUSTOM_HEADERS,
1310
+ // so casing is not under ccv's control). `_fetchOpts` starts as the same object
1311
+ // as `options` and is only shallow-copied on rewrite, so scrub BOTH.
1312
+ // `x-ccv-instance` (the multi-instance self-report) is stripped the same way —
1313
+ // it must never reach Anthropic.
1314
+ const CCV_INTERNAL_HEADERS = ['x-ccv-project-dir', 'x-ccv-instance'];
1315
+ const scrubHeaderBag = (hdrs) => {
1316
+ if (!hdrs) return;
1317
+ if (hdrs instanceof Headers) {
1318
+ for (const name of CCV_INTERNAL_HEADERS) { if (hdrs.has(name)) hdrs.delete(name); }
1319
+ } else if (typeof hdrs === 'object') {
1320
+ for (const k of Object.keys(hdrs)) {
1321
+ if (CCV_INTERNAL_HEADERS.includes(k.toLowerCase())) delete hdrs[k];
1322
+ }
1323
+ }
1324
+ };
1325
+ for (const optsBag of [options, _fetchOpts]) {
1326
+ scrubHeaderBag(optsBag && optsBag.headers);
1327
+ }
1328
+ // Request-object form: `fetch(new Request(...))` carries headers on the Request itself
1329
+ // (options may be undefined), so scrub those too or the internal header reaches upstream.
1330
+ if (typeof Request !== 'undefined' && _fetchUrl instanceof Request) scrubHeaderBag(_fetchUrl.headers);
1331
+ if (typeof Request !== 'undefined' && url instanceof Request && url !== _fetchUrl) scrubHeaderBag(url.headers);
1332
+
1172
1333
  let response;
1173
1334
  try {
1174
1335
  response = await _originalFetch.call(this, _fetchUrl, _fetchOpts);
@@ -1229,6 +1390,10 @@ export function setupInterceptor() {
1229
1390
  url: requestEntry.url,
1230
1391
  response: { body: snap },
1231
1392
  body: { model: requestEntry.body?.model },
1393
+ // B3 (multi-session view-attach): tag the live overlay stream with the
1394
+ // session uuid so an attached client can show only ITS sid's typewriter
1395
+ // and drop the other session's. Additive — older clients ignore it.
1396
+ sessionId: parseUserId(requestEntry.body?.metadata?.user_id)?.sessionId ?? null,
1232
1397
  };
1233
1398
  sendStreamChunk(chunkEntry, ++liveChunkSeq, (ok) => {
1234
1399
  // 413 → 禁用当次流式,后续全由最终 v2 completion 交付
@@ -1252,6 +1417,9 @@ export function setupInterceptor() {
1252
1417
  url: requestEntry.url,
1253
1418
  response: { body: null },
1254
1419
  body: { model: requestEntry.body?.model },
1420
+ // B3: same sessionId tag on the skeleton (seq 0) so the overlay filter
1421
+ // keys on (timestamp, sessionId) from the very first frame.
1422
+ sessionId: parseUserId(requestEntry.body?.metadata?.user_id)?.sessionId ?? null,
1255
1423
  }, 0, (ok) => { if (!ok) liveStreamEnabled = false; });
1256
1424
  }
1257
1425
 
@@ -13,7 +13,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from '
13
13
  import { join } from 'node:path';
14
14
  import { randomBytes } from 'node:crypto';
15
15
  import { renameSyncWithRetry } from '../file-api.js';
16
- import { withFileLockAsync } from '../async-file-lock.js';
16
+ import { withJsonLock } from '../json-store.js';
17
17
  import { LOG_DIR } from '../../../findcc.js';
18
18
 
19
19
  const SCHEMA_VERSION = 1;
@@ -23,10 +23,14 @@ const SCHEMA_VERSION = 1;
23
23
  let _loggedPersistError = false;
24
24
 
25
25
  function getStoreFile() { return join(LOG_DIR, 'ask-store.json'); }
26
- function getLockFile() { return join(LOG_DIR, 'ask-store.lock'); }
27
26
 
27
+ // Serialize every read-modify-write through the unified json-store kernel's async lock
28
+ // (derived from the file name → ask-store.json.lock). The domain read/save (schema filter,
29
+ // version wrap, entry cleaning) stays below in loadAskStore/saveAskStore; the kernel only
30
+ // supplies the mutex via withJsonLock (lock-only, no read/write) because the store's on-disk
31
+ // shape ({version,entries}) differs from its in-memory shape (plain entries map).
28
32
  function withLock(fn) {
29
- return withFileLockAsync(getLockFile(), fn, { ensureDir: LOG_DIR });
33
+ return withJsonLock(getStoreFile(), fn, { ensureDir: LOG_DIR });
30
34
  }
31
35
 
32
36
  /**
@@ -8,6 +8,17 @@ import { mkdirSync } from 'node:fs';
8
8
  // 同进程内的 Promise 互斥锁(按 lockPath 分组)
9
9
  const _inProcessLocks = new Map();
10
10
 
11
+ // Disk lock files this process currently holds via the async path (between _acquireFileLock
12
+ // success and the matching unlink). Consulted by the SYNC lock flavor in json-store.js so it
13
+ // never mistakes a live async holder (which yields the JS thread at every await inside its
14
+ // critical section) for a stale own-pid lock and steals it.
15
+ const _heldDiskLocks = new Set();
16
+
17
+ /** True when this process currently holds `lockPath`'s disk lock via the async flavor. */
18
+ export function hasLiveDiskHolder(lockPath) {
19
+ return _heldDiskLocks.has(lockPath);
20
+ }
21
+
11
22
  function isPidAlive(pid) {
12
23
  if (!Number.isInteger(pid) || pid <= 0) return false;
13
24
  try {
@@ -56,11 +67,23 @@ async function _acquireFileLock(lockPath, opts) {
56
67
  while (true) {
57
68
  try {
58
69
  const fh = await open(lockPath, 'wx');
59
- if (writePid) {
60
- await fh.writeFile(JSON.stringify({ pid: process.pid, ts: Date.now() }));
70
+ // Register the hold IMMEDIATELY on acquisition, before the pid-write await. A SYNC caller
71
+ // (json-store._acquireLockSync) that runs at that await boundary would otherwise see the lock
72
+ // file with our own pid but hasLiveDiskHolder===false, judge it a stale crash-leftover, steal
73
+ // and unlink it — clobbering this async holder's critical section (the exact lost update the
74
+ // sync flavor's reentrancy guard exists to prevent).
75
+ _heldDiskLocks.add(lockPath);
76
+ try {
77
+ if (writePid) {
78
+ await fh.writeFile(JSON.stringify({ pid: process.pid, ts: Date.now() }));
79
+ }
80
+ await fh.close();
81
+ return;
82
+ } catch (err) {
83
+ _heldDiskLocks.delete(lockPath); // never fully acquired → drop the registration
84
+ try { await fh.close(); } catch {}
85
+ throw err;
61
86
  }
62
- await fh.close();
63
- return;
64
87
  } catch (err) {
65
88
  if (err?.code === 'EEXIST') {
66
89
  if (Date.now() < deadline) {
@@ -108,10 +131,11 @@ export async function withFileLockAsync(lockPath, fn, opts = {}) {
108
131
  await prev;
109
132
 
110
133
  try {
111
- await _acquireFileLock(lockPath, opts);
134
+ await _acquireFileLock(lockPath, opts); // registers in _heldDiskLocks at the acquisition point
112
135
  try {
113
136
  return await fn();
114
137
  } finally {
138
+ _heldDiskLocks.delete(lockPath);
115
139
  try { await unlink(lockPath); } catch {}
116
140
  }
117
141
  } finally {
@@ -9,9 +9,10 @@
9
9
  // unit-tested directly (loopback HTTP tests always look local, so they cannot
10
10
  // exercise the remote branch — `decideAuth` covers it as a pure function).
11
11
  import { randomBytes } from 'node:crypto';
12
- import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync } from 'node:fs';
13
- import { join, dirname } from 'node:path';
14
- import { renameSyncWithRetry } from './file-api.js';
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { join, resolve } from 'node:path';
14
+ import { mutateJsonSync } from './json-store.js';
15
+ import { readSecretOr, writeSecret, removeSecret } from './credential-access.js';
15
16
  import { LOG_DIR } from '../../findcc.js';
16
17
  import { tFor, localeFromAcceptLanguage } from '../i18n.js';
17
18
 
@@ -79,9 +80,14 @@ function normalizeAuth(cfg) {
79
80
  }
80
81
 
81
82
  // The password is kept in memory & over the admin API as plaintext (admin must be able
82
- // to view/copy it), but is base64-encoded on disk so preferences.json never shows the
83
- // raw password. This is light obfuscation, NOT real security — base64 is trivially
84
- // reversible; it only avoids the password sitting in literal plaintext in the file.
83
+ // to view/copy it). On disk it lives in the encrypted credential vault (credentials.json)
84
+ // as AES-256-GCM ciphertext — NOT in preferences.json. The legacy encoding was base64 inside
85
+ // preferences.json (obfuscation only); those fields are migrated out at startup and cleared.
86
+ //
87
+ // CRITICAL fail-closed rule: a password that exists but cannot be decrypted (lost master.key,
88
+ // tampered ciphertext) is "unreadable", NOT "empty". decideAuth treats an EMPTY password as
89
+ // allow-all, so collapsing an unreadable secret to '' would open the LAN gate. We therefore
90
+ // thread a `passwordUnreadable` flag through and deny on it before the empty-password branch.
85
91
  function encodePassword(plain) {
86
92
  return plain ? Buffer.from(plain, 'utf-8').toString('base64') : '';
87
93
  }
@@ -91,18 +97,32 @@ function decodePassword(stored) {
91
97
  }
92
98
 
93
99
  // ─── Scoped persistence (global default + optional per-project override) ───
94
- // preferences.json:
95
- // auth: { enabled, password(b64) } ← global default
96
- // authByProject: { "<projectDir>": { enabled, password(b64) } } ← optional overrides
100
+ // preferences.json (after migration):
101
+ // auth: { enabled } ← global default (password in vault)
102
+ // authByProject: { "<projectDir>": { enabled } } ← optional overrides (password in vault)
103
+ // Vault refs: 'lan-password:global' and 'lan-password:proj:<resolvedDir>'.
97
104
  // A project "has an override" iff a key exists for it (even if disabled). The gate
98
105
  // resolves: project override (if the key exists) else global. To inherit global again,
99
106
  // the override must be REMOVED (clearProjectOverride), not merely disabled.
100
107
 
101
- function decodeStored(a) {
102
- return { enabled: !!(a && a.enabled), password: decodePassword(a && a.password) };
108
+ // Vault ref for the LAN password of a scope. Exported so the one-time migration
109
+ // (credential-migrate.js) derives refs from the SAME source as the runtime readers — a divergence
110
+ // would silently orphan every migrated secret (the writer and reader would use different keys).
111
+ export function refFor(projectDir) {
112
+ return projectDir ? `proj:${resolve(projectDir)}` : 'global';
103
113
  }
104
- function encodeForDisk(normalized) {
105
- return { enabled: normalized.enabled, password: encodePassword(normalized.password) };
114
+
115
+ function decodeStored(a, projectDir = null) {
116
+ // No auth entry at all → the default "disabled, no password" shape; do NOT consult the vault
117
+ // (a stray vault entry must not resurrect a password for a scope the prefs say nothing about,
118
+ // e.g. after preferences.json was wiped/corrupt while credentials.json survived).
119
+ if (!a || typeof a !== 'object') return { enabled: false, password: '', passwordUnreadable: false };
120
+ const enabled = !!a.enabled;
121
+ // Legacy on-disk value (base64) is the read-fallback for the one-version transition window;
122
+ // the vault is authoritative when it holds the entry.
123
+ const legacyPlain = decodePassword(a.password);
124
+ const { value, unreadable } = readSecretOr('lan-password', refFor(projectDir), legacyPlain);
125
+ return { enabled, password: value, passwordUnreadable: unreadable };
106
126
  }
107
127
 
108
128
  /** Does this project have its own auth override? (key present, regardless of enabled) */
@@ -117,8 +137,9 @@ function hasOverride(prefs, projectDir) {
117
137
  */
118
138
  export function loadAuthConfig(projectDir = null) {
119
139
  const prefs = readPrefs();
120
- const src = hasOverride(prefs, projectDir) ? prefs.authByProject[projectDir] : prefs.auth;
121
- return decodeStored(src);
140
+ const overridden = hasOverride(prefs, projectDir);
141
+ const src = overridden ? prefs.authByProject[projectDir] : prefs.auth;
142
+ return decodeStored(src, overridden ? projectDir : null);
122
143
  }
123
144
 
124
145
  /**
@@ -130,38 +151,14 @@ export function loadAuthState(projectDir = null) {
130
151
  const prefs = readPrefs();
131
152
  const overridden = hasOverride(prefs, projectDir);
132
153
  return {
133
- effective: decodeStored(overridden ? prefs.authByProject[projectDir] : prefs.auth),
134
- global: decodeStored(prefs.auth),
154
+ effective: decodeStored(overridden ? prefs.authByProject[projectDir] : prefs.auth, overridden ? projectDir : null),
155
+ global: decodeStored(prefs.auth, null),
135
156
  scope: overridden ? 'project' : 'global',
136
157
  hasProjectOverride: overridden,
137
158
  projectDir: projectDir || null,
138
159
  };
139
160
  }
140
161
 
141
- function writePrefs(prefs) {
142
- const p = getPrefsPath();
143
- const dir = dirname(p);
144
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
145
- // Atomic tmp→rename so a crash mid-write can't truncate the password-bearing file
146
- // (preferences.json also holds UI prefs + per-project forks). Mirrors prefs-store.js.
147
- // TODO(prefs-lock): this path does NOT take the prefs-store lock (writePrefs is sync, its
148
- // callers are sync) — auth writes are rare/admin-initiated, so the residual lost-update
149
- // window vs a concurrent locked prefs write is accepted; atomicity rules out corruption.
150
- // Dormant only while every mutatePrefs mutator stays synchronous (no await between its
151
- // locked read and write); route auth writes through prefs-store.mutatePrefs if that changes.
152
- const tmp = `${p}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
153
- try {
154
- writeFileSync(tmp, JSON.stringify(prefs, null, 2), { mode: 0o600 });
155
- renameSyncWithRetry(tmp, p);
156
- // writeFileSync's mode only applies on creation; a pre-existing file keeps its old
157
- // perms. Re-assert 0600 — preferences.json now carries the (base64) password.
158
- try { chmodSync(p, 0o600); } catch { /* best-effort; non-POSIX or race */ }
159
- } catch (err) {
160
- try { unlinkSync(tmp); } catch {}
161
- throw err;
162
- }
163
- }
164
-
165
162
  /**
166
163
  * Persist auth config (read-merge-write, preserving all other prefs). `opts.scope`
167
164
  * 'global' → the `auth` key; 'project' → authByProject[opts.projectDir] (requires
@@ -170,26 +167,37 @@ function writePrefs(prefs) {
170
167
  */
171
168
  export function saveAuthConfig(cfg, opts = {}) {
172
169
  const normalized = normalizeAuth(cfg);
173
- const prefs = readPrefs();
174
170
  const scope = opts.scope === 'project' && opts.projectDir ? 'project' : 'global';
175
- if (scope === 'project') {
176
- if (!prefs.authByProject || typeof prefs.authByProject !== 'object') prefs.authByProject = {};
177
- prefs.authByProject[opts.projectDir] = encodeForDisk(normalized);
178
- } else {
179
- prefs.auth = encodeForDisk(normalized);
180
- }
181
- writePrefs(prefs);
171
+ const projectDir = scope === 'project' ? opts.projectDir : null;
172
+ // Write the password to the vault FIRST. Only on success do we persist { enabled } (sans
173
+ // password) to preferences.json — otherwise the legacy field must be preserved so the secret
174
+ // is not lost. (writeSecret verifies the key is usable and reports+false on failure.)
175
+ const wrote = writeSecret('lan-password', refFor(projectDir), normalized.password);
176
+ // Read-merge-write INSIDE the kernel's sync lock so a concurrent prefs/IM save can't be
177
+ // lost between our read and write.
178
+ mutateJsonSync(getPrefsPath(), (prefs) => {
179
+ // persisted shape: { enabled } plus the legacy base64 password ONLY when the vault write
180
+ // failed (preserves the secret for a later migration) — removed once vault-backed.
181
+ const legacyField = wrote ? {} : { password: encodePassword(normalized.password) };
182
+ if (scope === 'project') {
183
+ if (!prefs.authByProject || typeof prefs.authByProject !== 'object') prefs.authByProject = {};
184
+ prefs.authByProject[opts.projectDir] = { enabled: normalized.enabled, ...legacyField };
185
+ } else {
186
+ prefs.auth = { enabled: normalized.enabled, ...legacyField };
187
+ }
188
+ }, { mode: 0o600 });
182
189
  return normalized;
183
190
  }
184
191
 
185
192
  /** Remove a project's override so it inherits the global default again. No-op if absent. */
186
193
  export function clearProjectOverride(projectDir) {
187
194
  if (!projectDir) return;
188
- const prefs = readPrefs();
189
- if (prefs.authByProject && Object.prototype.hasOwnProperty.call(prefs.authByProject, projectDir)) {
190
- delete prefs.authByProject[projectDir];
191
- writePrefs(prefs);
192
- }
195
+ removeSecret('lan-password', refFor(projectDir));
196
+ mutateJsonSync(getPrefsPath(), (prefs) => {
197
+ if (prefs.authByProject && Object.prototype.hasOwnProperty.call(prefs.authByProject, projectDir)) {
198
+ delete prefs.authByProject[projectDir];
199
+ }
200
+ }, { mode: 0o600 });
193
201
  }
194
202
 
195
203
  /**
@@ -236,6 +244,7 @@ export function decideAuth(ctx) {
236
244
  isStaticAsset, pathname, isLocal,
237
245
  urlToken, cookieToken, accessToken,
238
246
  enabled, password, wantsHtml,
247
+ passwordUnreadable = false,
239
248
  } = ctx;
240
249
 
241
250
  // Admin = the credential-bearing / trusted-peer allow branches. Deliberately EXCLUDES the
@@ -246,6 +255,14 @@ export function decideAuth(ctx) {
246
255
  urlToken === accessToken ||
247
256
  cookieToken === accessToken;
248
257
 
258
+ // Fail-closed: a password that EXISTS but could not be decrypted (lost master.key / tampered
259
+ // ciphertext) must deny BEFORE the empty-password allow branch below — otherwise an unreadable
260
+ // secret collapses to '' and opens the LAN gate. Login page stays reachable so the admin can
261
+ // still authenticate via token (which does not depend on the vault).
262
+ if (passwordUnreadable && !isAdmin) {
263
+ return { action: wantsHtml ? 'login-page' : 'unauthorized', isAdmin };
264
+ }
265
+
249
266
  if (
250
267
  isStaticAsset ||
251
268
  pathname === '/api/auth/login' || // login endpoint must be reachable unauthenticated