From ac5c3b042fd9156422e5c3d91c974010e174c4c0 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 01:29:01 +0800 Subject: [PATCH 01/17] fix(desktop): keep cached Library sessions from mkdir empty custom roots Re-resolve the live binding on each request so a missing custom root stays unavailable instead of recreating an empty library. Same-disk restore continues through existing open/status; a rebuilt path remains binding-moved. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 68 +++++++++++++++++++ .../src/main/cindy-brain/librarySlot.ts | 27 +++++++- docs/dev-rules/plugin-library-storage.md | 5 +- 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index 70d1873205a..73e59633381 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -364,6 +364,74 @@ describe('GhostLibrarySlot', () => { expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID, 'a.txt'))).toBe(false); }); + it('cached custom session: missing root stays unavailable without mkdir; same disk recovers; recreated path is binding-moved', async () => { + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + expect(open.state).toBe('ready'); + expect(open.location).toBe('custom'); + const live = open as unknown as { libraryGeneration: number; libraryIdentity: string }; + expect(live.libraryGeneration).toBe(1); + const keep = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); + expect(keep.ok).toBe(true); + const customRoot = path.join(candidate, GHOST_ID); + expect(fs.existsSync(path.join(customRoot, 'keep.txt'))).toBe(true); + + const parked = `${candidate}.parked`; + await fs.promises.rename(candidate, parked); + expect(fs.existsSync(customRoot)).toBe(false); + + const missingOpen = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!missingOpen.ok || missingOpen.op !== 'open') throw new Error(JSON.stringify(missingOpen)); + expect(missingOpen.state).toBe('unavailable'); + expect(missingOpen.reason).toBe('disk-missing'); + expect(missingOpen.location).toBe('custom'); + const missingStatus = await slot.handleLibraryRequest(GHOST_ID, { op: 'status' }); + if (!missingStatus.ok || missingStatus.op !== 'status') throw new Error(JSON.stringify(missingStatus)); + expect(missingStatus.state).toBe('unavailable'); + expect(missingStatus.reason).toBe('disk-missing'); + const blocked = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'empty.txt', content: 'nope' }); + expect(blocked).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + expect(fs.existsSync(customRoot)).toBe(false); + expect(fs.existsSync(candidate)).toBe(false); + expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID, 'keep.txt'))).toBe(false); + expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID, 'empty.txt'))).toBe(false); + expect((missingOpen as unknown as { libraryIdentity: string }).libraryIdentity).toBe(live.libraryIdentity); + expect((missingOpen as unknown as { libraryGeneration: number }).libraryGeneration).toBe(live.libraryGeneration); + + await fs.promises.rename(parked, candidate); + const recovered = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!recovered.ok || recovered.op !== 'open') throw new Error(JSON.stringify(recovered)); + expect(recovered.state).toBe('ready'); + expect(recovered.reason).toBeUndefined(); + expect(recovered.location).toBe('custom'); + expect((recovered as unknown as { libraryIdentity: string }).libraryIdentity).toBe(live.libraryIdentity); + expect((recovered as unknown as { libraryGeneration: number }).libraryGeneration).toBe(live.libraryGeneration); + const recoveredStatus = await slot.handleLibraryRequest(GHOST_ID, { op: 'status' }); + if (!recoveredStatus.ok || recoveredStatus.op !== 'status') throw new Error(JSON.stringify(recoveredStatus)); + expect(recoveredStatus.state).toBe('ready'); + const reread = await slot.handleLibraryRequest(GHOST_ID, { op: 'read', path: 'keep.txt' }); + if (!reread.ok || reread.op !== 'read') throw new Error(JSON.stringify(reread)); + expect(reread.content).toBe('keep-me'); + const retryWrite = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'retry.txt', content: 'after-restore' }); + expect(retryWrite.ok).toBe(true); + expect(fs.existsSync(path.join(customRoot, 'retry.txt'))).toBe(true); + + if (process.platform === 'win32') return; + await fs.promises.rm(candidate, { recursive: true }); + await fs.promises.mkdir(candidate, { recursive: true }); + const moved = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!moved.ok || moved.op !== 'open') throw new Error(JSON.stringify(moved)); + expect(moved.state).toBe('unavailable'); + expect(moved.reason).toBe('binding-moved'); + expect(moved.location).toBe('custom'); + const movedWrite = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'wrong-root.txt', content: 'nope' }); + expect(movedWrite).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + expect(fs.existsSync(path.join(candidate, GHOST_ID, 'wrong-root.txt'))).toBe(false); + expect(fs.existsSync(path.join(candidate, GHOST_ID, 'keep.txt'))).toBe(false); + }); + it('重装自愈:meta 带 orphaned 标记时,会话建立自动清除', async () => { const root = path.join(defaultRootBase, GHOST_ID); await fs.promises.mkdir(path.join(root, '.cindy-library'), { recursive: true }); diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index 3fc4e4db3ef..74481a39c7a 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -314,8 +314,12 @@ export class GhostLibrarySlot { await this.teardownSession(ghostId); session = undefined; } + const resolution = await this.deps.bindingStore.resolveLibraryRoot(ghostId); + if (session && !this.sessionMatchesResolution(session, resolution)) { + await this.teardownSession(ghostId); + session = undefined; + } if (!session) { - const resolution = await this.deps.bindingStore.resolveLibraryRoot(ghostId); session = this.createSession(ghostId, resolution, scopeKey); this.sessions.set(ghostId, session); // 会话建立即自动 open vault(幂等):消除"write 前忘 open"的脚枪。 @@ -363,6 +367,22 @@ export class GhostLibrarySlot { } } + /** Cached sessions must re-check the live binding; a missing custom root is unavailable, not an empty mkdir. */ + private sessionMatchesResolution( + session: GhostLibrarySession, + resolution: LibraryLocationResolution, + ): boolean { + const drift = 'drift' in resolution && resolution.root === null ? resolution.drift : null; + if (session.drift !== drift || session.locationKind !== resolution.kind) return false; + const record = 'record' in resolution ? resolution.record : undefined; + if (session.generation !== (record?.generation ?? 0)) return false; + if (drift !== null) return true; + const root = resolution.kind === 'custom' && resolution.root !== null + ? resolution.root + : this.deps.getDefaultRoot(session.ghostId); + return session.vault.getRootDir() === root; + } + private createSession( ghostId: string, resolution: LibraryLocationResolution, @@ -387,11 +407,14 @@ export class GhostLibrarySlot { }); const record = 'record' in resolution ? resolution.record : undefined; const generation = record?.generation ?? 0; + const identityRoot = drift !== null && record + ? path.join(record.realPathAtGrant, ghostId) + : root; const identity = mintLibraryEpochIdentity({ ghostId, ownerScopeKey: scopeKey, generation, - rootDir: root, + rootDir: identityRoot, grantedAt: record?.grantedAt ?? 0, }); return { diff --git a/docs/dev-rules/plugin-library-storage.md b/docs/dev-rules/plugin-library-storage.md index f511ebf66cb..404a875fb75 100644 --- a/docs/dev-rules/plugin-library-storage.md +++ b/docs/dev-rules/plugin-library-storage.md @@ -45,7 +45,10 @@ backups)对插件不可达——路径语法段首不许点,协议层天然 1. **不可用 ≠ 空**:meta 损坏 → `unavailable(corrupt)`;binding 漂移 → `binding-moved` / `disk-missing`。宿主不自动重建、不清空、不回退写默认根、 - 不触发 GC、不判素材已删。插件侧同样语义写进了 FORGE_GUIDE。 + 不触发 GC、不判素材已删。缓存中的 custom 会话在真实根消失后必须现解 + binding:`open`/`status` 报 unavailable,不得 `mkdir` 重建空库;同一磁盘 + 对象归位后既有 `open`/`status` 恢复,删后重建的同路径是 `binding-moved` + 不是原盘回归。插件侧同样语义写进了 FORGE_GUIDE。 2. **卸载不删**:uninstall 只标 orphaned + 作废会话;binding 保留(用户亲选 事实不因重装消失)。删除 = 设置页独立破坏性确认 + `trashGhostLibrary` (rename 进回收站,漂移时 NOT_FOUND 不误删)。内置插件退役清理 From dd7f150689b30a8cebb3cc60fc12e7dcd05c1628 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 01:35:13 +0800 Subject: [PATCH 02/17] fix(desktop): do not mint unavailable Library identity from default root Unavailable cached custom sessions are not required to keep the live identity. Restore still uses the original disk object; a replaced directory stays binding-moved. Signed-off-by: PraiseZhu --- .../src/main/cindy-brain/__tests__/librarySlot.test.ts | 5 ++--- apps/desktop/src/main/cindy-brain/librarySlot.ts | 5 +---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index 73e59633381..26a3cb2391d 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -397,8 +397,6 @@ describe('GhostLibrarySlot', () => { expect(fs.existsSync(candidate)).toBe(false); expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID, 'keep.txt'))).toBe(false); expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID, 'empty.txt'))).toBe(false); - expect((missingOpen as unknown as { libraryIdentity: string }).libraryIdentity).toBe(live.libraryIdentity); - expect((missingOpen as unknown as { libraryGeneration: number }).libraryGeneration).toBe(live.libraryGeneration); await fs.promises.rename(parked, candidate); const recovered = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); @@ -419,7 +417,8 @@ describe('GhostLibrarySlot', () => { expect(fs.existsSync(path.join(customRoot, 'retry.txt'))).toBe(true); if (process.platform === 'win32') return; - await fs.promises.rm(candidate, { recursive: true }); + const replaced = `${candidate}.replaced`; + await fs.promises.rename(candidate, replaced); await fs.promises.mkdir(candidate, { recursive: true }); const moved = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); if (!moved.ok || moved.op !== 'open') throw new Error(JSON.stringify(moved)); diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index 74481a39c7a..99cdcf1bfcf 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -407,14 +407,11 @@ export class GhostLibrarySlot { }); const record = 'record' in resolution ? resolution.record : undefined; const generation = record?.generation ?? 0; - const identityRoot = drift !== null && record - ? path.join(record.realPathAtGrant, ghostId) - : root; const identity = mintLibraryEpochIdentity({ ghostId, ownerScopeKey: scopeKey, generation, - rootDir: identityRoot, + rootDir: root, grantedAt: record?.grantedAt ?? 0, }); return { From f255e0db1e8b889491d5ed22690d19825009dafd Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 17:12:44 +0800 Subject: [PATCH 03/17] fix(desktop): fail closed when a custom Library parent vanishes A stale custom resolveLibraryRoot could still reach vault.open, which recursively created the missing user parent and reported ready empty. Custom open now refuses to recreate a vanished parent (disk-missing); default first-time create still mkdir. keep files remain in the renamed directory and are not treated as deleted. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 30 +++++++++++++ .../__tests__/libraryVault.test.ts | 25 +++++++++++ .../src/main/cindy-brain/librarySlot.ts | 29 +++++++++++- .../src/main/cindy-brain/libraryVault.ts | 44 ++++++++++++++++++- docs/dev-rules/plugin-library-storage.md | 8 ++-- 5 files changed, 130 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index 26a3cb2391d..7e8d3937429 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -431,6 +431,36 @@ describe('GhostLibrarySlot', () => { expect(fs.existsSync(path.join(candidate, GHOST_ID, 'keep.txt'))).toBe(false); }); + it('delayed resolveLibraryRoot: stale custom after parent rename is disk-missing without recreating empty library', async () => { + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + expect(open.state).toBe('ready'); + const keep = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); + expect(keep.ok).toBe(true); + const customRoot = path.join(candidate, GHOST_ID); + expect(fs.existsSync(path.join(customRoot, 'keep.txt'))).toBe(true); + const parked = `${candidate}.parked`; + resolveLibraryRoot.mockImplementation(async (ghostId: string) => { + const resolution = await LibraryBindingStore.prototype.resolveLibraryRoot.call(bindingStore, ghostId); + if (resolution.kind === 'custom' && resolution.root !== null) { + if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); + } + return resolution; + }); + const after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!after.ok || after.op !== 'open') throw new Error(JSON.stringify(after)); + expect(after.state).toBe('unavailable'); + expect(after.reason).toBe('disk-missing'); + expect(fs.existsSync(candidate)).toBe(false); + expect(fs.existsSync(customRoot)).toBe(false); + expect(fs.existsSync(path.join(parked, GHOST_ID, 'keep.txt'))).toBe(true); + expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID, 'keep.txt'))).toBe(false); + const blocked = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'empty.txt', content: 'nope' }); + expect(blocked).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + }); + it('重装自愈:meta 带 orphaned 标记时,会话建立自动清除', async () => { const root = path.join(defaultRootBase, GHOST_ID); await fs.promises.mkdir(path.join(root, '.cindy-library'), { recursive: true }); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index 5069a386c54..c74180ef552 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -123,6 +123,31 @@ describe('LibraryVault', () => { expect(stat.isDirectory()).toBe(true); }); + it('custom 用户父目录消失: open 报 disk-missing 且不重建空库; keep 仍在 rename 走的目录', async () => { + const parent = path.join(tmpRoot, 'picked'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(custom, { recursive: true }); + await fs.promises.writeFile(path.join(custom, 'keep.txt'), 'keep-me'); + const first = makeVault({ rootDir: () => custom, locationKind: 'custom' }); + const opened = await first.open(); + expect(opened).toMatchObject({ ok: true, state: 'ready' }); + await fs.promises.rename(parent, `${parent}.parked`); + const second = makeVault({ rootDir: () => custom, locationKind: 'custom' }); + const missing = await second.open(); + expect(missing).toMatchObject({ ok: true, state: 'unavailable', reason: 'disk-missing' }); + expect(fs.existsSync(parent)).toBe(false); + expect(fs.existsSync(custom)).toBe(false); + expect(fs.existsSync(path.join(`${parent}.parked`, 'mivo-canvas', 'keep.txt'))).toBe(true); + }); + + it('default 缺失根仍可首次创建', async () => { + const missing = path.join(tmpRoot, 'brand-new-default', 'ghost'); + const vault = makeVault({ rootDir: () => missing, locationKind: 'default' }); + const opened = await vault.open(); + expect(opened).toMatchObject({ ok: true, state: 'ready' }); + expect(fs.existsSync(path.join(missing, '.cindy-library', 'meta.json'))).toBe(true); + }); + it('meta 损坏 → unavailable(corrupt),绝不静默重建空库', async () => { await fs.promises.mkdir(path.join(libraryRoot, '.cindy-library'), { recursive: true }); await fs.promises.writeFile(path.join(libraryRoot, '.cindy-library', 'meta.json'), '{not json'); diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index 99cdcf1bfcf..ec546ff8da5 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -314,7 +314,9 @@ export class GhostLibrarySlot { await this.teardownSession(ghostId); session = undefined; } - const resolution = await this.deps.bindingStore.resolveLibraryRoot(ghostId); + const resolution = await this.confirmLiveCustomRoot( + await this.deps.bindingStore.resolveLibraryRoot(ghostId), + ); if (session && !this.sessionMatchesResolution(session, resolution)) { await this.teardownSession(ghostId); session = undefined; @@ -367,6 +369,23 @@ export class GhostLibrarySlot { } } + /** Stale custom resolution after the user parent vanished is disk-missing; do not open/mkdir. */ + private async confirmLiveCustomRoot( + resolution: LibraryLocationResolution, + ): Promise { + if (resolution.kind !== 'custom' || resolution.root === null) return resolution; + const parent = path.dirname(resolution.root); + try { + const st = await fs.promises.lstat(parent); + if (st.isSymbolicLink() || !st.isDirectory()) { + return { kind: 'custom', root: null, drift: 'disk-missing', record: resolution.record }; + } + } catch { + return { kind: 'custom', root: null, drift: 'disk-missing', record: resolution.record }; + } + return resolution; + } + /** Cached sessions must re-check the live binding; a missing custom root is unavailable, not an empty mkdir. */ private sessionMatchesResolution( session: GhostLibrarySession, @@ -612,6 +631,14 @@ export class GhostLibrarySlot { case 'open': { const r = await vault.open(); if (!r.ok) return vaultFail(r); + if (r.state === 'unavailable' && (r.reason === 'disk-missing' || r.reason === 'binding-moved')) { + session.drift = r.reason; + const drifted = { + ok: true as const, op: 'open' as const, state: 'unavailable' as const, + reason: r.reason, usedBytes: 0, fileCount: 0, location: session.locationKind, + }; + return { ...drifted, ...this.handshakeFields(session, 'unavailable') } as GhostPipeLibraryResult; + } this.extraDirOpenerGhostId = ghostId; await this.syncAgentReadonlyExtraDir(ghostId, vault.getRootDir()); const body = { diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index 7856fdb1f18..61f7185e2e9 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -136,7 +136,7 @@ export interface LibraryVaultDeps { * 兜底——比假装知道更诚实。 */ getDiskFreeBytes?(root: string): Promise; - /** 位置类别(仅透传给 status;binding 层提供,默认系统管理位置)。 */ + /** 位置类别。custom 根的用户父目录消失时 open 必须 fail-closed,不得 recursive mkdir 空库。 */ locationKind?: 'default' | 'custom'; log?: { info: (msg: string, meta?: Record) => void; @@ -341,6 +341,26 @@ export class LibraryVault { return fsFailure(err, 'INTERNAL', message); } + /** Custom roots must not recreate a vanished user-selected parent. keep files stay in the renamed-away directory. */ + private customRootUnavailable(): LibrarySuccess<{ state: LibraryState; reason: string | null; usedBytes: number; fileCount: number }> { + this.state = 'unavailable'; + this.unavailableReason = 'disk-missing'; + this.opened = true; + return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; + } + + private async missingCustomParent(): Promise | null> { + const parent = path.dirname(this.root); + try { + const st = await fs.promises.lstat(parent); + if (st.isSymbolicLink() || !st.isDirectory()) return this.customRootUnavailable(); + return null; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable(); + throw err; + } + } + /* ── 打开与状态 ─────────────────────────────────────────────────── */ /** @@ -353,10 +373,30 @@ export class LibraryVault { return fail('LIBRARY_UNAVAILABLE', 'Library 实例已作废(owner 切换/宿主收口);请重新 open'); } try { - await fs.promises.mkdir(this.root, { recursive: true }); + if ((this.deps.locationKind ?? 'default') === 'custom') { + const missing = await this.missingCustomParent(); + if (missing) return missing; + try { + await fs.promises.mkdir(this.root); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return this.customRootUnavailable(); + } + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + const st = await fs.promises.lstat(this.root); + if (st.isSymbolicLink() || !st.isDirectory()) { + return this.customRootUnavailable(); + } + } + } else { + await fs.promises.mkdir(this.root, { recursive: true }); + } await fs.promises.mkdir(this.tmpDir, { recursive: true }); await fs.promises.mkdir(path.join(this.metaDir, 'backups'), { recursive: true }); } catch (err) { + if ((this.deps.locationKind ?? 'default') === 'custom' && (err as NodeJS.ErrnoException).code === 'ENOENT') { + return this.customRootUnavailable(); + } this.state = 'unavailable'; this.unavailableReason = 'permission'; this.deps.log?.warn('library open: cannot create root', { error: err instanceof Error ? err.message : String(err) }); diff --git a/docs/dev-rules/plugin-library-storage.md b/docs/dev-rules/plugin-library-storage.md index 404a875fb75..e0b8c46f003 100644 --- a/docs/dev-rules/plugin-library-storage.md +++ b/docs/dev-rules/plugin-library-storage.md @@ -46,9 +46,11 @@ backups)对插件不可达——路径语法段首不许点,协议层天然 1. **不可用 ≠ 空**:meta 损坏 → `unavailable(corrupt)`;binding 漂移 → `binding-moved` / `disk-missing`。宿主不自动重建、不清空、不回退写默认根、 不触发 GC、不判素材已删。缓存中的 custom 会话在真实根消失后必须现解 - binding:`open`/`status` 报 unavailable,不得 `mkdir` 重建空库;同一磁盘 - 对象归位后既有 `open`/`status` 恢复,删后重建的同路径是 `binding-moved` - 不是原盘回归。插件侧同样语义写进了 FORGE_GUIDE。 + binding:`open`/`status` 报 unavailable,不得 `mkdir` 重建空库。custom vault.open + 不得 recursive mkdir 已消失的用户父目录(父目录不在 → `disk-missing`);仅在父目录仍在时 + 允许创建 `/` 子目录。合法 default 首次创建仍可 mkdir。被 rename 走的 + 原件仍在旧目录,不称已删除。同一磁盘对象归位后既有 `open`/`status` 恢复,删后重建的 + 同路径是 `binding-moved` 不是原盘回归。插件侧同样语义写进了 FORGE_GUIDE。 2. **卸载不删**:uninstall 只标 orphaned + 作废会话;binding 保留(用户亲选 事实不因重装消失)。删除 = 设置页独立破坏性确认 + `trashGhostLibrary` (rename 进回收站,漂移时 NOT_FOUND 不误删)。内置插件退役清理 From f43ab2a52ba7cbea6d7aeab9f422874b94583088 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 17:59:53 +0800 Subject: [PATCH 04/17] fix(desktop): reject replaced custom parents and revoke extraDir on drift Stale resolve plus a same-path new inode is binding-moved at the mkdir boundary. disk-missing after a granted extraDir actually revokes the slot. Auto-open failures latch drift so status-only restore works. Keep files stay in the renamed-away directory. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 85 +++++++++++++++++++ .../src/main/cindy-brain/librarySlot.ts | 44 ++++++++-- .../src/main/cindy-brain/libraryVault.ts | 44 +++++++--- docs/dev-rules/plugin-library-storage.md | 9 +- 4 files changed, 163 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index 7e8d3937429..80181145d7e 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -461,6 +461,91 @@ describe('GhostLibrarySlot', () => { expect(blocked).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); }); + it('stale resolve 后 rename+同路径新 inode:不得建空库或授权错误根', async () => { + if (process.platform === 'win32') return; + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + expect(open.state).toBe('ready'); + const keep = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); + expect(keep.ok).toBe(true); + const parked = `${candidate}.parked`; + resolveLibraryRoot.mockImplementation(async (ghostId: string) => { + const resolution = await LibraryBindingStore.prototype.resolveLibraryRoot.call(bindingStore, ghostId); + if (resolution.kind === 'custom' && resolution.root !== null && fs.existsSync(candidate)) { + await fs.promises.rename(candidate, parked); + await fs.promises.mkdir(candidate, { recursive: true }); + } + return resolution; + }); + const after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!after.ok || after.op !== 'open') throw new Error(JSON.stringify(after)); + expect(after.state).toBe('unavailable'); + expect(after.reason).toBe('binding-moved'); + expect(fs.existsSync(path.join(candidate, GHOST_ID, '.cindy-library', 'meta.json'))).toBe(false); + expect(fs.existsSync(path.join(parked, GHOST_ID, 'keep.txt'))).toBe(true); + expect(after.authorizedReadonly).toBe(false); + const extraRoots = syncAgentReadonlyExtraDir.mock.calls.filter((call) => call[0] === GHOST_ID).map((call) => call[1]); + expect(extraRoots.at(-1) ?? 'none').not.toBe(path.join(candidate, GHOST_ID)); + }); + + it('已挂 extraDir 时 confirm 与 vault.open 间 disk-missing 必须撤 grant', async () => { + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + expect(open.authorizedReadonly).toBe(true); + const grantedRoot = syncAgentReadonlyExtraDir.mock.calls.find((call) => call[0] === GHOST_ID && call[1] !== null)?.[1]; + expect(typeof grantedRoot).toBe('string'); + const parked = `${candidate}.parked`; + const vault = createVault.mock.results.at(-1)?.value as LibraryVault; + const orig = vault.open.bind(vault); + vault.open = async () => { + if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); + return orig(); + }; + const raced = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!raced.ok || raced.op !== 'open') throw new Error(JSON.stringify(raced)); + expect(raced.state).toBe('unavailable'); + expect(raced.reason).toBe('disk-missing'); + expect(raced.authorizedReadonly).toBe(false); + const nullGrants = syncAgentReadonlyExtraDir.mock.calls.filter((call) => call[0] === GHOST_ID && call[1] === null); + expect(nullGrants.length).toBeGreaterThan(0); + }); + + it('auto-open 失败后同盘归位只 status 须恢复', async () => { + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); + const parked = `${candidate}.parked`; + let vanishOnOpen = true; + createVault.mockImplementation((deps) => { + const vault = new LibraryVault(deps); + const orig = vault.open.bind(vault); + vault.open = async () => { + if (vanishOnOpen && fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); + return orig(); + }; + return vault; + }); + await slot.disposeAll(); + const statusMissing = await slot.handleLibraryRequest(GHOST_ID, { op: 'status' }); + if (!statusMissing.ok || statusMissing.op !== 'status') throw new Error(JSON.stringify(statusMissing)); + expect(statusMissing.state).toBe('unavailable'); + expect(statusMissing.reason).toBe('disk-missing'); + vanishOnOpen = false; + await fs.promises.rename(parked, candidate); + const statusRestored = await slot.handleLibraryRequest(GHOST_ID, { op: 'status' }); + if (!statusRestored.ok || statusRestored.op !== 'status') throw new Error(JSON.stringify(statusRestored)); + expect(statusRestored.state).toBe('ready'); + const reread = await slot.handleLibraryRequest(GHOST_ID, { op: 'read', path: 'keep.txt' }); + if (!reread.ok || reread.op !== 'read') throw new Error(JSON.stringify(reread)); + expect(reread.content).toBe('keep-me'); + }); + it('重装自愈:meta 带 orphaned 标记时,会话建立自动清除', async () => { const root = path.join(defaultRootBase, GHOST_ID); await fs.promises.mkdir(path.join(root, '.cindy-library'), { recursive: true }); diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index ec546ff8da5..5e3b36a4f25 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -327,10 +327,16 @@ export class GhostLibrarySlot { // 会话建立即自动 open vault(幂等):消除"write 前忘 open"的脚枪。 // extraDirs 只在显式 open 时挂,status / 首次任意请求不得抢槽。 if (session.drift === null) { - await session.vault.open(); - // 重装自愈:能走到这里 = 插件已装入且启用,清掉卸载时留的 orphaned - // 标记(best-effort,失败不影响使用)。 - if (session.vault.getMeta()?.orphaned) { + const opened = await session.vault.open(); + if ( + opened.ok + && opened.state === 'unavailable' + && (opened.reason === 'disk-missing' || opened.reason === 'binding-moved') + ) { + await this.latchCustomUnavailable(session, ghostId, opened.reason); + } else if (session.vault.getMeta()?.orphaned) { + // 重装自愈:能走到这里 = 插件已装入且启用,清掉卸载时留的 orphaned + // 标记(best-effort,失败不影响使用)。 await session.vault.clearOrphaned().catch(() => {}); } } else if (this.extraDirGrant?.ghostId === ghostId) { @@ -369,7 +375,7 @@ export class GhostLibrarySlot { } } - /** Stale custom resolution after the user parent vanished is disk-missing; do not open/mkdir. */ + /** Stale custom resolution after the user parent vanished or was replaced must not open/mkdir. */ private async confirmLiveCustomRoot( resolution: LibraryLocationResolution, ): Promise { @@ -380,12 +386,35 @@ export class GhostLibrarySlot { if (st.isSymbolicLink() || !st.isDirectory()) { return { kind: 'custom', root: null, drift: 'disk-missing', record: resolution.record }; } + let real: string; + try { + real = await fs.promises.realpath(parent); + } catch { + return { kind: 'custom', root: null, drift: 'disk-missing', record: resolution.record }; + } + if (real !== resolution.record.realPathAtGrant) { + return { kind: 'custom', root: null, drift: 'binding-moved', record: resolution.record }; + } + const identity = resolution.record.identity; + if (identity && identity.ino !== 0 && (st.dev !== identity.dev || st.ino !== identity.ino)) { + return { kind: 'custom', root: null, drift: 'binding-moved', record: resolution.record }; + } } catch { return { kind: 'custom', root: null, drift: 'disk-missing', record: resolution.record }; } return resolution; } + private async latchCustomUnavailable( + session: GhostLibrarySession, + ghostId: string, + reason: 'disk-missing' | 'binding-moved', + ): Promise { + session.drift = reason; + if (this.extraDirOpenerGhostId === ghostId) this.extraDirOpenerGhostId = null; + await this.syncAgentReadonlyExtraDir(ghostId, null); + } + /** Cached sessions must re-check the live binding; a missing custom root is unavailable, not an empty mkdir. */ private sessionMatchesResolution( session: GhostLibrarySession, @@ -417,6 +446,9 @@ export class GhostLibrarySlot { ghostId, getDiskFreeBytes: this.deps.getDiskFreeBytes, locationKind: resolution.kind, + customParentGrant: resolution.kind === 'custom' && resolution.root !== null + ? { realPathAtGrant: resolution.record.realPathAtGrant, identity: resolution.record.identity } + : undefined, log: this.deps.log, }); const sql = this.deps.createSqlService({ @@ -632,7 +664,7 @@ export class GhostLibrarySlot { const r = await vault.open(); if (!r.ok) return vaultFail(r); if (r.state === 'unavailable' && (r.reason === 'disk-missing' || r.reason === 'binding-moved')) { - session.drift = r.reason; + await this.latchCustomUnavailable(session, ghostId, r.reason); const drifted = { ok: true as const, op: 'open' as const, state: 'unavailable' as const, reason: r.reason, usedBytes: 0, fileCount: 0, location: session.locationKind, diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index 61f7185e2e9..53b99edee17 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -138,6 +138,11 @@ export interface LibraryVaultDeps { getDiskFreeBytes?(root: string): Promise; /** 位置类别。custom 根的用户父目录消失时 open 必须 fail-closed,不得 recursive mkdir 空库。 */ locationKind?: 'default' | 'custom'; + /** Custom parent identity from binding; compared at mkdir so a replaced inode cannot mint an empty library. */ + customParentGrant?: { + realPathAtGrant: string; + identity: { dev: number; ino: number } | null; + }; log?: { info: (msg: string, meta?: Record) => void; warn: (msg: string, meta?: Record) => void; @@ -341,22 +346,39 @@ export class LibraryVault { return fsFailure(err, 'INTERNAL', message); } - /** Custom roots must not recreate a vanished user-selected parent. keep files stay in the renamed-away directory. */ - private customRootUnavailable(): LibrarySuccess<{ state: LibraryState; reason: string | null; usedBytes: number; fileCount: number }> { + /** Custom roots must not recreate a vanished or replaced user-selected parent. keep files stay in the renamed-away directory. */ + private customRootUnavailable( + reason: 'disk-missing' | 'binding-moved' = 'disk-missing', + ): LibrarySuccess<{ state: LibraryState; reason: string | null; usedBytes: number; fileCount: number }> { this.state = 'unavailable'; - this.unavailableReason = 'disk-missing'; + this.unavailableReason = reason; this.opened = true; return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; } - private async missingCustomParent(): Promise | null> { + private async inspectCustomParent(): Promise | null> { const parent = path.dirname(this.root); + const grant = this.deps.customParentGrant; try { const st = await fs.promises.lstat(parent); - if (st.isSymbolicLink() || !st.isDirectory()) return this.customRootUnavailable(); + if (st.isSymbolicLink() || !st.isDirectory()) return this.customRootUnavailable('disk-missing'); + let real: string; + try { + real = await fs.promises.realpath(parent); + } catch { + return this.customRootUnavailable('disk-missing'); + } + if (grant && real !== grant.realPathAtGrant) return this.customRootUnavailable('binding-moved'); + if ( + grant?.identity + && grant.identity.ino !== 0 + && (st.dev !== grant.identity.dev || st.ino !== grant.identity.ino) + ) { + return this.customRootUnavailable('binding-moved'); + } return null; } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable(); + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable('disk-missing'); throw err; } } @@ -374,20 +396,22 @@ export class LibraryVault { } try { if ((this.deps.locationKind ?? 'default') === 'custom') { - const missing = await this.missingCustomParent(); - if (missing) return missing; + const before = await this.inspectCustomParent(); + if (before) return before; try { await fs.promises.mkdir(this.root); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return this.customRootUnavailable(); + return this.customRootUnavailable('disk-missing'); } if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; const st = await fs.promises.lstat(this.root); if (st.isSymbolicLink() || !st.isDirectory()) { - return this.customRootUnavailable(); + return this.customRootUnavailable('disk-missing'); } } + const after = await this.inspectCustomParent(); + if (after) return after; } else { await fs.promises.mkdir(this.root, { recursive: true }); } diff --git a/docs/dev-rules/plugin-library-storage.md b/docs/dev-rules/plugin-library-storage.md index e0b8c46f003..c2ece161e05 100644 --- a/docs/dev-rules/plugin-library-storage.md +++ b/docs/dev-rules/plugin-library-storage.md @@ -48,9 +48,12 @@ backups)对插件不可达——路径语法段首不许点,协议层天然 不触发 GC、不判素材已删。缓存中的 custom 会话在真实根消失后必须现解 binding:`open`/`status` 报 unavailable,不得 `mkdir` 重建空库。custom vault.open 不得 recursive mkdir 已消失的用户父目录(父目录不在 → `disk-missing`);仅在父目录仍在时 - 允许创建 `/` 子目录。合法 default 首次创建仍可 mkdir。被 rename 走的 - 原件仍在旧目录,不称已删除。同一磁盘对象归位后既有 `open`/`status` 恢复,删后重建的 - 同路径是 `binding-moved` 不是原盘回归。插件侧同样语义写进了 FORGE_GUIDE。 + 允许创建 `/` 子目录。建根边界须复核 binding 的 realpath/dev/ino:同路径新对象是 + `binding-moved`,不得初始化空库或把 extraDir 授权到错误根。已挂 extraDir 后若 open 进入 drift,必须 + 实际撤 grant。自动 open 失败要把 drift 记进 session,同盘归位后仅 `status` 也须恢复。合法 default + 首次创建仍可 mkdir。被 rename 走的原件仍在旧目录,不称已删除。同一磁盘对象归位后既有 `open`/`status` + 恢复,删后重建的同路径是 `binding-moved` 不是原盘回归。Windows st_ino=0 检不出同路径重建,已知限制。 + 插件侧同样语义写进了 FORGE_GUIDE。 2. **卸载不删**:uninstall 只标 orphaned + 作废会话;binding 保留(用户亲选 事实不因重装消失)。删除 = 设置页独立破坏性确认 + `trashGhostLibrary` (rename 进回收站,漂移时 NOT_FOUND 不误删)。内置插件退役清理 From e5df252ef4ccf7b53ee214275175543c152ca3f6 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 18:43:55 +0800 Subject: [PATCH 05/17] fix(desktop): do not recursive-mkdir custom Library parent after inspect Custom skeleton mkdir is non-recursive so a vanished user parent cannot be rebuilt as an empty library. Default roots still create recursively. Keep files stay in the renamed-away directory. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 38 ++++++++++++++++++ .../__tests__/libraryVault.test.ts | 39 ++++++++++++++++++- .../src/main/cindy-brain/libraryVault.ts | 21 +++++++++- 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index 80181145d7e..949938fd7ee 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -490,6 +490,44 @@ describe('GhostLibrarySlot', () => { expect(extraRoots.at(-1) ?? 'none').not.toBe(path.join(candidate, GHOST_ID)); }); + it('最后一次 inspect 后骨架 mkdir 前父目录被移走: disk-missing, keep 留 parked, 不授权空根', async () => { + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + expect(open.state).toBe('ready'); + const keep = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); + expect(keep.ok).toBe(true); + const parked = `${candidate}.parked`; + const realMkdir = fs.promises.mkdir.bind(fs.promises); + let injected = false; + const mkdirSpy = vi.spyOn(fs.promises, 'mkdir').mockImplementation(async (target, options) => { + const dest = String(target); + if (!injected && dest.includes(`${path.sep}.cindy-library`)) { + injected = true; + if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); + } + return realMkdir(target, options); + }); + let after: Awaited>; + try { + after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + } finally { + mkdirSpy.mockRestore(); + } + if (!after.ok || after.op !== 'open') throw new Error(JSON.stringify(after)); + expect(injected).toBe(true); + expect(after.state).toBe('unavailable'); + expect(after.reason).toBe('disk-missing'); + expect(after.authorizedReadonly).toBe(false); + expect(fs.existsSync(candidate)).toBe(false); + expect(fs.existsSync(path.join(candidate, GHOST_ID, '.cindy-library', 'meta.json'))).toBe(false); + expect(fs.existsSync(path.join(parked, GHOST_ID, 'keep.txt'))).toBe(true); + expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID, 'keep.txt'))).toBe(false); + const extraRoots = syncAgentReadonlyExtraDir.mock.calls.filter((call) => call[0] === GHOST_ID).map((call) => call[1]); + expect(extraRoots.at(-1) ?? 'none').not.toBe(path.join(candidate, GHOST_ID)); + }); + it('已挂 extraDir 时 confirm 与 vault.open 间 disk-missing 必须撤 grant', async () => { const bound = await bindingStore.setBinding(GHOST_ID, candidate); expect(bound.ok).toBe(true); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index c74180ef552..b21f263645d 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -4,7 +4,7 @@ * os.tmpdir 临时目录(规则 23:生成物不落仓库工作区),零 Electron。 * symlink 用例带能力探针(Windows 无特权时跳过;POSIX CI 实跑)。 */ -import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -140,6 +140,43 @@ describe('LibraryVault', () => { expect(fs.existsSync(path.join(`${parent}.parked`, 'mivo-canvas', 'keep.txt'))).toBe(true); }); + it('custom 最后一次 inspect 后、骨架 mkdir 前父目录被移走:不得 recursive 重建空库', async () => { + const parent = path.join(tmpRoot, 'picked-411'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(custom, { recursive: true }); + await fs.promises.writeFile(path.join(custom, 'keep.txt'), 'keep-me'); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const first = makeVault({ rootDir: () => custom, locationKind: 'custom', customParentGrant: grant }); + expect(await first.open()).toMatchObject({ ok: true, state: 'ready' }); + const parked = `${parent}.parked`; + const realMkdir = fs.promises.mkdir.bind(fs.promises); + let injected = false; + const mkdirSpy = vi.spyOn(fs.promises, 'mkdir').mockImplementation(async (target, options) => { + const dest = String(target); + if (!injected && dest.includes(`${path.sep}.cindy-library`)) { + injected = true; + if (fs.existsSync(parent)) await fs.promises.rename(parent, parked); + } + return realMkdir(target, options); + }); + const raced = makeVault({ rootDir: () => custom, locationKind: 'custom', customParentGrant: grant }); + let missing: Awaited>; + try { + missing = await raced.open(); + } finally { + mkdirSpy.mockRestore(); + } + expect(injected).toBe(true); + expect(missing).toMatchObject({ ok: true, state: 'unavailable', reason: 'disk-missing' }); + expect(fs.existsSync(parent)).toBe(false); + expect(fs.existsSync(path.join(custom, '.cindy-library', 'meta.json'))).toBe(false); + expect(fs.existsSync(path.join(parked, 'mivo-canvas', 'keep.txt'))).toBe(true); + }); + it('default 缺失根仍可首次创建', async () => { const missing = path.join(tmpRoot, 'brand-new-default', 'ghost'); const vault = makeVault({ rootDir: () => missing, locationKind: 'default' }); diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index 53b99edee17..589a8f47df5 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -383,6 +383,19 @@ export class LibraryVault { } } + /** Custom skeleton never uses recursive mkdir: that would rebuild a vanished user parent. */ + private async mkdirCustomSkeleton(): Promise | null> { + for (const dir of [this.metaDir, this.tmpDir, path.join(this.metaDir, 'backups')]) { + try { + await fs.promises.mkdir(dir); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable('disk-missing'); + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + } + } + return null; + } + /* ── 打开与状态 ─────────────────────────────────────────────────── */ /** @@ -412,11 +425,15 @@ export class LibraryVault { } const after = await this.inspectCustomParent(); if (after) return after; + const skeleton = await this.mkdirCustomSkeleton(); + if (skeleton) return skeleton; + const afterSkeleton = await this.inspectCustomParent(); + if (afterSkeleton) return afterSkeleton; } else { await fs.promises.mkdir(this.root, { recursive: true }); + await fs.promises.mkdir(this.tmpDir, { recursive: true }); + await fs.promises.mkdir(path.join(this.metaDir, 'backups'), { recursive: true }); } - await fs.promises.mkdir(this.tmpDir, { recursive: true }); - await fs.promises.mkdir(path.join(this.metaDir, 'backups'), { recursive: true }); } catch (err) { if ((this.deps.locationKind ?? 'default') === 'custom' && (err as NodeJS.ErrnoException).code === 'ENOENT') { return this.customRootUnavailable(); From 08627b366d1a70e8b2d8511eef03069ef5ba1d6c Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 19:02:40 +0800 Subject: [PATCH 06/17] fix(desktop): bind custom Library mkdir to held parent identity After the last successful inspect, hold the parent directory and compare path identity after mkdir(root). A same-path new inode fails closed as binding-moved; an empty root created on the replacement is rolled back and keep files stay parked. Node has no mkdirat; this is not atomic. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 45 ++++++++++ .../__tests__/libraryVault.test.ts | 47 +++++++++++ .../src/main/cindy-brain/libraryVault.ts | 82 +++++++++++++++---- 3 files changed, 159 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index 949938fd7ee..b1586dca252 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -528,6 +528,51 @@ describe('GhostLibrarySlot', () => { expect(extraRoots.at(-1) ?? 'none').not.toBe(path.join(candidate, GHOST_ID)); }); + it('最后一次成功 inspect 后换成同路径新 inode:不得 mkdir/meta/授权替换根', async () => { + if (process.platform === 'win32') return; + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + expect(open.state).toBe('ready'); + const keep = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); + expect(keep.ok).toBe(true); + await slot.disposeAll(); + const parked = `${candidate}.parked`; + const custom = path.join(candidate, GHOST_ID); + const isGhostRoot = (dest: string): boolean => + path.basename(dest) === GHOST_ID && !dest.includes(`${path.sep}.cindy-library`); + const realMkdir = fs.promises.mkdir.bind(fs.promises); + const observed = { injected: false, mkdirOnReplacement: false }; + const mkdirSpy = vi.spyOn(fs.promises, 'mkdir').mockImplementation(async (target, options) => { + const dest = String(target); + if (!observed.injected && isGhostRoot(dest)) { + observed.injected = true; + if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); + await realMkdir(candidate); + } + const result = await realMkdir(target, options); + if (observed.injected && isGhostRoot(dest)) observed.mkdirOnReplacement = fs.existsSync(custom); + return result; + }); + let after: Awaited>; + try { + after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + } finally { + mkdirSpy.mockRestore(); + } + if (!after.ok || after.op !== 'open') throw new Error(JSON.stringify(after)); + expect(observed.injected).toBe(true); + expect(after.state).toBe('unavailable'); + expect(after.reason).toBe('binding-moved'); + expect(after.authorizedReadonly).toBe(false); + expect(fs.existsSync(custom)).toBe(false); + expect(fs.existsSync(path.join(custom, '.cindy-library', 'meta.json'))).toBe(false); + expect(fs.existsSync(path.join(parked, GHOST_ID, 'keep.txt'))).toBe(true); + const extraRoots = syncAgentReadonlyExtraDir.mock.calls.filter((call) => call[0] === GHOST_ID).map((call) => call[1]); + expect(extraRoots.at(-1) ?? 'none').not.toBe(custom); + }); + it('已挂 extraDir 时 confirm 与 vault.open 间 disk-missing 必须撤 grant', async () => { const bound = await bindingStore.setBinding(GHOST_ID, candidate); expect(bound.ok).toBe(true); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index b21f263645d..29bc7de4f11 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -177,6 +177,53 @@ describe('LibraryVault', () => { expect(fs.existsSync(path.join(parked, 'mivo-canvas', 'keep.txt'))).toBe(true); }); + it('最后一次成功 inspect 后、mkdir(root) 前换成同路径新 inode:不得在替换目录创建/写 meta', async () => { + if (process.platform === 'win32') return; + const parent = path.join(tmpRoot, 'picked-379'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(custom, { recursive: true }); + await fs.promises.writeFile(path.join(custom, 'keep.txt'), 'keep-me'); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const first = makeVault({ rootDir: () => custom, locationKind: 'custom', customParentGrant: grant }); + expect(await first.open()).toMatchObject({ ok: true, state: 'ready' }); + const parked = `${parent}.parked`; + const realMkdir = fs.promises.mkdir.bind(fs.promises); + const observed = { injected: false, mkdirOnReplacement: false, mkdirTarget: '' }; + const mkdirSpy = vi.spyOn(fs.promises, 'mkdir').mockImplementation(async (target, options) => { + const dest = String(target); + if (!observed.injected && dest === custom) { + observed.injected = true; + if (fs.existsSync(parent)) await fs.promises.rename(parent, parked); + await realMkdir(parent); + observed.mkdirTarget = dest; + } + const result = await realMkdir(target, options); + if (observed.injected && dest === custom) { + observed.mkdirOnReplacement = fs.existsSync(custom); + } + return result; + }); + const raced = makeVault({ rootDir: () => custom, locationKind: 'custom', customParentGrant: grant }); + let opened: Awaited>; + try { + opened = await raced.open(); + } finally { + mkdirSpy.mockRestore(); + } + const replacementRoot = fs.existsSync(custom); + const replacementMeta = fs.existsSync(path.join(custom, '.cindy-library', 'meta.json')); + expect(observed.injected).toBe(true); + expect(opened).toMatchObject({ ok: true, state: 'unavailable' }); + expect(['binding-moved', 'disk-missing']).toContain(opened.ok ? opened.reason : ''); + expect(replacementRoot).toBe(false); + expect(replacementMeta).toBe(false); + expect(fs.existsSync(path.join(parked, 'mivo-canvas', 'keep.txt'))).toBe(true); + }); + it('default 缺失根仍可首次创建', async () => { const missing = path.join(tmpRoot, 'brand-new-default', 'ghost'); const vault = makeVault({ rootDir: () => missing, locationKind: 'default' }); diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index 589a8f47df5..a8deaf9e60d 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -369,10 +369,12 @@ export class LibraryVault { return this.customRootUnavailable('disk-missing'); } if (grant && real !== grant.realPathAtGrant) return this.customRootUnavailable('binding-moved'); + const stAfter = await fs.promises.lstat(parent); + if (stAfter.isSymbolicLink() || !stAfter.isDirectory()) return this.customRootUnavailable('disk-missing'); if ( grant?.identity && grant.identity.ino !== 0 - && (st.dev !== grant.identity.dev || st.ino !== grant.identity.ino) + && (stAfter.dev !== grant.identity.dev || stAfter.ino !== grant.identity.ino) ) { return this.customRootUnavailable('binding-moved'); } @@ -383,6 +385,37 @@ export class LibraryVault { } } + /** Path vs held parent inode vs grant. Not an extra inspect loop and not an atomic mkdirat. */ + private async assertHeldCustomParent(held: { + dev: number; + ino: number; + }): Promise | null> { + const parent = path.dirname(this.root); + try { + const st = await fs.promises.lstat(parent); + if (st.isSymbolicLink() || !st.isDirectory()) return this.customRootUnavailable('disk-missing'); + if (held.ino !== 0 && (st.dev !== held.dev || st.ino !== held.ino)) { + return this.customRootUnavailable('binding-moved'); + } + return this.inspectCustomParent(); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable('disk-missing'); + throw err; + } + } + + /** Best-effort: drop an empty root we just created on a replaced parent. */ + private async rollbackEmptyCustomRoot(): Promise { + try { + const st = await fs.promises.lstat(this.root); + if (st.isSymbolicLink() || !st.isDirectory()) return; + const names = await fs.promises.readdir(this.root); + if (names.length === 0) await fs.promises.rmdir(this.root); + } catch { + /* keep files stay in the renamed-away directory */ + } + } + /** Custom skeleton never uses recursive mkdir: that would rebuild a vanished user parent. */ private async mkdirCustomSkeleton(): Promise | null> { for (const dir of [this.metaDir, this.tmpDir, path.join(this.metaDir, 'backups')]) { @@ -411,24 +444,43 @@ export class LibraryVault { if ((this.deps.locationKind ?? 'default') === 'custom') { const before = await this.inspectCustomParent(); if (before) return before; + let parentHandle: fs.promises.FileHandle | null = null; + let createdRoot = false; try { - await fs.promises.mkdir(this.root); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return this.customRootUnavailable('disk-missing'); + try { + parentHandle = await fs.promises.open(path.dirname(this.root), fs.constants.O_RDONLY); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable('disk-missing'); + throw err; + } + const held = await parentHandle.stat(); + if (!held.isDirectory()) return this.customRootUnavailable('disk-missing'); + const heldId = { dev: held.dev, ino: held.ino }; + const heldBefore = await this.assertHeldCustomParent(heldId); + if (heldBefore) return heldBefore; + try { + await fs.promises.mkdir(this.root); + createdRoot = true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable('disk-missing'); + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + const st = await fs.promises.lstat(this.root); + if (st.isSymbolicLink() || !st.isDirectory()) { + return this.customRootUnavailable('disk-missing'); + } } - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; - const st = await fs.promises.lstat(this.root); - if (st.isSymbolicLink() || !st.isDirectory()) { - return this.customRootUnavailable('disk-missing'); + const afterRoot = await this.assertHeldCustomParent(heldId); + if (afterRoot) { + if (createdRoot) await this.rollbackEmptyCustomRoot(); + return afterRoot; } + const skeleton = await this.mkdirCustomSkeleton(); + if (skeleton) return skeleton; + const afterSkeleton = await this.assertHeldCustomParent(heldId); + if (afterSkeleton) return afterSkeleton; + } finally { + await parentHandle?.close().catch(() => undefined); } - const after = await this.inspectCustomParent(); - if (after) return after; - const skeleton = await this.mkdirCustomSkeleton(); - if (skeleton) return skeleton; - const afterSkeleton = await this.inspectCustomParent(); - if (afterSkeleton) return afterSkeleton; } else { await fs.promises.mkdir(this.root, { recursive: true }); await fs.promises.mkdir(this.tmpDir, { recursive: true }); From 35ced6648445143cb3f82d95af06d914caf7efd0 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 19:27:13 +0800 Subject: [PATCH 07/17] fix(desktop): create custom Library trees via held parent fd Darwin mkdirat/openat (SYS_mkdirat=475) and Linux /proc/self/fd keep ghostId/meta on the granted parent object. Missing helper fail-closed before mutation; no path mkdir fallback. Windows custom init unsupported. Default roots still mkdir recursively. Keep files stay parked. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 64 +++-- .../__tests__/libraryVault.test.ts | 75 +++--- .../src/main/cindy-brain/libraryDirFd.ts | 219 ++++++++++++++++++ .../src/main/cindy-brain/libraryVault.ts | 93 ++++---- 4 files changed, 322 insertions(+), 129 deletions(-) create mode 100644 apps/desktop/src/main/cindy-brain/libraryDirFd.ts diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index b1586dca252..dff26b6c350 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -21,6 +21,7 @@ import { import { createHash } from 'node:crypto'; import { LibraryBindingStore } from '../libraryBinding.js'; import { LibraryVault } from '../libraryVault.js'; +import { initCustomLibraryTree } from '../libraryDirFd.js'; import { createLibraryDbCore, type SqliteDatabaseConstructor } from '../libraryDbCore.js'; import { LibrarySqlService } from '../librarySqlService.js'; import { @@ -498,23 +499,20 @@ describe('GhostLibrarySlot', () => { expect(open.state).toBe('ready'); const keep = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); expect(keep.ok).toBe(true); + await slot.disposeAll(); const parked = `${candidate}.parked`; - const realMkdir = fs.promises.mkdir.bind(fs.promises); let injected = false; - const mkdirSpy = vi.spyOn(fs.promises, 'mkdir').mockImplementation(async (target, options) => { - const dest = String(target); - if (!injected && dest.includes(`${path.sep}.cindy-library`)) { - injected = true; - if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); - } - return realMkdir(target, options); - }); - let after: Awaited>; - try { - after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); - } finally { - mkdirSpy.mockRestore(); - } + createVault.mockImplementation((d) => new LibraryVault({ + ...d, + initCustomTree: async (req) => { + if (!injected) { + injected = true; + if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); + } + return initCustomLibraryTree(req); + }, + })); + const after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); if (!after.ok || after.op !== 'open') throw new Error(JSON.stringify(after)); expect(injected).toBe(true); expect(after.state).toBe('unavailable'); @@ -540,29 +538,21 @@ describe('GhostLibrarySlot', () => { await slot.disposeAll(); const parked = `${candidate}.parked`; const custom = path.join(candidate, GHOST_ID); - const isGhostRoot = (dest: string): boolean => - path.basename(dest) === GHOST_ID && !dest.includes(`${path.sep}.cindy-library`); - const realMkdir = fs.promises.mkdir.bind(fs.promises); - const observed = { injected: false, mkdirOnReplacement: false }; - const mkdirSpy = vi.spyOn(fs.promises, 'mkdir').mockImplementation(async (target, options) => { - const dest = String(target); - if (!observed.injected && isGhostRoot(dest)) { - observed.injected = true; - if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); - await realMkdir(candidate); - } - const result = await realMkdir(target, options); - if (observed.injected && isGhostRoot(dest)) observed.mkdirOnReplacement = fs.existsSync(custom); - return result; - }); - let after: Awaited>; - try { - after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); - } finally { - mkdirSpy.mockRestore(); - } + let injected = false; + createVault.mockImplementation((d) => new LibraryVault({ + ...d, + initCustomTree: async (req) => { + if (!injected) { + injected = true; + if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); + await fs.promises.mkdir(candidate); + } + return initCustomLibraryTree(req); + }, + })); + const after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); if (!after.ok || after.op !== 'open') throw new Error(JSON.stringify(after)); - expect(observed.injected).toBe(true); + expect(injected).toBe(true); expect(after.state).toBe('unavailable'); expect(after.reason).toBe('binding-moved'); expect(after.authorizedReadonly).toBe(false); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index 29bc7de4f11..ce8cfba35c1 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -19,6 +19,7 @@ import { type LibraryFileIdentity, type LibraryReadHandle, } from '../libraryVault.js'; +import { initCustomLibraryTree } from '../libraryDirFd.js'; const sha256Of = (s: string): string => createHash('sha256').update(s).digest('hex'); @@ -153,23 +154,20 @@ describe('LibraryVault', () => { const first = makeVault({ rootDir: () => custom, locationKind: 'custom', customParentGrant: grant }); expect(await first.open()).toMatchObject({ ok: true, state: 'ready' }); const parked = `${parent}.parked`; - const realMkdir = fs.promises.mkdir.bind(fs.promises); let injected = false; - const mkdirSpy = vi.spyOn(fs.promises, 'mkdir').mockImplementation(async (target, options) => { - const dest = String(target); - if (!injected && dest.includes(`${path.sep}.cindy-library`)) { - injected = true; - if (fs.existsSync(parent)) await fs.promises.rename(parent, parked); - } - return realMkdir(target, options); + const raced = makeVault({ + rootDir: () => custom, + locationKind: 'custom', + customParentGrant: grant, + initCustomTree: async (req) => { + if (!injected) { + injected = true; + if (fs.existsSync(parent)) await fs.promises.rename(parent, parked); + } + return initCustomLibraryTree(req); + }, }); - const raced = makeVault({ rootDir: () => custom, locationKind: 'custom', customParentGrant: grant }); - let missing: Awaited>; - try { - missing = await raced.open(); - } finally { - mkdirSpy.mockRestore(); - } + const missing = await raced.open(); expect(injected).toBe(true); expect(missing).toMatchObject({ ok: true, state: 'unavailable', reason: 'disk-missing' }); expect(fs.existsSync(parent)).toBe(false); @@ -191,36 +189,25 @@ describe('LibraryVault', () => { const first = makeVault({ rootDir: () => custom, locationKind: 'custom', customParentGrant: grant }); expect(await first.open()).toMatchObject({ ok: true, state: 'ready' }); const parked = `${parent}.parked`; - const realMkdir = fs.promises.mkdir.bind(fs.promises); - const observed = { injected: false, mkdirOnReplacement: false, mkdirTarget: '' }; - const mkdirSpy = vi.spyOn(fs.promises, 'mkdir').mockImplementation(async (target, options) => { - const dest = String(target); - if (!observed.injected && dest === custom) { - observed.injected = true; - if (fs.existsSync(parent)) await fs.promises.rename(parent, parked); - await realMkdir(parent); - observed.mkdirTarget = dest; - } - const result = await realMkdir(target, options); - if (observed.injected && dest === custom) { - observed.mkdirOnReplacement = fs.existsSync(custom); - } - return result; + let injected = false; + const raced = makeVault({ + rootDir: () => custom, + locationKind: 'custom', + customParentGrant: grant, + initCustomTree: async (req) => { + if (!injected) { + injected = true; + if (fs.existsSync(parent)) await fs.promises.rename(parent, parked); + await fs.promises.mkdir(parent); + } + return initCustomLibraryTree(req); + }, }); - const raced = makeVault({ rootDir: () => custom, locationKind: 'custom', customParentGrant: grant }); - let opened: Awaited>; - try { - opened = await raced.open(); - } finally { - mkdirSpy.mockRestore(); - } - const replacementRoot = fs.existsSync(custom); - const replacementMeta = fs.existsSync(path.join(custom, '.cindy-library', 'meta.json')); - expect(observed.injected).toBe(true); - expect(opened).toMatchObject({ ok: true, state: 'unavailable' }); - expect(['binding-moved', 'disk-missing']).toContain(opened.ok ? opened.reason : ''); - expect(replacementRoot).toBe(false); - expect(replacementMeta).toBe(false); + const opened = await raced.open(); + expect(injected).toBe(true); + expect(opened).toMatchObject({ ok: true, state: 'unavailable', reason: 'binding-moved' }); + expect(fs.existsSync(custom)).toBe(false); + expect(fs.existsSync(path.join(custom, '.cindy-library', 'meta.json'))).toBe(false); expect(fs.existsSync(path.join(parked, 'mivo-canvas', 'keep.txt'))).toBe(true); }); diff --git a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts new file mode 100644 index 00000000000..0ef9970bd04 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts @@ -0,0 +1,219 @@ +/** + * Custom Library first-create: mkdir/open/meta stay on a held parent directory + * fd. Darwin uses a fixed /usr/bin/perl mkdirat/openat helper (SYS_mkdirat=475, + * SYS_openat=463 from MacOSX.sdk sys/syscall.h). Linux uses /proc/self/fd. + * Windows and missing helpers fail closed before any mutation. No path mkdir + * fallback. Segments are fixed/validated names, never concatenated user paths. + */ +import { spawn } from 'node:child_process'; +import * as fs from 'node:fs'; + +export type CustomTreeInitResult = + | { ok: true; createdMeta: boolean } + | { ok: false; code: 'UNSUPPORTED' | 'IO' | 'INVALID' }; + +const HELPER_TIMEOUT_MS = 15_000; +const SYS_OPENAT = 463; +const SYS_MKDIRAT = 475; +const SYS_FSYNC = 95; + +function validSegment(segment: string): boolean { + return ( + segment.length > 0 && + segment.length <= 255 && + segment !== '.' && + segment !== '..' && + !segment.includes('\0') && + !segment.includes('/') && + !segment.includes('\\') + ); +} + +const DARWIN_INIT_SCRIPT = String.raw` +use strict; +use warnings; +use Fcntl qw(O_RDONLY O_WRONLY O_CREAT O_EXCL O_DIRECTORY O_NOFOLLOW :mode); +use POSIX qw(write close); +use Errno qw(EEXIST); + +use constant SYS_openat => ${SYS_OPENAT}; +use constant SYS_mkdirat => ${SYS_MKDIRAT}; +use constant SYS_fsync => ${SYS_FSYNC}; + +sub fail_closed { exit 1; } + +sub valid_segment { + my ($segment) = @_; + return 0 if !defined($segment) || $segment eq '' || length($segment) > 255; + return 0 if $segment eq '.' || $segment eq '..'; + return 0 if index($segment, '/') >= 0 || index($segment, "\\") >= 0 || index($segment, "\0") >= 0; + return 1; +} + +sub mkdirat_seg { + my ($parent, $name) = @_; + fail_closed() unless valid_segment($name); + my $seg = "$name"; + my $mode = 0700; + my $rc = syscall(SYS_mkdirat, $parent + 0, $seg, $mode); + if (!defined($rc) || $rc < 0) { + fail_closed() unless $! == EEXIST; + } +} + +sub openat_dir { + my ($parent, $name) = @_; + fail_closed() unless valid_segment($name); + my $seg = "$name"; + my $flags = O_RDONLY | O_NOFOLLOW | O_DIRECTORY; + my $fd = syscall(SYS_openat, $parent + 0, $seg, $flags, 0); + fail_closed() if !defined($fd) || $fd < 0; + return $fd; +} + +my $ghost = $ARGV[0]; +fail_closed() unless valid_segment($ghost); +my $meta = $ENV{CINDY_LIBRARY_META_JSON} // ''; +fail_closed() unless $meta =~ /^\{"version":1,"ghostId":"[A-Za-z0-9._-]{1,128}","createdAt":[0-9]{1,16}\}$/; + +my $parent = fileno(STDIN); +fail_closed() unless defined $parent && $parent >= 0; +my @pst = stat(STDIN); +fail_closed() unless @pst && S_ISDIR($pst[2]); + +mkdirat_seg($parent, $ghost); +my $root = openat_dir($parent, $ghost); +mkdirat_seg($root, '.cindy-library'); +my $meta_dir = openat_dir($root, '.cindy-library'); +mkdirat_seg($meta_dir, 'tmp'); +mkdirat_seg($meta_dir, 'backups'); + +my $flags = O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW; +my $meta_name = 'meta.json'; +my $meta_mode = 0600; +my $mfd = syscall(SYS_openat, $meta_dir + 0, $meta_name, $flags, $meta_mode); +my $created = 0; +if (defined($mfd) && $mfd >= 0) { + $created = 1; + my $w = POSIX::write($mfd, $meta, length($meta)); + fail_closed() unless defined($w) && $w == length($meta); + syscall(SYS_fsync, $mfd); + POSIX::close($mfd); +} else { + fail_closed() unless $! == EEXIST; +} + +POSIX::close($meta_dir); +POSIX::close($root); +print STDOUT ($created ? 'created' : 'exists'); +`; + +function runDarwinInit(parentFd: number, ghostId: string, metaJson: string): Promise { + return new Promise((resolve) => { + let child: ReturnType; + try { + child = spawn('/usr/bin/perl', ['-e', DARWIN_INIT_SCRIPT, '--', ghostId], { + stdio: [parentFd, 'pipe', 'pipe'], + env: { CINDY_LIBRARY_META_JSON: metaJson }, + }); + } catch { + resolve({ ok: false, code: 'UNSUPPORTED' }); + return; + } + let settled = false; + const finish = (value: CustomTreeInitResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const chunks: Buffer[] = []; + child.stdout?.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.once('error', () => finish({ ok: false, code: 'UNSUPPORTED' })); + child.once('close', (code) => { + const text = Buffer.concat(chunks).toString('utf8'); + if (code === 0 && (text === 'created' || text === 'exists')) { + finish({ ok: true, createdMeta: text === 'created' }); + return; + } + finish({ ok: false, code: 'IO' }); + }); + const timer = setTimeout(() => { + child.kill(); + finish({ ok: false, code: 'IO' }); + }, HELPER_TIMEOUT_MS); + timer.unref?.(); + }); +} + +function linuxInit(parentFd: number, ghostId: string, metaJson: string): CustomTreeInitResult { + const opened: number[] = []; + try { + const mkdirAt = (dirFd: number, name: string): void => { + try { + fs.mkdirSync(`/proc/self/fd/${dirFd}/${name}`, { recursive: false }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + } + }; + const openDirAt = (dirFd: number, name: string): number => { + let flags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW; + if (fs.constants.O_DIRECTORY) flags |= fs.constants.O_DIRECTORY; + const fd = fs.openSync(`/proc/self/fd/${dirFd}/${name}`, flags); + opened.push(fd); + const st = fs.fstatSync(fd); + if (!st.isDirectory()) throw Object.assign(new Error('not dir'), { code: 'ENOTDIR' }); + return fd; + }; + mkdirAt(parentFd, ghostId); + const rootFd = openDirAt(parentFd, ghostId); + mkdirAt(rootFd, '.cindy-library'); + const metaDirFd = openDirAt(rootFd, '.cindy-library'); + mkdirAt(metaDirFd, 'tmp'); + mkdirAt(metaDirFd, 'backups'); + let createdMeta = false; + try { + const flags = + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + (fs.constants.O_NOFOLLOW ?? 0); + const metaFd = fs.openSync(`/proc/self/fd/${metaDirFd}/meta.json`, flags, 0o600); + opened.push(metaFd); + fs.writeSync(metaFd, metaJson); + fs.fsyncSync(metaFd); + createdMeta = true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + } + return { ok: true, createdMeta }; + } catch { + return { ok: false, code: 'IO' }; + } finally { + for (const fd of opened.reverse()) { + try { + fs.closeSync(fd); + } catch { + /* always close helper fds */ + } + } + } +} + +export async function initCustomLibraryTree(req: { + parentFd: number; + ghostId: string; + metaJson: string; +}): Promise { + if (!validSegment(req.ghostId) || !Number.isInteger(req.parentFd) || req.parentFd < 0) { + return { ok: false, code: 'INVALID' }; + } + if (!/^\{"version":1,"ghostId":"[A-Za-z0-9._-]{1,128}","createdAt":[0-9]{1,16}\}$/.test(req.metaJson)) { + return { ok: false, code: 'INVALID' }; + } + if (process.platform === 'darwin') return runDarwinInit(req.parentFd, req.ghostId, req.metaJson); + if (process.platform === 'linux') return linuxInit(req.parentFd, req.ghostId, req.metaJson); + return { ok: false, code: 'UNSUPPORTED' }; +} diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index a8deaf9e60d..898e991f4a9 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -27,6 +27,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { isSafeGhostRelativePath } from '../../shared/ghost.js'; +import { initCustomLibraryTree, type CustomTreeInitResult } from './libraryDirFd.js'; /** Library 操作的结构化错误码(fs 槽只有人话 message 的缺口在这里补上)。 */ export type LibraryErrorCode = @@ -157,6 +158,12 @@ export interface LibraryVaultDeps { * 读路径打开注入点,仅单测。生产缺省走 O_RDONLY|O_NOFOLLOW,失败不得回落裸 open。 */ openForRead?(absPath: string, flags: number): Promise; + /** Custom first-create via held parent fd. Tests may inject; production uses libraryDirFd. */ + initCustomTree?(req: { + parentFd: number; + ghostId: string; + metaJson: string; + }): Promise; } /** Windows 保留设备名(与 fsSlot/dirDeposit 同口径;目录名撞上同样出事)。 */ @@ -385,7 +392,7 @@ export class LibraryVault { } } - /** Path vs held parent inode vs grant. Not an extra inspect loop and not an atomic mkdirat. */ + /** Path vs held parent inode vs grant after fd-relative create. */ private async assertHeldCustomParent(held: { dev: number; ino: number; @@ -404,31 +411,6 @@ export class LibraryVault { } } - /** Best-effort: drop an empty root we just created on a replaced parent. */ - private async rollbackEmptyCustomRoot(): Promise { - try { - const st = await fs.promises.lstat(this.root); - if (st.isSymbolicLink() || !st.isDirectory()) return; - const names = await fs.promises.readdir(this.root); - if (names.length === 0) await fs.promises.rmdir(this.root); - } catch { - /* keep files stay in the renamed-away directory */ - } - } - - /** Custom skeleton never uses recursive mkdir: that would rebuild a vanished user parent. */ - private async mkdirCustomSkeleton(): Promise | null> { - for (const dir of [this.metaDir, this.tmpDir, path.join(this.metaDir, 'backups')]) { - try { - await fs.promises.mkdir(dir); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable('disk-missing'); - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; - } - } - return null; - } - /* ── 打开与状态 ─────────────────────────────────────────────────── */ /** @@ -445,10 +427,20 @@ export class LibraryVault { const before = await this.inspectCustomParent(); if (before) return before; let parentHandle: fs.promises.FileHandle | null = null; - let createdRoot = false; try { + const parent = path.dirname(this.root); + const dirSeg = path.basename(this.root); + const metaGhost = this.deps.ghostId || dirSeg; + const metaJson = JSON.stringify({ + version: 1, + ghostId: metaGhost, + createdAt: this.now(), + }); + let openFlags = fs.constants.O_RDONLY; + if (fs.constants.O_DIRECTORY) openFlags |= fs.constants.O_DIRECTORY; + if (fs.constants.O_NOFOLLOW) openFlags |= fs.constants.O_NOFOLLOW; try { - parentHandle = await fs.promises.open(path.dirname(this.root), fs.constants.O_RDONLY); + parentHandle = await fs.promises.open(parent, openFlags); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable('disk-missing'); throw err; @@ -458,28 +450,27 @@ export class LibraryVault { const heldId = { dev: held.dev, ino: held.ino }; const heldBefore = await this.assertHeldCustomParent(heldId); if (heldBefore) return heldBefore; - try { - await fs.promises.mkdir(this.root); - createdRoot = true; - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable('disk-missing'); - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; - const st = await fs.promises.lstat(this.root); - if (st.isSymbolicLink() || !st.isDirectory()) { - return this.customRootUnavailable('disk-missing'); - } - } - const afterRoot = await this.assertHeldCustomParent(heldId); - if (afterRoot) { - if (createdRoot) await this.rollbackEmptyCustomRoot(); - return afterRoot; + const tree = await (this.deps.initCustomTree ?? initCustomLibraryTree)({ + parentFd: parentHandle.fd, + ghostId: dirSeg, + metaJson, + }); + if (!tree.ok) { + this.state = 'unavailable'; + this.unavailableReason = tree.code === 'IO' ? 'permission' : 'permission'; + this.opened = true; + return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; } - const skeleton = await this.mkdirCustomSkeleton(); - if (skeleton) return skeleton; - const afterSkeleton = await this.assertHeldCustomParent(heldId); - if (afterSkeleton) return afterSkeleton; + const afterTree = await this.assertHeldCustomParent(heldId); + if (afterTree) return afterTree; } finally { - await parentHandle?.close().catch(() => undefined); + if (parentHandle) { + try { + await parentHandle.close(); + } catch { + /* still close */ + } + } } } else { await fs.promises.mkdir(this.root, { recursive: true }); @@ -510,6 +501,12 @@ export class LibraryVault { this.meta = parsed; } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + if ((this.deps.locationKind ?? 'default') === 'custom') { + this.opened = true; + this.state = 'unavailable'; + this.unavailableReason = 'permission'; + return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; + } this.meta = { version: 1, ghostId: this.deps.ghostId ?? '', createdAt: this.now() }; const w = await this.writeMetaUnlocked(); if (w) return w; From 5e19e9e43a4a222622cb72c843cdffe8ebcb401b Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 19:54:13 +0800 Subject: [PATCH 08/17] fix(desktop): skip custom first-open path usage persist and sweep After the held parent fd is closed, custom open does not write, rename, or unlink under this.root. Sweep unlink and persistUsage are skipped; read-only usage scan remains. Default roots unchanged. Later library writes still persist. Isolated D: no replacement usage.json. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 45 ++++++++++++++ .../__tests__/libraryVault.test.ts | 58 +++++++++++++++++++ .../src/main/cindy-brain/libraryVault.ts | 24 ++++++-- 3 files changed, 122 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index dff26b6c350..2cc525c772e 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -563,6 +563,51 @@ describe('GhostLibrarySlot', () => { expect(extraRoots.at(-1) ?? 'none').not.toBe(custom); }); + it('D: helper 后 tmp readdir 换根不得写 usage.json 且拒授权替换根', async () => { + if (process.platform === 'win32') return; + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const parked = `${candidate}.parked`; + const custom = path.join(candidate, GHOST_ID); + let afterInit = false; + let swapped = false; + const origReaddir = fs.promises.readdir.bind(fs.promises); + const origRename = fs.promises.rename.bind(fs.promises); + const origMkdir = fs.promises.mkdir.bind(fs.promises); + const origWriteFile = fs.promises.writeFile.bind(fs.promises); + const readdirSpy = vi.spyOn(fs.promises, 'readdir').mockImplementation(async (target, options) => { + const dest = String(target); + if (afterInit && !swapped && dest.includes(`${path.sep}${GHOST_ID}${path.sep}.cindy-library${path.sep}tmp`)) { + swapped = true; + if (fs.existsSync(candidate)) await origRename(candidate, parked); + await origMkdir(candidate); + await origMkdir(custom); + await origMkdir(path.join(custom, '.cindy-library', 'tmp'), { recursive: true }); + await origWriteFile(path.join(custom, '.cindy-library', 'meta.json'), JSON.stringify({ + version: 1, ghostId: GHOST_ID, createdAt: 1, + })); + await origWriteFile(path.join(custom, 'user-keep.txt'), 'user'); + } + return origReaddir(target, options); + }); + createVault.mockImplementation((d) => new LibraryVault({ + ...d, + initCustomTree: async (req) => { + const r = await initCustomLibraryTree(req); + afterInit = true; + return r; + }, + })); + const opened = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + readdirSpy.mockRestore(); + if (!opened.ok || opened.op !== 'open') throw new Error(JSON.stringify(opened)); + expect(fs.existsSync(path.join(custom, '.cindy-library', 'usage.json'))).toBe(false); + if (swapped) { + expect(fs.existsSync(path.join(custom, 'user-keep.txt'))).toBe(true); + expect(opened.authorizedReadonly).toBe(false); + } + }); + it('已挂 extraDir 时 confirm 与 vault.open 间 disk-missing 必须撤 grant', async () => { const bound = await bindingStore.setBinding(GHOST_ID, candidate); expect(bound.ok).toBe(true); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index ce8cfba35c1..46b199fc15d 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -211,6 +211,64 @@ describe('LibraryVault', () => { expect(fs.existsSync(path.join(parked, 'mivo-canvas', 'keep.txt'))).toBe(true); }); + it('D: initCustomLibraryTree 后 sweep readdir 换根不得写 replacement usage.json', async () => { + if (process.platform === 'win32') return; + const parent = path.join(tmpRoot, 'picked-D'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(parent); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const parked = `${parent}.parked`; + let afterInit = false; + let swapped = false; + const origReaddir = fs.promises.readdir.bind(fs.promises); + const origRename = fs.promises.rename.bind(fs.promises); + const origMkdir = fs.promises.mkdir.bind(fs.promises); + const origWriteFile = fs.promises.writeFile.bind(fs.promises); + const readdirSpy = vi.spyOn(fs.promises, 'readdir').mockImplementation(async (target, options) => { + const dest = String(target); + if (afterInit && !swapped && dest.includes(`${path.sep}mivo-canvas${path.sep}.cindy-library${path.sep}tmp`)) { + swapped = true; + if (fs.existsSync(parent)) await origRename(parent, parked); + await origMkdir(parent); + await origMkdir(custom); + await origMkdir(path.join(custom, '.cindy-library')); + await origMkdir(path.join(custom, '.cindy-library', 'tmp')); + await origWriteFile(path.join(custom, '.cindy-library', 'meta.json'), JSON.stringify({ + version: 1, ghostId: 'mivo-canvas', createdAt: 1, + })); + await origWriteFile(path.join(custom, 'user-keep.txt'), 'user'); + await origWriteFile(path.join(custom, '.cindy-library', 'tmp', 'old.tmp'), 'stale'); + } + return origReaddir(target, options); + }); + const vault = makeVault({ + rootDir: () => custom, + locationKind: 'custom', + customParentGrant: grant, + ghostId: 'mivo-canvas', + initCustomTree: async (req) => { + const r = await initCustomLibraryTree(req); + afterInit = true; + return r; + }, + }); + const opened = await vault.open(); + readdirSpy.mockRestore(); + expect(opened.ok).toBe(true); + expect(fs.existsSync(path.join(custom, '.cindy-library', 'usage.json'))).toBe(false); + if (swapped) { + expect(fs.existsSync(path.join(custom, 'user-keep.txt'))).toBe(true); + expect(opened).toMatchObject({ state: 'unavailable' }); + } else { + expect(opened).toMatchObject({ state: 'ready' }); + expect(fs.existsSync(path.join(parent, 'mivo-canvas', '.cindy-library', 'usage.json'))).toBe(false); + } + }); + it('default 缺失根仍可首次创建', async () => { const missing = path.join(tmpRoot, 'brand-new-default', 'ghost'); const vault = makeVault({ rootDir: () => missing, locationKind: 'default' }); diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index 898e991f4a9..dc7fb2b0074 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -463,6 +463,15 @@ export class LibraryVault { } const afterTree = await this.assertHeldCustomParent(heldId); if (afterTree) return afterTree; + if (tree.createdMeta) { + const parsed = JSON.parse(metaJson) as LibraryMeta; + if ( + typeof parsed === 'object' && parsed !== null && parsed.version === 1 && + typeof parsed.ghostId === 'string' && typeof parsed.createdAt === 'number' + ) { + this.meta = parsed; + } + } } finally { if (parentHandle) { try { @@ -486,9 +495,11 @@ export class LibraryVault { this.deps.log?.warn('library open: cannot create root', { error: err instanceof Error ? err.message : String(err) }); return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; } - await this.sweepStaleTmp(); + const customOpen = (this.deps.locationKind ?? 'default') === 'custom'; + if (!customOpen) await this.sweepStaleTmp(); - // meta:已存在必须可解析(不可用 ≠ 空);不存在则首建。 + // meta:已存在必须可解析(不可用 ≠ 空);不存在则首建。custom 首次 open 用 held-fd 已写的 meta,不再 path 写。 + if (!this.meta) { try { const raw = await fs.promises.readFile(this.metaFile, 'utf8'); const parsed = JSON.parse(raw) as LibraryMeta; @@ -501,7 +512,7 @@ export class LibraryVault { this.meta = parsed; } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - if ((this.deps.locationKind ?? 'default') === 'custom') { + if (customOpen) { this.opened = true; this.state = 'unavailable'; this.unavailableReason = 'permission'; @@ -518,9 +529,11 @@ export class LibraryVault { return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; } } + } - // 用量:账本读不出就全量重扫(账本是缓存,真身是文件树)。 + // 用量:账本读不出就全量重扫(账本是缓存,真身是文件树)。custom 首次 open 只读扫描,不 persist/unlink。 let ledger: UsageLedger | null = null; + if (!(customOpen && this.meta)) { try { const raw = JSON.parse(await fs.promises.readFile(this.usageFile, 'utf8')) as UsageLedger; if (typeof raw === 'object' && raw !== null && typeof raw.files === 'number' && typeof raw.bytes === 'number') { @@ -529,6 +542,7 @@ export class LibraryVault { } catch { /* 损坏/缺失 → 重扫 */ } + } if (!ledger) { const scanned = await this.scanUsageUnlocked(); if (scanned.tripped) { @@ -536,7 +550,7 @@ export class LibraryVault { this.usage = { files: scanned.files, bytes: scanned.bytes, updatedAt: this.now(), mutations: 0 }; } else { this.usage = { files: scanned.files, bytes: scanned.bytes, updatedAt: this.now(), mutations: 0 }; - await this.persistUsageUnlocked(); + if (!customOpen) await this.persistUsageUnlocked(); } } else { this.usage = ledger; From 0f372affada4604eb2cf45783ef50f793f4aa595 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 20:25:02 +0800 Subject: [PATCH 09/17] fix(desktop): existing custom open, session teardown, usage ledger Open an already-initialized custom library from the held parent fd without mkdir. Missing structure on platforms without create stays fail-closed. Teardown only the captured session. Reuse a valid usage ledger; scan only when it is missing or corrupt. Custom tmp is not path-unlinked. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 45 +++- .../__tests__/libraryVault.test.ts | 73 +++++- .../src/main/cindy-brain/libraryDirFd.ts | 221 ++++++++++++++++++ .../src/main/cindy-brain/librarySlot.ts | 38 ++- .../src/main/cindy-brain/libraryVault.ts | 58 +++-- 5 files changed, 403 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index 2cc525c772e..fd4bffff583 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -21,7 +21,7 @@ import { import { createHash } from 'node:crypto'; import { LibraryBindingStore } from '../libraryBinding.js'; import { LibraryVault } from '../libraryVault.js'; -import { initCustomLibraryTree } from '../libraryDirFd.js'; +import { initCustomLibraryTree, openExistingCustomLibrary } from '../libraryDirFd.js'; import { createLibraryDbCore, type SqliteDatabaseConstructor } from '../libraryDbCore.js'; import { LibrarySqlService } from '../librarySqlService.js'; import { @@ -504,12 +504,12 @@ describe('GhostLibrarySlot', () => { let injected = false; createVault.mockImplementation((d) => new LibraryVault({ ...d, - initCustomTree: async (req) => { + openExistingCustom: async (req) => { if (!injected) { injected = true; if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); } - return initCustomLibraryTree(req); + return openExistingCustomLibrary(req); }, })); const after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); @@ -541,13 +541,13 @@ describe('GhostLibrarySlot', () => { let injected = false; createVault.mockImplementation((d) => new LibraryVault({ ...d, - initCustomTree: async (req) => { + openExistingCustom: async (req) => { if (!injected) { injected = true; if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); await fs.promises.mkdir(candidate); } - return initCustomLibraryTree(req); + return openExistingCustomLibrary(req); }, })); const after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); @@ -608,6 +608,41 @@ describe('GhostLibrarySlot', () => { } }); + it('旧 session teardown 只删自己捕获的引用,不踩并发新建的 session', async () => { + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const first = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!first.ok || first.op !== 'open') throw new Error(JSON.stringify(first)); + expect(first.state).toBe('ready'); + let release!: () => void; + const gate = new Promise((r) => { + release = r; + }); + let n = 0; + const origResolve = LibraryBindingStore.prototype.resolveLibraryRoot.bind(bindingStore); + resolveLibraryRoot.mockImplementation(async (id: string) => { + const i = ++n; + const res = await origResolve(id); + if (i >= 2) await gate; + return res; + }); + const other = path.join(tmp, 'picked-concurrent'); + await fs.promises.mkdir(other); + const pendingA = slot.handleLibraryRequest(GHOST_ID, { op: 'status' }); + await Promise.resolve(); + await bindingStore.setBinding(GHOST_ID, other); + const pendingB = slot.handleLibraryRequest(GHOST_ID, { op: 'status' }); + release(); + const [a, b] = await Promise.all([pendingA, pendingB]); + expect(a.ok).toBe(true); + expect(b.ok).toBe(true); + const later = await slot.handleLibraryRequest(GHOST_ID, { op: 'status' }); + expect(later.ok).toBe(true); + if (later.ok && later.op === 'status') { + expect(later.state === 'ready' || later.state === 'unavailable').toBe(true); + } + }); + it('已挂 extraDir 时 confirm 与 vault.open 间 disk-missing 必须撤 grant', async () => { const bound = await bindingStore.setBinding(GHOST_ID, candidate); expect(bound.ok).toBe(true); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index 46b199fc15d..aa51ef5b7d2 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -19,7 +19,7 @@ import { type LibraryFileIdentity, type LibraryReadHandle, } from '../libraryVault.js'; -import { initCustomLibraryTree } from '../libraryDirFd.js'; +import { initCustomLibraryTree, openExistingCustomLibrary } from '../libraryDirFd.js'; const sha256Of = (s: string): string => createHash('sha256').update(s).digest('hex'); @@ -159,12 +159,12 @@ describe('LibraryVault', () => { rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, - initCustomTree: async (req) => { + openExistingCustom: async (req) => { if (!injected) { injected = true; if (fs.existsSync(parent)) await fs.promises.rename(parent, parked); } - return initCustomLibraryTree(req); + return openExistingCustomLibrary(req); }, }); const missing = await raced.open(); @@ -194,13 +194,13 @@ describe('LibraryVault', () => { rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, - initCustomTree: async (req) => { + openExistingCustom: async (req) => { if (!injected) { injected = true; if (fs.existsSync(parent)) await fs.promises.rename(parent, parked); await fs.promises.mkdir(parent); } - return initCustomLibraryTree(req); + return openExistingCustomLibrary(req); }, }); const opened = await raced.open(); @@ -269,6 +269,69 @@ describe('LibraryVault', () => { } }); + it('已有 custom 再 open 走 existing,不调用 create helper', async () => { + const parent = path.join(tmpRoot, 'picked-exist'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(custom, { recursive: true }); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const first = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + expect(await first.open()).toMatchObject({ ok: true, state: 'ready' }); + const init = vi.fn(async () => ({ ok: false as const, code: 'UNSUPPORTED' as const })); + const second = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + initCustomTree: init, + }); + expect(await second.open()).toMatchObject({ ok: true, state: 'ready' }); + expect(init).not.toHaveBeenCalled(); + }); + + it('existing UNSUPPORTED 且无完整结构: permission 且不 mkdir', async () => { + const parent = path.join(tmpRoot, 'picked-win'); + await fs.promises.mkdir(parent); + const custom = path.join(parent, 'mivo-canvas'); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const vault = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + openExistingCustom: async () => ({ ok: false as const, code: 'UNSUPPORTED' as const }), + initCustomTree: async () => ({ ok: false as const, code: 'UNSUPPORTED' as const }), + }); + const opened = await vault.open(); + expect(opened).toMatchObject({ ok: true, state: 'unavailable', reason: 'permission' }); + expect(fs.existsSync(custom)).toBe(false); + }); + + it('合法 usage.json 只读复用,不因 custom open 丢账本', async () => { + const parent = path.join(tmpRoot, 'picked-ledger'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(custom, { recursive: true }); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const first = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + expect(await first.open()).toMatchObject({ ok: true, state: 'ready' }); + const w = await first.write({ path: 'keep.txt', content: 'abcdef' }); + expect(w.ok).toBe(true); + const second = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + const opened = await second.open(); + expect(opened).toMatchObject({ ok: true, state: 'ready', usedBytes: Buffer.byteLength('abcdef') }); + }); + it('default 缺失根仍可首次创建', async () => { const missing = path.join(tmpRoot, 'brand-new-default', 'ghost'); const vault = makeVault({ rootDir: () => missing, locationKind: 'default' }); diff --git a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts index 0ef9970bd04..4343ea13315 100644 --- a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts +++ b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts @@ -12,6 +12,12 @@ export type CustomTreeInitResult = | { ok: true; createdMeta: boolean } | { ok: false; code: 'UNSUPPORTED' | 'IO' | 'INVALID' }; +export type CustomExistingMeta = { version: 1; ghostId: string; createdAt: number }; +export type CustomExistingUsage = { files: number; bytes: number; updatedAt: number; mutations: number }; +export type CustomExistingResult = + | { ok: true; meta: CustomExistingMeta; usage: CustomExistingUsage | null } + | { ok: false; code: 'UNSUPPORTED' | 'IO' | 'INVALID' | 'MISSING' | 'CORRUPT' }; + const HELPER_TIMEOUT_MS = 15_000; const SYS_OPENAT = 463; const SYS_MKDIRAT = 475; @@ -217,3 +223,218 @@ export async function initCustomLibraryTree(req: { if (process.platform === 'linux') return linuxInit(req.parentFd, req.ghostId, req.metaJson); return { ok: false, code: 'UNSUPPORTED' }; } + +const DARWIN_OPEN_EXISTING_SCRIPT = String.raw` +use strict; +use warnings; +use Fcntl qw(O_RDONLY O_DIRECTORY O_NOFOLLOW :mode); +use POSIX qw(read close); + +use constant SYS_openat => ${SYS_OPENAT}; + +sub fail_closed { exit 1; } +sub missing { print STDOUT 'MISSING'; exit 0; } + +sub valid_segment { + my ($segment) = @_; + return 0 if !defined($segment) || $segment eq '' || length($segment) > 255; + return 0 if $segment eq '.' || $segment eq '..'; + return 0 if index($segment, '/') >= 0 || index($segment, "\\") >= 0 || index($segment, "\0") >= 0; + return 1; +} + +sub openat_dir { + my ($parent, $name) = @_; + fail_closed() unless valid_segment($name); + my $seg = "$name"; + my $flags = O_RDONLY | O_NOFOLLOW | O_DIRECTORY; + my $fd = syscall(SYS_openat, $parent + 0, $seg, $flags, 0); + missing() if !defined($fd) || $fd < 0; + return $fd; +} + +sub openat_file { + my ($parent, $name) = @_; + fail_closed() unless valid_segment($name); + my $seg = "$name"; + my $flags = O_RDONLY | O_NOFOLLOW; + my $fd = syscall(SYS_openat, $parent + 0, $seg, $flags, 0); + return undef if !defined($fd) || $fd < 0; + return $fd; +} + +sub read_all { + my ($fd) = @_; + my $buf = ''; + while (1) { + my $chunk = ''; + my $n = POSIX::read($fd, $chunk, 8192); + last if !defined($n) || $n == 0; + $buf .= $chunk; + } + return $buf; +} + +my $ghost = $ARGV[0]; +fail_closed() unless valid_segment($ghost); +my $parent = fileno(STDIN); +fail_closed() unless defined $parent && $parent >= 0; +my @pst = stat(STDIN); +fail_closed() unless @pst && S_ISDIR($pst[2]); + +my $root = openat_dir($parent, $ghost); +my $meta_dir = openat_dir($root, '.cindy-library'); +openat_dir($meta_dir, 'tmp'); +openat_dir($meta_dir, 'backups'); +my $mfd = openat_file($meta_dir, 'meta.json'); +missing() unless defined $mfd; +my $meta_raw = read_all($mfd); +POSIX::close($mfd); +my $ufd = openat_file($meta_dir, 'usage.json'); +my $usage_raw = ''; +if (defined $ufd) { + $usage_raw = read_all($ufd); + POSIX::close($ufd); +} +POSIX::close($meta_dir); +POSIX::close($root); +print STDOUT "OK\n$meta_raw\n"; +print STDOUT $usage_raw; +`; + +function parseExistingStdout(text: string): CustomExistingResult { + if (text === 'MISSING') return { ok: false, code: 'MISSING' }; + if (!text.startsWith('OK\n')) return { ok: false, code: 'IO' }; + const rest = text.slice(3); + const nl = rest.indexOf('\n'); + const metaRaw = nl === -1 ? rest : rest.slice(0, nl); + const usageRaw = nl === -1 ? '' : rest.slice(nl + 1); + let meta: CustomExistingMeta; + try { + const parsed = JSON.parse(metaRaw) as CustomExistingMeta; + if ( + typeof parsed !== 'object' || parsed === null || parsed.version !== 1 || + typeof parsed.ghostId !== 'string' || typeof parsed.createdAt !== 'number' + ) { + return { ok: false, code: 'CORRUPT' }; + } + meta = parsed; + } catch { + return { ok: false, code: 'CORRUPT' }; + } + let usage: CustomExistingUsage | null = null; + if (usageRaw.trim()) { + try { + const parsed = JSON.parse(usageRaw) as CustomExistingUsage; + if ( + typeof parsed === 'object' && parsed !== null && + typeof parsed.files === 'number' && typeof parsed.bytes === 'number' + ) { + usage = { + files: parsed.files, + bytes: parsed.bytes, + updatedAt: parsed.updatedAt ?? 0, + mutations: parsed.mutations ?? 0, + }; + } + } catch { + usage = null; + } + } + return { ok: true, meta, usage }; +} + +function runDarwinOpenExisting(parentFd: number, ghostId: string): Promise { + return new Promise((resolve) => { + let child: ReturnType; + try { + child = spawn('/usr/bin/perl', ['-e', DARWIN_OPEN_EXISTING_SCRIPT, '--', ghostId], { + stdio: [parentFd, 'pipe', 'pipe'], + env: { PATH: '/usr/bin:/bin' }, + }); + } catch { + resolve({ ok: false, code: 'UNSUPPORTED' }); + return; + } + let settled = false; + const finish = (value: CustomExistingResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const chunks: Buffer[] = []; + child.stdout?.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.once('error', () => finish({ ok: false, code: 'UNSUPPORTED' })); + child.once('close', (code) => { + const text = Buffer.concat(chunks).toString('utf8'); + if (code !== 0) { + finish({ ok: false, code: text === 'MISSING' ? 'MISSING' : 'IO' }); + return; + } + finish(parseExistingStdout(text)); + }); + const timer = setTimeout(() => { + child.kill(); + finish({ ok: false, code: 'IO' }); + }, HELPER_TIMEOUT_MS); + timer.unref?.(); + }); +} + +function linuxOpenExisting(parentFd: number, ghostId: string): CustomExistingResult { + const opened: number[] = []; + try { + const openDirAt = (dirFd: number, name: string): number => { + let flags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW; + if (fs.constants.O_DIRECTORY) flags |= fs.constants.O_DIRECTORY; + const fd = fs.openSync(`/proc/self/fd/${dirFd}/${name}`, flags); + opened.push(fd); + if (!fs.fstatSync(fd).isDirectory()) throw Object.assign(new Error('not dir'), { code: 'ENOTDIR' }); + return fd; + }; + const rootFd = openDirAt(parentFd, ghostId); + const metaDirFd = openDirAt(rootFd, '.cindy-library'); + openDirAt(metaDirFd, 'tmp'); + openDirAt(metaDirFd, 'backups'); + const metaFd = fs.openSync(`/proc/self/fd/${metaDirFd}/meta.json`, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + opened.push(metaFd); + const metaRaw = fs.readFileSync(metaFd, 'utf8'); + let usageRaw = ''; + try { + const usageFd = fs.openSync(`/proc/self/fd/${metaDirFd}/usage.json`, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + opened.push(usageFd); + usageRaw = fs.readFileSync(usageFd, 'utf8'); + } catch { + usageRaw = ''; + } + return parseExistingStdout(`OK\n${metaRaw}\n${usageRaw}`); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') return { ok: false, code: 'MISSING' }; + return { ok: false, code: 'IO' }; + } finally { + for (const fd of opened.reverse()) { + try { + fs.closeSync(fd); + } catch { + /* always close */ + } + } + } +} + +/** Read an already-initialized custom library from the held parent fd. Never mkdir. */ +export async function openExistingCustomLibrary(req: { + parentFd: number; + ghostId: string; +}): Promise { + if (!validSegment(req.ghostId) || !Number.isInteger(req.parentFd) || req.parentFd < 0) { + return { ok: false, code: 'INVALID' }; + } + if (process.platform === 'darwin') return runDarwinOpenExisting(req.parentFd, req.ghostId); + if (process.platform === 'linux') return linuxOpenExisting(req.parentFd, req.ghostId); + return { ok: false, code: 'UNSUPPORTED' }; +} diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index 5e3b36a4f25..42fe6360978 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -310,16 +310,27 @@ export class GhostLibrarySlot { private async getOrCreateSession(ghostId: string, scopeKey: string | null): Promise { let session = this.sessions.get(ghostId); + const capturedScope = session; if (session && session.ownerScopeKey !== scopeKey) { - await this.teardownSession(ghostId); - session = undefined; + await this.teardownSession(ghostId, capturedScope); + session = this.sessions.get(ghostId); + if (session === capturedScope) session = undefined; } const resolution = await this.confirmLiveCustomRoot( await this.deps.bindingStore.resolveLibraryRoot(ghostId), ); + session = this.sessions.get(ghostId) ?? session; + if (session && session.ownerScopeKey !== scopeKey) { + const staleScope = session; + await this.teardownSession(ghostId, staleScope); + session = this.sessions.get(ghostId); + if (session === staleScope) session = undefined; + } if (session && !this.sessionMatchesResolution(session, resolution)) { - await this.teardownSession(ghostId); - session = undefined; + const staleRoot = session; + await this.teardownSession(ghostId, staleRoot); + session = this.sessions.get(ghostId); + if (session === staleRoot) session = undefined; } if (!session) { session = this.createSession(ghostId, resolution, scopeKey); @@ -552,9 +563,10 @@ export class GhostLibrarySlot { } } - private async teardownSession(ghostId: string): Promise { + private async teardownSession(ghostId: string, expected?: GhostLibrarySession): Promise { const session = this.sessions.get(ghostId); if (!session) return; + if (expected && session !== expected) return; this.sessions.delete(ghostId); for (const [streamId, epoch] of this.writeEpochByStream) { if (epoch.ghostId === ghostId) this.writeEpochByStream.delete(streamId); @@ -671,6 +683,22 @@ export class GhostLibrarySlot { }; return { ...drifted, ...this.handshakeFields(session, 'unavailable') } as GhostPipeLibraryResult; } + if (session.locationKind === 'custom') { + const live = await this.confirmLiveCustomRoot( + await this.deps.bindingStore.resolveLibraryRoot(ghostId), + ); + if (live.kind !== 'custom' || live.root === null) { + const reason = live.kind === 'custom' && live.root === null && live.drift === 'binding-moved' + ? 'binding-moved' + : 'disk-missing'; + await this.latchCustomUnavailable(session, ghostId, reason); + const drifted = { + ok: true as const, op: 'open' as const, state: 'unavailable' as const, + reason, usedBytes: 0, fileCount: 0, location: session.locationKind, + }; + return { ...drifted, ...this.handshakeFields(session, 'unavailable') } as GhostPipeLibraryResult; + } + } this.extraDirOpenerGhostId = ghostId; await this.syncAgentReadonlyExtraDir(ghostId, vault.getRootDir()); const body = { diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index dc7fb2b0074..fb4d34fd66b 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -27,7 +27,12 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { isSafeGhostRelativePath } from '../../shared/ghost.js'; -import { initCustomLibraryTree, type CustomTreeInitResult } from './libraryDirFd.js'; +import { + initCustomLibraryTree, + openExistingCustomLibrary, + type CustomExistingUsage, + type CustomTreeInitResult, +} from './libraryDirFd.js'; /** Library 操作的结构化错误码(fs 槽只有人话 message 的缺口在这里补上)。 */ export type LibraryErrorCode = @@ -164,6 +169,10 @@ export interface LibraryVaultDeps { ghostId: string; metaJson: string; }): Promise; + openExistingCustom?(req: { + parentFd: number; + ghostId: string; + }): Promise; } /** Windows 保留设备名(与 fsSlot/dirDeposit 同口径;目录名撞上同样出事)。 */ @@ -422,6 +431,7 @@ export class LibraryVault { if (this.invalidated) { return fail('LIBRARY_UNAVAILABLE', 'Library 实例已作废(owner 切换/宿主收口);请重新 open'); } + let customUsage: UsageLedger | null = null; try { if ((this.deps.locationKind ?? 'default') === 'custom') { const before = await this.inspectCustomParent(); @@ -450,28 +460,42 @@ export class LibraryVault { const heldId = { dev: held.dev, ino: held.ino }; const heldBefore = await this.assertHeldCustomParent(heldId); if (heldBefore) return heldBefore; - const tree = await (this.deps.initCustomTree ?? initCustomLibraryTree)({ + const existing = await (this.deps.openExistingCustom ?? openExistingCustomLibrary)({ parentFd: parentHandle.fd, ghostId: dirSeg, - metaJson, }); - if (!tree.ok) { + if (existing.ok) { + this.meta = existing.meta; + if (existing.usage) customUsage = existing.usage; + } else if (existing.code === 'MISSING') { + const tree = await (this.deps.initCustomTree ?? initCustomLibraryTree)({ + parentFd: parentHandle.fd, + ghostId: dirSeg, + metaJson, + }); + if (!tree.ok) { + this.state = 'unavailable'; + this.unavailableReason = 'permission'; + this.opened = true; + return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; + } + if (tree.createdMeta) { + const parsed = JSON.parse(metaJson) as LibraryMeta; + if ( + typeof parsed === 'object' && parsed !== null && parsed.version === 1 && + typeof parsed.ghostId === 'string' && typeof parsed.createdAt === 'number' + ) { + this.meta = parsed; + } + } + } else { this.state = 'unavailable'; - this.unavailableReason = tree.code === 'IO' ? 'permission' : 'permission'; + this.unavailableReason = 'permission'; this.opened = true; return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; } const afterTree = await this.assertHeldCustomParent(heldId); if (afterTree) return afterTree; - if (tree.createdMeta) { - const parsed = JSON.parse(metaJson) as LibraryMeta; - if ( - typeof parsed === 'object' && parsed !== null && parsed.version === 1 && - typeof parsed.ghostId === 'string' && typeof parsed.createdAt === 'number' - ) { - this.meta = parsed; - } - } } finally { if (parentHandle) { try { @@ -531,9 +555,9 @@ export class LibraryVault { } } - // 用量:账本读不出就全量重扫(账本是缓存,真身是文件树)。custom 首次 open 只读扫描,不 persist/unlink。 - let ledger: UsageLedger | null = null; - if (!(customOpen && this.meta)) { + // 用量:合法账本只读复用;坏/缺才 scan。custom 首次 open 不 persist/unlink。 + let ledger: UsageLedger | null = customUsage; + if (!ledger) { try { const raw = JSON.parse(await fs.promises.readFile(this.usageFile, 'utf8')) as UsageLedger; if (typeof raw === 'object' && raw !== null && typeof raw.files === 'number' && typeof raw.bytes === 'number') { From 0cf02ab07830df8ca584b1985199cfa929cc655f Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 20:34:18 +0800 Subject: [PATCH 10/17] fix(desktop): Windows existing custom open via FILE_OPEN ReadFile Existing libraries open from the inherited parent handle with NtCreateFile FILE_OPEN+ReadFile. FileIndex 0 and missing helpers fail closed. Create stays unsupported on Windows. Linux may unlink only uuid.tmp/.stream staging leftovers via /proc/self/fd. No path mkdir fallback. Signed-off-by: PraiseZhu --- .../__tests__/libraryVault.test.ts | 28 +- .../src/main/cindy-brain/libraryDirFd.ts | 312 ++++++++++++++++++ .../src/main/cindy-brain/libraryVault.ts | 9 +- 3 files changed, 347 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index aa51ef5b7d2..792dfec752e 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -19,7 +19,7 @@ import { type LibraryFileIdentity, type LibraryReadHandle, } from '../libraryVault.js'; -import { initCustomLibraryTree, openExistingCustomLibrary } from '../libraryDirFd.js'; +import { initCustomLibraryTree, openExistingCustomLibrary, PROVABLE_STAGING_NAME } from '../libraryDirFd.js'; const sha256Of = (s: string): string => createHash('sha256').update(s).digest('hex'); @@ -332,6 +332,32 @@ describe('LibraryVault', () => { expect(opened).toMatchObject({ ok: true, state: 'ready', usedBytes: Buffer.byteLength('abcdef') }); }); + it('可证 staging 名只匹配 uuid.tmp/stream,不匹配 old.tmp 或原件', () => { + expect(PROVABLE_STAGING_NAME.test('25ae5922-06f7-46dd-99f1-6d914d53af33.tmp')).toBe(true); + expect(PROVABLE_STAGING_NAME.test('25ae5922-06f7-46dd-99f1-6d914d53af33.stream')).toBe(true); + expect(PROVABLE_STAGING_NAME.test('old.tmp')).toBe(false); + expect(PROVABLE_STAGING_NAME.test('keep.txt')).toBe(false); + expect(PROVABLE_STAGING_NAME.test('meta.json')).toBe(false); + }); + + it('Windows 新建 custom 仍 unsupported,不 mkdir', async () => { + if (process.platform !== 'win32') return; + const parent = path.join(tmpRoot, 'picked-win-new'); + await fs.promises.mkdir(parent); + const custom = path.join(parent, 'mivo-canvas'); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const vault = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + const opened = await vault.open(); + expect(opened).toMatchObject({ ok: true, state: 'unavailable', reason: 'permission' }); + expect(fs.existsSync(custom)).toBe(false); + }); + it('default 缺失根仍可首次创建', async () => { const missing = path.join(tmpRoot, 'brand-new-default', 'ghost'); const vault = makeVault({ rootDir: () => missing, locationKind: 'default' }); diff --git a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts index 4343ea13315..f2a2c1117ff 100644 --- a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts +++ b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts @@ -7,6 +7,7 @@ */ import { spawn } from 'node:child_process'; import * as fs from 'node:fs'; +import * as path from 'node:path'; export type CustomTreeInitResult = | { ok: true; createdMeta: boolean } @@ -436,5 +437,316 @@ export async function openExistingCustomLibrary(req: { } if (process.platform === 'darwin') return runDarwinOpenExisting(req.parentFd, req.ghostId); if (process.platform === 'linux') return linuxOpenExisting(req.parentFd, req.ghostId); + if (process.platform === 'win32') return runWindowsOpenExisting(req.parentFd, req.ghostId); return { ok: false, code: 'UNSUPPORTED' }; } + +function windowsPowerShellPath(): string | null { + const systemRoot = process.env.SystemRoot ?? process.env.WINDIR; + if (!systemRoot || !path.win32.isAbsolute(systemRoot)) return null; + const executable = path.win32.join( + systemRoot, + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', + ); + try { + return fs.statSync(executable).isFile() ? executable : null; + } catch { + return null; + } +} + +/** Staging leftovers from atomicWrite (`uuid.tmp`) and streams (`uuid.stream`). Not unique originals. */ +export const PROVABLE_STAGING_NAME = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(tmp|stream)$/i; + +/** Linux only: unlinkat-equivalent via /proc/self/fd. Unknown names kept. */ +export function sweepProvableStagingOnLinux( + parentFd: number, + ghostId: string, + activeStreamIds: ReadonlySet, + nowMs: number, + maxAgeMs: number, +): { ok: true; unlinked: number } | { ok: false; code: 'UNSUPPORTED' | 'IO' | 'MISSING' } { + if (process.platform !== 'linux') return { ok: false, code: 'UNSUPPORTED' }; + if (!validSegment(ghostId) || !Number.isInteger(parentFd) || parentFd < 0) { + return { ok: false, code: 'IO' }; + } + const opened: number[] = []; + let unlinked = 0; + try { + const openDirAt = (dirFd: number, name: string): number => { + let flags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW; + if (fs.constants.O_DIRECTORY) flags |= fs.constants.O_DIRECTORY; + const fd = fs.openSync(`/proc/self/fd/${dirFd}/${name}`, flags); + opened.push(fd); + return fd; + }; + const rootFd = openDirAt(parentFd, ghostId); + const metaDirFd = openDirAt(rootFd, '.cindy-library'); + const tmpFd = openDirAt(metaDirFd, 'tmp'); + const names = fs.readdirSync(`/proc/self/fd/${tmpFd}`); + const cutoff = nowMs - maxAgeMs; + for (const name of names) { + if (!PROVABLE_STAGING_NAME.test(name)) continue; + if (name.endsWith('.stream') && activeStreamIds.has(name.slice(0, -'.stream'.length))) continue; + const target = `/proc/self/fd/${tmpFd}/${name}`; + try { + const st = fs.statSync(target); + if (!st.isFile() || st.mtimeMs >= cutoff) continue; + fs.unlinkSync(target); + unlinked += 1; + } catch { + /* keep on error */ + } + } + return { ok: true, unlinked }; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') return { ok: false, code: 'MISSING' }; + return { ok: false, code: 'IO' }; + } finally { + for (const fd of opened.reverse()) { + try { + fs.closeSync(fd); + } catch { + /* always close */ + } + } + } +} + +const WINDOWS_EXISTING_OPEN_SCRIPT = String.raw` +$utf8 = [System.Text.UTF8Encoding]::new($false) +[Console]::OutputEncoding = $utf8 +Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +public static class CindyLibraryExistingOpen { + private const uint FILE_READ_DATA = 0x00000001; + private const uint FILE_READ_ATTRIBUTES = 0x00000080; + private const uint SYNCHRONIZE = 0x00100000; + private const uint FILE_SHARE_ALL = 0x00000007; + private const uint FILE_SHARE_READ_WRITE = 0x00000003; + private const uint FILE_OPEN = 0x00000001; + private const uint FILE_DIRECTORY_FILE = 0x00000001; + private const uint FILE_NON_DIRECTORY_FILE = 0x00000040; + private const uint FILE_SYNCHRONOUS_IO_NONALERT = 0x00000020; + private const uint FILE_OPEN_REPARSE_POINT = 0x00200000; + private const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400; + + [StructLayout(LayoutKind.Sequential)] + private struct UNICODE_STRING { + public ushort Length; + public ushort MaximumLength; + public IntPtr Buffer; + } + [StructLayout(LayoutKind.Sequential)] + private struct OBJECT_ATTRIBUTES { + public int Length; + public IntPtr RootDirectory; + public IntPtr ObjectName; + public uint Attributes; + public IntPtr SecurityDescriptor; + public IntPtr SecurityQualityOfService; + } + [StructLayout(LayoutKind.Sequential)] + private struct IO_STATUS_BLOCK { + public IntPtr Status; + public IntPtr Information; + } + [StructLayout(LayoutKind.Sequential)] + private struct FILE_ATTRIBUTE_TAG_INFO { + public uint FileAttributes; + public uint ReparseTag; + } + [StructLayout(LayoutKind.Sequential)] + private struct BY_HANDLE_FILE_INFORMATION { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [DllImport("kernel32.dll")] static extern IntPtr GetStdHandle(int kind); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetFileInformationByHandle(IntPtr handle, out BY_HANDLE_FILE_INFORMATION info); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetFileInformationByHandleEx(IntPtr handle, int infoClass, out FILE_ATTRIBUTE_TAG_INFO info, uint size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool ReadFile(IntPtr hFile, byte[] buffer, uint toRead, out uint read, IntPtr overlapped); + [DllImport("ntdll.dll")] static extern int NtCreateFile(out SafeFileHandle fileHandle, uint desiredAccess, ref OBJECT_ATTRIBUTES objectAttributes, out IO_STATUS_BLOCK ioStatusBlock, IntPtr allocationSize, uint fileAttributes, uint shareAccess, uint createDisposition, uint createOptions, IntPtr eaBuffer, uint eaLength); + + private static bool ValidSegment(string segment) { + if (String.IsNullOrEmpty(segment) || segment.Length > 255) return false; + if (segment == "." || segment == "..") return false; + return segment.IndexOf('/') < 0 && segment.IndexOf((char)92) < 0 && segment.IndexOf((char)0) < 0 && segment.IndexOf(':') < 0; + } + + private static SafeFileHandle OpenRelative(IntPtr root, string name, bool directory, bool readData) { + if (!ValidSegment(name)) return null; + IntPtr nameBuffer = IntPtr.Zero; + IntPtr unicodePointer = IntPtr.Zero; + try { + nameBuffer = Marshal.StringToHGlobalUni(name); + var unicode = new UNICODE_STRING { + Length = checked((ushort)(name.Length * 2)), + MaximumLength = checked((ushort)((name.Length + 1) * 2)), + Buffer = nameBuffer + }; + unicodePointer = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(UNICODE_STRING))); + Marshal.StructureToPtr(unicode, unicodePointer, false); + var attributes = new OBJECT_ATTRIBUTES { + Length = Marshal.SizeOf(typeof(OBJECT_ATTRIBUTES)), + RootDirectory = root, + ObjectName = unicodePointer, + Attributes = 0, + SecurityDescriptor = IntPtr.Zero, + SecurityQualityOfService = IntPtr.Zero + }; + IO_STATUS_BLOCK statusBlock; + SafeFileHandle opened; + uint access = (readData ? FILE_READ_DATA : 0) | FILE_READ_ATTRIBUTES | SYNCHRONIZE; + uint options = FILE_SYNCHRONOUS_IO_NONALERT | FILE_OPEN_REPARSE_POINT | + (directory ? FILE_DIRECTORY_FILE : FILE_NON_DIRECTORY_FILE); + int status = NtCreateFile(out opened, access, ref attributes, out statusBlock, IntPtr.Zero, 0, + directory ? FILE_SHARE_READ_WRITE : FILE_SHARE_ALL, FILE_OPEN, options, IntPtr.Zero, 0); + if (status < 0 || opened == null || opened.IsInvalid) { + if (opened != null) opened.Dispose(); + return null; + } + FILE_ATTRIBUTE_TAG_INFO tag; + if (!GetFileInformationByHandleEx(opened.DangerousGetHandle(), 9, out tag, (uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO))) || + (tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + opened.Dispose(); + return null; + } + return opened; + } catch { + return null; + } finally { + if (unicodePointer != IntPtr.Zero) Marshal.FreeHGlobal(unicodePointer); + if (nameBuffer != IntPtr.Zero) Marshal.FreeHGlobal(nameBuffer); + } + } + + private static string ReadUtf8(IntPtr handle) { + var chunks = new List(); + byte[] buffer = new byte[4096]; + uint read; + while (ReadFile(handle, buffer, (uint)buffer.Length, out read, IntPtr.Zero) && read > 0) { + for (int i = 0; i < read; i++) chunks.Add(buffer[i]); + if (chunks.Count > 1048576) return null; + } + return Encoding.UTF8.GetString(chunks.ToArray()); + } + + public static int Run(string ghostId) { + if (!ValidSegment(ghostId)) return 2; + IntPtr parent = GetStdHandle(-10); + if (parent == IntPtr.Zero || parent == new IntPtr(-1)) return 2; + BY_HANDLE_FILE_INFORMATION parentInfo; + if (!GetFileInformationByHandle(parent, out parentInfo) || + (parentInfo.FileIndexHigh == 0 && parentInfo.FileIndexLow == 0)) return 2; + var opened = new List(); + try { + SafeFileHandle ghost = OpenRelative(parent, ghostId, true, false); + if (ghost == null) { Console.Out.Write("MISSING"); return 0; } + opened.Add(ghost); + SafeFileHandle metaDir = OpenRelative(ghost.DangerousGetHandle(), ".cindy-library", true, false); + if (metaDir == null) { Console.Out.Write("MISSING"); return 0; } + opened.Add(metaDir); + SafeFileHandle tmp = OpenRelative(metaDir.DangerousGetHandle(), "tmp", true, false); + if (tmp == null) { Console.Out.Write("MISSING"); return 0; } + opened.Add(tmp); + SafeFileHandle backups = OpenRelative(metaDir.DangerousGetHandle(), "backups", true, false); + if (backups == null) { Console.Out.Write("MISSING"); return 0; } + opened.Add(backups); + SafeFileHandle meta = OpenRelative(metaDir.DangerousGetHandle(), "meta.json", false, true); + if (meta == null) { Console.Out.Write("MISSING"); return 0; } + opened.Add(meta); + string metaRaw = ReadUtf8(meta.DangerousGetHandle()); + if (metaRaw == null) return 1; + string usageRaw = ""; + SafeFileHandle usage = OpenRelative(metaDir.DangerousGetHandle(), "usage.json", false, true); + if (usage != null) { + opened.Add(usage); + usageRaw = ReadUtf8(usage.DangerousGetHandle()) ?? ""; + } + Console.Out.Write("OK\n" + metaRaw + "\n" + usageRaw); + return 0; + } finally { + for (int i = opened.Count - 1; i >= 0; i--) opened[i].Dispose(); + } + } +} +'@ +try { + $ghost = $env:CINDY_LIBRARY_GHOST_ID + $code = [CindyLibraryExistingOpen]::Run([string]$ghost) + exit $code +} catch { + exit 1 +} +`; + +const WINDOWS_EXISTING_OPEN_COMMAND = Buffer.from(WINDOWS_EXISTING_OPEN_SCRIPT, 'utf16le').toString('base64'); + +function runWindowsOpenExisting(parentFd: number, ghostId: string): Promise { + return new Promise((resolve) => { + const powershell = windowsPowerShellPath(); + if (!powershell) { + resolve({ ok: false, code: 'UNSUPPORTED' }); + return; + } + let child: ReturnType; + try { + child = spawn(powershell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', WINDOWS_EXISTING_OPEN_COMMAND], { + stdio: [parentFd, 'pipe', 'pipe'], + windowsHide: true, + env: { ...process.env, CINDY_LIBRARY_GHOST_ID: ghostId }, + }); + } catch { + resolve({ ok: false, code: 'UNSUPPORTED' }); + return; + } + let settled = false; + const finish = (value: CustomExistingResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const chunks: Buffer[] = []; + child.stdout?.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.once('error', () => finish({ ok: false, code: 'UNSUPPORTED' })); + child.once('close', (code) => { + const text = Buffer.concat(chunks).toString('utf8'); + if (code === 2) { + finish({ ok: false, code: 'UNSUPPORTED' }); + return; + } + if (code !== 0) { + finish({ ok: false, code: text === 'MISSING' ? 'MISSING' : 'IO' }); + return; + } + finish(parseExistingStdout(text)); + }); + const timer = setTimeout(() => { + child.kill(); + finish({ ok: false, code: 'IO' }); + }, HELPER_TIMEOUT_MS); + timer.unref?.(); + }); +} diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index fb4d34fd66b..fccac77055f 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -30,7 +30,7 @@ import { isSafeGhostRelativePath } from '../../shared/ghost.js'; import { initCustomLibraryTree, openExistingCustomLibrary, - type CustomExistingUsage, + sweepProvableStagingOnLinux, type CustomTreeInitResult, } from './libraryDirFd.js'; @@ -496,6 +496,13 @@ export class LibraryVault { } const afterTree = await this.assertHeldCustomParent(heldId); if (afterTree) return afterTree; + sweepProvableStagingOnLinux( + parentHandle.fd, + dirSeg, + new Set(this.streams.keys()), + this.now(), + this.limits.tmpMaxAgeMs, + ); } finally { if (parentHandle) { try { From 11cb431ce5fc84b65955438f6cc105fb8a5619e8 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 20:40:37 +0800 Subject: [PATCH 11/17] fix(desktop): parse existing-open helper JSON as complete values Pretty-printed and compact meta/usage payloads both parse. Malformed JSON or failed field checks stay CORRUPT. Do not split on the first newline of the helper stdout. Signed-off-by: PraiseZhu --- .../__tests__/libraryVault.test.ts | 49 ++++++++++- .../src/main/cindy-brain/libraryDirFd.ts | 85 ++++++++++++++----- .../src/main/cindy-brain/libraryVault.ts | 2 +- 3 files changed, 115 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index 792dfec752e..8a37741c2e1 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -19,7 +19,7 @@ import { type LibraryFileIdentity, type LibraryReadHandle, } from '../libraryVault.js'; -import { initCustomLibraryTree, openExistingCustomLibrary, PROVABLE_STAGING_NAME } from '../libraryDirFd.js'; +import { initCustomLibraryTree, openExistingCustomLibrary, parseExistingStdout, PROVABLE_STAGING_NAME } from '../libraryDirFd.js'; const sha256Of = (s: string): string => createHash('sha256').update(s).digest('hex'); @@ -332,6 +332,53 @@ describe('LibraryVault', () => { expect(opened).toMatchObject({ ok: true, state: 'ready', usedBytes: Buffer.byteLength('abcdef') }); }); + it('existing-open payload: pretty 与 compact 合法 meta 都读, malformed 仍 CORRUPT', () => { + const compact = 'OK\n{"version":1,"ghostId":"mivo-canvas","createdAt":1}\n{"files":2,"bytes":10,"updatedAt":1,"mutations":0}'; + const prettyMeta = JSON.stringify({ version: 1, ghostId: 'mivo-canvas', createdAt: 1 }, null, 2); + const prettyUsage = JSON.stringify({ files: 2, bytes: 10, updatedAt: 1, mutations: 0 }, null, 2); + const pretty = `OK\n${prettyMeta}\n${prettyUsage}`; + expect(parseExistingStdout(compact)).toMatchObject({ + ok: true, meta: { version: 1, ghostId: 'mivo-canvas', createdAt: 1 }, usage: { files: 2, bytes: 10 }, + }); + expect(parseExistingStdout(pretty)).toMatchObject({ + ok: true, meta: { version: 1, ghostId: 'mivo-canvas', createdAt: 1 }, usage: { files: 2, bytes: 10 }, + }); + expect(parseExistingStdout('OK\n{"version":1,"ghostId":"mivo-canvas","createdAt":1}')).toMatchObject({ + ok: true, usage: null, + }); + expect(parseExistingStdout('OK\n{not json')).toMatchObject({ ok: false, code: 'CORRUPT' }); + expect(parseExistingStdout('OK\n{"version":2,"ghostId":"mivo-canvas","createdAt":1}')).toMatchObject({ ok: false, code: 'CORRUPT' }); + expect(parseExistingStdout('OK\n{"version":1,"ghostId":"mivo-canvas","createdAt":1}\n{nope')).toMatchObject({ ok: false, code: 'CORRUPT' }); + expect(parseExistingStdout('MISSING')).toMatchObject({ ok: false, code: 'MISSING' }); + }); + + it('pretty-printed 落盘 meta 再 open 仍 ready,不降校验', async () => { + const parent = path.join(tmpRoot, 'picked-pretty'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(custom, { recursive: true }); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const first = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + expect(await first.open()).toMatchObject({ ok: true, state: 'ready' }); + const metaPath = path.join(custom, '.cindy-library', 'meta.json'); + const compact = JSON.parse(await fs.promises.readFile(metaPath, 'utf8')); + await fs.promises.writeFile(metaPath, JSON.stringify(compact, null, 2)); + const second = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + expect(await second.open()).toMatchObject({ ok: true, state: 'ready' }); + await fs.promises.writeFile(metaPath, '{not json'); + const third = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + expect(await third.open()).toMatchObject({ ok: true, state: 'unavailable', reason: 'corrupt' }); + }); + it('可证 staging 名只匹配 uuid.tmp/stream,不匹配 old.tmp 或原件', () => { expect(PROVABLE_STAGING_NAME.test('25ae5922-06f7-46dd-99f1-6d914d53af33.tmp')).toBe(true); expect(PROVABLE_STAGING_NAME.test('25ae5922-06f7-46dd-99f1-6d914d53af33.stream')).toBe(true); diff --git a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts index f2a2c1117ff..0378eb330e6 100644 --- a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts +++ b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts @@ -303,16 +303,57 @@ print STDOUT "OK\n$meta_raw\n"; print STDOUT $usage_raw; `; -function parseExistingStdout(text: string): CustomExistingResult { - if (text === 'MISSING') return { ok: false, code: 'MISSING' }; - if (!text.startsWith('OK\n')) return { ok: false, code: 'IO' }; - const rest = text.slice(3); - const nl = rest.indexOf('\n'); - const metaRaw = nl === -1 ? rest : rest.slice(0, nl); - const usageRaw = nl === -1 ? '' : rest.slice(nl + 1); +function extractJsonValue(source: string, from: number): { json: string; end: number } | null { + let i = from; + while (i < source.length && (source[i] === ' ' || source[i] === '\n' || source[i] === '\r' || source[i] === '\t')) i += 1; + if (i >= source.length || (source[i] !== '{' && source[i] !== '[')) return null; + const start = i; + let depth = 0; + let inStr = false; + let esc = false; + for (; i < source.length; i += 1) { + const c = source[i]; + if (inStr) { + if (esc) { + esc = false; + continue; + } + if (c === '\\') { + esc = true; + continue; + } + if (c === '"') inStr = false; + continue; + } + if (c === '"') { + inStr = true; + continue; + } + if (c === '{' || c === '[') depth += 1; + if (c === '}' || c === ']') { + depth -= 1; + if (depth === 0) return { json: source.slice(start, i + 1), end: i + 1 }; + } + } + return null; +} + +/** Existing-open helper payload: `OK` + complete JSON value(s), or `MISSING`. Pretty or compact. */ +export function parseExistingStdout(text: string): CustomExistingResult { + const body = text.replace(/^\uFEFF/, ''); + const trimmed = body.replace(/^\s+/, ''); + if (trimmed === 'MISSING' || trimmed.startsWith('MISSING\n') || trimmed.startsWith('MISSING\r\n')) { + return { ok: false, code: 'MISSING' }; + } + if (!trimmed.startsWith('OK')) return { ok: false, code: 'IO' }; + let rest = trimmed.slice(2); + if (rest.startsWith('\r\n')) rest = rest.slice(2); + else if (rest.startsWith('\n')) rest = rest.slice(1); + const metaTok = extractJsonValue(rest, 0); + if (!metaTok) return { ok: false, code: 'CORRUPT' }; let meta: CustomExistingMeta; try { - const parsed = JSON.parse(metaRaw) as CustomExistingMeta; + const parsed = JSON.parse(metaTok.json) as CustomExistingMeta; if ( typeof parsed !== 'object' || parsed === null || parsed.version !== 1 || typeof parsed.ghostId !== 'string' || typeof parsed.createdAt !== 'number' @@ -323,24 +364,30 @@ function parseExistingStdout(text: string): CustomExistingResult { } catch { return { ok: false, code: 'CORRUPT' }; } + const afterMeta = rest.slice(metaTok.end); + const usageTok = extractJsonValue(afterMeta, 0); let usage: CustomExistingUsage | null = null; - if (usageRaw.trim()) { + if (usageTok) { try { - const parsed = JSON.parse(usageRaw) as CustomExistingUsage; + const parsed = JSON.parse(usageTok.json) as CustomExistingUsage; if ( - typeof parsed === 'object' && parsed !== null && - typeof parsed.files === 'number' && typeof parsed.bytes === 'number' + typeof parsed !== 'object' || parsed === null || + typeof parsed.files !== 'number' || typeof parsed.bytes !== 'number' ) { - usage = { - files: parsed.files, - bytes: parsed.bytes, - updatedAt: parsed.updatedAt ?? 0, - mutations: parsed.mutations ?? 0, - }; + return { ok: false, code: 'CORRUPT' }; } + usage = { + files: parsed.files, + bytes: parsed.bytes, + updatedAt: parsed.updatedAt ?? 0, + mutations: parsed.mutations ?? 0, + }; } catch { - usage = null; + return { ok: false, code: 'CORRUPT' }; } + if (afterMeta.slice(usageTok.end).trim() !== '') return { ok: false, code: 'CORRUPT' }; + } else if (afterMeta.trim() !== '') { + return { ok: false, code: 'CORRUPT' }; } return { ok: true, meta, usage }; } diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index fccac77055f..c6fd4464411 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -490,7 +490,7 @@ export class LibraryVault { } } else { this.state = 'unavailable'; - this.unavailableReason = 'permission'; + this.unavailableReason = existing.code === 'CORRUPT' ? 'corrupt' : 'permission'; this.opened = true; return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; } From 8cfa9d6fb822bf430beeb8b1d494120a81537f61 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 21:12:15 +0800 Subject: [PATCH 12/17] fix(desktop): do not delete custom Library uuid tmp/stream on open Age and an empty streams map cannot prove a fsynced .tmp/.stream is garbage after a crash before rename. Open no longer unlinks them. P2_tmp stays unresolved. Unknown names are kept. Signed-off-by: PraiseZhu --- .../src/main/cindy-brain/libraryDirFd.ts | 32 ++++++------------- .../src/main/cindy-brain/libraryVault.ts | 8 ----- 2 files changed, 9 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts index 0378eb330e6..0bf1f98ab07 100644 --- a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts +++ b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts @@ -509,20 +509,20 @@ function windowsPowerShellPath(): string | null { export const PROVABLE_STAGING_NAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(tmp|stream)$/i; -/** Linux only: unlinkat-equivalent via /proc/self/fd. Unknown names kept. */ -export function sweepProvableStagingOnLinux( +/** + * Age + empty streams Map cannot prove uuid.tmp/.stream are garbage: + * atomicWrite/stream may have fsynced a complete unique payload before rename. + * Diagnostic list only. No unlink. P2_tmp UNRESOLVED. + */ +export function listProvableStagingOnLinux( parentFd: number, ghostId: string, - activeStreamIds: ReadonlySet, - nowMs: number, - maxAgeMs: number, -): { ok: true; unlinked: number } | { ok: false; code: 'UNSUPPORTED' | 'IO' | 'MISSING' } { +): { ok: true; names: string[] } | { ok: false; code: 'UNSUPPORTED' | 'IO' | 'MISSING' } { if (process.platform !== 'linux') return { ok: false, code: 'UNSUPPORTED' }; if (!validSegment(ghostId) || !Number.isInteger(parentFd) || parentFd < 0) { return { ok: false, code: 'IO' }; } const opened: number[] = []; - let unlinked = 0; try { const openDirAt = (dirFd: number, name: string): number => { let flags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW; @@ -534,22 +534,8 @@ export function sweepProvableStagingOnLinux( const rootFd = openDirAt(parentFd, ghostId); const metaDirFd = openDirAt(rootFd, '.cindy-library'); const tmpFd = openDirAt(metaDirFd, 'tmp'); - const names = fs.readdirSync(`/proc/self/fd/${tmpFd}`); - const cutoff = nowMs - maxAgeMs; - for (const name of names) { - if (!PROVABLE_STAGING_NAME.test(name)) continue; - if (name.endsWith('.stream') && activeStreamIds.has(name.slice(0, -'.stream'.length))) continue; - const target = `/proc/self/fd/${tmpFd}/${name}`; - try { - const st = fs.statSync(target); - if (!st.isFile() || st.mtimeMs >= cutoff) continue; - fs.unlinkSync(target); - unlinked += 1; - } catch { - /* keep on error */ - } - } - return { ok: true, unlinked }; + const names = fs.readdirSync(`/proc/self/fd/${tmpFd}`).filter((name) => PROVABLE_STAGING_NAME.test(name)); + return { ok: true, names }; } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === 'ENOENT' || code === 'ENOTDIR') return { ok: false, code: 'MISSING' }; diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index c6fd4464411..03d856425ac 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -30,7 +30,6 @@ import { isSafeGhostRelativePath } from '../../shared/ghost.js'; import { initCustomLibraryTree, openExistingCustomLibrary, - sweepProvableStagingOnLinux, type CustomTreeInitResult, } from './libraryDirFd.js'; @@ -496,13 +495,6 @@ export class LibraryVault { } const afterTree = await this.assertHeldCustomParent(heldId); if (afterTree) return afterTree; - sweepProvableStagingOnLinux( - parentHandle.fd, - dirSeg, - new Set(this.streams.keys()), - this.now(), - this.limits.tmpMaxAgeMs, - ); } finally { if (parentHandle) { try { From cb55ea81e4eaafea86b68db8c6949b3aba890e33 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 21:46:45 +0800 Subject: [PATCH 13/17] feat(desktop): Windows custom Library first-create via NtCreateFile Create dirs with FILE_OPEN_IF and meta.json with FILE_CREATE from the inherited parent handle. No path mkdir, no rollback rmdir, no new native dependency. Existing FILE_OPEN stays read-only. Missing helper still fails closed before mutation. Signed-off-by: PraiseZhu --- .../__tests__/libraryVault.test.ts | 6 +- .../src/main/cindy-brain/libraryDirFd.ts | 254 +++++++++++++++++- 2 files changed, 255 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index 8a37741c2e1..55ce4afe88a 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -387,7 +387,7 @@ describe('LibraryVault', () => { expect(PROVABLE_STAGING_NAME.test('meta.json')).toBe(false); }); - it('Windows 新建 custom 仍 unsupported,不 mkdir', async () => { + it('Windows 新建 custom 走稳定 parent handle 首建 ready', async () => { if (process.platform !== 'win32') return; const parent = path.join(tmpRoot, 'picked-win-new'); await fs.promises.mkdir(parent); @@ -401,8 +401,8 @@ describe('LibraryVault', () => { rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', }); const opened = await vault.open(); - expect(opened).toMatchObject({ ok: true, state: 'unavailable', reason: 'permission' }); - expect(fs.existsSync(custom)).toBe(false); + expect(opened).toMatchObject({ ok: true, state: 'ready' }); + expect(fs.existsSync(path.join(custom, '.cindy-library', 'meta.json'))).toBe(true); }); it('default 缺失根仍可首次创建', async () => { diff --git a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts index 0bf1f98ab07..02b607d341c 100644 --- a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts +++ b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts @@ -2,8 +2,10 @@ * Custom Library first-create: mkdir/open/meta stay on a held parent directory * fd. Darwin uses a fixed /usr/bin/perl mkdirat/openat helper (SYS_mkdirat=475, * SYS_openat=463 from MacOSX.sdk sys/syscall.h). Linux uses /proc/self/fd. - * Windows and missing helpers fail closed before any mutation. No path mkdir - * fallback. Segments are fixed/validated names, never concatenated user paths. + * Windows uses the same PowerShell NtCreateFile helper: FILE_OPEN_IF dirs and + * FILE_CREATE meta from the inherited parent handle. Missing helpers fail + * closed before any mutation. No path mkdir fallback. Segments are + * fixed/validated names, never concatenated user paths. */ import { spawn } from 'node:child_process'; import * as fs from 'node:fs'; @@ -222,6 +224,7 @@ export async function initCustomLibraryTree(req: { } if (process.platform === 'darwin') return runDarwinInit(req.parentFd, req.ghostId, req.metaJson); if (process.platform === 'linux') return linuxInit(req.parentFd, req.ghostId, req.metaJson); + if (process.platform === 'win32') return runWindowsInit(req.parentFd, req.ghostId, req.metaJson); return { ok: false, code: 'UNSUPPORTED' }; } @@ -783,3 +786,250 @@ function runWindowsOpenExisting(parentFd: number, ghostId: string): Promise 255) return false; + if (segment == "." || segment == "..") return false; + return segment.IndexOf('/') < 0 && segment.IndexOf((char)92) < 0 && segment.IndexOf((char)0) < 0 && segment.IndexOf(':') < 0; + } + + private static SafeFileHandle CreateRelative(IntPtr root, string name, bool directory, uint disposition, uint access) { + if (!ValidSegment(name)) return null; + IntPtr nameBuffer = IntPtr.Zero; + IntPtr unicodePointer = IntPtr.Zero; + try { + nameBuffer = Marshal.StringToHGlobalUni(name); + var unicode = new UNICODE_STRING { + Length = checked((ushort)(name.Length * 2)), + MaximumLength = checked((ushort)((name.Length + 1) * 2)), + Buffer = nameBuffer + }; + unicodePointer = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(UNICODE_STRING))); + Marshal.StructureToPtr(unicode, unicodePointer, false); + var attributes = new OBJECT_ATTRIBUTES { + Length = Marshal.SizeOf(typeof(OBJECT_ATTRIBUTES)), + RootDirectory = root, + ObjectName = unicodePointer, + Attributes = 0, + SecurityDescriptor = IntPtr.Zero, + SecurityQualityOfService = IntPtr.Zero + }; + IO_STATUS_BLOCK statusBlock; + SafeFileHandle opened; + uint options = FILE_SYNCHRONOUS_IO_NONALERT | FILE_OPEN_REPARSE_POINT | + (directory ? FILE_DIRECTORY_FILE : FILE_NON_DIRECTORY_FILE); + uint fileAttributes = directory ? FILE_ATTRIBUTE_DIRECTORY : FILE_ATTRIBUTE_NORMAL; + uint share = directory ? FILE_SHARE_READ_WRITE : FILE_SHARE_ALL; + int status = NtCreateFile(out opened, access, ref attributes, out statusBlock, IntPtr.Zero, fileAttributes, + share, disposition, options, IntPtr.Zero, 0); + if (status == STATUS_OBJECT_NAME_COLLISION) { + if (opened != null) opened.Dispose(); + return null; + } + if (status < 0 || opened == null || opened.IsInvalid) { + if (opened != null) opened.Dispose(); + return null; + } + FILE_ATTRIBUTE_TAG_INFO tag; + if (!GetFileInformationByHandleEx(opened.DangerousGetHandle(), 9, out tag, (uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO))) || + (tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + opened.Dispose(); + return null; + } + return opened; + } catch { + return null; + } finally { + if (unicodePointer != IntPtr.Zero) Marshal.FreeHGlobal(unicodePointer); + if (nameBuffer != IntPtr.Zero) Marshal.FreeHGlobal(nameBuffer); + } + } + + public static int Run(string ghostId, string metaJson) { + if (!ValidSegment(ghostId)) return 2; + if (metaJson == null || !Regex.IsMatch(metaJson, "^\\{\"version\":1,\"ghostId\":\"[A-Za-z0-9._-]{1,128}\",\"createdAt\":[0-9]{1,16}\\}$")) return 2; + IntPtr parent = GetStdHandle(-10); + if (parent == IntPtr.Zero || parent == new IntPtr(-1)) return 2; + BY_HANDLE_FILE_INFORMATION parentInfo; + if (!GetFileInformationByHandle(parent, out parentInfo) || + (parentInfo.FileIndexHigh == 0 && parentInfo.FileIndexLow == 0)) return 2; + uint dirAccess = FILE_LIST_DIRECTORY | FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_TRAVERSE | FILE_READ_ATTRIBUTES | SYNCHRONIZE; + SafeFileHandle ghost = CreateRelative(parent, ghostId, true, FILE_OPEN_IF, dirAccess); + if (ghost == null) return 1; + try { + SafeFileHandle metaDir = CreateRelative(ghost.DangerousGetHandle(), ".cindy-library", true, FILE_OPEN_IF, dirAccess); + if (metaDir == null) return 1; + try { + SafeFileHandle tmp = CreateRelative(metaDir.DangerousGetHandle(), "tmp", true, FILE_OPEN_IF, dirAccess); + if (tmp == null) return 1; + tmp.Dispose(); + SafeFileHandle backups = CreateRelative(metaDir.DangerousGetHandle(), "backups", true, FILE_OPEN_IF, dirAccess); + if (backups == null) return 1; + backups.Dispose(); + uint fileAccess = FILE_WRITE_DATA | FILE_READ_ATTRIBUTES | SYNCHRONIZE; + SafeFileHandle meta = CreateRelative(metaDir.DangerousGetHandle(), "meta.json", false, FILE_CREATE, fileAccess); + if (meta == null) { + Console.Out.Write("exists"); + return 0; + } + try { + byte[] bytes = Encoding.UTF8.GetBytes(metaJson); + uint written; + if (!WriteFile(meta.DangerousGetHandle(), bytes, (uint)bytes.Length, out written, IntPtr.Zero) || written != (uint)bytes.Length) return 1; + if (!FlushFileBuffers(meta.DangerousGetHandle())) return 1; + Console.Out.Write("created"); + return 0; + } finally { + meta.Dispose(); + } + } finally { + metaDir.Dispose(); + } + } finally { + ghost.Dispose(); + } + } +} +'@ +try { + $ghost = $env:CINDY_LIBRARY_GHOST_ID + $meta = $env:CINDY_LIBRARY_META_JSON + $code = [CindyLibraryInit]::Run([string]$ghost, [string]$meta) + exit $code +} catch { + exit 1 +} +`; + +const WINDOWS_INIT_COMMAND = Buffer.from(WINDOWS_INIT_SCRIPT, 'utf16le').toString('base64'); + +function runWindowsInit(parentFd: number, ghostId: string, metaJson: string): Promise { + return new Promise((resolve) => { + const powershell = windowsPowerShellPath(); + if (!powershell) { + resolve({ ok: false, code: 'UNSUPPORTED' }); + return; + } + let child: ReturnType; + try { + child = spawn(powershell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', WINDOWS_INIT_COMMAND], { + stdio: [parentFd, 'pipe', 'pipe'], + windowsHide: true, + env: { + ...process.env, + CINDY_LIBRARY_GHOST_ID: ghostId, + CINDY_LIBRARY_META_JSON: metaJson, + }, + }); + } catch { + resolve({ ok: false, code: 'UNSUPPORTED' }); + return; + } + let settled = false; + const finish = (value: CustomTreeInitResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const chunks: Buffer[] = []; + child.stdout?.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.once('error', () => finish({ ok: false, code: 'UNSUPPORTED' })); + child.once('close', (code) => { + const text = Buffer.concat(chunks).toString('utf8').trim(); + if (code === 2) { + finish({ ok: false, code: 'UNSUPPORTED' }); + return; + } + if (code !== 0 || (text !== 'created' && text !== 'exists')) { + finish({ ok: false, code: 'IO' }); + return; + } + finish({ ok: true, createdMeta: text === 'created' }); + }); + const timer = setTimeout(() => { + child.kill(); + finish({ ok: false, code: 'IO' }); + }, HELPER_TIMEOUT_MS); + timer.unref?.(); + }); +} From 132dc9824dd7244b67cd796715254a542c3f2e31 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 21:47:24 +0800 Subject: [PATCH 14/17] test(maker-core): time durable Subagent system-deny waitFor Record list/resolver/control monotonic marks and dump them on waitFor failure so Linux CI can discriminate missed 500ms poll vs unpublished system-deny. Keep the original assertion and timeout. Mac is syntax sanity only. Signed-off-by: PraiseZhu --- .../__tests__/pi-startsession-cleanup.test.ts | 61 +++++++++++++++---- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/packages/maker-core/src/agents/pi/__tests__/pi-startsession-cleanup.test.ts b/packages/maker-core/src/agents/pi/__tests__/pi-startsession-cleanup.test.ts index 11ca0d28685..ed4bf0ed981 100644 --- a/packages/maker-core/src/agents/pi/__tests__/pi-startsession-cleanup.test.ts +++ b/packages/maker-core/src/agents/pi/__tests__/pi-startsession-cleanup.test.ts @@ -1665,22 +1665,59 @@ describe('PiAgent.startSession failure cleanup (mocked pi process)', () => { }); it('preserves system denial when a durable Subagent approval resolver fails', async () => { + const t0 = performance.now(); + const events: Array<{ t: number; k: string; n?: number }> = []; + const mark = (k: string, n?: number): void => { + events.push({ t: Math.round(performance.now() - t0), k, ...(n === undefined ? {} : { n }) }); + }; const run = pendingSubagentRun({ toolName: 'write', input: { path: 'a.txt' }, }, {}, 'input'); - vi.spyOn(piSubagentRuns, 'listPiSubagentRuns').mockResolvedValue([run]); - const control = vi.spyOn(piSubagentRuns, 'controlPiSubagentRuns').mockResolvedValue(1); - const handle = await new PiAgent(buildDeps()).startSession(opts()); - handle.setInteractionResolver(vi.fn(async () => { throw new Error('resolver failed'); })); - - await vi.waitFor(() => expect(control).toHaveBeenCalledWith( - expect.any(String), - run.taskId, - 'approval', - expect.objectContaining({ value: 'system-deny' }), - )); - await handle.close(); + const list = vi.spyOn(piSubagentRuns, 'listPiSubagentRuns').mockImplementation(async () => { + mark('list', list.mock.calls.length); + return [run]; + }); + const control = vi.spyOn(piSubagentRuns, 'controlPiSubagentRuns').mockImplementation(async () => { + mark('control', control.mock.calls.length + 1); + return 1; + }); + const resolver = vi.fn(async () => { + mark('resolver-throw'); + throw new Error('resolver failed'); + }); + let handle: Awaited> | undefined; + try { + handle = await new PiAgent(buildDeps()).startSession(opts()); + mark('startSession-returned'); + mark('resolver-install-start'); + handle.setInteractionResolver(resolver); + mark('resolver-install-end'); + mark('waitFor-start'); + try { + await vi.waitFor(() => expect(control).toHaveBeenCalledWith( + expect.any(String), + run.taskId, + 'approval', + expect.objectContaining({ value: 'system-deny' }), + )); + mark('waitFor-pass'); + } catch (err) { + mark('waitFor-fail', control.mock.calls.length); + console.error('[pi-startsession-cleanup.diag]', JSON.stringify({ + host: `${process.platform} ${process.version}`, + notLinuxProof: process.platform !== 'linux', + events, + listCalls: list.mock.calls.length, + controlCalls: control.mock.calls.length, + resolverCalls: resolver.mock.calls.length, + })); + throw err; + } + } finally { + await handle?.close(); + mark('handle-closed'); + } }); it('never answers durable Subagent approvals owned by another runtime', async () => { From 94f36c0e2917ab75d5e88cb1fdebf4c3453a5ff3 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 22:00:53 +0800 Subject: [PATCH 15/17] fix(desktop): distinguish Windows meta exists from reparse collision FILE_CREATE collision is exists only after FILE_OPEN of a regular file. Reparse and unknown collisions fail closed. CreateRelative always releases handles it still owns, including the catch path. Signed-off-by: PraiseZhu --- .../src/main/cindy-brain/libraryDirFd.ts | 105 +++++++++++------- 1 file changed, 66 insertions(+), 39 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts index 02b607d341c..cd6691bb883 100644 --- a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts +++ b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts @@ -807,6 +807,7 @@ public static class CindyLibraryInit { private const uint SYNCHRONIZE = 0x00100000; private const uint FILE_SHARE_READ_WRITE = 0x00000003; private const uint FILE_SHARE_ALL = 0x00000007; + private const uint FILE_OPEN = 0x00000001; private const uint FILE_CREATE = 0x00000002; private const uint FILE_OPEN_IF = 0x00000003; private const uint FILE_DIRECTORY_FILE = 0x00000001; @@ -870,10 +871,18 @@ public static class CindyLibraryInit { return segment.IndexOf('/') < 0 && segment.IndexOf((char)92) < 0 && segment.IndexOf((char)0) < 0 && segment.IndexOf(':') < 0; } - private static SafeFileHandle CreateRelative(IntPtr root, string name, bool directory, uint disposition, uint access) { - if (!ValidSegment(name)) return null; + private enum RelativeStatus { Ok, Collision, Reparse, Failed } + private struct RelativeOpen { + public SafeFileHandle Handle; + public RelativeStatus Status; + } + + private static RelativeOpen CreateRelative(IntPtr root, string name, bool directory, uint disposition, uint access) { + var failed = new RelativeOpen { Handle = null, Status = RelativeStatus.Failed }; + if (!ValidSegment(name)) return failed; IntPtr nameBuffer = IntPtr.Zero; IntPtr unicodePointer = IntPtr.Zero; + SafeFileHandle opened = null; try { nameBuffer = Marshal.StringToHGlobalUni(name); var unicode = new UNICODE_STRING { @@ -892,7 +901,6 @@ public static class CindyLibraryInit { SecurityQualityOfService = IntPtr.Zero }; IO_STATUS_BLOCK statusBlock; - SafeFileHandle opened; uint options = FILE_SYNCHRONOUS_IO_NONALERT | FILE_OPEN_REPARSE_POINT | (directory ? FILE_DIRECTORY_FILE : FILE_NON_DIRECTORY_FILE); uint fileAttributes = directory ? FILE_ATTRIBUTE_DIRECTORY : FILE_ATTRIBUTE_NORMAL; @@ -900,22 +908,36 @@ public static class CindyLibraryInit { int status = NtCreateFile(out opened, access, ref attributes, out statusBlock, IntPtr.Zero, fileAttributes, share, disposition, options, IntPtr.Zero, 0); if (status == STATUS_OBJECT_NAME_COLLISION) { - if (opened != null) opened.Dispose(); - return null; + if (opened != null) { opened.Dispose(); opened = null; } + return new RelativeOpen { Handle = null, Status = RelativeStatus.Collision }; } if (status < 0 || opened == null || opened.IsInvalid) { - if (opened != null) opened.Dispose(); - return null; + if (opened != null) { opened.Dispose(); opened = null; } + return failed; } FILE_ATTRIBUTE_TAG_INFO tag; - if (!GetFileInformationByHandleEx(opened.DangerousGetHandle(), 9, out tag, (uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO))) || - (tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { - opened.Dispose(); - return null; + if (!GetFileInformationByHandleEx(opened.DangerousGetHandle(), 9, out tag, (uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO)))) { + opened.Dispose(); opened = null; + return failed; } - return opened; + if ((tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + opened.Dispose(); opened = null; + return new RelativeOpen { Handle = null, Status = RelativeStatus.Reparse }; + } + if (directory && (tag.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0) { + opened.Dispose(); opened = null; + return failed; + } + if (!directory && (tag.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) { + opened.Dispose(); opened = null; + return failed; + } + var ok = new RelativeOpen { Handle = opened, Status = RelativeStatus.Ok }; + opened = null; + return ok; } catch { - return null; + if (opened != null) opened.Dispose(); + return failed; } finally { if (unicodePointer != IntPtr.Zero) Marshal.FreeHGlobal(unicodePointer); if (nameBuffer != IntPtr.Zero) Marshal.FreeHGlobal(nameBuffer); @@ -931,39 +953,44 @@ public static class CindyLibraryInit { if (!GetFileInformationByHandle(parent, out parentInfo) || (parentInfo.FileIndexHigh == 0 && parentInfo.FileIndexLow == 0)) return 2; uint dirAccess = FILE_LIST_DIRECTORY | FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_TRAVERSE | FILE_READ_ATTRIBUTES | SYNCHRONIZE; - SafeFileHandle ghost = CreateRelative(parent, ghostId, true, FILE_OPEN_IF, dirAccess); - if (ghost == null) return 1; + RelativeOpen ghost = CreateRelative(parent, ghostId, true, FILE_OPEN_IF, dirAccess); + if (ghost.Status != RelativeStatus.Ok || ghost.Handle == null) return 1; try { - SafeFileHandle metaDir = CreateRelative(ghost.DangerousGetHandle(), ".cindy-library", true, FILE_OPEN_IF, dirAccess); - if (metaDir == null) return 1; + RelativeOpen metaDir = CreateRelative(ghost.Handle.DangerousGetHandle(), ".cindy-library", true, FILE_OPEN_IF, dirAccess); + if (metaDir.Status != RelativeStatus.Ok || metaDir.Handle == null) return 1; try { - SafeFileHandle tmp = CreateRelative(metaDir.DangerousGetHandle(), "tmp", true, FILE_OPEN_IF, dirAccess); - if (tmp == null) return 1; - tmp.Dispose(); - SafeFileHandle backups = CreateRelative(metaDir.DangerousGetHandle(), "backups", true, FILE_OPEN_IF, dirAccess); - if (backups == null) return 1; - backups.Dispose(); + RelativeOpen tmp = CreateRelative(metaDir.Handle.DangerousGetHandle(), "tmp", true, FILE_OPEN_IF, dirAccess); + if (tmp.Status != RelativeStatus.Ok || tmp.Handle == null) return 1; + tmp.Handle.Dispose(); + RelativeOpen backups = CreateRelative(metaDir.Handle.DangerousGetHandle(), "backups", true, FILE_OPEN_IF, dirAccess); + if (backups.Status != RelativeStatus.Ok || backups.Handle == null) return 1; + backups.Handle.Dispose(); uint fileAccess = FILE_WRITE_DATA | FILE_READ_ATTRIBUTES | SYNCHRONIZE; - SafeFileHandle meta = CreateRelative(metaDir.DangerousGetHandle(), "meta.json", false, FILE_CREATE, fileAccess); - if (meta == null) { - Console.Out.Write("exists"); - return 0; - } - try { - byte[] bytes = Encoding.UTF8.GetBytes(metaJson); - uint written; - if (!WriteFile(meta.DangerousGetHandle(), bytes, (uint)bytes.Length, out written, IntPtr.Zero) || written != (uint)bytes.Length) return 1; - if (!FlushFileBuffers(meta.DangerousGetHandle())) return 1; - Console.Out.Write("created"); - return 0; - } finally { - meta.Dispose(); + RelativeOpen meta = CreateRelative(metaDir.Handle.DangerousGetHandle(), "meta.json", false, FILE_CREATE, fileAccess); + if (meta.Status == RelativeStatus.Ok && meta.Handle != null) { + try { + byte[] bytes = Encoding.UTF8.GetBytes(metaJson); + uint written; + if (!WriteFile(meta.Handle.DangerousGetHandle(), bytes, (uint)bytes.Length, out written, IntPtr.Zero) || written != (uint)bytes.Length) return 1; + if (!FlushFileBuffers(meta.Handle.DangerousGetHandle())) return 1; + Console.Out.Write("created"); + return 0; + } finally { + meta.Handle.Dispose(); + } } + if (meta.Handle != null) meta.Handle.Dispose(); + if (meta.Status != RelativeStatus.Collision) return 1; + RelativeOpen existing = CreateRelative(metaDir.Handle.DangerousGetHandle(), "meta.json", false, FILE_OPEN, FILE_READ_ATTRIBUTES | SYNCHRONIZE); + if (existing.Status != RelativeStatus.Ok || existing.Handle == null) return 1; + existing.Handle.Dispose(); + Console.Out.Write("exists"); + return 0; } finally { - metaDir.Dispose(); + metaDir.Handle.Dispose(); } } finally { - ghost.Dispose(); + ghost.Handle.Dispose(); } } } From e734b468fa339f269c48b93ddf73fd8a0ab68edc Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Mon, 21 Sep 2026 12:02:42 +0800 Subject: [PATCH 16/17] fix(desktop): recycle extra tmp hard links without deleting unique originals Custom libraries skip age-based path sweep. Unlink uuid.tmp/.stream only when nlink>=2 and ino!=0 (successful commit residue). nlink=1 after fsync and before rename is kept. Linux custom uses held-parent fd unlinkat-style paths; Darwin/Windows custom stay fail-closed with no path fallback. Signed-off-by: PraiseZhu --- .../__tests__/libraryVault.test.ts | 65 ++++++++++++++++++ .../src/main/cindy-brain/libraryDirFd.ts | 66 ++++++++++++++++++- .../src/main/cindy-brain/libraryVault.ts | 21 ++++-- 3 files changed, 146 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index 55ce4afe88a..ac8e8130a17 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -387,6 +387,71 @@ describe('LibraryVault', () => { expect(PROVABLE_STAGING_NAME.test('meta.json')).toBe(false); }); + it('default: nlink>=2 uuid.tmp 在 open 时回收且正本不变; nlink=1 超龄仍保留', async () => { + const vault = makeVault(); + expect(await vault.open()).toMatchObject({ ok: true, state: 'ready' }); + const written = await vault.write({ + path: 'keep.bin', + content: Buffer.from('unique-original').toString('base64'), + encoding: 'base64', + }); + expect(written.ok).toBe(true); + const target = path.join(libraryRoot, 'keep.bin'); + const extra = path.join(libraryRoot, '.cindy-library', 'tmp', 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.tmp'); + const unique = path.join(libraryRoot, '.cindy-library', 'tmp', '11111111-2222-4333-8444-555555555555.tmp'); + await fs.promises.link(target, extra); + await fs.promises.writeFile(unique, 'crash-unique'); + await fs.promises.utimes(unique, new Date(0), new Date(0)); + const extraStat = await fs.promises.lstat(extra); + const again = makeVault(); + expect(await again.open()).toMatchObject({ ok: true, state: 'ready' }); + expect(fs.existsSync(unique)).toBe(true); + expect(await fs.promises.readFile(target, 'utf8')).toBe('unique-original'); + expect(await fs.promises.readFile(unique, 'utf8')).toBe('crash-unique'); + if (extraStat.ino === 0 || extraStat.nlink < 2) { + expect(fs.existsSync(extra)).toBe(true); + } else { + expect(fs.existsSync(extra)).toBe(false); + } + }); + + it('custom: Darwin/Windows 不 path 扫 nlink>=2 tmp; Linux held-fd 才回收', async () => { + const parent = path.join(tmpRoot, 'picked-nlink'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(custom, { recursive: true }); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const first = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + expect(await first.open()).toMatchObject({ ok: true, state: 'ready' }); + const keep = await first.write({ + path: 'keep.bin', + content: Buffer.from('custom-original').toString('base64'), + encoding: 'base64', + }); + expect(keep.ok).toBe(true); + const target = path.join(custom, 'keep.bin'); + const extra = path.join(custom, '.cindy-library', 'tmp', 'aaaaaaaa-bbbb-4ccc-8ddd-ffffffffffff.tmp'); + const unique = path.join(custom, '.cindy-library', 'tmp', '11111111-2222-4333-8444-666666666666.tmp'); + await fs.promises.link(target, extra); + await fs.promises.writeFile(unique, 'custom-crash-unique'); + const again = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + expect(await again.open()).toMatchObject({ ok: true, state: 'ready' }); + expect(await fs.promises.readFile(target, 'utf8')).toBe('custom-original'); + expect(fs.existsSync(unique)).toBe(true); + if (process.platform === 'linux') { + expect(fs.existsSync(extra)).toBe(false); + } else { + expect(fs.existsSync(extra)).toBe(true); + } + }); + it('Windows 新建 custom 走稳定 parent handle 首建 ready', async () => { if (process.platform !== 'win32') return; const parent = path.join(tmpRoot, 'picked-win-new'); diff --git a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts index cd6691bb883..adbf7148b9c 100644 --- a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts +++ b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts @@ -515,7 +515,7 @@ export const PROVABLE_STAGING_NAME = /** * Age + empty streams Map cannot prove uuid.tmp/.stream are garbage: * atomicWrite/stream may have fsynced a complete unique payload before rename. - * Diagnostic list only. No unlink. P2_tmp UNRESOLVED. + * List is diagnostic. Unlink only via unlinkProvableExtraLinksOnLinux when nlink>=2. */ export function listProvableStagingOnLinux( parentFd: number, @@ -554,6 +554,70 @@ export function listProvableStagingOnLinux( } } +/** + * Unlink uuid.tmp/.stream that are extra hard links (nlink>=2, ino!=0). + * Those cannot be the unique original. nlink=1 (fsync-before-rename) is kept. + * Linux held-parent fd only. No path fallback. + */ +export function unlinkProvableExtraLinksOnLinux( + parentFd: number, + ghostId: string, + skipNames: ReadonlySet = new Set(), +): { ok: true; unlinked: number } | { ok: false; code: 'UNSUPPORTED' | 'IO' | 'MISSING' } { + if (process.platform !== 'linux') return { ok: false, code: 'UNSUPPORTED' }; + if (!validSegment(ghostId) || !Number.isInteger(parentFd) || parentFd < 0) { + return { ok: false, code: 'IO' }; + } + const opened: number[] = []; + try { + const openDirAt = (dirFd: number, name: string): number => { + let flags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW; + if (fs.constants.O_DIRECTORY) flags |= fs.constants.O_DIRECTORY; + const fd = fs.openSync(`/proc/self/fd/${dirFd}/${name}`, flags); + opened.push(fd); + return fd; + }; + const rootFd = openDirAt(parentFd, ghostId); + const metaDirFd = openDirAt(rootFd, '.cindy-library'); + const tmpFd = openDirAt(metaDirFd, 'tmp'); + const names = fs.readdirSync(`/proc/self/fd/${tmpFd}`).filter((name) => PROVABLE_STAGING_NAME.test(name)); + let unlinked = 0; + for (const name of names) { + if (skipNames.has(name)) continue; + let fileFd = -1; + try { + fileFd = fs.openSync( + `/proc/self/fd/${tmpFd}/${name}`, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW, + ); + const st = fs.fstatSync(fileFd); + if (!st.isFile() || st.ino === 0 || st.nlink < 2) continue; + fs.unlinkSync(`/proc/self/fd/${tmpFd}/${name}`); + unlinked += 1; + } catch { + /* skip unprovable / raced names */ + } finally { + if (fileFd >= 0) { + try { fs.closeSync(fileFd); } catch { /* always close */ } + } + } + } + return { ok: true, unlinked }; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') return { ok: false, code: 'MISSING' }; + return { ok: false, code: 'IO' }; + } finally { + for (const fd of opened.reverse()) { + try { + fs.closeSync(fd); + } catch { + /* always close */ + } + } + } +} + const WINDOWS_EXISTING_OPEN_SCRIPT = String.raw` $utf8 = [System.Text.UTF8Encoding]::new($false) [Console]::OutputEncoding = $utf8 diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index 03d856425ac..e5fdb4f85b9 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -30,6 +30,8 @@ import { isSafeGhostRelativePath } from '../../shared/ghost.js'; import { initCustomLibraryTree, openExistingCustomLibrary, + unlinkProvableExtraLinksOnLinux, + PROVABLE_STAGING_NAME, type CustomTreeInitResult, } from './libraryDirFd.js'; @@ -495,6 +497,7 @@ export class LibraryVault { } const afterTree = await this.assertHeldCustomParent(heldId); if (afterTree) return afterTree; + unlinkProvableExtraLinksOnLinux(parentHandle.fd, dirSeg, this.liveStagingNames()); } finally { if (parentHandle) { try { @@ -785,21 +788,29 @@ export class LibraryVault { }); } - /** 清理超龄 staging 残渣(崩溃遗留;龄 > tmpMaxAgeMs)。 */ + private liveStagingNames(): Set { + const names = new Set(); + for (const stream of this.streams.values()) names.add(path.basename(stream.tmpAbs)); + return names; + } + + /** Recycle extra hard links of uuid.tmp/.stream. nlink=1 may be the unique original — keep. */ private async sweepStaleTmp(): Promise { - const cutoff = this.now() - this.limits.tmpMaxAgeMs; let entries: fs.Dirent[]; try { entries = await fs.promises.readdir(this.tmpDir, { withFileTypes: true }); } catch { return; } + const live = this.liveStagingNames(); for (const entry of entries) { - if (!entry.isFile()) continue; // 非普通条目不动(对齐受管根纪律) + if (!PROVABLE_STAGING_NAME.test(entry.name) || live.has(entry.name)) continue; const full = path.join(this.tmpDir, entry.name); try { - const st = await fs.promises.stat(full); - if (st.mtimeMs < cutoff) await fs.promises.unlink(full); + const st = await fs.promises.lstat(full); + if (st.isSymbolicLink() || !st.isFile()) continue; + if (st.ino === 0 || st.nlink < 2) continue; + await fs.promises.unlink(full); } catch { /* 竞态,跳过 */ } From 2d031dac81227edc1ca2c364937927d28001c3e7 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Mon, 21 Sep 2026 12:28:42 +0800 Subject: [PATCH 17/17] Revert "fix(desktop): recycle extra tmp hard links without deleting unique originals" Lead ruled nlink>=2 unlink is not a sound unique-original proof (name can be swapped after lstat; held parent does not lock names). Restore d7c custom skip + default age sweep. Do not close P2_tmp. Signed-off-by: PraiseZhu --- .../__tests__/libraryVault.test.ts | 65 ------------------ .../src/main/cindy-brain/libraryDirFd.ts | 66 +------------------ .../src/main/cindy-brain/libraryVault.ts | 21 ++---- 3 files changed, 6 insertions(+), 146 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index ac8e8130a17..55ce4afe88a 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -387,71 +387,6 @@ describe('LibraryVault', () => { expect(PROVABLE_STAGING_NAME.test('meta.json')).toBe(false); }); - it('default: nlink>=2 uuid.tmp 在 open 时回收且正本不变; nlink=1 超龄仍保留', async () => { - const vault = makeVault(); - expect(await vault.open()).toMatchObject({ ok: true, state: 'ready' }); - const written = await vault.write({ - path: 'keep.bin', - content: Buffer.from('unique-original').toString('base64'), - encoding: 'base64', - }); - expect(written.ok).toBe(true); - const target = path.join(libraryRoot, 'keep.bin'); - const extra = path.join(libraryRoot, '.cindy-library', 'tmp', 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.tmp'); - const unique = path.join(libraryRoot, '.cindy-library', 'tmp', '11111111-2222-4333-8444-555555555555.tmp'); - await fs.promises.link(target, extra); - await fs.promises.writeFile(unique, 'crash-unique'); - await fs.promises.utimes(unique, new Date(0), new Date(0)); - const extraStat = await fs.promises.lstat(extra); - const again = makeVault(); - expect(await again.open()).toMatchObject({ ok: true, state: 'ready' }); - expect(fs.existsSync(unique)).toBe(true); - expect(await fs.promises.readFile(target, 'utf8')).toBe('unique-original'); - expect(await fs.promises.readFile(unique, 'utf8')).toBe('crash-unique'); - if (extraStat.ino === 0 || extraStat.nlink < 2) { - expect(fs.existsSync(extra)).toBe(true); - } else { - expect(fs.existsSync(extra)).toBe(false); - } - }); - - it('custom: Darwin/Windows 不 path 扫 nlink>=2 tmp; Linux held-fd 才回收', async () => { - const parent = path.join(tmpRoot, 'picked-nlink'); - const custom = path.join(parent, 'mivo-canvas'); - await fs.promises.mkdir(custom, { recursive: true }); - const parentStat = await fs.promises.lstat(parent); - const grant = { - realPathAtGrant: await fs.promises.realpath(parent), - identity: { dev: parentStat.dev, ino: parentStat.ino }, - }; - const first = makeVault({ - rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', - }); - expect(await first.open()).toMatchObject({ ok: true, state: 'ready' }); - const keep = await first.write({ - path: 'keep.bin', - content: Buffer.from('custom-original').toString('base64'), - encoding: 'base64', - }); - expect(keep.ok).toBe(true); - const target = path.join(custom, 'keep.bin'); - const extra = path.join(custom, '.cindy-library', 'tmp', 'aaaaaaaa-bbbb-4ccc-8ddd-ffffffffffff.tmp'); - const unique = path.join(custom, '.cindy-library', 'tmp', '11111111-2222-4333-8444-666666666666.tmp'); - await fs.promises.link(target, extra); - await fs.promises.writeFile(unique, 'custom-crash-unique'); - const again = makeVault({ - rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', - }); - expect(await again.open()).toMatchObject({ ok: true, state: 'ready' }); - expect(await fs.promises.readFile(target, 'utf8')).toBe('custom-original'); - expect(fs.existsSync(unique)).toBe(true); - if (process.platform === 'linux') { - expect(fs.existsSync(extra)).toBe(false); - } else { - expect(fs.existsSync(extra)).toBe(true); - } - }); - it('Windows 新建 custom 走稳定 parent handle 首建 ready', async () => { if (process.platform !== 'win32') return; const parent = path.join(tmpRoot, 'picked-win-new'); diff --git a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts index adbf7148b9c..cd6691bb883 100644 --- a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts +++ b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts @@ -515,7 +515,7 @@ export const PROVABLE_STAGING_NAME = /** * Age + empty streams Map cannot prove uuid.tmp/.stream are garbage: * atomicWrite/stream may have fsynced a complete unique payload before rename. - * List is diagnostic. Unlink only via unlinkProvableExtraLinksOnLinux when nlink>=2. + * Diagnostic list only. No unlink. P2_tmp UNRESOLVED. */ export function listProvableStagingOnLinux( parentFd: number, @@ -554,70 +554,6 @@ export function listProvableStagingOnLinux( } } -/** - * Unlink uuid.tmp/.stream that are extra hard links (nlink>=2, ino!=0). - * Those cannot be the unique original. nlink=1 (fsync-before-rename) is kept. - * Linux held-parent fd only. No path fallback. - */ -export function unlinkProvableExtraLinksOnLinux( - parentFd: number, - ghostId: string, - skipNames: ReadonlySet = new Set(), -): { ok: true; unlinked: number } | { ok: false; code: 'UNSUPPORTED' | 'IO' | 'MISSING' } { - if (process.platform !== 'linux') return { ok: false, code: 'UNSUPPORTED' }; - if (!validSegment(ghostId) || !Number.isInteger(parentFd) || parentFd < 0) { - return { ok: false, code: 'IO' }; - } - const opened: number[] = []; - try { - const openDirAt = (dirFd: number, name: string): number => { - let flags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW; - if (fs.constants.O_DIRECTORY) flags |= fs.constants.O_DIRECTORY; - const fd = fs.openSync(`/proc/self/fd/${dirFd}/${name}`, flags); - opened.push(fd); - return fd; - }; - const rootFd = openDirAt(parentFd, ghostId); - const metaDirFd = openDirAt(rootFd, '.cindy-library'); - const tmpFd = openDirAt(metaDirFd, 'tmp'); - const names = fs.readdirSync(`/proc/self/fd/${tmpFd}`).filter((name) => PROVABLE_STAGING_NAME.test(name)); - let unlinked = 0; - for (const name of names) { - if (skipNames.has(name)) continue; - let fileFd = -1; - try { - fileFd = fs.openSync( - `/proc/self/fd/${tmpFd}/${name}`, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW, - ); - const st = fs.fstatSync(fileFd); - if (!st.isFile() || st.ino === 0 || st.nlink < 2) continue; - fs.unlinkSync(`/proc/self/fd/${tmpFd}/${name}`); - unlinked += 1; - } catch { - /* skip unprovable / raced names */ - } finally { - if (fileFd >= 0) { - try { fs.closeSync(fileFd); } catch { /* always close */ } - } - } - } - return { ok: true, unlinked }; - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'ENOENT' || code === 'ENOTDIR') return { ok: false, code: 'MISSING' }; - return { ok: false, code: 'IO' }; - } finally { - for (const fd of opened.reverse()) { - try { - fs.closeSync(fd); - } catch { - /* always close */ - } - } - } -} - const WINDOWS_EXISTING_OPEN_SCRIPT = String.raw` $utf8 = [System.Text.UTF8Encoding]::new($false) [Console]::OutputEncoding = $utf8 diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index e5fdb4f85b9..03d856425ac 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -30,8 +30,6 @@ import { isSafeGhostRelativePath } from '../../shared/ghost.js'; import { initCustomLibraryTree, openExistingCustomLibrary, - unlinkProvableExtraLinksOnLinux, - PROVABLE_STAGING_NAME, type CustomTreeInitResult, } from './libraryDirFd.js'; @@ -497,7 +495,6 @@ export class LibraryVault { } const afterTree = await this.assertHeldCustomParent(heldId); if (afterTree) return afterTree; - unlinkProvableExtraLinksOnLinux(parentHandle.fd, dirSeg, this.liveStagingNames()); } finally { if (parentHandle) { try { @@ -788,29 +785,21 @@ export class LibraryVault { }); } - private liveStagingNames(): Set { - const names = new Set(); - for (const stream of this.streams.values()) names.add(path.basename(stream.tmpAbs)); - return names; - } - - /** Recycle extra hard links of uuid.tmp/.stream. nlink=1 may be the unique original — keep. */ + /** 清理超龄 staging 残渣(崩溃遗留;龄 > tmpMaxAgeMs)。 */ private async sweepStaleTmp(): Promise { + const cutoff = this.now() - this.limits.tmpMaxAgeMs; let entries: fs.Dirent[]; try { entries = await fs.promises.readdir(this.tmpDir, { withFileTypes: true }); } catch { return; } - const live = this.liveStagingNames(); for (const entry of entries) { - if (!PROVABLE_STAGING_NAME.test(entry.name) || live.has(entry.name)) continue; + if (!entry.isFile()) continue; // 非普通条目不动(对齐受管根纪律) const full = path.join(this.tmpDir, entry.name); try { - const st = await fs.promises.lstat(full); - if (st.isSymbolicLink() || !st.isFile()) continue; - if (st.ino === 0 || st.nlink < 2) continue; - await fs.promises.unlink(full); + const st = await fs.promises.stat(full); + if (st.mtimeMs < cutoff) await fs.promises.unlink(full); } catch { /* 竞态,跳过 */ }