diff --git a/adapters/common/__tests__/migration-control.test.ts b/adapters/common/__tests__/migration-control.test.ts new file mode 100644 index 00000000..e95a568a --- /dev/null +++ b/adapters/common/__tests__/migration-control.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from 'bun:test' +import { PassThrough } from 'node:stream' +import { installAdapterMigrationControl } from '../migration-control.js' + +test('authenticated inherited pipe drains writes before acknowledgement and clean exit', async () => { + const input = new PassThrough() + const output = new PassThrough() + let release!: () => void + let didExit!: (code: number) => void + const exited = new Promise(resolve => { didExit = resolve }) + const write = new Promise(resolve => { release = resolve }) + let messages = '' + let drains = 0 + output.on('data', chunk => { messages += chunk }) + installAdapterMigrationControl({ token: 'isolated-secret', input, output, quiesce: async () => { drains++; await write }, exit: didExit }) + input.write(JSON.stringify({ type: 'migration_quiesce', requestId: 'wrong', token: 'wrong' }) + '\n') + expect(drains).toBe(0) + input.write(JSON.stringify({ type: 'migration_quiesce', requestId: 'right', token: 'isolated-secret' }) + '\n') + await Promise.resolve() + expect(messages).toBe('') + release() + expect(await exited).toBe(0) + expect(JSON.parse(messages)).toEqual({ type: 'migration_quiesced', requestId: 'right' }) + input.destroy() + output.destroy() +}) + +test('failed credential drain cannot emit a successful migration acknowledgement', async () => { + const input = new PassThrough() + const output = new PassThrough() + let didExit!: (code: number) => void + const exited = new Promise(resolve => { didExit = resolve }) + let messages = '' + output.on('data', chunk => { messages += chunk }) + installAdapterMigrationControl({ token: 'isolated-secret', input, output, quiesce: async () => { throw new Error('Disk full') }, exit: didExit }) + input.write(JSON.stringify({ type: 'migration_quiesce', requestId: 'failure', token: 'isolated-secret' }) + '\n') + expect(await exited).toBe(1) + expect(JSON.parse(messages).type).toBe('migration_quiesce_failed') + input.destroy() + output.destroy() +}) diff --git a/adapters/common/__tests__/migration-lifecycle.test.ts b/adapters/common/__tests__/migration-lifecycle.test.ts new file mode 100644 index 00000000..b363a320 --- /dev/null +++ b/adapters/common/__tests__/migration-lifecycle.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'bun:test' +import { AdapterMigrationLifecycle } from '../migration-lifecycle.js' + +describe('adapter migration lifecycle', () => { + it('closes ingress before teardown and waits for in-flight credential writes', async () => { + const lifecycle = new AdapterMigrationLifecycle() + let release!: () => void + const calls: string[] = [] + lifecycle.track(new Promise(resolve => { release = resolve })) + lifecycle.registerShutdown(() => { expect(lifecycle.isQuiescing).toBe(true); calls.push('transport') }) + let completed = false + const drain = lifecycle.quiesce().then(() => { completed = true }) + await Promise.resolve() + expect(completed).toBe(false) + release() + await drain + expect(calls).toEqual(['transport']) + }) + + it('does not acknowledge a failed pending write', async () => { + const lifecycle = new AdapterMigrationLifecycle() + let fail!: (error: Error) => void + const pending = lifecycle.track(new Promise((_resolve, reject) => { fail = reject })) + void pending.catch(() => {}) + const drain = lifecycle.quiesce() + fail(new Error('Cannot save credentials')) + await expect(drain).rejects.toThrow('Cannot save credentials') + }) + + it('drains writes even when transport cleanup fails and closes each transport once', async () => { + const lifecycle = new AdapterMigrationLifecycle() + let release!: () => void + let closed = 0 + lifecycle.track(new Promise(resolve => { release = resolve })) + lifecycle.registerShutdown(() => { + closed++ + throw new Error('Transport close failed') + }) + const stopping = lifecycle.quiesce() + expect(lifecycle.quiesce()).toBe(stopping) + let settled = false + void stopping.catch(() => { settled = true }) + await Promise.resolve() + await Promise.resolve() + expect(settled).toBe(false) + release() + await expect(stopping).rejects.toThrow('Transport close failed') + expect(closed).toBe(1) + }) +}) diff --git a/adapters/common/attachment/__tests__/attachment-store.test.ts b/adapters/common/attachment/__tests__/attachment-store.test.ts index 9b54c6b6..4dab60df 100644 --- a/adapters/common/attachment/__tests__/attachment-store.test.ts +++ b/adapters/common/attachment/__tests__/attachment-store.test.ts @@ -16,6 +16,21 @@ afterEach(async () => { }) describe('AttachmentStore', () => { + it('keeps default IM downloads inside the active storage directory', async () => { + const previous = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = tmpRoot + try { + const store = new AttachmentStore() + const target = store.resolvePath('feishu', 'session', 'fixture.png') + expect(target).toBe(path.join(tmpRoot, 'im-downloads', 'feishu', 'session', 'fixture.png')) + await store.write(target, Buffer.from('fixture')) + expect(await fs.readFile(target, 'utf8')).toBe('fixture') + } finally { + if (previous === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = previous + } + }) + it('writes a buffer and returns the absolute path', async () => { const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) const target = store.resolvePath('feishu', 'sess-1', 'hello.png') diff --git a/adapters/common/attachment/__tests__/image-block-watcher.test.ts b/adapters/common/attachment/__tests__/image-block-watcher.test.ts index 3187f8c4..3a23c57d 100644 --- a/adapters/common/attachment/__tests__/image-block-watcher.test.ts +++ b/adapters/common/attachment/__tests__/image-block-watcher.test.ts @@ -24,6 +24,14 @@ describe('ImageBlockWatcher', () => { } }) + it('recognizes Windows drive and UNC paths after a data directory move', () => { + for (const sourcePath of [String.raw`D:\data\attachment.png`, 'D:/data/attachment.png', String.raw`\\server\share\attachment.png`]) { + const watcher = new ImageBlockWatcher() + expect(watcher.feed(`![moved](${sourcePath})`)[0]?.source).toEqual({ kind: 'path', path: sourcePath }) + } + expect(new ImageBlockWatcher().feed('![relative](D:attachment.png)')).toEqual([]) + }) + it('extracts a markdown image with file:// URL as path', () => { const w = new ImageBlockWatcher() const out = w.feed('![x](file:///var/img/x.png)') diff --git a/adapters/common/attachment/attachment-store.ts b/adapters/common/attachment/attachment-store.ts index 0214f9f3..5d43812c 100644 --- a/adapters/common/attachment/attachment-store.ts +++ b/adapters/common/attachment/attachment-store.ts @@ -11,6 +11,7 @@ */ import * as fs from 'node:fs/promises' +import { adapterMigrationLifecycle } from '../migration-lifecycle.js' import * as fsSync from 'node:fs' import type { Dirent } from 'node:fs' import * as path from 'node:path' @@ -29,7 +30,7 @@ const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000 const DEFAULT_ORPHAN_GRACE_MS = 10 * 60 * 1000 function defaultRoot(): string { - return path.join(os.homedir(), '.claude', 'im-downloads') + return path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'im-downloads') } /** Strip path separators / .. / control chars from a filename. */ @@ -70,7 +71,11 @@ export class AttachmentStore { } /** Write atomically: stream to {target}.part, then rename. */ - async write(target: string, data: Buffer): Promise { + write(target: string, data: Buffer): Promise { + return adapterMigrationLifecycle.track(this.writeOnce(target, data)) + } + + private async writeOnce(target: string, data: Buffer): Promise { await fs.mkdir(path.dirname(target), { recursive: true }) const tmp = `${target}.${process.pid}.${Date.now()}.part` await fs.writeFile(tmp, data) @@ -79,7 +84,11 @@ export class AttachmentStore { } /** Remove files older than retentionMs. Returns summary. */ - async gc(): Promise<{ removed: number; bytes: number }> { + gc(): Promise<{ removed: number; bytes: number }> { + return adapterMigrationLifecycle.track(this.gcOnce()) + } + + private async gcOnce(): Promise<{ removed: number; bytes: number }> { let removed = 0 let bytes = 0 const now = Date.now() diff --git a/adapters/common/attachment/image-block-watcher.ts b/adapters/common/attachment/image-block-watcher.ts index cce5c826..ad5d30dc 100644 --- a/adapters/common/attachment/image-block-watcher.ts +++ b/adapters/common/attachment/image-block-watcher.ts @@ -35,7 +35,7 @@ function classify(target: string): PendingUpload['source'] | null { if (target.startsWith('http://') || target.startsWith('https://')) { return { kind: 'url', url: target } } - if (target.startsWith('/')) { + if (target.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(target) || /^\\\\[^\\]+\\[^\\]+/.test(target)) { return { kind: 'path', path: target } } return null // relative paths — skip, we can't resolve them safely diff --git a/adapters/common/chat-queue.ts b/adapters/common/chat-queue.ts index a0c0190e..75136285 100644 --- a/adapters/common/chat-queue.ts +++ b/adapters/common/chat-queue.ts @@ -6,11 +6,15 @@ * 参考 openclaw-lark chat-queue.ts 的 Promise 链设计。 */ +import { adapterMigrationLifecycle } from './migration-lifecycle.js' + const queues = new Map>() export async function enqueue(chatId: string, fn: () => Promise): Promise { + if (adapterMigrationLifecycle.isQuiescing) return const prev = queues.get(chatId) ?? Promise.resolve() - const next = prev.then(fn, () => fn()).catch((err) => { + const invoke = () => adapterMigrationLifecycle.isQuiescing ? undefined : fn() + const next = adapterMigrationLifecycle.track(prev.then(invoke, invoke)).catch((err) => { console.error(`[ChatQueue] Error in task for chat ${chatId}:`, err) }) queues.set(chatId, next) diff --git a/adapters/common/chat-runtime.ts b/adapters/common/chat-runtime.ts index 56d22ffd..d1298e3a 100644 --- a/adapters/common/chat-runtime.ts +++ b/adapters/common/chat-runtime.ts @@ -18,6 +18,7 @@ import * as path from 'node:path' import { enqueue } from './chat-queue.js' +import { adapterMigrationLifecycle } from './migration-lifecycle.js' import { getConfiguredWorkDir, type AdapterConfig, @@ -388,7 +389,7 @@ export class ImChatRuntime { this.getBuffer(chatId).append(msg.text) if (this.port.sendImage) { for (const pending of this.getImageWatcher(chatId).feed(msg.text)) { - void this.dispatchOutboundImage(chatId, pending) + void adapterMigrationLifecycle.track(this.dispatchOutboundImage(chatId, pending)) } } } diff --git a/adapters/common/migration-control.ts b/adapters/common/migration-control.ts new file mode 100644 index 00000000..d71ce800 --- /dev/null +++ b/adapters/common/migration-control.ts @@ -0,0 +1,41 @@ +import { timingSafeEqual } from 'node:crypto' +import type { Readable, Writable } from 'node:stream' +import { adapterMigrationLifecycle } from './migration-lifecycle.js' + +/** An inherited anonymous pipe is available only to the owning desktop host. */ +export function installAdapterMigrationControl(options: { + token: string + input?: Readable + output?: Writable + quiesce?: () => Promise + exit?: (code: number) => void +}): void { + const input = options.input ?? process.stdin + const output = options.output ?? process.stdout + const exit = options.exit ?? (code => process.exit(code)) + let buffer = '' + let stopping = false + input.on('data', chunk => { + buffer += chunk.toString() + if (buffer.length > 8192) { + buffer = '' + return + } + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + let request: { type?: string; token?: string; requestId?: string } + try { request = JSON.parse(line) } catch { continue } + if (stopping || request.type !== 'migration_quiesce' || typeof request.token !== 'string' || typeof request.requestId !== 'string') continue + const actual = Buffer.from(request.token) + const expected = Buffer.from(options.token) + if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) continue + stopping = true + void (options.quiesce ?? (() => adapterMigrationLifecycle.quiesce()))().then(() => { + output.write(JSON.stringify({ type: 'migration_quiesced', requestId: request.requestId }) + '\n', () => exit(0)) + }, () => { + output.write(JSON.stringify({ type: 'migration_quiesce_failed', requestId: request.requestId }) + '\n', () => exit(1)) + }) + } + }) +} diff --git a/adapters/common/migration-lifecycle.ts b/adapters/common/migration-lifecycle.ts new file mode 100644 index 00000000..cf530d09 --- /dev/null +++ b/adapters/common/migration-lifecycle.ts @@ -0,0 +1,51 @@ +/** Shared maintenance boundary for sidecar-owned IM transports and disk writes. */ +export class AdapterMigrationLifecycle { + private quiescing = false + private pending = new Set>() + private shutdown = new Set<() => Promise | void>() + private failures: unknown[] = [] + private stopping: Promise | null = null + + get isQuiescing(): boolean { return this.quiescing } + + registerShutdown(cleanup: () => Promise | void): void { this.shutdown.add(cleanup) } + + track(operation: Promise): Promise { + this.pending.add(operation) + void operation.then(() => this.pending.delete(operation), error => { + this.pending.delete(operation) + if (this.quiescing) this.failures.push(error) + }) + return operation + } + + quiesce(): Promise { + if (this.stopping) return this.stopping + this.quiescing = true + this.stopping = this.drain() + return this.stopping + } + + private async drain(): Promise { + const cleanupResults = await Promise.allSettled([...this.shutdown].map(cleanup => Promise.resolve().then(cleanup))) + for (const result of cleanupResults) { + if (result.status === 'rejected') this.failures.push(result.reason) + } + while (this.pending.size > 0) await Promise.allSettled([...this.pending]) + if (this.failures.length > 0) throw this.failures[0] + } +} + +export const adapterMigrationLifecycle = new AdapterMigrationLifecycle() + +export function registerAdapterShutdown(cleanup: () => Promise | void): void { + adapterMigrationLifecycle.registerShutdown(cleanup) + const stop = () => { + void adapterMigrationLifecycle.quiesce().then(() => process.exit(0), error => { + console.error('[Adapter] Shutdown failed', error) + process.exit(1) + }) + } + process.once('SIGINT', stop) + process.once('SIGTERM', stop) +} diff --git a/adapters/common/ws-bridge.ts b/adapters/common/ws-bridge.ts index f0cab3d5..74a9f4c7 100644 --- a/adapters/common/ws-bridge.ts +++ b/adapters/common/ws-bridge.ts @@ -1,3 +1,4 @@ +import { adapterMigrationLifecycle } from './migration-lifecycle.js' /** * WebSocket Bridge * @@ -210,7 +211,7 @@ export class WsBridge { // races where a later message reads stale map entries set up by an // earlier-but-still-in-flight handler. const prev = this.handlerChains.get(chatId) ?? Promise.resolve() - const next = prev + const next = adapterMigrationLifecycle.track(prev .catch(() => {}) // upstream errors must not poison the chain .then(() => { // Resetting a chat cannot cancel promises already queued for its old @@ -222,7 +223,7 @@ export class WsBridge { }) .catch((err) => { console.error(`[WsBridge] Handler error on ${chatId}:`, err) - }) + })) this.handlerChains.set(chatId, next) }) diff --git a/adapters/dingtalk/index.ts b/adapters/dingtalk/index.ts index b7c4b975..12b6f6e7 100644 --- a/adapters/dingtalk/index.ts +++ b/adapters/dingtalk/index.ts @@ -1,3 +1,4 @@ +import { adapterMigrationLifecycle, registerAdapterShutdown } from '../common/migration-lifecycle.js' /** * DingTalk Adapter for Claude Code Desktop. * @@ -674,6 +675,12 @@ async function start(): Promise { keepAlive: true, } as any) + registerAdapterShutdown(async () => { + bridge.destroy() + dedup.destroy() + await client.disconnect() + }) + client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => { const messageId = res.headers?.messageId if (messageId) { @@ -685,7 +692,7 @@ async function start(): Promise { if (!data) return if (data.msgId && !dedup.tryRecord(`body:${data.msgId}`)) return - await handleRobotMessage(data) + await adapterMigrationLifecycle.track(handleRobotMessage(data)) }) client.registerCallbackListener(TOPIC_CARD, async (res: any) => { @@ -695,28 +702,16 @@ async function start(): Promise { if (!dedup.tryRecord(`card:${messageId}`)) return } - await handleCardCallback(res.data ?? res) + await adapterMigrationLifecycle.track(handleCardCallback(res.data ?? res)) }) + if (adapterMigrationLifecycle.isQuiescing) return await client.connect() console.log(`[DingTalk] Stream connected. Server: ${config.serverUrl}`) - const shutdown = async () => { - console.log('[DingTalk] Shutting down...') - bridge.destroy() - dedup.destroy() - try { - await client.disconnect() - } catch { - // ignore - } - process.exit(0) - } - process.once('SIGINT', () => void shutdown()) - process.once('SIGTERM', () => void shutdown()) } -if (import.meta.main || process.argv.includes('--dingtalk')) start().catch((err) => { +if (import.meta.main || process.argv.includes('--dingtalk')) adapterMigrationLifecycle.track(start()).catch((err) => { console.error('[DingTalk] Fatal:', err instanceof Error ? err.message : err) process.exit(1) }) diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts index a3c513c8..ead06ca1 100644 --- a/adapters/feishu/index.ts +++ b/adapters/feishu/index.ts @@ -1,3 +1,4 @@ +import { adapterMigrationLifecycle, registerAdapterShutdown } from '../common/migration-lifecycle.js' /** * 飞书 (Feishu/Lark) Adapter for Claude Code Desktop * @@ -1254,6 +1255,7 @@ async function start(): Promise { console.log(`[Feishu] App ID: ${config.feishu.appId}`) await resolveBotOpenId() + if (adapterMigrationLifecycle.isQuiescing) return const dispatcher = new Lark.EventDispatcher({ encryptKey: config.feishu.encryptKey, @@ -1263,14 +1265,14 @@ async function start(): Promise { dispatcher.register({ 'im.message.receive_v1': async (data: any) => { try { - await handleMessage(data) + await adapterMigrationLifecycle.track(handleMessage(data)) } catch (err) { console.error('[Feishu] Message handler error:', err) } }, 'card.action.trigger': async (data: any) => { try { - return await handleCardAction(data) + return await adapterMigrationLifecycle.track(handleCardAction(data)) } catch (err) { console.error('[Feishu] Card action error:', err) } @@ -1288,16 +1290,16 @@ async function start(): Promise { console.log('[Feishu] Bot is running! (WebSocket connected)') } -if (import.meta.main || process.argv.includes('--feishu')) start().catch((err) => { +if (import.meta.main || process.argv.includes('--feishu')) adapterMigrationLifecycle.track(start()).catch((err) => { console.error('[Feishu] Failed to start:', err) process.exit(1) }) -if (import.meta.main || process.argv.includes('--feishu')) process.on('SIGINT', () => { +if (import.meta.main || process.argv.includes('--feishu')) registerAdapterShutdown(async () => { console.log('[Feishu] Shutting down...') bridge.destroy() dedup.destroy() - process.exit(0) + wsClient?.close({ force: true }) }) export { bridge, dedup, sessionStore, sessionSelectionController, handleServerMessage, getRuntimeState, clearTransientChatState, createSessionForChat, showProjectPicker, handleMessage, handleCardAction, larkClient, prepareNewSession } diff --git a/adapters/feishu/media.ts b/adapters/feishu/media.ts index a978e999..2caac3dd 100644 --- a/adapters/feishu/media.ts +++ b/adapters/feishu/media.ts @@ -1,3 +1,4 @@ +import { adapterMigrationLifecycle } from '../common/migration-lifecycle.js' /** * Feishu media service — wraps im.messageResource / im.image / im.file * so adapters/feishu/index.ts stays focused on flow control. @@ -101,7 +102,7 @@ export class FeishuMediaService { }) if (typeof resp?.writeFile === 'function') { - await resp.writeFile(target) + await adapterMigrationLifecycle.track(Promise.resolve(resp.writeFile(target))) } else if (resp?.data instanceof Buffer) { await this.store.write(target, resp.data) } else if (resp instanceof Buffer) { diff --git a/adapters/qq/index.ts b/adapters/qq/index.ts index d2ee5b77..1f5cd1d9 100644 --- a/adapters/qq/index.ts +++ b/adapters/qq/index.ts @@ -1,3 +1,4 @@ +import { adapterMigrationLifecycle, registerAdapterShutdown } from '../common/migration-lifecycle.js' /** * QQ Adapter for Claude Code Desktop * @@ -259,19 +260,18 @@ bot.on('error', (err: Error) => console.error('[QQ] Connection error:', err.mess console.log('[QQ] Starting adapter...') console.log(`[QQ] Server: ${config.serverUrl}`) console.log(`[QQ] App: ${config.qq.appId}`) -void bot.start().catch((err) => { +void adapterMigrationLifecycle.track(bot.start()).catch((err) => { console.error('[QQ] Failed to start:', err instanceof Error ? err.message : err) process.exit(1) }) -process.on('SIGINT', () => { +registerAdapterShutdown(async () => { console.log('[QQ] Shutting down...') try { - bot.stop() + await bot.stop() } catch { // Best-effort: the process is exiting either way. } bridge.destroy() dedup.destroy() - process.exit(0) }) diff --git a/adapters/slack/index.ts b/adapters/slack/index.ts index 5bc3e5e6..b5becdf2 100644 --- a/adapters/slack/index.ts +++ b/adapters/slack/index.ts @@ -1,3 +1,4 @@ +import { adapterMigrationLifecycle, registerAdapterShutdown } from '../common/migration-lifecycle.js' /** * Slack Adapter for Claude Code Desktop * @@ -250,7 +251,8 @@ const socket = new SlackSocketMode({ replyThreads.set(payload.chatId, payload.threadTs) - void (async () => { + if (adapterMigrationLifecycle.isQuiescing) return + void adapterMigrationLifecycle.track((async () => { try { await runtime.handleInbound({ chatId: payload.chatId, @@ -264,14 +266,14 @@ const socket = new SlackSocketMode({ } catch (err) { console.error('[Slack] Failed to prepare inbound message:', err) } - })() + })()) }, }) console.log('[Slack] Starting adapter...') console.log(`[Slack] Server: ${config.serverUrl}`) -void (async () => { +void adapterMigrationLifecycle.track((async () => { try { const identity = await api.authTest() botUserId = identity.userId || undefined @@ -280,13 +282,12 @@ void (async () => { console.error('[Slack] auth.test failed:', err instanceof Error ? err.message : err) process.exit(1) } - await socket.start() -})() + if (!adapterMigrationLifecycle.isQuiescing) await socket.start() +})()) -process.on('SIGINT', () => { +registerAdapterShutdown(async () => { console.log('[Slack] Shutting down...') - socket.stop() + await socket.stop() bridge.destroy() dedup.destroy() - process.exit(0) }) diff --git a/adapters/telegram/__tests__/entrypoint-session-routing.test.ts b/adapters/telegram/__tests__/entrypoint-session-routing.test.ts index ab728c1f..db9f6a05 100644 --- a/adapters/telegram/__tests__/entrypoint-session-routing.test.ts +++ b/adapters/telegram/__tests__/entrypoint-session-routing.test.ts @@ -6,6 +6,7 @@ import type { ServerWebSocket } from 'bun' import { SessionStore } from '../../common/session-store.js' import { WsBridge } from '../../common/ws-bridge.js' import { AttachmentStore } from '../../common/attachment/attachment-store.js' +import { adapterMigrationLifecycle } from '../../common/migration-lifecycle.js' // Import the actual entrypoint with isolated configuration. Telegram API calls // terminate in grammY's documented transformer; HTTP and WS use loopback only. @@ -357,7 +358,7 @@ describe('Telegram entrypoint session routing', () => { it('starts the registered bot and publishes its menu without external access', async () => { const gc = spyOn(AttachmentStore.prototype, 'gc').mockResolvedValue({ removed: 0, bytes: 0 }) const start = spyOn(entry.bot, 'start').mockImplementation(async (options) => { await options?.onStart?.(entry.bot.botInfo) }) - const previousListeners = process.listeners('SIGINT') + const previousListeners = new Map(['SIGINT', 'SIGTERM'].map(signal => [signal, process.listeners(signal)])) try { entry.startTelegramAdapter() await eventually(() => expect(apiCalls.some((call) => call.method === 'setMyCommands')).toBe(true)) @@ -366,11 +367,54 @@ describe('Telegram entrypoint session routing', () => { const commands = apiCalls.find((call) => call.method === 'setMyCommands')!.payload.commands expect(commands.some((command: { command: string }) => command.command === 'sessions')).toBe(true) } finally { - for (const listener of process.listeners('SIGINT')) { - if (!previousListeners.includes(listener)) process.removeListener('SIGINT', listener) + for (const [signal, listeners] of previousListeners) { + for (const listener of process.listeners(signal)) { + if (!listeners.includes(listener)) process.removeListener(signal, listener) + } } start.mockRestore() gc.mockRestore() } }) + + it('blocks real update ingress during migration and its registered shutdown waits for polling to stop', async () => { + let cleanup!: () => Promise | void + let release!: () => void + const pendingStop = new Promise(resolve => { release = resolve }) + const register = spyOn(adapterMigrationLifecycle, 'registerShutdown').mockImplementation(callback => { cleanup = callback }) + const start = spyOn(entry.bot, 'start').mockResolvedValue() + const running = spyOn(entry.bot, 'isRunning').mockReturnValue(true) + const stop = spyOn(entry.bot, 'stop').mockImplementation(async () => { await pendingStop }) + const gc = spyOn(AttachmentStore.prototype, 'gc').mockResolvedValue({ removed: 0, bytes: 0 }) + const destroy = spyOn(WsBridge.prototype, 'destroy') + const previousListeners = new Map(['SIGINT', 'SIGTERM'].map(signal => [signal, process.listeners(signal)])) + try { + entry.startTelegramAdapter() + expect(register).toHaveBeenCalledTimes(1) + Object.defineProperty(adapterMigrationLifecycle, 'isQuiescing', { configurable: true, get: () => true }) + const previousRequests = requests.length + const previousMessages = messages.length + await text(710, 'Should remain unadmitted') + expect(requests).toHaveLength(previousRequests) + expect(messages).toHaveLength(previousMessages) + let completed = false + const stopping = Promise.resolve(cleanup()).then(() => { completed = true }) + await Promise.resolve() + expect(stop).toHaveBeenCalledTimes(1) + expect(completed).toBe(false) + expect(destroy).not.toHaveBeenCalled() + release() + await stopping + expect(destroy).toHaveBeenCalledTimes(1) + } finally { + release() + Reflect.deleteProperty(adapterMigrationLifecycle, 'isQuiescing') + for (const [signal, listeners] of previousListeners) { + for (const listener of process.listeners(signal)) { + if (!listeners.includes(listener)) process.removeListener(signal, listener) + } + } + for (const spy of [destroy, gc, stop, running, start, register]) spy.mockRestore() + } + }) }) diff --git a/adapters/telegram/index.ts b/adapters/telegram/index.ts index bed48a49..1a38cd69 100644 --- a/adapters/telegram/index.ts +++ b/adapters/telegram/index.ts @@ -1,3 +1,4 @@ +import { adapterMigrationLifecycle, registerAdapterShutdown } from '../common/migration-lifecycle.js' /** * Telegram Adapter for Claude Code Desktop * @@ -51,6 +52,7 @@ if (!config.telegram.botToken) { } export const bot = new Bot(config.telegram.botToken) +bot.use((_ctx, next) => adapterMigrationLifecycle.isQuiescing ? Promise.resolve() : adapterMigrationLifecycle.track(next())) const bridge = new WsBridge(config.serverUrl, 'tg') const streamDelivery = new TelegramStreamDelivery(bot.api) const dedup = new MessageDedup() @@ -491,7 +493,7 @@ const isAuthorizedTelegramUser = (userId: number) => isAllowedUser('telegram', u registerAuthorizedTelegramCommand(bot, 'stop', isAuthorizedTelegramUser, (ctx) => { const chatId = String(ctx.chat!.id) - void (async () => { + void adapterMigrationLifecycle.track((async () => { const result = await ensureExistingSession(chatId) if (result.status !== 'restored') { await ctx.reply(result.status === 'unavailable' ? SESSION_RECONNECT_NOTICE : formatImStatus(null)) @@ -499,7 +501,7 @@ registerAuthorizedTelegramCommand(bot, 'stop', isAuthorizedTelegramUser, (ctx) = } bridge.sendStopGeneration(chatId) await ctx.reply('⏹ 已发送停止信号。') - })() + })()) }) registerAuthorizedTelegramCommand(bot, 'status', isAuthorizedTelegramUser, async (ctx) => { @@ -509,7 +511,7 @@ registerAuthorizedTelegramCommand(bot, 'status', isAuthorizedTelegramUser, async registerAuthorizedTelegramCommand(bot, 'clear', isAuthorizedTelegramUser, (ctx) => { const chatId = String(ctx.chat!.id) - void (async () => { + void adapterMigrationLifecycle.track((async () => { const result = await ensureExistingSession(chatId) if (result.status !== 'restored') { await ctx.reply(result.status === 'unavailable' ? SESSION_RECONNECT_NOTICE : formatImStatus(null)) @@ -523,7 +525,7 @@ registerAuthorizedTelegramCommand(bot, 'clear', isAuthorizedTelegramUser, (ctx) } getRuntimeState(chatId).state = 'thinking' await ctx.reply('🧹 已清空当前会话上下文。') - })() + })()) }) for (const command of ['allow', 'always', 'allow-always', 'deny'] as const) { @@ -714,10 +716,11 @@ export function startTelegramAdapter(): void { }) void syncTelegramBotCommands(bot.api).then(() => console.log('[Telegram] Command menu synced')).catch((err) => console.warn('[Telegram] Command menu sync failed:', err instanceof Error ? err.message : err)) void bot.start({ onStart: () => console.log('[Telegram] Bot is running!') }) - process.once('SIGINT', () => { + registerAdapterShutdown(async () => { console.log('[Telegram] Shutting down...') - stopTelegramAdapter() - process.exit(0) + if (bot.isRunning()) await bot.stop() + bridge.destroy() + dedup.destroy() }) } diff --git a/adapters/wechat/index.ts b/adapters/wechat/index.ts index 3bd796c1..a242daba 100644 --- a/adapters/wechat/index.ts +++ b/adapters/wechat/index.ts @@ -1,3 +1,4 @@ +import { registerAdapterShutdown } from '../common/migration-lifecycle.js' import * as path from 'node:path' import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-bridge.js' import { MessageDedup } from '../common/message-dedup.js' @@ -616,6 +617,7 @@ async function pollLoop(): Promise { timeoutMs: GET_UPDATES_TIMEOUT_MS, }) if (resp.get_updates_buf) getUpdatesBuf = resp.get_updates_buf + if (stopped) return const hasRetError = typeof resp.ret === 'number' && resp.ret !== 0 const hasErrCode = typeof resp.errcode === 'number' && resp.errcode !== 0 if (hasRetError || hasErrCode) { @@ -646,13 +648,12 @@ console.log('[WeChat] Starting adapter...') console.log(`[WeChat] Account: ${accountId}`) if (import.meta.main || process.argv.includes('--wechat')) void pollLoop() -if (import.meta.main || process.argv.includes('--wechat')) process.on('SIGINT', () => { +if (import.meta.main || process.argv.includes('--wechat')) registerAdapterShutdown(() => { console.log('[WeChat] Shutting down...') stopped = true typingController.destroy() bridge.destroy() dedup.destroy() - process.exit(0) }) export { bridge, dedup, sessionStore, sessionSelectionController, handleServerMessage, getRuntimeState, clearTransientChatState, createSessionForChat, showProjectPicker, routeUserMessage, startNewSession, typingController } diff --git a/adapters/wecom/index.ts b/adapters/wecom/index.ts index f411ce08..5ed2cd7d 100644 --- a/adapters/wecom/index.ts +++ b/adapters/wecom/index.ts @@ -1,3 +1,4 @@ +import { registerAdapterShutdown } from '../common/migration-lifecycle.js' /** * 企业微信 (Enterprise WeChat / WeCom) Adapter for Claude Code Desktop * @@ -255,7 +256,7 @@ console.log(`[WeCom] Server: ${config.serverUrl}`) console.log(`[WeCom] Bot: ${config.wecom.botId}`) client.connect() -process.on('SIGINT', () => { +registerAdapterShutdown(() => { console.log('[WeCom] Shutting down...') try { client.disconnect() @@ -264,5 +265,4 @@ process.on('SIGINT', () => { } bridge.destroy() dedup.destroy() - process.exit(0) }) diff --git a/adapters/whatsapp/__tests__/session.test.ts b/adapters/whatsapp/__tests__/session.test.ts index a93d9d4a..9f1ba2bc 100644 --- a/adapters/whatsapp/__tests__/session.test.ts +++ b/adapters/whatsapp/__tests__/session.test.ts @@ -1,10 +1,14 @@ -import { describe, expect, it } from 'bun:test' +import { describe, expect, it, spyOn } from 'bun:test' +import { EventEmitter } from 'node:events' +import * as baileys from '@whiskeysockets/baileys' +import { AdapterMigrationLifecycle, adapterMigrationLifecycle } from '../../common/migration-lifecycle.js' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { clearWhatsAppAuth, closeWhatsAppSocket, + createWhatsAppSocket, getWhatsAppDisconnectStatus, hasWhatsAppAuth, isWhatsAppLoggedOut, @@ -58,6 +62,102 @@ describe('whatsapp session helpers', () => { }) it('returns immediately when no credential save is queued', async () => { - await expect(waitForWhatsAppCredsSave(makeTempAuthDir())).resolves.toBeUndefined() + const authDir = makeTempAuthDir() + try { await expect(waitForWhatsAppCredsSave(authDir)).resolves.toBeUndefined() } finally { fs.rmSync(authDir, { recursive: true, force: true }) } + }) + + it('drains queued credentials and signal-key writes from the actual socket binding before migration', async () => { + const authDir = makeTempAuthDir() + const lifecycle = new AdapterMigrationLifecycle() + const events = new EventEmitter() + let releaseCreds!: () => void + let releaseKeys!: () => void + const pendingCreds = new Promise(resolve => { releaseCreds = resolve }) + const pendingKeys = new Promise(resolve => { releaseKeys = resolve }) + let saves = 0 + let authKeys: any + fs.writeFileSync(path.join(authDir, 'creds.json'), '{"old":true}') + const auth = spyOn(baileys, 'useMultiFileAuthState').mockResolvedValue({ + state: { creds: {} as any, keys: { get: async () => ({}), set: async () => { await pendingKeys } } }, + saveCreds: async () => { + if (++saves === 1) await pendingCreds + fs.writeFileSync(path.join(authDir, 'creds.json'), JSON.stringify({ saves })) + }, + }) + const version = spyOn(baileys, 'fetchLatestBaileysVersion').mockResolvedValue({ version: [2, 3, 4], isLatest: true }) + const socket = spyOn(baileys, 'makeWASocket').mockImplementation((options: any) => { + authKeys = options.auth.keys + return { ev: events, ws: new EventEmitter() } as any + }) + const track = spyOn(adapterMigrationLifecycle, 'track').mockImplementation(operation => lifecycle.track(operation)) + try { + await createWhatsAppSocket({ authDir }) + events.emit('creds.update', {}) + events.emit('creds.update', {}) + const keys = authKeys.set({ 'pre-key': { 'fixture-key': { data: 'fixture' } } }) + lifecycle.registerShutdown(() => waitForWhatsAppCredsSave(authDir)) + let drained = false + const stopping = lifecycle.quiesce().then(() => { drained = true }) + await Promise.resolve() + expect(drained).toBe(false) + releaseCreds() + await waitForWhatsAppCredsSave(authDir) + expect(saves).toBe(2) + expect(drained).toBe(false) + releaseKeys() + await Promise.all([keys, stopping]) + expect(JSON.parse(fs.readFileSync(path.join(authDir, 'creds.json'), 'utf8'))).toEqual({ saves: 2 }) + expect(fs.existsSync(path.join(authDir, 'creds.json.bak'))).toBe(true) + } finally { + releaseCreds() + releaseKeys() + for (const spy of [track, socket, version, auth]) spy.mockRestore() + await waitForWhatsAppCredsSave(authDir) + fs.rmSync(authDir, { recursive: true, force: true }) + } + }) + + it('keeps the migration barrier pending for a credential save that has not started yet', async () => { + const authDir = makeTempAuthDir() + const lifecycle = new AdapterMigrationLifecycle() + const events = new EventEmitter() + let first!: () => void + let second!: () => void + const pending = [new Promise(resolve => { first = resolve }), new Promise(resolve => { second = resolve })] + let saves = 0 + const auth = spyOn(baileys, 'useMultiFileAuthState').mockResolvedValue({ + state: { creds: {} as any, keys: { get: async () => ({}), set: async () => {} } }, + saveCreds: async () => { + await pending[saves++] + fs.writeFileSync(path.join(authDir, 'creds.json'), JSON.stringify({ saves })) + }, + }) + const version = spyOn(baileys, 'fetchLatestBaileysVersion').mockResolvedValue({ version: [2, 3, 4], isLatest: true }) + const socket = spyOn(baileys, 'makeWASocket').mockReturnValue({ ev: events, ws: new EventEmitter() } as any) + const track = spyOn(adapterMigrationLifecycle, 'track').mockImplementation(operation => lifecycle.track(operation)) + let stopping: Promise | undefined + try { + await createWhatsAppSocket({ authDir }) + events.emit('creds.update', {}) + events.emit('creds.update', {}) + await Promise.resolve() + expect(saves).toBe(1) + let drained = false + stopping = lifecycle.quiesce().then(() => { drained = true }) + first() + for (let tick = 0; tick < 15; tick++) await Promise.resolve() + expect(saves).toBe(2) + expect(drained).toBe(false) + second() + await stopping + expect(drained).toBe(true) + } finally { + first() + second() + await stopping + await waitForWhatsAppCredsSave(authDir) + for (const spy of [track, socket, version, auth]) spy.mockRestore() + fs.rmSync(authDir, { recursive: true, force: true }) + } }) }) diff --git a/adapters/whatsapp/index.ts b/adapters/whatsapp/index.ts index 5dc41a70..4b760474 100644 --- a/adapters/whatsapp/index.ts +++ b/adapters/whatsapp/index.ts @@ -1,3 +1,5 @@ +import { adapterMigrationLifecycle, registerAdapterShutdown } from '../common/migration-lifecycle.js' +import { waitForWhatsAppCredsSave } from './session.js' /** * WhatsApp Adapter for Claude Code Desktop * @@ -605,16 +607,21 @@ export function useWhatsAppSocket(socket: WhatsAppSocket): void { } async function startSocket(): Promise { + if (shuttingDown || adapterMigrationLifecycle.isQuiescing) return if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = null } useWhatsAppSocket(await createWhatsAppSocket({ authDir })) + if (shuttingDown || adapterMigrationLifecycle.isQuiescing) { + closeWhatsAppSocket(sock, 'data migration') + return + } sock.ev.on('messages.upsert', ({ type, messages }) => { - if (type !== 'notify') return + if (type !== 'notify' || adapterMigrationLifecycle.isQuiescing) return for (const message of messages) { - void handleIncomingMessage(message) + void adapterMigrationLifecycle.track(handleIncomingMessage(message)) } }) @@ -635,13 +642,14 @@ async function startSocket(): Promise { } function scheduleReconnect(): void { + if (shuttingDown || adapterMigrationLifecycle.isQuiescing) return if (reconnectTimer) return const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** reconnectAttempts) reconnectAttempts += 1 console.warn(`[WhatsApp] Connection closed. Reconnecting in ${delay}ms...`) reconnectTimer = setTimeout(() => { reconnectTimer = null - startSocket().catch((err) => { + adapterMigrationLifecycle.track(startSocket()).catch((err) => { console.error('[WhatsApp] Reconnect failed:', err instanceof Error ? err.message : err) scheduleReconnect() }) @@ -655,14 +663,14 @@ console.log(`[WhatsApp] Allowed users: ${config.whatsapp.allowedUsers.length === if (import.meta.main || process.argv.includes('--whatsapp')) await startSocket() -if (import.meta.main || process.argv.includes('--whatsapp')) process.on('SIGINT', () => { +if (import.meta.main || process.argv.includes('--whatsapp')) registerAdapterShutdown(async () => { console.log('[WhatsApp] Shutting down...') shuttingDown = true if (reconnectTimer) clearTimeout(reconnectTimer) closeWhatsAppSocket(sock, 'SIGINT') bridge.destroy() dedup.destroy() - process.exit(0) + await waitForWhatsAppCredsSave(authDir) }) export { bridge, dedup, sessionStore, sessionSelectionController, handleServerMessage, getRuntimeState, clearTransientChatState, createSessionForChat, showProjectPicker, routeUserMessage, startNewSession } diff --git a/adapters/whatsapp/session.ts b/adapters/whatsapp/session.ts index 9dd5ee5b..2a20cf73 100644 --- a/adapters/whatsapp/session.ts +++ b/adapters/whatsapp/session.ts @@ -1,4 +1,5 @@ import * as fs from 'node:fs' +import { adapterMigrationLifecycle } from '../common/migration-lifecycle.js' import * as path from 'node:path' import { DisconnectReason, @@ -48,6 +49,8 @@ export async function createWhatsAppSocket(options: { const logger = makeBaileysLogger(options.verbose ? 'info' : 'silent') const { state, saveCreds } = await useMultiFileAuthState(authDir) + const setKeys = state.keys.set.bind(state.keys) + state.keys.set = (...args) => adapterMigrationLifecycle.track(Promise.resolve(setKeys(...args))) const { version } = await fetchLatestBaileysVersion() const sock = makeWASocket({ auth: { @@ -108,8 +111,8 @@ function maybeRestoreCredsFromBackup(authDir: string): void { function enqueueSaveCreds(authDir: string, saveCreds: () => Promise | void): void { const resolved = path.resolve(authDir) const prev = credsSaveQueues.get(resolved) ?? Promise.resolve() - const next = prev - .then(() => safeSaveCreds(resolved, saveCreds)) + const save = adapterMigrationLifecycle.track(prev.then(() => safeSaveCreds(resolved, saveCreds))) + const next = save .catch((err) => { console.warn('[WhatsApp] Failed to save credentials:', err instanceof Error ? err.message : err) }) diff --git a/desktop/electron/ipc/capabilities.test.ts b/desktop/electron/ipc/capabilities.test.ts index 961a5176..5b42d190 100644 --- a/desktop/electron/ipc/capabilities.test.ts +++ b/desktop/electron/ipc/capabilities.test.ts @@ -9,6 +9,24 @@ import { } from './capabilities' describe('Electron IPC capabilities', () => { + it('validates migration paths and identities and keeps migration IPC unavailable to pet windows', () => { + expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.migrationPrepare, { targetDir: 'D:\\cc-haha-data' })).toBe(true) + for (const payload of [undefined, {}, { targetDir: '' }, { targetDir: ' ' }, { targetDir: 2 }, { targetDir: 'bad\u0000path' }, { targetDir: 'D:\\data', sourceDir: 'C:\\data' }]) { + expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.migrationPrepare, payload)).toBe(false) + } + for (const channel of [ELECTRON_IPC_CHANNELS.migrationStart, ELECTRON_IPC_CHANNELS.migrationCancel]) { + expect(validateElectronIpcPayload(channel, { id: 'migration-1' })).toBe(true) + for (const payload of [undefined, {}, { id: '' }, { id: '../other' }, { id: 'x'.repeat(201) }, { id: 'migration-1', targetDir: 'D:\\data' }]) { + expect(validateElectronIpcPayload(channel, payload)).toBe(false) + } + } + expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.migrationStatus, undefined)).toBe(true) + expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.migrationStatus, {})).toBe(false) + for (const channel of [ELECTRON_IPC_CHANNELS.migrationPrepare, ELECTRON_IPC_CHANNELS.migrationStart, ELECTRON_IPC_CHANNELS.migrationStatus, ELECTRON_IPC_CHANNELS.migrationCancel]) { + expect(isElectronIpcChannelAllowedForPetWindow(channel)).toBe(false) + } + }) + it('restricts public access credentials and consent to validated desktop IPC', () => { expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.publicAccessSaveCredential, 'fake-token')).toBe(true) for (const value of ['', 'a b', 'x'.repeat(4097), {}, null]) { diff --git a/desktop/electron/ipc/capabilities.ts b/desktop/electron/ipc/capabilities.ts index 55242c01..ccd71db1 100644 --- a/desktop/electron/ipc/capabilities.ts +++ b/desktop/electron/ipc/capabilities.ts @@ -13,6 +13,22 @@ const booleanPayload: Validator = value => typeof value === 'boolean' const hasOnlyKeys = (value: Record, allowedKeys: string[]) => Object.keys(value).every(key => allowedKeys.includes(key)) +const migrationPrepare: Validator = value => + isRecord(value) + && hasOnlyKeys(value, ['targetDir']) + && typeof value.targetDir === 'string' + && value.targetDir.trim().length > 0 + && value.targetDir.length <= 32_768 + && !/[\u0000-\u001f\u007f]/.test(value.targetDir) + +const migrationIdentity: Validator = value => + isRecord(value) + && hasOnlyKeys(value, ['id']) + && typeof value.id === 'string' + && value.id.length > 0 + && value.id.length <= 200 + && /^[A-Za-z0-9._:-]+$/.test(value.id) + const MAX_TERMINAL_DIMENSION = 1_000 const MAX_TERMINAL_CWD_LENGTH = 4_096 const MAX_TERMINAL_WRITE_LENGTH = 1_048_576 @@ -400,6 +416,10 @@ export const ELECTRON_IPC_VALIDATORS = { [ELECTRON_IPC_CHANNELS.appModeSet]: optionalRecord, [ELECTRON_IPC_CHANNELS.appModePrepareRestart]: noPayload, [ELECTRON_IPC_CHANNELS.appModeRestart]: noPayload, + [ELECTRON_IPC_CHANNELS.migrationPrepare]: migrationPrepare, + [ELECTRON_IPC_CHANNELS.migrationStart]: migrationIdentity, + [ELECTRON_IPC_CHANNELS.migrationStatus]: noPayload, + [ELECTRON_IPC_CHANNELS.migrationCancel]: migrationIdentity, [ELECTRON_IPC_CHANNELS.adaptersRestartSidecar]: noPayload, [ELECTRON_IPC_CHANNELS.zoomSet]: zoomPayload, [ELECTRON_IPC_CHANNELS.appearanceSetApplied]: appliedAppearance, diff --git a/desktop/electron/ipc/channels.ts b/desktop/electron/ipc/channels.ts index cbe30372..7d95ce84 100644 --- a/desktop/electron/ipc/channels.ts +++ b/desktop/electron/ipc/channels.ts @@ -85,6 +85,10 @@ export const ELECTRON_IPC_CHANNELS = { appModeSet: 'desktop:app-mode:set', appModePrepareRestart: 'desktop:app-mode:prepare-restart', appModeRestart: 'desktop:app-mode:restart', + migrationPrepare: 'desktop:app-mode:migration:prepare', + migrationStart: 'desktop:app-mode:migration:start', + migrationStatus: 'desktop:app-mode:migration:status', + migrationCancel: 'desktop:app-mode:migration:cancel', adaptersRestartSidecar: 'desktop:adapters:restart-sidecar', zoomSet: 'desktop:zoom:set', appearanceSetApplied: 'desktop:appearance:set-applied', @@ -105,6 +109,7 @@ export const ELECTRON_EVENT_CHANNELS = { petNavigateSession: 'desktop:pets:navigate-session', petVisibilityChanged: 'desktop:pets:visibility-changed', petPanelPlacementChanged: 'desktop:pets:panel-placement-changed', + migrationProgress: 'desktop:app-mode:migration:progress', } as const export const ELECTRON_INTERNAL_CHANNELS = { diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index e2351f9c..fcd528b3 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -9,6 +9,9 @@ import { validateElectronIpcPayload, } from './ipc/capabilities' import { ElectronServerRuntime } from './services/serverRuntime' +import { DataMigration } from './services/dataMigration' +import { areStorageWritesFrozen, setStorageWritesFrozen } from './services/storageMaintenance' +import { resolveRelocatedAttachmentPath } from '../../src/utils/storageRelocations' import { appendHostDiagnostic, electronHostDiagnosticsFile, sanitizeHostDiagnostic } from './services/sidecarManager' import { openDialog, saveDialog } from './services/dialogs' import { openExternalUrl, openSystemPath, openSystemSettingsUrl } from './services/shell' @@ -44,6 +47,7 @@ import { } from './services/previewSession' import { applyStartupPortableMode, + clearAppManagedPortableEnv, getAppMode, setAppMode, } from './services/appMode' @@ -102,6 +106,8 @@ import { let mainWindow: BrowserWindow | null = null let serverRuntime: ElectronServerRuntime | null = null +let dataMigration: DataMigration | null = null +const hostOperations = new Set>() let publicAccessManager: PublicAccessManager | null = null let updaterService: ElectronUpdaterService | null = null let terminalService: ElectronTerminalService | null = null @@ -257,6 +263,38 @@ function getServerRuntime() { return serverRuntime } +function getDataMigration() { + dataMigration ??= new DataMigration(app, { + preview: () => getServerRuntime().getMigrationPreview(), + async quiesce() { + await Promise.allSettled([...hostOperations]) + if (mainWindow && !mainWindow.isDestroyed()) saveWindowState(app, mainWindow) + petWindowController?.dispose() + petWindowController = null + await publicAccessManager?.dispose() + publicAccessManager = null + setStorageWritesFrozen(true) + await getServerRuntime().quiesceForMigration() + }, + async resume() { + setStorageWritesFrozen(false) + await getServerRuntime().resumeAfterMigration() + await getPublicAccessManager().restore().catch(() => {}) + }, + restart() { + isQuitting = true + app.relaunch() + app.quit() + }, + progress(status) { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) window.webContents.send(ELECTRON_EVENT_CHANNELS.migrationProgress, status) + } + }, + }) + return dataMigration +} + function getPublicAccessManager() { if (publicAccessManager) return publicAccessManager let queue: Promise = Promise.resolve() @@ -471,6 +509,13 @@ function registerHandler( throw new Error(`Invalid Electron IPC payload for ${channel}`) } const senderWindow = BrowserWindow.fromWebContents(event.sender) + const migrationControl = channel.startsWith('desktop:app-mode:migration:') + if (migrationControl && (senderWindow !== mainWindow || event.senderFrame !== event.sender.mainFrame)) { + throw new Error('Data migration requires the main desktop window') + } + if ((dataMigration?.running || areStorageWritesFrozen()) && !migrationControl && channel !== ELECTRON_IPC_CHANNELS.shellOpenPath) { + throw new Error('Data migration is in progress') + } if (channel.startsWith('desktop:public-access:') && (senderWindow !== mainWindow || event.senderFrame !== event.sender.mainFrame)) { throw new Error('Public access management requires the main desktop window') } @@ -480,7 +525,10 @@ function registerHandler( ) { throw new Error(`Electron IPC channel ${channel} is not available to the pet window`) } - return handler(event, payload) + if (migrationControl) return handler(event, payload) + const operation = Promise.resolve().then(() => handler(event, payload)) + hostOperations.add(operation) + try { return await operation } finally { hostOperations.delete(operation) } }) } @@ -569,7 +617,7 @@ function registerIpcHandlers() { registerHandler(ELECTRON_IPC_CHANNELS.clipboardReadText, () => clipboard.readText()) registerHandler(ELECTRON_IPC_CHANNELS.clipboardWriteText, (_event, payload) => clipboard.writeText(String(payload))) registerHandler(ELECTRON_IPC_CHANNELS.shellOpen, (_event, payload) => openExternalUrl(String(payload))) - registerHandler(ELECTRON_IPC_CHANNELS.shellOpenPath, (_event, payload) => openSystemPath(String(payload))) + registerHandler(ELECTRON_IPC_CHANNELS.shellOpenPath, (_event, payload) => openSystemPath(resolveRelocatedAttachmentPath(String(payload)))) registerHandler(ELECTRON_IPC_CHANNELS.traceOpenWindow, (_event, payload) => openTraceWindow(String(payload))) registerHandler(ELECTRON_IPC_CHANNELS.petsList, () => listCustomPets()) registerHandler(ELECTRON_IPC_CHANNELS.petsCreateFromImage, async (event, payload) => { @@ -853,6 +901,10 @@ function registerIpcHandlers() { registerHandler(ELECTRON_IPC_CHANNELS.workspaceBrowserClose, (_event, payload) => getWorkspaceBrowserService().close((payload as { tabId: string }).tabId)) registerHandler(ELECTRON_IPC_CHANNELS.appModeGet, () => getAppMode(app)) + registerHandler(ELECTRON_IPC_CHANNELS.migrationPrepare, (_event, payload) => getDataMigration().prepare((payload as { targetDir: string }).targetDir)) + registerHandler(ELECTRON_IPC_CHANNELS.migrationStart, (_event, payload) => getDataMigration().start((payload as { id: string }).id)) + registerHandler(ELECTRON_IPC_CHANNELS.migrationStatus, () => getDataMigration().status) + registerHandler(ELECTRON_IPC_CHANNELS.migrationCancel, (_event, payload) => getDataMigration().cancel((payload as { id: string }).id)) registerHandler(ELECTRON_IPC_CHANNELS.appModeSet, (_event, payload) => setAppMode(app, payload as Parameters[1])) registerHandler(ELECTRON_IPC_CHANNELS.appModePrepareRestart, () => getServerRuntime().stopAll(true)) registerHandler(ELECTRON_IPC_CHANNELS.appModeRestart, () => { @@ -983,7 +1035,18 @@ registerIpcHandlers() app.whenReady().then(async () => { applyWindowsAppUserModelId(app) - applyStartupPortableMode(app) + clearAppManagedPortableEnv() + let validation = false + try { + validation = await getDataMigration().recover() === 'validate' + applyStartupPortableMode(app) + } catch (error) { + setStorageWritesFrozen(true) + dialog.showErrorBox('数据目录不可用 / Data directory unavailable', error instanceof Error ? error.message : 'Migration recovery failed') + await createMainWindow() + return + } + if (validation) process.env.CC_HAHA_MIGRATION_VALIDATION = '1' installSystemAppearanceWatch() screen.on('display-metrics-changed', (_event, _display, changedMetrics) => { if (changedMetrics.includes('scaleFactor') || changedMetrics.includes('bounds')) { @@ -991,9 +1054,33 @@ app.whenReady().then(async () => { workspaceBrowserService?.refreshBounds() } }) - await getServerRuntime().startServer().catch(error => { - console.error('[desktop] failed to start Electron server sidecar', error) - }) + try { + await getServerRuntime().startServer() + if (validation) { + await getServerRuntime().validateMigrationStartup() + await getDataMigration().completeValidation() + validation = false + await getServerRuntime().activateAfterMigrationValidation() + delete process.env.CC_HAHA_MIGRATION_VALIDATION + } + } catch (error) { + if (validation) { + try { + await getServerRuntime().stopAllAndWait() + await getDataMigration().failValidation(error) + delete process.env.CC_HAHA_MIGRATION_VALIDATION + clearAppManagedPortableEnv() + applyStartupPortableMode(app) + serverRuntime = null + await getServerRuntime().startServer().catch(startError => console.error('[desktop] original directory restart failed', startError)) + } catch (recoveryError) { + setStorageWritesFrozen(true) + dialog.showErrorBox('数据目录不可用 / Data directory unavailable', recoveryError instanceof Error ? recoveryError.message : 'Migration recovery failed') + await createMainWindow() + return + } + } else console.error('[desktop] failed to start Electron server sidecar', error) + } await getPublicAccessManager().restore().catch(() => {}) await installApplicationMenu(app, () => mainWindow) if (shouldInstallTray(process.platform)) { @@ -1032,6 +1119,12 @@ app.on('window-all-closed', () => { app.on('before-quit', event => { isQuitting = true + if (dataMigration?.running) { + event.preventDefault() + dataMigration.cancelActive() + void dataMigration.wait().then(() => app.quit()) + return + } if (quitCleanupFinished) return event.preventDefault() if (quitCleanupStarted) return diff --git a/desktop/electron/services/appMode.test.ts b/desktop/electron/services/appMode.test.ts index 35629581..fead2e2e 100644 --- a/desktop/electron/services/appMode.test.ts +++ b/desktop/electron/services/appMode.test.ts @@ -50,6 +50,14 @@ afterEach(() => { }) describe('Electron app mode service', () => { + it('preserves unknown startup pointer fields when migrating to another root', () => { + const fakeApp = app() + writeMode(fakeApp, { mode: 'default', portable_dir: null, future: { preserve: true } }) + const selected = path.join(fakeApp.root, 'migrated') + setAppMode(fakeApp, { mode: 'portable', portableDir: selected }, {}) + expect(JSON.parse(fs.readFileSync(path.join(fakeApp.getPath('userData'), 'app-mode.json'), 'utf8'))) + .toMatchObject({ mode: 'portable', portable_dir: selected, future: { preserve: true } }) + }) it('always uses ~/.claude in system mode and ignores app-adjacent legacy data at runtime', () => { const fakeApp = app() const legacyDir = path.join(path.dirname(fakeApp.getPath('exe')), 'CLAUDE_CONFIG_DIR') diff --git a/desktop/electron/services/appMode.ts b/desktop/electron/services/appMode.ts index 5f532058..1543700f 100644 --- a/desktop/electron/services/appMode.ts +++ b/desktop/electron/services/appMode.ts @@ -35,8 +35,19 @@ function writeAppModeConfig(configDir: string, config: PersistedAppModeConfig): fs.mkdirSync(configDir, { recursive: true }) const target = path.join(configDir, APP_MODE_FILE) const temporary = path.join(configDir, `.${APP_MODE_FILE}.${randomUUID()}.tmp`) + let previous: Record = {} try { - fs.writeFileSync(temporary, JSON.stringify(config, null, 2)) + const parsed: unknown = JSON.parse(fs.readFileSync(target, 'utf8')) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) previous = parsed as Record + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + try { + const descriptor = fs.openSync(temporary, 'wx', 0o600) + try { + fs.writeFileSync(descriptor, JSON.stringify({ ...previous, ...config }, null, 2)) + fs.fsyncSync(descriptor) + } finally { fs.closeSync(descriptor) } fs.renameSync(temporary, target) } finally { fs.rmSync(temporary, { force: true }) @@ -77,7 +88,7 @@ function isPathAtOrBelow(parentDir: string, candidateDir: string): boolean { return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)) } -function normalizedCustomDir(app: AppModeAppLike, value: string | null | undefined): string { +export function normalizedCustomDir(app: AppModeAppLike, value: string | null | undefined): string { const selectedDir = value?.trim() if (!selectedDir) throw new Error('Choose an absolute custom data directory') if (!path.isAbsolute(selectedDir)) throw new Error('Custom data storage must use an absolute path') diff --git a/desktop/electron/services/dataMigration.test.ts b/desktop/electron/services/dataMigration.test.ts new file mode 100644 index 00000000..368e91b5 --- /dev/null +++ b/desktop/electron/services/dataMigration.test.ts @@ -0,0 +1,497 @@ +import * as fs from 'node:fs/promises' +import nativeFs from 'node:fs/promises' +import { syncBuiltinESMExports } from 'node:module' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DataMigration, MIGRATION_JOURNAL_FILE, type DataMigrationHooks } from './dataMigration' +import type { AppModeAppLike } from './appMode' +import { resolveRelocatedAttachmentPath } from '../../../src/utils/storageRelocations' +import { assertMigrationManifest, migrationManifest, validateMigrationManifest } from './dataMigrationFiles' + +const roots: string[] = [] +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'haha-migration-')) + roots.push(root) + const home = path.join(root, 'home') + const source = path.join(home, '.claude') + const target = path.join(root, '新数据 directory') + const userData = path.join(root, 'profile') + await fs.mkdir(source, { recursive: true }) + await fs.mkdir(path.join(root, 'install'), { recursive: true }) + const app: AppModeAppLike = { getPath(name) { return name === 'home' ? home : name === 'userData' ? userData : path.join(root, 'install', 'haha.exe') } } + const hooks: DataMigrationHooks = { preview: vi.fn(async () => ({ activeTasks: 2, externalProcesses: 0 })), quiesce: vi.fn(async () => {}), resume: vi.fn(async () => {}), restart: vi.fn(), progress: vi.fn(), platform: 'win32', permissions: { restrictStaging: vi.fn(async () => {}), preserve: vi.fn(async () => {}) } } + const put = async (relative: string, value: string) => { + const file = path.join(source, relative) + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.writeFile(file, value) + } + await put('settings.json', '{"unknown":{"preserve":true}}') + await put('projects/fixture/session.jsonl', '{"type":"user","message":"hello"}\n') + await put('uploads/session/file.txt', 'attachment bytes') + await fs.writeFile(path.join(home, '.claude.json'), '{"trustedProjects":{"x":true},"unknown":7}') + return { root, home, source, target, userData, app, hooks, put, migration: new DataMigration(app, hooks, {}) } +} + +afterEach(async () => { + vi.restoreAllMocks() + syncBuiltinESMExports() + for (const root of roots.splice(0)) await fs.rm(root, { recursive: true, force: true }) +}) + +describe('data directory migration', () => { + it('previews without stopping work and switches only after a verified complete copy', async () => { + const f = await fixture() + await f.put('cc-haha/db/index-v1.sqlite', 'regenerable') + await f.put('cc-haha/db/unknown.sqlite', 'protected unknown database') + await f.put('.runtime/venv/Scripts/pip.exe', 'old absolute-path executable') + await f.put('.runtime/requirements.sha256', 'old dependency stamp') + const original = await fs.readFile(path.join(f.source, 'settings.json')) + const preview = await f.migration.prepare(f.target) + expect(preview).toMatchObject({ sourceDir: f.source, targetDir: f.target, activeTasks: 2 }) + expect(f.hooks.quiesce).not.toHaveBeenCalled() + expect(f.migration.status).toBeNull() + await f.migration.start(preview.id) + await f.migration.wait() + expect(f.migration.status?.stage).toBe('restarting') + expect(f.hooks.restart).toHaveBeenCalledOnce() + expect(await fs.readFile(path.join(f.source, 'settings.json'))).toEqual(original) + expect(await fs.readFile(path.join(f.target, 'settings.json'))).toEqual(original) + expect(await fs.readFile(path.join(f.target, '.claude.json'), 'utf8')).toContain('"unknown":7') + expect(await fs.readFile(path.join(f.target, 'projects/fixture/session.jsonl'), 'utf8')).toContain('hello') + expect(await fs.readFile(path.join(f.target, 'cc-haha/db/unknown.sqlite'), 'utf8')).toBe('protected unknown database') + await expect(fs.stat(path.join(f.target, 'cc-haha/db/index-v1.sqlite'))).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(fs.stat(path.join(f.target, '.runtime/venv'))).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(fs.stat(path.join(f.target, '.runtime/requirements.sha256'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(JSON.parse(await fs.readFile(path.join(f.userData, 'app-mode.json'), 'utf8'))).toMatchObject({ mode: 'portable', portable_dir: f.target }) + await fs.rename(f.source, `${f.source}-retained`) + expect(await fs.readFile(resolveRelocatedAttachmentPath(path.join(f.source, 'uploads/session/file.txt'), f.target), 'utf8')).toBe('attachment bytes') + }) + + it('rejects nonempty, overlapping, install-contained and externally controlled targets', async () => { + const f = await fixture() + await fs.mkdir(f.target) + await fs.writeFile(path.join(f.target, 'keep'), 'existing data') + await expect(f.migration.prepare(f.target)).rejects.toThrow('empty') + await expect(f.migration.prepare(f.source)).rejects.toThrow('separate') + await expect(f.migration.prepare(path.join(f.source, 'nested'))).rejects.toThrow('separate') + await expect(f.migration.prepare(path.dirname(f.source))).rejects.toThrow('separate') + await expect(f.migration.prepare(path.join(f.root, 'install', 'data'))).rejects.toThrow('install') + const external = new DataMigration(f.app, f.hooks, { CLAUDE_CONFIG_DIR: f.source }) + await expect(external.prepare(path.join(f.root, 'empty'))).rejects.toThrow('launch environment') + expect(await fs.readFile(path.join(f.target, 'keep'), 'utf8')).toBe('existing data') + expect(f.hooks.quiesce).not.toHaveBeenCalled() + }) + + it('blocks outside writers before stopping any session', async () => { + const f = await fixture() + f.hooks.preview = async () => ({ activeTasks: 0, externalProcesses: 1 }) + await expect(f.migration.prepare(f.target)).rejects.toThrow('Another Claude process') + expect(f.hooks.quiesce).not.toHaveBeenCalled() + }) + + it('checks free space before stopping work', async () => { + const f = await fixture() + vi.spyOn(nativeFs, 'statfs').mockImplementation(async () => ({ bavail: 0, bsize: 4096 } as never)) + syncBuiltinESMExports() + await expect(f.migration.prepare(f.target)).rejects.toThrow('Not enough free space') + expect(f.hooks.quiesce).not.toHaveBeenCalled() + expect(await fs.readdir(f.target)).toEqual([]) + }) + + it('relocates internal directory links without copying or changing external link data', async () => { + const f = await fixture() + await f.put('linked-data/file.txt', 'inside fixture') + const external = path.join(f.root, 'external') + await fs.mkdir(external) + await fs.writeFile(path.join(external, 'keep'), 'external fixture') + await fs.symlink(path.join(f.source, 'linked-data'), path.join(f.source, 'internal-link'), 'junction') + await fs.symlink(external, path.join(f.source, 'external-link'), 'junction') + const preview = await f.migration.prepare(f.target) + await f.migration.start(preview.id) + await f.migration.wait() + expect(f.migration.status?.stage).toBe('restarting') + expect((await fs.lstat(path.join(f.target, 'internal-link'))).isSymbolicLink()).toBe(true) + expect(await fs.realpath(path.join(f.target, 'internal-link'))).toBe(await fs.realpath(path.join(f.target, 'linked-data'))) + expect(await fs.realpath(path.join(f.target, 'external-link'))).toBe(await fs.realpath(external)) + await fs.rename(f.source, `${f.source}-retained`) + expect(await fs.readFile(path.join(f.target, 'internal-link/file.txt'), 'utf8')).toBe('inside fixture') + expect(await fs.readFile(path.join(external, 'keep'), 'utf8')).toBe('external fixture') + }) + + it('preserves canonical-path attachments and internal links when the runtime root is an alias', async () => { + const f = await fixture() + const alias = path.join(f.root, 'active-data-alias') + await fs.symlink(f.source, alias, 'junction') + await fs.symlink(path.join(f.source, 'uploads'), path.join(f.source, 'internal-link'), 'junction') + const migration = new DataMigration(f.app, f.hooks, { CLAUDE_CONFIG_DIR: alias, CC_HAHA_APP_PORTABLE_DIR: '1' }) + const preview = await migration.prepare(f.target) + expect(preview.sourceDir).toBe(alias) + await migration.start(preview.id) + await migration.wait() + expect(migration.status?.stage).toBe('restarting') + await fs.rename(f.source, `${f.source}-retained`) + expect(await fs.readFile(path.join(f.target, 'internal-link/session/file.txt'), 'utf8')).toBe('attachment bytes') + for (const previous of [alias, f.source]) { + expect(await fs.readFile(resolveRelocatedAttachmentPath(path.join(previous, 'uploads/session/file.txt'), f.target), 'utf8')).toBe('attachment bytes') + } + }) + + it('fails safely when a target runs out of space during publication', async () => { + const f = await fixture() + const rename = fs.rename + vi.spyOn(nativeFs, 'rename').mockImplementation(async (source, target) => { + if (String(source).includes('.cc-haha-migration-')) throw Object.assign(new Error('Target disk is full'), { code: 'ENOSPC' }) + return rename(source, target) + }) + syncBuiltinESMExports() + const preview = await f.migration.prepare(f.target) + await f.migration.start(preview.id) + await f.migration.wait() + expect(f.migration.status).toMatchObject({ stage: 'failed', error: 'Target disk is full' }) + expect(f.hooks.resume).toHaveBeenCalledOnce() + expect(f.hooks.restart).not.toHaveBeenCalled() + expect(await fs.readdir(f.target)).toEqual([]) + expect(await fs.readFile(path.join(f.source, 'settings.json'), 'utf8')).toContain('preserve') + await expect(fs.stat(path.join(f.userData, 'app-mode.json'))).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('restores file and directory permissions after copying children and rewriting managed metadata', async () => { + const f = await fixture() + await f.put('protected/unknown.txt', 'read-only data') + const directory = path.join(f.source, 'protected') + await fs.chmod(directory, 0o500) + await fs.chmod(path.join(directory, 'unknown.txt'), 0o400) + const originalMode = (await fs.stat(directory)).mode + try { + const preview = await f.migration.prepare(f.target) + await f.migration.start(preview.id) + await f.migration.wait() + expect(f.migration.status?.stage).toBe('restarting') + expect(await fs.readFile(path.join(f.target, 'protected/unknown.txt'), 'utf8')).toBe('read-only data') + expect((await fs.stat(path.join(f.target, 'protected'))).mode).toBe(originalMode) + if (process.platform !== 'win32') expect((await fs.stat(path.join(f.target, 'protected'))).mode & 0o777).toBe(0o500) + } finally { + // Leave disposable fixtures writable for cleanup on Unix too. + await fs.chmod(directory, 0o700) + await fs.chmod(path.join(f.target, 'protected'), 0o700).catch(() => {}) + } + }) + + it('fails safely on copy permission errors and never switches the original pointer', async () => { + const f = await fixture() + const mkdir = fs.mkdir + vi.spyOn(nativeFs, 'mkdir').mockImplementation((async (directory: string, options: unknown) => { + if (String(directory).includes('.cc-haha-migration-')) return Promise.reject(Object.assign(new Error('Copy permission denied'), { code: 'EACCES' })) + return mkdir(directory, options as never) + }) as typeof fs.mkdir) + syncBuiltinESMExports() + const preview = await f.migration.prepare(f.target) + await f.migration.start(preview.id) + await f.migration.wait() + expect(f.migration.status).toMatchObject({ stage: 'failed', error: 'Copy permission denied' }) + expect(await fs.readdir(f.target)).toEqual([]) + expect(f.hooks.restart).not.toHaveBeenCalled() + expect(f.hooks.resume).toHaveBeenCalledOnce() + }) + + it('does not copy before Windows staging is protected or commit after an ACL failure', async () => { + const f = await fixture() + f.hooks.permissions!.restrictStaging = async directory => { + expect(await fs.readdir(directory)).toEqual([]) + expect(f.hooks.quiesce).toHaveBeenCalledOnce() + } + f.hooks.permissions!.preserve = async () => { throw new Error('Windows permission verification failed') } + const preview = await f.migration.prepare(f.target) + await f.migration.start(preview.id) + await f.migration.wait() + expect(f.migration.status).toMatchObject({ stage: 'failed', error: 'Windows permission verification failed' }) + expect(f.hooks.resume).toHaveBeenCalledOnce() + expect(f.hooks.restart).not.toHaveBeenCalled() + expect(await fs.readdir(f.target)).toEqual([]) + expect(await fs.readFile(path.join(f.source, 'settings.json'), 'utf8')).toContain('preserve') + }) + + it('cancels before commit, retains the source and resumes services without replaying work', async () => { + const f = await fixture() + f.hooks.progress = status => { if (status.stage === 'copying') f.migration.cancel(status.id) } + const preview = await f.migration.prepare(f.target) + await f.migration.start(preview.id) + await f.migration.wait() + expect(f.migration.status?.stage).toBe('cancelled') + expect(f.hooks.resume).toHaveBeenCalledOnce() + expect(f.hooks.restart).not.toHaveBeenCalled() + expect(await fs.readdir(f.target)).toEqual([]) + await expect(fs.stat(path.join(f.userData, 'app-mode.json'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await fs.readFile(path.join(f.source, 'uploads/session/file.txt'), 'utf8')).toBe('attachment bytes') + }) + + it('cancels a quit request even before the first journal write finishes', async () => { + const f = await fixture() + const preview = await f.migration.prepare(f.target) + const started = f.migration.start(preview.id) + f.migration.cancelActive() + await started + await f.migration.wait() + expect(f.migration.status?.stage).toBe('cancelled') + expect(f.hooks.quiesce).not.toHaveBeenCalled() + expect(f.hooks.restart).not.toHaveBeenCalled() + expect(await fs.readdir(f.target)).toEqual([]) + }) + + it('never copies when the runtime cannot confirm quiescence', async () => { + const f = await fixture() + f.hooks.quiesce = async () => { throw new Error('process did not exit') } + const preview = await f.migration.prepare(f.target) + await f.migration.start(preview.id) + await f.migration.wait() + expect(f.migration.status).toMatchObject({ stage: 'failed', error: 'process did not exit' }) + expect(await fs.readdir(f.target)).toEqual([]) + expect(f.hooks.resume).toHaveBeenCalledOnce() + }) + + it('reports an unavailable original runtime when recovery after cancellation fails', async () => { + const f = await fixture() + f.hooks.progress = status => { if (status.stage === 'copying') f.migration.cancel(status.id) } + f.hooks.resume = async () => { throw new Error('process is still draining') } + const preview = await f.migration.prepare(f.target) + await f.migration.start(preview.id) + await f.migration.wait() + expect(f.migration.status).toMatchObject({ stage: 'failed', error: expect.stringContaining('Original services could not restart: process is still draining') }) + expect(await fs.readdir(f.target)).toEqual([]) + expect(await fs.readFile(path.join(f.source, 'settings.json'), 'utf8')).toContain('preserve') + }) + + it('detects late source writes and preserves the original startup pointer', async () => { + const f = await fixture() + await fs.mkdir(f.userData) + const old = '{"mode":"default","portable_dir":null,"unknown":{"keep":1}}' + await fs.writeFile(path.join(f.userData, 'app-mode.json'), old) + f.hooks.progress = status => { if (status.stage === 'verifying') require('node:fs').writeFileSync(path.join(f.source, 'settings.json'), '{"late":true}') } + const preview = await f.migration.prepare(f.target) + await f.migration.start(preview.id) + await f.migration.wait() + expect(f.migration.status?.stage).toBe('failed') + expect(await fs.readFile(path.join(f.userData, 'app-mode.json'), 'utf8')).toBe(old) + expect(f.hooks.restart).not.toHaveBeenCalled() + }) + + it('recovers a verified pending restart and retains its success receipt', async () => { + const f = await fixture() + const preview = await f.migration.prepare(f.target) + await f.migration.start(preview.id) + await f.migration.wait() + const relaunched = new DataMigration(f.app, f.hooks, {}) + expect(await relaunched.recover()).toBe('validate') + await relaunched.completeValidation() + const later = new DataMigration(f.app, f.hooks, {}) + expect(await later.recover()).toBe('normal') + expect(later.status).toMatchObject({ stage: 'completed', sourceDir: f.source, targetDir: f.target }) + }) + + it('rolls back a corrupted target before any new tasks are allowed', async () => { + const f = await fixture() + const preview = await f.migration.prepare(f.target) + await f.migration.start(preview.id) + await f.migration.wait() + await fs.writeFile(path.join(f.target, 'settings.json'), '{"tampered":true}') + const relaunched = new DataMigration(f.app, f.hooks, {}) + expect(await relaunched.recover()).toBe('normal') + expect(relaunched.status?.stage).toBe('failed') + await expect(fs.stat(path.join(f.userData, 'app-mode.json'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await fs.readFile(path.join(f.source, 'settings.json'), 'utf8')).toContain('preserve') + }) + + it.each(['directory', 'pointer'] as const)('fails closed when a completed migration loses its %s', async unavailable => { + const f = await fixture() + const preview = await f.migration.prepare(f.target) + await f.migration.start(preview.id) + await f.migration.wait() + await f.migration.completeValidation() + if (unavailable === 'directory') await fs.rename(f.target, `${f.target}-disconnected`) + else await fs.rm(path.join(f.userData, 'app-mode.json')) + const later = new DataMigration(f.app, f.hooks, {}) + await expect(later.recover()).rejects.toThrow(unavailable === 'directory' ? 'Migrated data directory is unavailable' : 'startup configuration is unavailable') + expect(f.hooks.resume).not.toHaveBeenCalled() + if (unavailable === 'directory') await expect(fs.stat(f.target)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('keeps deliberate default and external launch selections after a completed migration', async () => { + const f = await fixture() + const preview = await f.migration.prepare(f.target) + await f.migration.start(preview.id) + await f.migration.wait() + await f.migration.completeValidation() + await fs.rename(f.target, `${f.target}-disconnected`) + const override = new DataMigration(f.app, f.hooks, { CLAUDE_CONFIG_DIR: path.join(f.root, 'explicit-data') }) + expect(await override.recover()).toBe('normal') + await fs.writeFile(path.join(f.userData, 'app-mode.json'), '{"mode":"default","portable_dir":null,"unknown":7}') + expect(await new DataMigration(f.app, f.hooks, {}).recover()).toBe('normal') + expect(JSON.parse(await fs.readFile(path.join(f.userData, 'app-mode.json'), 'utf8')).unknown).toBe(7) + }) + + it('upgrades a v1 interrupted journal and removes only its private staging directory', async () => { + const f = await fixture() + await fs.mkdir(f.target) + const id = 'fixture-id' + const stagingDir = path.join(f.target, `.cc-haha-migration-${id}`) + await fs.mkdir(stagingDir) + await fs.writeFile(path.join(stagingDir, 'partial'), 'partial') + await fs.writeFile(path.join(f.target, 'unrelated'), 'must survive') + await fs.mkdir(f.userData) + await fs.writeFile(path.join(f.userData, MIGRATION_JOURNAL_FILE), JSON.stringify({ version: 1, status: { id, sourceDir: f.source, targetDir: f.target, stage: 'copying', cancellable: true }, stagingDir, oldMode: '{"mode":"default","unknown":42}', manifest: [], createdCredentials: [], credentials: [] })) + expect(await f.migration.recover()).toBe('normal') + expect(f.migration.status?.stage).toBe('failed') + expect(await fs.readFile(path.join(f.target, 'unrelated'), 'utf8')).toBe('must survive') + expect(await fs.readFile(path.join(f.userData, 'app-mode.json'), 'utf8')).toContain('"unknown":42') + await expect(fs.stat(stagingDir)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('fails closed when startup rollback would recreate a missing original directory', async () => { + const f = await fixture() + const preview = await f.migration.prepare(f.target) + await f.migration.start(preview.id) + await f.migration.wait() + await fs.rename(f.source, `${f.source}-retained`) + await fs.writeFile(path.join(f.target, 'settings.json'), 'corrupted target') + const recovered = new DataMigration(f.app, f.hooks, {}) + await expect(recovered.recover()).rejects.toThrow('Original data directory is unavailable') + expect(JSON.parse(await fs.readFile(path.join(f.userData, 'app-mode.json'), 'utf8')).portable_dir).toBe(f.target) + await expect(fs.stat(f.source)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it.each(['source', 'target'] as const)('rejects a replaced %s directory after preview even at the same canonical path', async directory => { + const f = await fixture() + const preview = await f.migration.prepare(f.target) + const selected = f[directory] + await fs.rename(selected, `${selected}-original`) + await fs.mkdir(selected) + await fs.writeFile(path.join(selected, 'keep'), 'replacement must survive') + await f.migration.start(preview.id) + await f.migration.wait() + expect(f.migration.status).toMatchObject({ stage: 'failed', error: expect.stringContaining('replaced') }) + expect(f.hooks.quiesce).not.toHaveBeenCalled() + expect(f.hooks.restart).not.toHaveBeenCalled() + expect(await fs.readFile(path.join(selected, 'keep'), 'utf8')).toBe('replacement must survive') + expect(await fs.readFile(path.join(directory === 'source' ? `${f.source}-original` : f.source, 'settings.json'), 'utf8')).toContain('preserve') + }) + + it('refuses a target junction swap before copying and never cleans through its replacement', async () => { + const f = await fixture() + const outside = path.join(f.root, 'outside') + await fs.mkdir(outside) + let replacementStage = '' + f.hooks.progress = status => { + if (status.stage !== 'copying') return + const sync = require('node:fs') as typeof import('node:fs') + sync.renameSync(f.target, `${f.target}-original`) + replacementStage = path.join(outside, `.cc-haha-migration-${status.id}`) + sync.mkdirSync(replacementStage) + sync.writeFileSync(path.join(replacementStage, 'keep'), 'unrelated data') + sync.symlinkSync(outside, f.target, 'junction') + } + const preview = await f.migration.prepare(f.target) + await f.migration.start(preview.id) + await f.migration.wait() + expect(f.migration.status).toMatchObject({ stage: 'failed', error: expect.stringContaining('replaced') }) + expect(await fs.readFile(path.join(replacementStage, 'keep'), 'utf8')).toBe('unrelated data') + expect(await fs.readdir(path.join(`${f.target}-original`, `.cc-haha-migration-${preview.id}`))).toEqual([]) + expect(await fs.readFile(path.join(f.source, 'settings.json'), 'utf8')).toContain('preserve') + expect(f.hooks.resume).toHaveBeenCalledOnce() + expect(f.hooks.restart).not.toHaveBeenCalled() + }) + + it('refuses a replaced target ancestor before verification and preserves external staging data', async () => { + const f = await fixture() + const parent = path.join(f.root, 'selected-parent') + const target = path.join(parent, 'data') + const outside = path.join(f.root, 'outside-parent') + await fs.mkdir(path.join(outside, 'data'), { recursive: true }) + let replacementStage = '' + f.hooks.progress = status => { + if (status.stage !== 'verifying') return + const sync = require('node:fs') as typeof import('node:fs') + sync.renameSync(parent, `${parent}-original`) + replacementStage = path.join(outside, 'data', `.cc-haha-migration-${status.id}`) + sync.mkdirSync(replacementStage) + sync.writeFileSync(path.join(replacementStage, 'keep'), 'external transaction') + sync.symlinkSync(outside, parent, 'junction') + } + const preview = await f.migration.prepare(target) + await f.migration.start(preview.id) + await f.migration.wait() + expect(f.migration.status).toMatchObject({ stage: 'failed', error: expect.stringContaining('replaced') }) + expect(await fs.readFile(path.join(replacementStage, 'keep'), 'utf8')).toBe('external transaction') + expect(await fs.readFile(path.join(f.source, 'uploads/session/file.txt'), 'utf8')).toBe('attachment bytes') + expect(f.hooks.restart).not.toHaveBeenCalled() + }) + + it('rechecks external writers at the commit boundary', async () => { + const f = await fixture() + let previews = 0 + f.hooks.preview = async () => ({ activeTasks: 0, externalProcesses: ++previews >= 3 ? 1 : 0 }) + const preview = await f.migration.prepare(f.target) + await f.migration.start(preview.id) + await f.migration.wait() + expect(f.migration.status).toMatchObject({ stage: 'failed', error: expect.stringContaining('Another Claude process') }) + expect(await fs.readdir(f.target)).toEqual([]) + expect(f.hooks.restart).not.toHaveBeenCalled() + }) + + it('rejects hostile recovery records before restoring settings or deleting any directory', async () => { + const f = await fixture() + await fs.mkdir(f.target) + await fs.mkdir(f.userData) + const id = 'safe-id' + const stagingDir = path.join(f.target, `.cc-haha-migration-${id}`) + await fs.mkdir(stagingDir) + await fs.writeFile(path.join(stagingDir, 'keep'), 'protected transaction') + const journal = { version: 1, status: { id, sourceDir: f.source, targetDir: f.target, stage: 'copying', cancellable: true }, stagingDir, oldMode: '{"mode":"default"}', manifest: [], createdCredentials: [], credentials: [] } + const digest = 'a'.repeat(64) + const hostile = [ + { ...journal, stagingDir: f.target }, + { ...journal, stagingDir: f.source }, + { ...journal, status: { ...journal.status, id: '../escape' } }, + { ...journal, status: { ...journal.status, stage: 'unknown' } }, + { ...journal, status: { ...journal.status, sourceDir: path.dirname(f.target) } }, + { ...journal, manifest: [{ relative: '../settings.json', kind: 'file', digest }] }, + { ...journal, manifest: [{ relative: '..\\settings.json', kind: 'file', digest }] }, + { ...journal, manifest: [{ relative: f.source, kind: 'directory' }] }, + { ...journal, manifest: [{ relative: 'settings.json', kind: 'unexpected' }] }, + { ...journal, createdCredentials: [{ service: 'Claude Code', digest }] }, + { ...journal, oldMode: '[]' }, + ] + for (const entry of hostile) { + await fs.writeFile(path.join(f.userData, MIGRATION_JOURNAL_FILE), JSON.stringify(entry)) + const recovery = new DataMigration(f.app, f.hooks, {}) + await expect(recovery.recover()).rejects.toThrow() + expect(await fs.readFile(path.join(stagingDir, 'keep'), 'utf8')).toBe('protected transaction') + await expect(fs.stat(path.join(f.userData, 'app-mode.json'))).rejects.toMatchObject({ code: 'ENOENT' }) + } + expect(await fs.readFile(path.join(f.source, 'settings.json'), 'utf8')).toContain('preserve') + }) + + it('rejects a legacy recovery target whose canonical path overlaps the source', async () => { + const f = await fixture() + await fs.symlink(f.source, f.target, 'junction') + await fs.mkdir(f.userData) + const id = 'safe-id' + await fs.writeFile(path.join(f.source, 'keep'), 'source must survive') + await fs.writeFile(path.join(f.userData, MIGRATION_JOURNAL_FILE), JSON.stringify({ version: 1, status: { id, sourceDir: f.source, targetDir: f.target, stage: 'copying' }, stagingDir: path.join(f.target, `.cc-haha-migration-${id}`), oldMode: null, manifest: [], credentials: [], createdCredentials: [] })) + await expect(f.migration.recover()).rejects.toThrow('overlap') + expect(await fs.readFile(path.join(f.source, 'keep'), 'utf8')).toBe('source must survive') + }) + + it('rejects a parent junction replacement even when manifest file bytes match', async () => { + const f = await fixture() + const manifest = await migrationManifest(f.source) + const original = path.join(f.source, 'uploads') + const outside = path.join(f.root, 'same-bytes') + await fs.rename(original, outside) + await fs.symlink(outside, original, 'junction') + await expect(validateMigrationManifest(f.source, manifest)).rejects.toThrow() + await expect(validateMigrationManifest(f.source, [{ relative: 'uploads/session/file.txt', kind: 'file', digest: manifest.find(file => file.relative === path.join('uploads', 'session', 'file.txt'))!.digest }])).rejects.toThrow('parent') + expect(await fs.readFile(path.join(outside, 'session/file.txt'), 'utf8')).toBe('attachment bytes') + expect(() => assertMigrationManifest([{ relative: 'a', kind: 'file' }])).toThrow() + }) +}) diff --git a/desktop/electron/services/dataMigration.ts b/desktop/electron/services/dataMigration.ts new file mode 100644 index 00000000..682218a9 --- /dev/null +++ b/desktop/electron/services/dataMigration.ts @@ -0,0 +1,404 @@ +import { createHash, randomUUID } from 'node:crypto' +import fs from 'node:fs/promises' +import path from 'node:path' +import type { MigrationPreview, MigrationStatus } from '../../src/lib/desktopHost/types' +import { relocateManagedData } from '../../../src/utils/storageMigrationMetadata' +import { normalizedCustomDir, setAppMode, systemClaudeConfigDir, type AppModeAppLike } from './appMode' +import { assertMigrationDirectory, assertMigrationManifest, assertUnlinkedDirectoryPath, canonicalPath, copyMigrationFiles, isWithin, migrationDirectoryIdentity, migrationManifest, restoreMigrationPermissions, scanMigrationFiles, validateMigrationManifest, verifyMigrationSource, type MigrationDirectoryIdentity, type VerifiedMigrationFile } from './dataMigrationFiles' +import { copyMigrationCredentials, removeMigrationCredentials, systemMigrationCredentialStore, verifyMigrationCredentials, type MigrationCredentialReceipt, type MigrationCredentialStore } from './migrationCredentials' +import { preserveWindowsMigrationPermissions, restrictWindowsMigrationStaging } from './migrationPermissions' + +export const MIGRATION_JOURNAL_FILE = 'data-migration-v1.json' +type Journal = { + version: 1 + status: MigrationStatus + oldMode: string | null + stagingDir: string + manifest: VerifiedMigrationFile[] + createdCredentials: MigrationCredentialReceipt[] + credentials: MigrationCredentialReceipt[] + directories?: { source: MigrationDirectoryIdentity; target: MigrationDirectoryIdentity } + stagingIdentity?: MigrationDirectoryIdentity +} + +const stages = ['preparing', 'quiescing', 'copying', 'verifying', 'committing', 'restarting', 'completed', 'failed', 'cancelled'] +const absoluteDirectory = (value: unknown): value is string => typeof value === 'string' && !value.includes('\0') && path.isAbsolute(value) && path.resolve(value) === value +const directoryIdentity = (value: unknown): value is MigrationDirectoryIdentity => { + if (!value || typeof value !== 'object') return false + const identity = value as MigrationDirectoryIdentity + return absoluteDirectory(identity.canonical) && typeof identity.dev === 'string' && /^\d+$/.test(identity.dev) && typeof identity.ino === 'string' && /^\d+$/.test(identity.ino) +} + +function parseJournal(value: unknown): Journal { + if (!value || typeof value !== 'object') throw new Error('Unsupported migration recovery record') + const parsed = value as Journal + if (parsed.version !== 1 || !parsed.status || typeof parsed.status.id !== 'string' || !/^[a-zA-Z0-9_-]{1,80}$/.test(parsed.status.id) || + !stages.includes(parsed.status.stage) || !absoluteDirectory(parsed.status.sourceDir) || !absoluteDirectory(parsed.status.targetDir) || + !absoluteDirectory(parsed.stagingDir) || parsed.stagingDir !== path.join(parsed.status.targetDir, `.cc-haha-migration-${parsed.status.id}`) || + !Array.isArray(parsed.createdCredentials) || !Array.isArray(parsed.credentials) || (parsed.oldMode !== null && typeof parsed.oldMode !== 'string') || + (parsed.directories !== undefined && (!directoryIdentity(parsed.directories?.source) || !directoryIdentity(parsed.directories?.target))) || + (parsed.stagingIdentity !== undefined && !directoryIdentity(parsed.stagingIdentity))) throw new Error('Unsupported migration recovery record') + assertMigrationManifest(parsed.manifest) + if (isWithin(parsed.status.sourceDir, parsed.status.targetDir) || isWithin(parsed.status.targetDir, parsed.status.sourceDir)) throw new Error('Migration recovery directories overlap') + if (parsed.directories && (isWithin(parsed.directories.source.canonical, parsed.directories.target.canonical) || isWithin(parsed.directories.target.canonical, parsed.directories.source.canonical))) throw new Error('Migration recovery directories overlap') + const targetHash = createHash('sha256').update(parsed.status.targetDir.normalize('NFC')).digest('hex').slice(0, 8) + for (const receipts of [parsed.credentials, parsed.createdCredentials]) { + const services = new Set() + for (const receipt of receipts) { + if (!receipt || typeof receipt.service !== 'string' || !new RegExp(`^Claude Code(?:-(?:custom|staging|local)-oauth)?(?:-credentials)?-${targetHash}$`).test(receipt.service) || + typeof receipt.digest !== 'string' || !/^[a-f0-9]{64}$/.test(receipt.digest) || services.has(receipt.service)) throw new Error('Invalid migration credential receipt') + services.add(receipt.service) + } + } + if (parsed.oldMode !== null) { + const mode: unknown = JSON.parse(parsed.oldMode) + // Restore the user-owned snapshot byte for byte, including old defaults + // and unknown fields. AppMode remains the authority for its known fields. + if (!mode || typeof mode !== 'object' || Array.isArray(mode)) throw new Error('Invalid previous migration mode') + } + return parsed +} + +export type DataMigrationHooks = { + preview(): Promise<{ activeTasks: number; externalProcesses: number }> + quiesce(): Promise + resume(): Promise + restart(): Promise | void + progress(status: MigrationStatus): void + credentialStore?: MigrationCredentialStore + permissions?: { + restrictStaging(directory: string, signal: AbortSignal): Promise + preserve(entries: { source: string; target: string }[], signal: AbortSignal): Promise + } + platform?: NodeJS.Platform +} + +async function exists(file: string): Promise { + try { await fs.lstat(file); return true } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false + throw error + } +} + +async function atomicWrite(file: string, value: string): Promise { + await fs.mkdir(path.dirname(file), { recursive: true }) + const temporary = `${file}.${randomUUID()}.tmp` + const handle = await fs.open(temporary, 'wx', 0o600) + try { + await handle.writeFile(value) + await handle.sync() + } finally { await handle.close() } + try { await fs.rename(temporary, file) } finally { await fs.rm(temporary, { force: true }) } +} + +export class DataMigration { + private previewState: MigrationPreview | null = null + private previewDirectories: Journal['directories'] = undefined + private journal: Journal | null = null + private operation: Promise | null = null + private abort: AbortController | null = null + private activeId: string | null = null + get sourceDir(): string { return path.resolve(this.env.CLAUDE_CONFIG_DIR || systemClaudeConfigDir(this.app)) } + get sourceDefault(): boolean { return !this.env.CLAUDE_CONFIG_DIR } + private readonly journalPath: string + private readonly modePath: string + + constructor(private readonly app: AppModeAppLike, private readonly hooks: DataMigrationHooks, private readonly env: NodeJS.ProcessEnv = process.env) { + this.journalPath = path.join(app.getPath('userData'), MIGRATION_JOURNAL_FILE) + this.modePath = path.join(app.getPath('userData'), 'app-mode.json') + } + + get running(): boolean { return this.operation !== null } + get status(): MigrationStatus | null { return this.journal?.status ?? null } + wait(): Promise { return this.operation ?? Promise.resolve() } + + async prepare(targetDir: string): Promise { + if (this.running) throw new Error('Data migration is already running') + this.previewState = null + this.previewDirectories = undefined + if (this.env.CLAUDE_CONFIG_DIR && this.env.CC_HAHA_APP_PORTABLE_DIR !== '1') throw new Error('CLAUDE_CONFIG_DIR is controlled by the launch environment') + const target = normalizedCustomDir(this.app, targetDir) + const sourceReal = await canonicalPath(this.sourceDir) + const targetReal = await canonicalPath(target) + if (isWithin(sourceReal, targetReal) || isWithin(targetReal, sourceReal)) throw new Error('Source and target data directories must be separate') + if (await exists(target) && (await fs.readdir(target)).length) throw new Error('Choose an empty target directory; existing data will not be merged') + await fs.mkdir(target, { recursive: true }) + if ((await fs.lstat(target)).isSymbolicLink()) throw new Error('Choose a target directory without a link') + const directories = { source: await migrationDirectoryIdentity(this.sourceDir), target: await migrationDirectoryIdentity(target) } + if (path.relative(targetReal, directories.target.canonical) !== '' || path.relative(sourceReal, directories.source.canonical) !== '') throw new Error('Migration directory was replaced; choose the directory again') + const probe = await fs.mkdtemp(path.join(target, '.migration-probe-')) + const probeIdentity = await migrationDirectoryIdentity(probe) + try { + await this.assertDirectories(directories, this.sourceDir, target) + await fs.writeFile(path.join(probe, 'probe'), '', { flag: 'wx' }) + const files = await scanMigrationFiles(this.sourceDir, this.sourceDefault ? this.app.getPath('home') : undefined) + for (const file of files.filter(entry => entry.kind === 'link')) { + const stat = await fs.stat(file.source).catch(() => null) + const testLink = path.join(probe, 'link') + await fs.symlink(file.source, testLink, stat?.isDirectory() ? 'junction' : 'file') + await fs.unlink(testLink) + } + const bytes = files.reduce((sum, file) => sum + file.size, 0) + const disk = await fs.statfs(target) + if (disk.bavail * disk.bsize < bytes) throw new Error('Not enough free space in the target directory') + const runtime = await this.hooks.preview() + if (runtime.externalProcesses) throw new Error('Another Claude process is using this data directory; close it and retry') + this.previewState = { id: randomUUID(), sourceDir: this.sourceDir, targetDir: target, files: files.length, bytes, activeTasks: runtime.activeTasks } + await this.assertDirectories(directories, this.sourceDir, target) + this.previewDirectories = directories + return this.previewState + } finally { + await assertMigrationDirectory(target, directories.target) + await assertMigrationDirectory(probe, probeIdentity) + await fs.rm(probe, { recursive: true, force: true }) + } + } + + async start(id: string): Promise { + if (this.running || !this.previewState || this.previewState.id !== id) throw new Error('Migration preview expired; choose the directory again') + const preview = this.previewState + const directories = this.previewDirectories + this.previewState = null + this.previewDirectories = undefined + // Mark running synchronously before any await, blocking two simultaneous starts. + this.abort = new AbortController() + this.activeId = id + this.operation = this.execute(preview, directories!, this.abort.signal).finally(() => { this.operation = null; this.abort = null; this.activeId = null }) + // Errors are kept in the durable receipt; don't leave unhandled promises. + void this.operation.catch(() => {}) + } + + cancel(id: string): void { + if (this.activeId !== id || (this.status?.id === id && !this.status.cancellable)) throw new Error('Migration can no longer be cancelled') + this.abort?.abort(new Error('Migration cancelled')) + } + + cancelActive(): void { + if (!this.activeId || (this.status?.id === this.activeId && !this.status.cancellable)) return + this.cancel(this.activeId) + } + + private async save(): Promise { + await atomicWrite(this.journalPath, JSON.stringify(this.journal)) + } + + private async stage(stage: MigrationStatus['stage']): Promise { + this.journal!.status.stage = stage + this.journal!.status.cancellable = !['committing', 'restarting', 'completed', 'failed', 'cancelled'].includes(stage) + await this.save() + this.hooks.progress({ ...this.journal!.status }) + } + + private credentialStore(): MigrationCredentialStore { + return this.hooks.credentialStore ?? systemMigrationCredentialStore() + } + + private async assertDirectories(directories: NonNullable, source: string, target: string): Promise { + await assertMigrationDirectory(source, directories.source) + await assertMigrationDirectory(target, directories.target) + if (isWithin(directories.source.canonical, directories.target.canonical) || isWithin(directories.target.canonical, directories.source.canonical)) throw new Error('Source and target data directories must be separate') + } + + private async execute(preview: MigrationPreview, directories: NonNullable, signal: AbortSignal): Promise { + let quiescing = false + let switched = false + const stagingDir = path.join(preview.targetDir, `.cc-haha-migration-${preview.id}`) + try { + const oldMode = await fs.readFile(this.modePath, 'utf8').catch(error => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + return null + }) + this.journal = { version: 1, oldMode, stagingDir, directories, manifest: [], createdCredentials: [], credentials: [], status: { + ...preview, stage: 'quiescing', files: 0, totalFiles: preview.files, bytes: 0, totalBytes: preview.bytes, cancellable: true, + } } + await this.stage('quiescing') + signal.throwIfAborted() + await this.assertDirectories(directories, preview.sourceDir, preview.targetDir) + if ((await fs.readdir(preview.targetDir)).length) throw new Error('Target directory is no longer empty') + if ((await this.hooks.preview()).externalProcesses) throw new Error('Another Claude process is using this data directory') + quiescing = true + await this.hooks.quiesce() + signal.throwIfAborted() + await this.assertDirectories(directories, preview.sourceDir, preview.targetDir) + const files = await scanMigrationFiles(preview.sourceDir, this.sourceDefault ? this.app.getPath('home') : undefined, signal) + this.journal.status.totalFiles = files.length + this.journal.status.totalBytes = files.reduce((sum, file) => sum + file.size, 0) + await fs.mkdir(stagingDir, { mode: 0o700 }) + const stagingIdentity = await migrationDirectoryIdentity(stagingDir) + this.journal.stagingIdentity = stagingIdentity + if ((this.hooks.platform ?? process.platform) === 'win32') { + await (this.hooks.permissions?.restrictStaging ?? restrictWindowsMigrationStaging)(stagingIdentity.canonical, signal) + } + await this.stage('copying') + await this.assertDirectories(directories, preview.sourceDir, preview.targetDir) + await assertMigrationDirectory(stagingDir, this.journal.stagingIdentity) + await copyMigrationFiles(files, preview.sourceDir, preview.targetDir, stagingDir, signal, file => { + this.journal!.status.files += 1 + this.journal!.status.bytes += file.size + this.hooks.progress({ ...this.journal!.status }) + }, async () => { + await this.assertDirectories(directories, preview.sourceDir, preview.targetDir) + await assertMigrationDirectory(stagingDir, this.journal!.stagingIdentity!) + }) + await this.stage('verifying') + await this.assertDirectories(directories, preview.sourceDir, preview.targetDir) + await assertMigrationDirectory(stagingDir, this.journal.stagingIdentity) + await verifyMigrationSource(files, await scanMigrationFiles(preview.sourceDir, this.sourceDefault ? this.app.getPath('home') : undefined, signal), signal) + await relocateManagedData(preview.sourceDir, preview.targetDir, stagingDir) + if ((this.hooks.platform ?? process.platform) === 'darwin') { + this.journal.credentials = await copyMigrationCredentials(preview.sourceDir, this.sourceDefault, preview.targetDir, this.credentialStore(), async receipt => { + this.journal!.createdCredentials.push(receipt) + await this.save() + }, signal) + } + await restoreMigrationPermissions(files, stagingDir, signal) + if ((this.hooks.platform ?? process.platform) === 'win32') { + await this.assertDirectories(directories, preview.sourceDir, preview.targetDir) + await assertMigrationDirectory(stagingDir, this.journal.stagingIdentity) + await (this.hooks.permissions?.preserve ?? preserveWindowsMigrationPermissions)([ + ...files.filter(file => file.kind !== 'link').map(file => ({ + source: isWithin(preview.sourceDir, file.source) ? path.join(directories.source.canonical, path.relative(preview.sourceDir, file.source)) : file.source, + target: path.join(stagingIdentity.canonical, file.relative), + })), + { source: directories.source.canonical, target: directories.target.canonical }, + ], signal) + } + this.journal.manifest = await migrationManifest(stagingDir, signal) + // Keychain prompts and target rewrites may take time. Validate the source + // again at the commit boundary, rather than trusting the earlier scan. + await this.assertDirectories(directories, preview.sourceDir, preview.targetDir) + await assertMigrationDirectory(stagingDir, this.journal.stagingIdentity) + await verifyMigrationSource(files, await scanMigrationFiles(preview.sourceDir, this.sourceDefault ? this.app.getPath('home') : undefined, signal), signal) + if ((await this.hooks.preview()).externalProcesses) throw new Error('Another Claude process is using this data directory') + signal.throwIfAborted() + const names = await fs.readdir(preview.targetDir) + if (names.length !== 1 || names[0] !== path.basename(stagingDir)) throw new Error('Target directory changed during migration') + await this.stage('committing') + await this.assertDirectories(directories, preview.sourceDir, preview.targetDir) + await assertMigrationDirectory(stagingDir, this.journal.stagingIdentity) + for (const name of await fs.readdir(stagingDir)) { + await assertMigrationDirectory(preview.targetDir, directories.target) + await assertMigrationDirectory(stagingDir, this.journal.stagingIdentity) + const output = path.join(preview.targetDir, name) + if (await exists(output)) throw new Error('Target directory changed during migration') + await fs.rename(path.join(stagingDir, name), output) + } + await fs.rmdir(stagingDir) + await assertMigrationDirectory(preview.targetDir, directories.target) + await fs.chmod(preview.targetDir, (await fs.stat(preview.sourceDir)).mode) + await validateMigrationManifest(preview.targetDir, this.journal.manifest) + await this.stage('restarting') + await assertMigrationDirectory(preview.targetDir, directories.target) + setAppMode(this.app, { mode: 'portable', portableDir: preview.targetDir }, this.env) + switched = true + await this.hooks.restart() + } catch (error) { + if (this.journal) { + if (switched || this.journal.status.stage === 'restarting') await this.restoreMode() + if ((this.hooks.platform ?? process.platform) === 'darwin') await removeMigrationCredentials(this.journal.createdCredentials, this.credentialStore()).catch(() => {}) + // Remove only the private transaction directory, never a source or + // target directory that may now contain independently written files. + await this.removeStaging().catch(() => {}) + this.journal.status.error = error instanceof Error ? error.message : 'Data migration failed' + await this.stage(signal.aborted ? 'cancelled' : 'failed') + } + if (quiescing) { + try { await this.hooks.resume() } catch (resumeError) { + if (this.journal) { + const detail = resumeError instanceof Error ? resumeError.message : 'Runtime is unavailable' + this.journal.status.error = `${this.journal.status.error ?? 'Migration stopped'}. Original services could not restart: ${detail}` + await this.stage('failed') + } + } + } + } + } + + private async restoreMode(): Promise { + try { + if (this.journal!.directories) await assertMigrationDirectory(this.journal!.status.sourceDir, this.journal!.directories.source) + else if (!(await fs.stat(this.journal!.status.sourceDir)).isDirectory()) throw new Error('Original path is not a directory') + } catch (error) { + throw new Error('Original data directory is unavailable; restore it before retrying migration recovery', { cause: error }) + } + if (this.journal!.oldMode === null) await fs.rm(this.modePath, { force: true }) + else await atomicWrite(this.modePath, this.journal!.oldMode) + } + + private async removeStaging(): Promise { + const journal = this.journal! + const expected = path.join(journal.status.targetDir, `.cc-haha-migration-${journal.status.id}`) + if (journal.stagingDir !== expected || path.dirname(expected) !== journal.status.targetDir || expected === journal.status.targetDir) throw new Error('Invalid migration staging path') + const stat = await fs.lstat(expected).catch(() => null) + if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error('Migration staging directory was replaced') + if (!stat) return + // The v1 fixture predates directory identities. Upgrade it only when its + // entire target path has no links; a legacy receipt cannot prove an alias + // still points to the directory originally chosen by the user. + if (journal.directories) await assertMigrationDirectory(journal.status.targetDir, journal.directories.target) + else await assertUnlinkedDirectoryPath(journal.status.targetDir) + const targetReal = await fs.realpath(journal.status.targetDir) + const stageReal = await fs.realpath(expected) + if (path.relative(targetReal, path.dirname(stageReal)) !== '' || path.basename(stageReal) !== path.basename(expected)) throw new Error('Invalid migration staging path') + if (journal.stagingIdentity) await assertMigrationDirectory(expected, journal.stagingIdentity) + await fs.rm(expected, { recursive: true, force: true }) + } + + async recover(): Promise<'validate' | 'normal'> { + if (!await exists(this.journalPath)) return 'normal' + const parsed = parseJournal(JSON.parse(await fs.readFile(this.journalPath, 'utf8'))) + const sourceReal = await canonicalPath(parsed.status.sourceDir) + const targetReal = await canonicalPath(parsed.status.targetDir) + if (isWithin(sourceReal, targetReal) || isWithin(targetReal, sourceReal)) throw new Error('Migration recovery directories overlap') + this.journal = parsed + if (parsed.status.stage === 'restarting') { + try { + if (parsed.directories) await assertMigrationDirectory(parsed.status.targetDir, parsed.directories.target) + else await assertUnlinkedDirectoryPath(parsed.status.targetDir) + await validateMigrationManifest(parsed.status.targetDir, parsed.manifest) + if ((this.hooks.platform ?? process.platform) === 'darwin') await verifyMigrationCredentials(parsed.credentials, this.credentialStore()) + if (parsed.directories) await assertMigrationDirectory(parsed.status.targetDir, parsed.directories.target) + else await assertUnlinkedDirectoryPath(parsed.status.targetDir) + setAppMode(this.app, { mode: 'portable', portableDir: parsed.status.targetDir }, this.env) + return 'validate' + } catch (error) { + await this.failValidation(error) + return 'normal' + } + } + if (parsed.status.stage === 'completed') { + // A disconnected migrated disk must not be recreated as an empty store. + // An explicit launch override still selects its own independent store. + if (this.env.CLAUDE_CONFIG_DIR && this.env.CC_HAHA_APP_PORTABLE_DIR !== '1' && + path.relative(path.resolve(this.env.CLAUDE_CONFIG_DIR), parsed.status.targetDir) !== '') return 'normal' + let mode: { mode?: string; portable_dir?: string } + try { mode = JSON.parse(await fs.readFile(this.modePath, 'utf8')) } catch (error) { + throw new Error('Migrated data startup configuration is unavailable; restore its saved directory selection', { cause: error }) + } + if (!mode || (mode.mode !== 'default' && (mode.mode !== 'portable' || !absoluteDirectory(mode.portable_dir)))) { + throw new Error('Migrated data startup configuration is invalid; restore its saved directory selection') + } + if (mode.mode === 'portable' && mode.portable_dir === parsed.status.targetDir) { + if (!await exists(parsed.status.targetDir)) throw new Error('Migrated data directory is unavailable; reconnect its disk') + if (parsed.directories) await assertMigrationDirectory(parsed.status.targetDir, parsed.directories.target) + } + return 'normal' + } + if (!['failed', 'cancelled'].includes(parsed.status.stage)) { + await this.restoreMode() + await this.removeStaging() + if ((this.hooks.platform ?? process.platform) === 'darwin') await removeMigrationCredentials(parsed.createdCredentials, this.credentialStore()) + this.journal.status.error = 'Migration was interrupted; original data directory restored' + await this.stage('failed') + } + return 'normal' + } + + async completeValidation(): Promise { await this.stage('completed') } + + async failValidation(error: unknown): Promise { + await this.restoreMode() + this.journal!.status.error = error instanceof Error ? error.message : 'New directory startup failed' + await this.stage('failed') + } +} diff --git a/desktop/electron/services/dataMigrationFiles.ts b/desktop/electron/services/dataMigrationFiles.ts new file mode 100644 index 00000000..abb90232 --- /dev/null +++ b/desktop/electron/services/dataMigrationFiles.ts @@ -0,0 +1,231 @@ +import { createHash } from 'node:crypto' +import { createReadStream, createWriteStream } from 'node:fs' +import { Transform } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import * as fs from 'node:fs/promises' +import path from 'node:path' + +export type MigrationFile = { + relative: string + source: string + kind: 'file' | 'directory' | 'link' + size: number + mode: number + mtime: number + link?: string + digest?: string +} + +const indexes = new Set(['index-v1.sqlite', 'trace-index-v1.sqlite', 'search-index-v1.sqlite', 'scheduled-runs-v1.sqlite']) + +export function skipMigrationEntry(relative: string): boolean { + const normalized = relative.split(path.sep).join('/') + if (normalized.startsWith('cc-haha/db/')) { + const name = normalized.slice('cc-haha/db/'.length) + if (indexes.has(name.replace(/-(wal|shm|journal)$/, ''))) return true + } + if (normalized === '.runtime/venv' || normalized.startsWith('.runtime/venv/')) return true + if (/^\.runtime\/(?:requirements\.sha256|venv-base-interpreter\.txt)$/.test(normalized)) return true + if (/^\.runtime\/cu-helper\.daemon\..*\.sock(?:\.pid)?$/.test(normalized)) return true + if (/^sessions\/\d+\.json$/.test(normalized)) return true + return normalized === '.credentials.json.lock' || normalized === '.config.json.lock' || /^\.claude(?:-.*)?\.json\.lock$/.test(normalized) +} + +export function isWithin(parent: string, candidate: string): boolean { + const relative = path.relative(parent, candidate) + return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) +} + +export async function canonicalPath(input: string): Promise { + let current = path.resolve(input) + const missing: string[] = [] + for (;;) { + try { return path.join(await fs.realpath(current), ...missing) } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + const parent = path.dirname(current) + if (parent === current) throw error + missing.unshift(path.basename(current)) + current = parent + } + } +} + +export type MigrationDirectoryIdentity = { canonical: string; dev: string; ino: string } + +export async function migrationDirectoryIdentity(directory: string): Promise { + const canonical = await fs.realpath(directory) + const stat = await fs.stat(directory, { bigint: true }) + if (!stat.isDirectory()) throw new Error('Migration directory is not a directory') + return { canonical, dev: String(stat.dev), ino: String(stat.ino) } +} + +export async function assertMigrationDirectory(directory: string, expected: MigrationDirectoryIdentity): Promise { + const actual = await migrationDirectoryIdentity(directory) + // Some volumes do not expose an inode. Canonical paths still bind the root; + // compare the volume and inode whenever the filesystem provides them. + if (path.relative(actual.canonical, expected.canonical) !== '' || actual.dev !== expected.dev || (expected.ino !== '0' && actual.ino !== expected.ino)) { + throw new Error('Migration directory was replaced; choose the directory again') + } +} + +export async function assertUnlinkedDirectoryPath(directory: string): Promise { + let current = path.resolve(directory) + for (;;) { + const stat = await fs.lstat(current) + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Migration directory path contains a link') + const parent = path.dirname(current) + if (parent === current) return + current = parent + } +} + +export async function scanMigrationFiles(sourceDir: string, homeDir?: string, signal?: AbortSignal): Promise { + const files: MigrationFile[] = [] + async function visit(source: string, relative: string): Promise { + signal?.throwIfAborted() + if (skipMigrationEntry(relative)) return + const stat = await fs.lstat(source) + if (stat.isSymbolicLink()) { + files.push({ relative, source, kind: 'link', size: 0, mode: stat.mode, mtime: stat.mtimeMs, link: await fs.readlink(source) }) + } else if (stat.isDirectory()) { + files.push({ relative, source, kind: 'directory', size: 0, mode: stat.mode, mtime: stat.mtimeMs }) + for (const name of (await fs.readdir(source)).sort()) await visit(path.join(source, name), path.join(relative, name)) + } else if (stat.isFile()) { + files.push({ relative, source, kind: 'file', size: stat.size, mode: stat.mode, mtime: stat.mtimeMs }) + } else { + throw new Error(`Unsupported data entry: ${relative}`) + } + } + for (const name of (await fs.readdir(sourceDir)).sort()) await visit(path.join(sourceDir, name), name) + if (homeDir && !files.some(file => file.relative === '.config.json')) { + for (const suffix of ['', '-custom-oauth', '-staging-oauth', '-local-oauth']) { + signal?.throwIfAborted() + const name = `.claude${suffix}.json` + const source = path.join(homeDir, name) + try { + const stat = await fs.lstat(source) + if (!stat.isFile()) throw new Error(`Global configuration is not a regular file: ${name}`) + if (files.some(file => file.relative === name)) throw new Error(`Global configuration collision: ${name}`) + files.push({ relative: name, source, kind: 'file', size: stat.size, mode: stat.mode, mtime: stat.mtimeMs }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + } + } + return files +} + +export async function hashFile(file: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + const hash = createHash('sha256') + for await (const chunk of createReadStream(file, { signal })) hash.update(chunk) + return hash.digest('hex') +} + +export async function copyMigrationFiles( + files: MigrationFile[], sourceDir: string, targetDir: string, stagedDir: string, + signal: AbortSignal, progress: (file: MigrationFile) => void, + validateDirectories?: () => Promise, +): Promise { + const sourceReal = await fs.realpath(sourceDir) + for (const file of files) { + signal.throwIfAborted() + await validateDirectories?.() + const output = path.join(stagedDir, file.relative) + if (!isWithin(stagedDir, output)) throw new Error('Unsafe migration path') + if (file.kind === 'directory') { + // Keep the private copy writable until managed metadata has been updated. + // Applying a read-only source directory mode here prevents copying its children. + await fs.mkdir(output, { mode: 0o700 }) + } else if (file.kind === 'link') { + const original = file.link! + const resolved = path.resolve(path.dirname(file.source), original) + const resolvedReal = await canonicalPath(resolved) + const internalRoot = isWithin(sourceDir, resolved) ? sourceDir : isWithin(sourceReal, resolved) ? sourceReal : undefined + const internal = internalRoot ? path.relative(internalRoot, resolved) + : isWithin(sourceReal, resolvedReal) ? path.relative(sourceReal, resolvedReal) : undefined + let link = path.isAbsolute(original) && internal !== undefined ? path.join(targetDir, internal) : original + const targetStat = await fs.stat(file.source).catch(() => null) + if (process.platform === 'win32' && targetStat?.isDirectory()) { + link = internal !== undefined ? path.join(targetDir, internal) : resolved + } + await fs.symlink(link, output, targetStat?.isDirectory() ? 'junction' : 'file') + } else { + const hash = createHash('sha256') + const hashing = new Transform({ transform(chunk, _encoding, done) { hash.update(chunk); done(null, chunk) } }) + await pipeline(createReadStream(file.source), hashing, createWriteStream(output, { flags: 'wx', mode: file.mode, flush: true }), { signal }) + await fs.chmod(output, file.mode) + await fs.utimes(output, new Date(file.mtime), new Date(file.mtime)) + file.digest = hash.digest('hex') + if (await hashFile(output, signal) !== file.digest) throw new Error(`Copy verification failed: ${file.relative}`) + } + progress(file) + } +} + +export async function restoreMigrationPermissions(files: MigrationFile[], stagedDir: string, signal: AbortSignal): Promise { + for (const file of [...files].reverse()) { + signal.throwIfAborted() + if (file.kind !== 'link') await fs.chmod(path.join(stagedDir, file.relative), file.mode) + } +} + +export async function verifyMigrationSource(files: MigrationFile[], current: MigrationFile[], signal: AbortSignal): Promise { + const shape = (entries: MigrationFile[]) => JSON.stringify(entries.map(({ relative, kind, size, mtime, link }) => ({ relative, kind, size, mtime, link }))) + if (shape(files) !== shape(current)) throw new Error('Source data changed during migration; close other Claude processes and retry') + for (const file of files) { + signal.throwIfAborted() + if (file.kind === 'file' && await hashFile(file.source, signal) !== file.digest) throw new Error(`Source data changed: ${file.relative}`) + } +} + +export type VerifiedMigrationFile = { relative: string; kind: MigrationFile['kind']; digest?: string; link?: string } + +export async function migrationManifest(root: string, signal?: AbortSignal): Promise { + const files = await scanMigrationFiles(root, undefined, signal) + const manifest: VerifiedMigrationFile[] = [] + for (const file of files) manifest.push({ + relative: file.relative, kind: file.kind, + ...(file.kind === 'file' ? { digest: await hashFile(file.source, signal) } : {}), + ...(file.kind === 'link' ? { link: file.link } : {}), + }) + return manifest +} + +export function assertMigrationManifest(manifest: unknown): asserts manifest is VerifiedMigrationFile[] { + if (!Array.isArray(manifest)) throw new Error('Invalid migration manifest') + const seen = new Set() + for (const file of manifest) { + if (!file || typeof file !== 'object' || typeof file.relative !== 'string' || !file.relative || file.relative.includes('\0') || + path.isAbsolute(file.relative) || path.win32.isAbsolute(file.relative) || file.relative.split(/[\\/]/).some((part: string) => !part || part === '.' || part === '..') || + !['file', 'directory', 'link'].includes(file.kind) || + (file.kind === 'file' && (typeof file.digest !== 'string' || !/^[a-f0-9]{64}$/.test(file.digest))) || + (file.kind === 'link' && typeof file.link !== 'string')) throw new Error('Invalid migration manifest entry') + const key = process.platform === 'win32' ? file.relative.toLowerCase() : file.relative + if (seen.has(key)) throw new Error('Duplicate migration manifest entry') + seen.add(key) + } +} + +export async function validateMigrationManifest(root: string, manifest: VerifiedMigrationFile[]): Promise { + assertMigrationManifest(manifest) + // Validate each ancestor before reading a file: lstat(file) alone follows a + // replaced parent junction and can hash data outside the copied tree. + const checked = new Set() + for (const file of manifest) { + const target = path.join(root, file.relative) + if (!isWithin(root, target)) throw new Error('Invalid migration manifest path') + let parent = path.dirname(target) + while (isWithin(root, parent) && !checked.has(parent)) { + const stat = await fs.lstat(parent) + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Migration manifest parent was replaced') + checked.add(parent) + if (parent === root) break + parent = path.dirname(parent) + } + const stat = await fs.lstat(target) + if (file.kind === 'directory' && !stat.isDirectory()) throw new Error(`Migration directory missing: ${file.relative}`) + if (file.kind === 'link' && (!stat.isSymbolicLink() || await fs.readlink(target) !== file.link)) throw new Error(`Migration link changed: ${file.relative}`) + if (file.kind === 'file' && (!stat.isFile() || await hashFile(target) !== file.digest)) throw new Error(`Migration file changed: ${file.relative}`) + } +} diff --git a/desktop/electron/services/migrationCredentials.test.ts b/desktop/electron/services/migrationCredentials.test.ts new file mode 100644 index 00000000..9a56901d --- /dev/null +++ b/desktop/electron/services/migrationCredentials.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { copyMigrationCredentials, removeMigrationCredentials, verifyMigrationCredentials, type MigrationCredentialReceipt, type MigrationCredentialStore } from './migrationCredentials' + +function store() { + const values = new Map([['Claude Code', 'fake-api-key'], ['Claude Code-credentials', '{"fakeOauth":"token"}']]) + const api: MigrationCredentialStore = { async read(key) { return values.get(key) ?? null }, async write(key, value) { values.set(key, value) }, async remove(key) { values.delete(key) } } + return { values, api } +} + +describe('macOS migration credential copy', () => { + it('copies both namespaces, validates, and never removes source credentials', async () => { + const { values, api } = store() + const created: MigrationCredentialReceipt[] = [] + const receipts = await copyMigrationCredentials('/home/.claude', true, '/new/data', api, async receipt => { created.push(receipt) }) + expect(receipts).toHaveLength(2) + expect(JSON.stringify(receipts)).not.toContain('fake-api-key') + await verifyMigrationCredentials(receipts, api) + expect(values.get('Claude Code')).toBe('fake-api-key') + expect(values.get('Claude Code-credentials')).toBe('{"fakeOauth":"token"}') + await removeMigrationCredentials(created, api) + expect(values.size).toBe(2) + }) + + it('rejects conflicting target credentials without overwriting either namespace', async () => { + const { values, api } = store() + const receipts = await copyMigrationCredentials('/home/.claude', true, '/new/data', api, async () => {}) + values.set(receipts[0]!.service, 'different-user') + await expect(copyMigrationCredentials('/home/.claude', true, '/new/data', api, async () => {})).rejects.toThrow('different macOS credentials') + expect(values.get(receipts[0]!.service)).toBe('different-user') + expect(values.get('Claude Code')).toBe('fake-api-key') + }) + + it('reuses equal target credentials and does not mark them for rollback deletion', async () => { + const { values, api } = store() + await copyMigrationCredentials('/home/.claude', true, '/new/data', api, async () => {}) + const created: MigrationCredentialReceipt[] = [] + await copyMigrationCredentials('/home/.claude', true, '/new/data', api, async receipt => { created.push(receipt) }) + expect(created).toEqual([]) + expect(values.size).toBe(4) + }) + + it('requires verified credentials on startup and preserves independently changed entries on rollback', async () => { + const { values, api } = store() + const receipts = await copyMigrationCredentials('/home/.claude', true, '/new/data', api, async () => {}) + values.set(receipts[0]!.service, 'independently changed') + await expect(verifyMigrationCredentials(receipts, api)).rejects.toThrow('unavailable') + await removeMigrationCredentials(receipts, api) + expect(values.get(receipts[0]!.service)).toBe('independently changed') + }) + + it('uses runtime NFC namespace hashing for decomposed macOS directory names', async () => { + const first = store() + const second = store() + const receiptsNfc = await copyMigrationCredentials('/home/.claude', true, '/new/café', first.api, async () => {}) + const receiptsNfd = await copyMigrationCredentials('/home/.claude', true, '/new/cafe\u0301', second.api, async () => {}) + expect(receiptsNfd).toEqual(receiptsNfc) + }) +}) diff --git a/desktop/electron/services/migrationCredentials.ts b/desktop/electron/services/migrationCredentials.ts new file mode 100644 index 00000000..5e0041b0 --- /dev/null +++ b/desktop/electron/services/migrationCredentials.ts @@ -0,0 +1,94 @@ +import { createHash } from 'node:crypto' +import { spawn } from 'node:child_process' +import { userInfo } from 'node:os' + +export type MigrationCredentialReceipt = { service: string; digest: string } +export type MigrationCredentialStore = { + read(service: string, signal?: AbortSignal): Promise + write(service: string, value: string, signal?: AbortSignal): Promise + remove(service: string, signal?: AbortSignal): Promise +} + +function security(args: string[], input?: string, signal?: AbortSignal): Promise<{ code: number | null; output: string }> { + return new Promise((resolve, reject) => { + const child = spawn('/usr/bin/security', args, { stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true, signal, timeout: 30_000 }) + let output = '' + child.stdout.on('data', chunk => { output += String(chunk) }) + child.once('error', () => reject(new Error('Cannot access macOS migration credentials'))) + child.stdin.on('error', () => reject(new Error('Cannot write macOS migration credentials'))) + child.once('close', code => resolve({ code, output: output.replace(/\r?\n$/, '') })) + child.stdin.end(input) + }) +} + +export function systemMigrationCredentialStore(username = process.env.USER || userInfo().username): MigrationCredentialStore { + return { + async read(service, signal) { + const result = await security(['find-generic-password', '-a', username, '-s', service, '-w'], undefined, signal) + if (result.code === 44) return null + if (result.code !== 0) throw new Error('macOS keychain is unavailable; unlock it and retry migration') + return result.output + }, + async write(service, value, signal) { + const quote = (text: string) => `"${text.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` + const command = `add-generic-password -a ${quote(username)} -s ${quote(service)} -X "${Buffer.from(value).toString('hex')}"\n` + // Match secureStorage's platform contract: security -i truncates lines + // above its 4096-byte buffer. Prefer stdin; large records use its hex + // argument interface. Neither path logs payloads or uses plaintext files. + const result = Buffer.byteLength(command) <= 4032 + ? await security(['-i'], command, signal) + : await security(['add-generic-password', '-a', username, '-s', service, '-X', Buffer.from(value).toString('hex')], undefined, signal) + if (result.code !== 0) throw new Error('Could not copy macOS migration credentials') + }, + async remove(service) { + if ((await security(['delete-generic-password', '-a', username, '-s', service])).code !== 0) throw new Error('Could not remove migration-created credential') + }, + } +} + +const digest = (value: string) => createHash('sha256').update(value).digest('hex') + +export async function copyMigrationCredentials( + sourceDir: string, sourceDefault: boolean, targetDir: string, + store: MigrationCredentialStore, + beforeCreate: (receipt: MigrationCredentialReceipt) => Promise, + signal?: AbortSignal, +): Promise { + const receipts: MigrationCredentialReceipt[] = [] + const sourceHash = sourceDefault ? '' : `-${digest(sourceDir.normalize('NFC')).slice(0, 8)}` + const targetHash = `-${digest(targetDir.normalize('NFC')).slice(0, 8)}` + for (const oauth of ['', '-custom-oauth', '-staging-oauth', '-local-oauth']) { + for (const credentials of ['', '-credentials']) { + const base = `Claude Code${oauth}${credentials}` + signal?.throwIfAborted() + const source = await store.read(`${base}${sourceHash}`, signal) + if (source === null) continue + const service = `${base}${targetHash}` + const existing = await store.read(service, signal) + if (existing !== null && existing !== source) throw new Error('Target directory has different macOS credentials; choose another directory') + const receipt = { service, digest: digest(source) } + if (existing === null) { + await beforeCreate(receipt) + signal?.throwIfAborted() + await store.write(service, source, signal) + } + if (await store.read(service, signal) !== source) throw new Error('macOS credential verification failed') + receipts.push(receipt) + } + } + return receipts +} + +export async function verifyMigrationCredentials(receipts: MigrationCredentialReceipt[], store: MigrationCredentialStore): Promise { + for (const receipt of receipts) { + const value = await store.read(receipt.service) + if (value === null || digest(value) !== receipt.digest) throw new Error('Migrated macOS credentials are unavailable') + } +} + +export async function removeMigrationCredentials(receipts: MigrationCredentialReceipt[], store: MigrationCredentialStore): Promise { + for (const receipt of receipts) { + const value = await store.read(receipt.service) + if (value !== null && digest(value) === receipt.digest) await store.remove(receipt.service) + } +} diff --git a/desktop/electron/services/migrationPermissions.test.ts b/desktop/electron/services/migrationPermissions.test.ts new file mode 100644 index 00000000..d8cb49d4 --- /dev/null +++ b/desktop/electron/services/migrationPermissions.test.ts @@ -0,0 +1,195 @@ +import { spawn } from 'node:child_process' +import { EventEmitter } from 'node:events' +import * as fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createWindowsMigrationPermissionsRunner, preserveWindowsMigrationPermissions, restrictWindowsMigrationStaging, type MigrationPermissionsRunner } from './migrationPermissions' + +const roots: string[] = [] +async function fixture() { + const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'haha-migration-acl-'))) + roots.push(root) + const sourceParent = path.join(root, 'source-parent') + const targetParent = path.join(root, 'target-parent') + const sourceDir = path.join(sourceParent, 'source') + const targetDir = path.join(targetParent, 'target') + await fs.mkdir(sourceDir, { recursive: true }) + await fs.mkdir(targetDir, { recursive: true }) + const sourceFile = path.join(sourceDir, "中 [literal]; $(Write-Output injected) '.txt") + const targetFile = path.join(targetDir, "中 [literal]; $(Write-Output injected) '.txt") + await fs.writeFile(sourceFile, 'source fixture bytes') + await fs.copyFile(sourceFile, targetFile) + return { root, sourceParent, targetParent, sourceDir, targetDir, sourceFile, targetFile } +} + +afterEach(async () => { for (const root of roots.splice(0)) await fs.rm(root, { recursive: true, force: true }) }) + +function powershell(script: string, payload: object): Promise { + const executable = path.win32.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') + return new Promise((resolve, reject) => { + const child = spawn(executable, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(`$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; [Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false); Import-Module (Join-Path $PSHOME 'Modules/Microsoft.PowerShell.Security/Microsoft.PowerShell.Security.psd1') -ErrorAction Stop; $payload = ConvertFrom-Json -InputObject ([Console]::In.ReadToEnd()); ${script}`, 'utf16le').toString('base64')], { windowsHide: true, shell: false, stdio: ['pipe', 'pipe', 'ignore'], timeout: 60_000 }) + let output = '' + child.stdout.setEncoding('utf8') + child.stdout.on('data', chunk => { output += chunk }) + child.once('error', () => reject(new Error('Disposable ACL fixture could not run'))) + child.stdin.once('error', () => reject(new Error('Disposable ACL fixture input failed'))) + child.once('close', code => { if (code === 0) resolve(output.trim()); else reject(new Error('Disposable ACL fixture command failed')) }) + child.stdin.end(JSON.stringify(payload), 'utf8') + }) +} + +describe('Windows migration permissions', () => { + it('sends paths as JSON, converts inherited source rules and protects descendants first', async () => { + const f = await fixture() + const runner = vi.fn(async () => {}) + await preserveWindowsMigrationPermissions([{ source: f.sourceDir, target: f.targetDir }, { source: f.sourceFile, target: f.targetFile }], undefined, runner) + expect(runner).toHaveBeenCalledOnce() + const [script, input] = runner.mock.calls[0]! + expect(script).not.toContain(f.sourceFile) + expect(script).not.toContain(f.targetDir) + expect(script).toContain('$acl.SetAccessRuleProtection($true, $true)') + expect(script).toContain('Get-Acl -LiteralPath $entry.source') + expect(script).toContain('Set-Acl -LiteralPath $entry.target') + expect(script).not.toContain('Set-Acl -LiteralPath $entry.source') + expect(JSON.parse(input)).toEqual({ entries: [{ source: f.sourceFile, target: f.targetFile }, { source: f.sourceDir, target: f.targetDir }] }) + }) + + it('restricts staging to the current owner with protected inheritable FullControl', async () => { + const f = await fixture() + const runner = vi.fn(async () => {}) + await restrictWindowsMigrationStaging(f.targetDir, undefined, runner) + const [script, input] = runner.mock.calls[0]! + expect(JSON.parse(input)).toEqual({ directory: f.targetDir }) + expect(script).toContain('$acl.SetAccessRuleProtection($true, $false)') + expect(script).toContain('WindowsIdentity]::GetCurrent().User') + expect(script).toContain('FileSystemRights]::FullControl') + expect(script).toContain('ContainerInherit, ObjectInherit') + expect(script).not.toContain(f.targetDir) + }) + + it('rejects source targets and hardlinks without invoking ACL mutation', async () => { + const f = await fixture() + const runner = vi.fn(async () => {}) + await expect(preserveWindowsMigrationPermissions([{ source: f.sourceFile, target: f.sourceFile }], undefined, runner)).rejects.toThrow('source entry') + const linked = path.join(f.targetDir, 'hardlink') + await fs.link(f.sourceFile, linked) + await expect(preserveWindowsMigrationPermissions([{ source: f.sourceFile, target: linked }], undefined, runner)).rejects.toThrow('independent copy') + expect(runner).not.toHaveBeenCalled() + expect(await fs.readFile(f.sourceFile, 'utf8')).toBe('source fixture bytes') + }) + + it('never applies permissions through a junction or symlink', async () => { + const f = await fixture() + const link = path.join(f.root, 'linked-directory') + await fs.symlink(f.targetDir, link, 'junction') + const runner = vi.fn(async () => {}) + await expect(restrictWindowsMigrationStaging(link, undefined, runner)).rejects.toThrow('links') + await expect(preserveWindowsMigrationPermissions([{ source: f.sourceDir, target: link }], undefined, runner)).rejects.toThrow('links') + await expect(preserveWindowsMigrationPermissions([{ source: link, target: f.sourceDir }], undefined, runner)).rejects.toThrow('links') + expect(runner).not.toHaveBeenCalled() + const parentLink = path.join(f.root, 'linked-parent') + await fs.symlink(f.targetParent, parentLink, 'junction') + const targetThroughParent = path.join(parentLink, path.basename(f.targetDir)) + await expect(restrictWindowsMigrationStaging(targetThroughParent, undefined, runner)).rejects.toThrow('links') + await expect(preserveWindowsMigrationPermissions([{ source: f.sourceDir, target: targetThroughParent }], undefined, runner)).rejects.toThrow('links') + expect(runner).not.toHaveBeenCalled() + }) + + it.each(['process error', 'stdin error', 'abort'])('waits for child close after %s before allowing rollback', async event => { + const stdin = new PassThrough() + const child = Object.assign(new EventEmitter(), { stdin, kill: vi.fn(() => true) }) + const spawnProcess = vi.fn<(...args: Parameters) => typeof child>(() => child) + const runner = createWindowsMigrationPermissionsRunner(spawnProcess as unknown as typeof spawn, 'win32') + const abort = new AbortController() + let settled = false + const operation = runner('static fixture script', '{"fixture":true}', abort.signal) + void operation.then(() => { settled = true }, () => { settled = true }) + if (event === 'stdin error') stdin.emit('error', new Error('private diagnostic')) + else { + if (event === 'abort') abort.abort() + child.emit('error', new Error('private diagnostic')) + } + await new Promise(resolve => setImmediate(resolve)) + expect(child.kill).toHaveBeenCalled() + expect(settled).toBe(false) + expect(spawnProcess.mock.calls[0]?.[2]).toMatchObject({ windowsHide: true, shell: false, stdio: ['pipe', 'ignore', 'ignore'], timeout: 60_000, signal: abort.signal }) + child.emit('close', null) + await expect(operation).rejects.toThrow('migration was stopped') + }) + + it('stops on an ACL failure and bounds entries in each process', async () => { + const f = await fixture() + const entries = Array.from({ length: 129 }, () => ({ source: f.sourceFile, target: f.targetFile })) + const runner = vi.fn(async () => {}) + await preserveWindowsMigrationPermissions(entries, undefined, runner) + expect(runner.mock.calls.map(call => JSON.parse(call[1]).entries.length)).toEqual([128, 1]) + const failing = vi.fn(async () => { throw new Error('ACL unavailable') }) + await expect(preserveWindowsMigrationPermissions(entries, undefined, failing)).rejects.toThrow('ACL unavailable') + expect(failing).toHaveBeenCalledOnce() + }) + + it('honors cancellation before mutation and after each runner', async () => { + const f = await fixture() + const abort = new AbortController() + const runner = vi.fn(async () => {}) + abort.abort(new Error('cancel fixture')) + await expect(restrictWindowsMigrationStaging(f.targetDir, abort.signal, runner)).rejects.toThrow('cancel fixture') + await expect(preserveWindowsMigrationPermissions([{ source: f.sourceFile, target: f.targetFile }], abort.signal, runner)).rejects.toThrow('cancel fixture') + expect(runner).not.toHaveBeenCalled() + const during = new AbortController() + await expect(preserveWindowsMigrationPermissions([{ source: f.sourceFile, target: f.targetFile }], during.signal, async () => { during.abort(new Error('cancel during ACL')) })).rejects.toThrow('cancel during ACL') + }) + + it.skipIf(process.platform !== 'win32')('removes wider inherited target grants and leaves actual source ACLs unchanged', async () => { + const f = await fixture() + // Only disposable directories are changed. Give their two parent trees + // different ACLs, reproducing a move to a volume with broader inheritance. + await restrictWindowsMigrationStaging(f.sourceParent) + const sourceHash = await powershell(` + $acl = Get-Acl -LiteralPath $payload.targetParent + $everyone = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new($everyone, [System.Security.AccessControl.FileSystemRights]::ReadAndExecute, [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit', [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow) + $acl.AddAccessRule($rule) + Set-Acl -LiteralPath $payload.targetParent -AclObject $acl + $before = @($payload.sourceDir, $payload.sourceFile | ForEach-Object { (Get-Acl -LiteralPath $_).Sddl }) -join '|' + $hash = [System.Security.Cryptography.SHA256]::Create() + [Convert]::ToBase64String($hash.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($before))) + `, f) + const before = JSON.parse(await powershell(` + $sidType = [System.Security.Principal.SecurityIdentifier] + $acl = Get-Acl -LiteralPath $payload.targetFile + @{ wideInherited = @($acl.GetAccessRules($true, $true, $sidType) | Where-Object { $_.IdentityReference.Value -eq 'S-1-1-0' -and $_.IsInherited }).Count -gt 0; sourceInherited = -not (Get-Acl -LiteralPath $payload.sourceFile).AreAccessRulesProtected } | ConvertTo-Json -Compress + `, f)) + expect(before).toEqual({ wideInherited: true, sourceInherited: true }) + await preserveWindowsMigrationPermissions([{ source: f.sourceDir, target: f.targetDir }, { source: f.sourceFile, target: f.targetFile }]) + const after = JSON.parse(await powershell(` + $sidType = [System.Security.Principal.SecurityIdentifier] + function Rules([string]$entry) { @((Get-Acl -LiteralPath $entry).GetAccessRules($true, $true, $sidType) | ForEach-Object { '{0}|{1}|{2}|{3}|{4}' -f $_.IdentityReference.Value, [int]$_.FileSystemRights, [int]$_.InheritanceFlags, [int]$_.PropagationFlags, [int]$_.AccessControlType } | Sort-Object) -join '|' } + $acl = Get-Acl -LiteralPath $payload.targetFile + $current = @($payload.sourceDir, $payload.sourceFile | ForEach-Object { (Get-Acl -LiteralPath $_).Sddl }) -join '|' + $hash = [System.Security.Cryptography.SHA256]::Create() + @{ sourceHash = [Convert]::ToBase64String($hash.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($current))); rulesEqual = (Rules $payload.sourceFile) -ceq (Rules $payload.targetFile); directoriesEqual = (Rules $payload.sourceDir) -ceq (Rules $payload.targetDir); protected = $acl.AreAccessRulesProtected; widerGrant = @($acl.GetAccessRules($true, $true, $sidType) | Where-Object { $_.IdentityReference.Value -eq 'S-1-1-0' }).Count -gt 0 } | ConvertTo-Json -Compress + `, f)) + expect(after).toEqual({ sourceHash, rulesEqual: true, directoriesEqual: true, protected: true, widerGrant: false }) + expect(await fs.readFile(f.sourceFile, 'utf8')).toBe('source fixture bytes') + expect(await fs.readFile(f.targetFile, 'utf8')).toBe('source fixture bytes') + }, 30_000) + + it.skipIf(process.platform !== 'win32')('makes actual staging ACL current-user-only before files are copied', async () => { + const f = await fixture() + await restrictWindowsMigrationStaging(f.targetDir) + const file = path.join(f.targetDir, 'created-after-restriction') + await fs.writeFile(file, 'private fixture') + const actual = JSON.parse(await powershell(` + $sidType = [System.Security.Principal.SecurityIdentifier] + $owner = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $acl = Get-Acl -LiteralPath $payload.directory + $rules = @($acl.GetAccessRules($true, $true, $sidType)) + $fileRules = @((Get-Acl -LiteralPath $payload.file).GetAccessRules($true, $true, $sidType)) + @{ protected = $acl.AreAccessRulesProtected; onlyOwner = $rules.Count -eq 1 -and $rules[0].IdentityReference.Value -eq $owner -and $rules[0].FileSystemRights -eq [System.Security.AccessControl.FileSystemRights]::FullControl -and -not $rules[0].IsInherited; fileOnlyOwner = $fileRules.Count -eq 1 -and $fileRules[0].IdentityReference.Value -eq $owner -and $fileRules[0].IsInherited } | ConvertTo-Json -Compress + `, { directory: f.targetDir, file })) + expect(actual).toEqual({ protected: true, onlyOwner: true, fileOnlyOwner: true }) + }, 30_000) +}) diff --git a/desktop/electron/services/migrationPermissions.ts b/desktop/electron/services/migrationPermissions.ts new file mode 100644 index 00000000..36a36c77 --- /dev/null +++ b/desktop/electron/services/migrationPermissions.ts @@ -0,0 +1,156 @@ +import { spawn } from 'node:child_process' +import * as fs from 'node:fs/promises' +import path from 'node:path' + +export type MigrationPermissionsRunner = (script: string, input: string, signal?: AbortSignal) => Promise +export type MigrationPermissionEntry = { source: string; target: string } + +const scriptPrelude = ` +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false) +foreach ($module in @('Security', 'Management', 'Utility')) { + Import-Module (Join-Path $PSHOME ('Modules/Microsoft.PowerShell.{0}/Microsoft.PowerShell.{0}.psd1' -f $module)) -ErrorAction Stop +} +function Assert-RegularItem([string]$itemPath) { + $item = Get-Item -LiteralPath $itemPath -Force -ErrorAction Stop + if ($item -isnot [System.IO.FileSystemInfo] -or ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { throw 'Unsupported migration entry' } + return $item +} +function Assert-UnlinkedTarget([string]$itemPath) { + $current = [System.IO.Path]::GetFullPath($itemPath) + while ($current) { + $null = Assert-RegularItem $current + $parent = [System.IO.Directory]::GetParent($current) + if ($null -eq $parent) { return } + $current = $parent.FullName + } +} +function Assert-Permissions($expected, [string]$target) { + $actual = Get-Acl -LiteralPath $target -ErrorAction Stop + if (-not $actual.AreAccessRulesProtected) { throw 'Migration permissions are not protected' } + $sidType = [System.Security.Principal.SecurityIdentifier] + if ($expected.GetOwner($sidType).Value -ne $actual.GetOwner($sidType).Value) { throw 'Migration owner changed' } + $expectedRules = @($expected.GetAccessRules($true, $true, $sidType) | ForEach-Object { '{0}|{1}|{2}|{3}|{4}' -f $_.IdentityReference.Value, [int]$_.FileSystemRights, [int]$_.InheritanceFlags, [int]$_.PropagationFlags, [int]$_.AccessControlType } | Sort-Object) + $actualRules = @($actual.GetAccessRules($true, $true, $sidType) | ForEach-Object { if ($_.IsInherited) { throw 'Unexpected inherited migration permissions' }; '{0}|{1}|{2}|{3}|{4}' -f $_.IdentityReference.Value, [int]$_.FileSystemRights, [int]$_.InheritanceFlags, [int]$_.PropagationFlags, [int]$_.AccessControlType } | Sort-Object) + if (($expectedRules -join '\n') -cne ($actualRules -join '\n')) { throw 'Migration permissions differ' } +} +` + +const preserveScript = `${scriptPrelude} +try { + $payload = ConvertFrom-Json -InputObject ([Console]::In.ReadToEnd()) + foreach ($entry in $payload.entries) { + $source = Assert-RegularItem $entry.source + $target = Assert-RegularItem $entry.target + if ($source.PSIsContainer -ne $target.PSIsContainer -or [string]::Equals($source.FullName, $target.FullName, [System.StringComparison]::OrdinalIgnoreCase)) { throw 'Invalid migration entry' } + $acl = Get-Acl -LiteralPath $entry.source -ErrorAction Stop + $acl.SetAccessRuleProtection($true, $true) + Assert-UnlinkedTarget $entry.target + Set-Acl -LiteralPath $entry.target -AclObject $acl -ErrorAction Stop + Assert-Permissions $acl $entry.target + } + exit 0 +} catch { exit 1 } +` + +const restrictScript = `${scriptPrelude} +try { + $payload = ConvertFrom-Json -InputObject ([Console]::In.ReadToEnd()) + $target = Assert-RegularItem $payload.directory + if (-not $target.PSIsContainer) { throw 'Staging is not a directory' } + $owner = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + $acl = [System.Security.AccessControl.DirectorySecurity]::new() + $acl.SetAccessRuleProtection($true, $false) + $acl.SetOwner($owner) + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new($owner, [System.Security.AccessControl.FileSystemRights]::FullControl, [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit', [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow) + $acl.AddAccessRule($rule) + Assert-UnlinkedTarget $payload.directory + Set-Acl -LiteralPath $payload.directory -AclObject $acl -ErrorAction Stop + Assert-Permissions $acl $payload.directory + exit 0 +} catch { exit 1 } +` + +export function createWindowsMigrationPermissionsRunner(spawnProcess: typeof spawn = spawn, platform: NodeJS.Platform = process.platform): MigrationPermissionsRunner { + return async (script, input, signal) => { + if (platform !== 'win32') throw new Error('Windows migration permissions are unavailable on this platform') + signal?.throwIfAborted() + const executable = path.win32.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') + await new Promise((resolve, reject) => { + const child = spawnProcess(executable, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { + windowsHide: true, shell: false, stdio: ['pipe', 'ignore', 'ignore'], timeout: 60_000, signal, + }) + // Never include PowerShell diagnostics: paths and ACL identities belong + // to user state and must not be exposed in a receipt. + let failed = false + const stop = () => { failed = true; child.kill() } + child.once('error', stop) + child.stdin!.once('error', stop) + // Wait for close even on abort or EPIPE, so rollback never races a still + // running ACL writer. spawn's signal/timeout also terminate the child. + child.once('close', code => { + if (!failed && code === 0) resolve() + else reject(new Error('Could not preserve Windows data permissions; migration was stopped')) + }) + try { child.stdin!.end(input, 'utf8') } catch { stop() } + }) + } +} + +const systemRunner = createWindowsMigrationPermissionsRunner() + +async function regularItem(file: string, directoryOnly = false): Promise { + if (!path.isAbsolute(file) || file.includes('\0')) throw new Error('Invalid migration permission path') + const stat = await fs.lstat(file, { bigint: true }) + if (stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory()) || (directoryOnly && !stat.isDirectory())) throw new Error('Migration permissions cannot be applied to links or special entries') + return stat +} + +async function assertUnlinkedTarget(file: string): Promise { + let current = path.dirname(path.resolve(file)) + for (;;) { + await regularItem(current, true) + const parent = path.dirname(current) + if (parent === current) return + current = parent + } +} + +/** Call immediately after creating the empty private stage, before any copy. */ +export async function restrictWindowsMigrationStaging(stagingDir: string, signal?: AbortSignal, runner: MigrationPermissionsRunner = systemRunner): Promise { + signal?.throwIfAborted() + await regularItem(stagingDir, true) + await assertUnlinkedTarget(stagingDir) + await runner(restrictScript, JSON.stringify({ directory: stagingDir }), signal) + signal?.throwIfAborted() +} + +/** Copies DACLs and owners to independent copies, never updating a source ACL. */ +export async function preserveWindowsMigrationPermissions(entries: MigrationPermissionEntry[], signal?: AbortSignal, runner: MigrationPermissionsRunner = systemRunner): Promise { + signal?.throwIfAborted() + const sourcePaths = new Set(entries.map(entry => path.resolve(entry.source).toLowerCase())) + for (const entry of entries) { + signal?.throwIfAborted() + if (sourcePaths.has(path.resolve(entry.target).toLowerCase())) throw new Error('Migration permissions must not modify a source entry') + const source = await regularItem(entry.source) + const target = await regularItem(entry.target) + await assertUnlinkedTarget(entry.target) + if (source.isDirectory() !== target.isDirectory() || (target.isFile() && target.nlink !== 1n) || (source.ino !== 0n && source.dev === target.dev && source.ino === target.ino)) throw new Error('Migration permissions require an independent copy') + } + // Protect descendants first so replacing a parent DACL cannot retain or + // propagate the wider ACL inherited from the destination volume. + const ordered = [...entries].sort((left, right) => right.target.split(/[\\/]/).length - left.target.split(/[\\/]/).length) + for (let index = 0; index < ordered.length; index += 128) { + signal?.throwIfAborted() + const batch = ordered.slice(index, index + 128) + for (const entry of batch) { + const target = await regularItem(entry.target) + if (target.isFile() && target.nlink !== 1n) throw new Error('Migration permissions require an independent copy') + await assertUnlinkedTarget(entry.target) + signal?.throwIfAborted() + } + await runner(preserveScript, JSON.stringify({ entries: batch }), signal) + } + signal?.throwIfAborted() +} diff --git a/desktop/electron/services/migrationStartup.test.ts b/desktop/electron/services/migrationStartup.test.ts new file mode 100644 index 00000000..f8b34833 --- /dev/null +++ b/desktop/electron/services/migrationStartup.test.ts @@ -0,0 +1,71 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { runInNewContext } from 'node:vm' +import { describe, expect, it, vi } from 'vitest' + +// Run the actual startup registration with isolated host/runtime dependencies. +// No Electron process, user configuration, providers or credentials are loaded. +async function startupFixture(failure?: 'recover' | 'validate' | 'rollback' | 'shutdown') { + const desktopDir = path.basename(process.cwd()) === 'desktop' ? process.cwd() : path.join(process.cwd(), 'desktop') + const source = readFileSync(path.join(desktopDir, 'electron/main.ts'), 'utf8') + const start = source.indexOf('app.whenReady().then(async () => {') + const end = source.indexOf("app.on('window-all-closed'", start) + expect(start).toBeGreaterThan(0) + expect(end).toBeGreaterThan(start) + const calls: string[] = [] + const step = (name: string) => { calls.push(name) } + const env: NodeJS.ProcessEnv = {} + const migration = { + async recover() { step('recover'); if (failure === 'recover') throw new Error('Missing target disk'); return 'validate' }, + async completeValidation() { step('complete') }, + async failValidation() { step('rollback'); if (failure === 'rollback') throw new Error('Original directory is unavailable') }, + } + const runtime = { + async startServer() { step('start'); expect(env.CC_HAHA_MIGRATION_VALIDATION === '1' || calls.includes('rollback')).toBe(true) }, + async validateMigrationStartup() { step('validate'); if (failure && failure !== 'recover') throw new Error('Invalid copied configuration') }, + async activateAfterMigrationValidation() { step('activate'); expect(calls).toContain('complete') }, + async stopAllAndWait() { step('stop'); if (failure === 'shutdown') throw new Error('Runtime process did not exit') }, + } + const context = { + app: { whenReady: () => Promise.resolve(), on: vi.fn() }, + process: { env, platform: 'win32' }, + applyWindowsAppUserModelId: () => {}, clearAppManagedPortableEnv: () => step('clearEnv'), + applyStartupPortableMode: () => step('applyRoot'), getDataMigration: () => migration, + setStorageWritesFrozen: vi.fn(), dialog: { showErrorBox: vi.fn() }, + createMainWindow: async () => step('window'), installSystemAppearanceWatch: () => {}, + screen: { on: vi.fn() }, mainWindow: null, workspaceBrowserService: null, + getServerRuntime: () => runtime, serverRuntime: runtime as typeof runtime | null, + getPublicAccessManager: () => ({ restore: async () => step('publicAccess') }), + installApplicationMenu: async () => {}, shouldInstallTray: () => false, + scheduleNotificationSmoke: () => {}, Notification: {}, emitNotificationAction: () => {}, + console: { error: vi.fn() }, + } + await runInNewContext(source.slice(start, end), context) + return { calls, context, env } +} + +describe('migration startup admission boundary', () => { + it('persists validation before admitting tasks or restoring public access', async () => { + const f = await startupFixture() + expect(f.calls).toEqual(['clearEnv', 'recover', 'applyRoot', 'start', 'validate', 'complete', 'activate', 'publicAccess', 'window']) + expect(f.env.CC_HAHA_MIGRATION_VALIDATION).toBeUndefined() + expect(f.context.setStorageWritesFrozen).not.toHaveBeenCalled() + }) + + it('stops the validating runtime before rolling back and restarting the old root', async () => { + const f = await startupFixture('validate') + expect(f.calls).toEqual(['clearEnv', 'recover', 'applyRoot', 'start', 'validate', 'stop', 'rollback', 'clearEnv', 'applyRoot', 'start', 'publicAccess', 'window']) + expect(f.calls).not.toContain('activate') + expect(f.env.CC_HAHA_MIGRATION_VALIDATION).toBeUndefined() + }) + + it.each(['recover', 'rollback', 'shutdown'] as const)('fails closed when %s cannot complete safely', async failure => { + const f = await startupFixture(failure) + expect(f.context.setStorageWritesFrozen).toHaveBeenCalledWith(true) + expect(f.context.dialog.showErrorBox).toHaveBeenCalledOnce() + expect(f.calls.filter(call => call === 'start')).toHaveLength(failure === 'recover' ? 0 : 1) + expect(f.calls).not.toContain('activate') + expect(f.calls).not.toContain('publicAccess') + expect(f.calls.at(-1)).toBe('window') + }) +}) diff --git a/desktop/electron/services/nativeAppearance.ts b/desktop/electron/services/nativeAppearance.ts index 6dcbbd0b..79af3eda 100644 --- a/desktop/electron/services/nativeAppearance.ts +++ b/desktop/electron/services/nativeAppearance.ts @@ -32,6 +32,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' +import { areStorageWritesFrozen } from './storageMaintenance' import type { App, BrowserWindow } from 'electron' export const APPEARANCE_STATE_FILE = 'appearance-state.json' @@ -99,7 +100,7 @@ export function writeAppearanceState( state: AppliedAppearance, env: NodeJS.ProcessEnv = process.env, ): void { - if (!isAppliedAppearance(state)) return + if (areStorageWritesFrozen() || !isAppliedAppearance(state)) return const statePath = appearanceStatePath(app, env) try { mkdirSync(path.dirname(statePath), { recursive: true }) diff --git a/desktop/electron/services/petWindow.ts b/desktop/electron/services/petWindow.ts index 89c465c2..52c8556a 100644 --- a/desktop/electron/services/petWindow.ts +++ b/desktop/electron/services/petWindow.ts @@ -16,6 +16,7 @@ import { } from 'node:fs' import os from 'node:os' import path from 'node:path' +import { areStorageWritesFrozen } from './storageMaintenance' export const PET_WINDOW_WIDTH = 384 export const PET_WINDOW_HEIGHT = 400 @@ -184,7 +185,7 @@ export function writePetWindowPosition( env: NodeJS.ProcessEnv = process.env, homeDir: string = os.homedir(), ): void { - if (!isPetWindowPosition(state)) return + if (areStorageWritesFrozen() || !isPetWindowPosition(state)) return const statePath = petWindowStatePath(env, homeDir) const temporaryPath = `${statePath}.${process.pid}.tmp` try { diff --git a/desktop/electron/services/quitLifecycle.test.ts b/desktop/electron/services/quitLifecycle.test.ts index d7767384..5393a786 100644 --- a/desktop/electron/services/quitLifecycle.test.ts +++ b/desktop/electron/services/quitLifecycle.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest' // Execute the production registration without booting Electron or user sidecars. // Dependencies are fixtures; the before-quit handler and its state are real. -function quitFixture(failingStep?: string, rejectServer = false, rejectPublicAccess = false) { +function quitFixture(failingStep?: string, rejectServer = false, rejectPublicAccess = false, dataMigration: { running: boolean; cancelActive(): void; wait(): Promise } | null = null) { const desktopDir = path.basename(process.cwd()) === 'desktop' ? process.cwd() : path.join(process.cwd(), 'desktop') @@ -34,6 +34,7 @@ function quitFixture(failingStep?: string, rejectServer = false, rejectPublicAcc app.quit = vi.fn(requestQuit) const context = { app, isQuitting: false, quitCleanupStarted: false, quitCleanupFinished: false, + dataMigration, mainWindow: {}, saveWindowState: cleanup('window'), trayController: { dispose: cleanup('tray') }, terminalService: { killAll: cleanup('terminal') }, @@ -60,6 +61,24 @@ function quitFixture(failingStep?: string, rejectServer = false, rejectPublicAcc const settle = () => new Promise(resolve => setImmediate(resolve)) describe('Electron quit lifecycle', () => { + it('keeps the host alive until a pending migration cancels and resumes safely', async () => { + let finish!: () => void + const wait = new Promise(resolve => { finish = resolve }) + const migration = { running: true, cancelActive: vi.fn(), wait: () => wait } + const fixture = quitFixture(undefined, false, false, migration) + expect(fixture.requestQuit().preventDefault).toHaveBeenCalledOnce() + expect(migration.cancelActive).toHaveBeenCalledOnce() + expect(fixture.calls).toEqual([]) + expect(fixture.exit).not.toHaveBeenCalled() + migration.running = false + finish() + await settle() + expect(fixture.calls).toContain('server') + fixture.finishServer() + await settle() + expect(fixture.exit).toHaveBeenCalledOnce() + }) + it('waits for server cleanup, coalesces repeated quits, then allows the final quit', async () => { const fixture = quitFixture() expect(fixture.requestQuit().preventDefault).toHaveBeenCalledOnce() diff --git a/desktop/electron/services/serverRuntime.test.ts b/desktop/electron/services/serverRuntime.test.ts index 27c54314..32cbcb6d 100644 --- a/desktop/electron/services/serverRuntime.test.ts +++ b/desktop/electron/services/serverRuntime.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events' import { spawn, type ChildProcess } from 'node:child_process' -import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import path from 'node:path' import { PassThrough } from 'node:stream' @@ -40,6 +40,9 @@ const ADAPTER_COUNT = ADAPTER_FLAGS.length let isolatedConfigDir = '' class FakeSidecarChild extends EventEmitter { + exitCode: number | null = null + signalCode: string | null = null + readonly stdin = new PassThrough() readonly stdout = new PassThrough() readonly stderr = new PassThrough() readonly kill = vi.fn() @@ -53,6 +56,7 @@ function createRuntime(options: { resolveSystemProxy?: (url: string) => Promise sleep?: (delayMs: number) => Promise proxyBridge?: SystemProxyBridgeLike + fetch?: typeof fetch } = {}) { return new ElectronServerRuntime({ desktopRoot: '/isolated/desktop', @@ -61,6 +65,7 @@ function createRuntime(options: { env: { CLAUDE_CONFIG_DIR: isolatedConfigDir, ...options.env }, resolveSystemProxy: options.resolveSystemProxy, deps: { + ...(options.fetch ? { fetch: options.fetch } : {}), appendHostDiagnostic: sidecarMocks.appendHostDiagnostic, ...(options.now ? { now: options.now } : {}), preferredServerPorts: () => [], @@ -91,6 +96,153 @@ async function waitForMockCalls(mock: ReturnType, count: number): } describe('ElectronServerRuntime', () => { + it('drains the authenticated server and every adapter before stopping the server', async () => { + const order: string[] = [] + const runtime = createRuntime({ fetch: (async (_url, options) => { + expect(options?.headers).toEqual({ Authorization: `Bearer ${runtime.getLocalAccessToken()}` }) + order.push('server-drained') + return Response.json({ quiesced: true }) + }) as typeof fetch }) + await runtime.startServer() + const server = sidecarMocks.serverChildren[0]! + server.kill.mockImplementation(() => { + expect(order.filter(entry => entry === 'adapter-drained')).toHaveLength(ADAPTER_COUNT) + order.push('server-stopped') + server.exitCode = 0 + server.emit('exit', 0, null) + }) + for (const child of sidecarMocks.adapterChildren) { + child.stdin.on('data', value => { + const request = JSON.parse(value.toString()) + expect(request.token).toBe(runtime.getLocalAccessToken()) + order.push('adapter-drained') + child.stdout.write(JSON.stringify({ type: 'migration_quiesced', requestId: request.requestId }) + '\n') + child.exitCode = 0 + child.emit('exit', 0, null) + }) + } + await runtime.quiesceForMigration() + expect(order[0]).toBe('server-drained') + expect(order.at(-1)).toBe('server-stopped') + await expect(runtime.getServerUrl()).rejects.toThrow('Data migration') + await expect(runtime.restartAdaptersSidecars()).rejects.toThrow('Data migration') + expect(sidecarMocks.serverChildren).toHaveLength(1) + }) + + it('does not kill the server or acknowledge migration after an adapter drain failure', async () => { + const runtime = createRuntime({ fetch: (async () => Response.json({ quiesced: true })) as typeof fetch }) + await runtime.startServer() + for (const child of sidecarMocks.adapterChildren) { + child.stdin.on('data', value => { + const request = JSON.parse(value.toString()) + child.stdout.write(JSON.stringify({ type: 'migration_quiesce_failed', requestId: request.requestId }) + '\n') + child.exitCode = 1 + child.emit('exit', 1, null) + }) + } + await expect(runtime.quiesceForMigration()).rejects.toThrow('Adapter did not confirm') + expect(sidecarMocks.serverChildren[0]!.kill).not.toHaveBeenCalled() + expect(sidecarMocks.serverChildren).toHaveLength(1) + runtime.stopAll() + }) + + it.each(['before-control', 'during-control'] as const)('accepts credential-gated sidecars that become inactive %s and positively exit', async timing => { + const runtime = createRuntime({ fetch: (async () => Response.json({ quiesced: true })) as typeof fetch }) + await runtime.startServer() + for (const child of sidecarMocks.adapterChildren) { + const complete = () => { + child.stdout.write('{"type":"migration_adapter_inactive"}\n') + queueMicrotask(() => { child.exitCode = 0; child.emit('exit', 0, null) }) + } + if (timing === 'before-control') complete() + else child.stdin.on('data', complete) + } + const server = sidecarMocks.serverChildren[0]! + server.kill.mockImplementation(() => { server.exitCode = 0; server.emit('exit', 0, null) }) + await runtime.quiesceForMigration() + expect(sidecarMocks.adapterChildren.every(child => child.exitCode === 0)).toBe(true) + expect(await runtime.getMigrationPreview()).toEqual({ activeTasks: 0, externalProcesses: 0 }) + }) + + it('rechecks outside live PID registrations after the source server has exited', async () => { + const request = vi.fn(async () => Response.json({ quiesced: true })) + const runtime = createRuntime({ fetch: request as typeof fetch }) + await runtime.startServer() + for (const child of sidecarMocks.adapterChildren) { + child.stdin.on('data', value => { + const message = JSON.parse(value.toString()) + child.stdout.write(JSON.stringify({ type: 'migration_quiesced', requestId: message.requestId }) + '\n') + child.exitCode = 0 + child.emit('exit', 0, null) + }) + } + const server = sidecarMocks.serverChildren[0]! + server.kill.mockImplementation(() => { server.exitCode = 0; server.emit('exit', 0, null) }) + await runtime.quiesceForMigration() + expect(await runtime.getMigrationPreview()).toEqual({ activeTasks: 0, externalProcesses: 0 }) + const external = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }) + try { + mkdirSync(path.join(isolatedConfigDir, 'sessions')) + writeFileSync(path.join(isolatedConfigDir, 'sessions', `${external.pid}.json`), JSON.stringify({ pid: external.pid })) + expect(await runtime.getMigrationPreview()).toEqual({ activeTasks: 0, externalProcesses: 1 }) + expect(request).toHaveBeenCalledTimes(1) + } finally { + external.kill('SIGKILL') + await new Promise(resolve => external.once('exit', () => resolve())) + } + }) + + it('drains owned writers before restarting the source after an outside-writer rejection', async () => { + const order: string[] = [] + const runtime = createRuntime({ fetch: (async input => { + const route = String(input).split('/').at(-1)! + order.push(route) + return route === 'quiesce' + ? Response.json({ error: 'Close external CLI sessions' }, { status: 409 }) + : Response.json({ quiesced: true }) + }) as typeof fetch }) + await runtime.startServer() + const server = sidecarMocks.serverChildren[0]! + server.kill.mockImplementation(() => { + order.push('server-stopped') + server.exitCode = 0 + server.emit('exit', 0, null) + }) + for (const child of sidecarMocks.adapterChildren) { + child.stdin.on('data', value => { + const message = JSON.parse(value.toString()) + order.push('adapter-drained') + child.stdout.write(JSON.stringify({ type: 'migration_quiesced', requestId: message.requestId }) + '\n') + child.exitCode = 0 + child.emit('exit', 0, null) + }) + } + await expect(runtime.quiesceForMigration()).rejects.toThrow('Close external CLI') + expect(server.kill).not.toHaveBeenCalled() + await runtime.resumeAfterMigration() + expect(order.slice(0, 2)).toEqual(['quiesce', 'recover']) + expect(order.at(-1)).toBe('server-stopped') + expect(order.filter(value => value === 'adapter-drained')).toHaveLength(ADAPTER_COUNT) + expect(sidecarMocks.serverChildren).toHaveLength(2) + runtime.stopAll() + }) + + it('keeps adapters stopped until migrated startup is validated and explicitly activated', async () => { + const paths: string[] = [] + const runtime = createRuntime({ env: { CC_HAHA_MIGRATION_VALIDATION: '1' }, fetch: (async input => { + paths.push(String(input)) + return Response.json(String(input).endsWith('/validate') ? { valid: true } : { activated: true }) + }) as typeof fetch }) + await runtime.startServer() + expect(sidecarMocks.adapterChildren).toHaveLength(0) + await runtime.validateMigrationStartup() + expect(sidecarMocks.adapterChildren).toHaveLength(0) + await runtime.activateAfterMigrationValidation() + expect(paths.map(value => value.split('/').at(-1))).toEqual(['validate', 'activate']) + expect(sidecarMocks.adapterChildren).toHaveLength(ADAPTER_COUNT) + runtime.stopAll() + }) + beforeEach(() => { isolatedConfigDir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-runtime-')) sidecarMocks.nextPort = 49321 @@ -247,7 +399,7 @@ describe('ElectronServerRuntime', () => { const activeTurn = process.argv[1] const readyFile = process.argv[2] let owned = false - process.on('SIGTERM', () => { + process.stdin.on('data', () => { setTimeout(() => { if (owned) fs.rmSync(activeTurn, { force: true }) process.exit(0) @@ -269,16 +421,23 @@ describe('ElectronServerRuntime', () => { env: { CLAUDE_CONFIG_DIR: root }, deps: { appendHostDiagnostic: () => undefined, + // Use an inherited pipe: Windows does not deliver POSIX SIGTERM. + killSidecar: child => { + if (children.includes(child)) child.stdin!.write('stop\n') + else child.kill() + }, preferredServerPorts: () => [], reserveServerPort: async () => 49321 + serverStarts, spawnSidecar: plan => { if (plan.args[0] !== 'server') { - return new FakeSidecarChild() as unknown as SidecarChild + const child = new FakeSidecarChild() + child.kill.mockImplementation(() => { child.exitCode = 0; child.emit('exit', 0, null) }) + return child as unknown as SidecarChild } const readyFile = path.join(root, `ready-${++serverStarts}`) readyFiles.push(readyFile) const child = spawn(process.execPath, ['-e', fixture, activeTurn, readyFile], { - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['pipe', 'pipe', 'pipe'], }) children.push(child) return child as SidecarChild diff --git a/desktop/electron/services/serverRuntime.ts b/desktop/electron/services/serverRuntime.ts index da5ccbc3..e20a93cc 100644 --- a/desktop/electron/services/serverRuntime.ts +++ b/desktop/electron/services/serverRuntime.ts @@ -1,5 +1,7 @@ import path from 'node:path' import { randomBytes } from 'node:crypto' +import { homedir } from 'node:os' +import { countExternalMigrationProcesses } from '../../../src/server/migrationInventory' import { appendHostDiagnostic, clearProxyEnv, @@ -44,6 +46,8 @@ type ServerRuntimeOptions = { } type ServerRuntimeDeps = { + fetch: typeof fetch + killSidecar: typeof killSidecar appendHostDiagnostic: typeof appendHostDiagnostic now: () => number preferredServerPorts: typeof preferredServerPorts @@ -56,6 +60,8 @@ type ServerRuntimeDeps = { } const DEFAULT_SERVER_RUNTIME_DEPS: ServerRuntimeDeps = { + fetch: (...args) => fetch(...args), + killSidecar, appendHostDiagnostic, now: Date.now, preferredServerPorts, @@ -137,6 +143,11 @@ export class ElectronServerRuntime { private lifecycleGeneration = 0 private startingServer: ServerStartState | null = null private adapterRestartPromise: Promise | null = null + private migrationActive = false + private migrationQuiescence: Promise | null = null + private migrationQuiesced = false + private migrationSourceDir: string | null = null + private readonly inactiveAdapters = new WeakSet() constructor(options: ServerRuntimeOptions) { this.onServerUnavailable = options.onServerUnavailable @@ -151,6 +162,7 @@ export class ElectronServerRuntime { } async startServer(): Promise { + if (this.migrationActive) throw new Error('Data migration is in progress') if (this.server) return this.server.url if (this.startPromise) return this.startPromise this.assertRestartCircuitAllowsStart() @@ -167,6 +179,7 @@ export class ElectronServerRuntime { } async getServerUrl(): Promise { + if (this.migrationActive) throw new Error('Data migration is in progress') if (this.server) return this.server.url if (this.startPromise) return await this.startServer() this.assertRestartCircuitAllowsStart() @@ -186,7 +199,90 @@ export class ElectronServerRuntime { return this.server?.url ?? null } + getOwnedProcessIds(): number[] { + return [this.server?.child, this.startingServer?.child, ...this.adapters] + .map(child => child?.pid).filter((pid): pid is number => typeof pid === 'number' && pid > 0) + } + + async getMigrationPreview(): Promise<{ activeTasks: number; externalProcesses: number }> { + if (this.migrationQuiesced) { + return { activeTasks: 0, externalProcesses: await countExternalMigrationProcesses([], this.migrationSourceDir!) } + } + return await this.requestMigrationControl('preview', 'GET') as { activeTasks: number; externalProcesses: number } + } + + async validateMigrationStartup(): Promise { + const result = await this.requestMigrationControl('validate', 'GET') + if (result.valid !== true) throw new Error('Migrated data failed runtime validation') + } + + async activateAfterMigrationValidation(): Promise { + const result = await this.requestMigrationControl('activate', 'POST') + if (result.activated !== true) throw new Error('Migration runtime activation failed') + delete this.baseEnv.CC_HAHA_MIGRATION_VALIDATION + this.sidecarEnvPromise = null + if (this.server) await this.startAdaptersSidecars(this.server.url, undefined, this.server) + } + + quiesceForMigration(): Promise { + if (this.migrationQuiescence) return this.migrationQuiescence + if (!this.server || this.startingServer || this.startPromise) return Promise.reject(new Error('Server startup must finish before migration')) + this.migrationActive = true + this.migrationSourceDir = (this.baseEnv.CLAUDE_CONFIG_DIR ?? path.join(homedir(), '.claude')).normalize('NFC') + this.migrationQuiescence = this.quiesceForMigrationOnce() + return this.migrationQuiescence + } + + private async quiesceForMigrationOnce(): Promise { + const server = this.server! + const result = await this.requestMigrationControl('quiesce', 'POST', server.url) + if (result.quiesced !== true) throw new Error('Server did not confirm safe migration shutdown') + await Promise.all([...server.adapterChildren].map(child => quiesceAdapterForMigration(child, this.localAccessToken, () => this.inactiveAdapters.has(child)))) + const exited = waitForSidecarExit(server.child, SERVER_SHUTDOWN_TIMEOUT_MS) + // Clear ownership before termination so exit listeners cannot restart the server. + this.onServerUnavailable?.() + ++this.lifecycleGeneration + this.server = null + this.adapters = [] + server.adapterChildren.splice(0) + this.deps.killSidecar(server.child, process.platform === 'win32') + if (!await exited) throw new Error('Server process did not exit after migration shutdown') + this.migrationQuiesced = true + this.stopSystemProxyBridge() + } + + async resumeAfterMigration(): Promise { + if (this.migrationActive && this.server) { + const result = await this.requestMigrationControl('recover', 'POST') + if (result.quiesced !== true) throw new Error('Source runtime did not confirm safe recovery shutdown') + await Promise.all([...this.server.adapterChildren].map(child => quiesceAdapterForMigration(child, this.localAccessToken, () => this.inactiveAdapters.has(child)))) + } + if (this.server || this.startingServer || this.adapters.length > 0) await this.stopAllAndWait() + this.migrationActive = false + this.migrationQuiescence = null + this.migrationQuiesced = false + this.migrationSourceDir = null + this.sidecarEnvPromise = null + this.startupError = null + this.restartBlockedUntil = 0 + this.restartAfterExit = false + await this.startServer() + } + + private async requestMigrationControl(path: string, method: 'GET' | 'POST', url = this.server?.url): Promise> { + if (!url) throw new Error('Server is unavailable') + const response = await this.deps.fetch(`${url}/api/runtime/migration/${path}`, { + method, + headers: { Authorization: `Bearer ${this.localAccessToken}` }, + signal: AbortSignal.timeout(60_000), + }) + const result = await response.json() as Record + if (!response.ok) throw new Error(typeof result.error === 'string' ? result.error : 'Migration runtime control failed') + return result + } + restartAdaptersSidecars(): Promise { + if (this.migrationActive || this.baseEnv.CC_HAHA_MIGRATION_VALIDATION === '1') return Promise.reject(new Error('Data migration is in progress')) if (this.adapterRestartPromise) return this.adapterRestartPromise const operation = this.restartAdaptersSidecarsOnce() const tracked = operation.finally(() => { @@ -216,12 +312,12 @@ export class ElectronServerRuntime { starting.fail(new Error('server startup stopped')) if (!starting.childStopped) { starting.childStopped = true - killSidecar(starting.child, sync) + this.deps.killSidecar(starting.child, sync) } } this.stopAdaptersSidecars(sync) if (this.server) { - killSidecar(this.server.child, sync) + this.deps.killSidecar(this.server.child, sync) this.server = null } this.stopSystemProxyBridge() @@ -229,6 +325,7 @@ export class ElectronServerRuntime { async stopAllAndWait(timeoutMs = SERVER_SHUTDOWN_TIMEOUT_MS): Promise { const serverChildren = new Set() + for (const child of this.adapters) serverChildren.add(child) if (this.startingServer) serverChildren.add(this.startingServer.child) if (this.server) serverChildren.add(this.server.child) const exitWaits = new Map( @@ -244,12 +341,13 @@ export class ElectronServerRuntime { if (stillRunning.length === 0) return for (const child of stillRunning) { - if (process.platform === 'win32') killSidecar(child, true) + if (process.platform === 'win32') this.deps.killSidecar(child, true) else child.kill('SIGKILL') } - await Promise.all( + const forced = await Promise.all( stillRunning.map(child => waitForSidecarExit(child, SERVER_FORCE_EXIT_TIMEOUT_MS)), ) + if (forced.some(exited => !exited)) throw new Error('Runtime processes did not exit') } private async startServerAfterDelay(generation: number, delayMs: number): Promise { @@ -294,7 +392,7 @@ export class ElectronServerRuntime { startState.failurePromise, ]) if (startState.failure) throw startState.failure - this.deps.writeLastServerPort(port, this.baseEnv) + if (this.baseEnv.CC_HAHA_MIGRATION_VALIDATION !== '1') this.deps.writeLastServerPort(port, this.baseEnv) this.server = { url, child, @@ -304,10 +402,12 @@ export class ElectronServerRuntime { const activeServer = this.server this.startupError = null this.stopAdaptersSidecars() - await Promise.race([ - this.startAdaptersSidecars(url, startState, activeServer), - startState.failurePromise, - ]) + if (this.baseEnv.CC_HAHA_MIGRATION_VALIDATION !== '1') { + await Promise.race([ + this.startAdaptersSidecars(url, startState, activeServer), + startState.failurePromise, + ]) + } if (startState.failure) throw startState.failure this.onServerReady?.() return url @@ -317,7 +417,7 @@ export class ElectronServerRuntime { if (this.server?.child === startState.child) this.server = null if (!startState.childStopped) { startState.childStopped = true - killSidecar(startState.child) + this.deps.killSidecar(startState.child) } } if (startState?.failure) { @@ -341,7 +441,7 @@ export class ElectronServerRuntime { startState?: ServerStartState, activeServer?: ActiveServer, ): Promise { - const baseEnv = this.withLocalAccessToken(await this.resolveSidecarBaseEnv()) + const baseEnv: NodeJS.ProcessEnv = { ...this.withLocalAccessToken(await this.resolveSidecarBaseEnv()), CC_HAHA_MIGRATION_CONTROL: '1' } const bridgeUrl = baseEnv.CC_HAHA_SYSTEM_PROXY_URL const env = bridgeUrl ? withAdapterProxyBridgeEnv(baseEnv, bridgeUrl) @@ -375,7 +475,7 @@ export class ElectronServerRuntime { env, })) if (!isCurrentGeneration()) { - killSidecar(child) + this.deps.killSidecar(child) break } this.captureLogs(child, `claude-adapters:${label}`) @@ -392,7 +492,7 @@ export class ElectronServerRuntime { this.removeOwnedAdapters(this.server?.adapterChildren, children) this.removeOwnedAdapters(this.startingServer?.adapterChildren, children) for (const child of children) { - killSidecar(child, sync) + this.deps.killSidecar(child, sync) } } @@ -428,7 +528,18 @@ export class ElectronServerRuntime { onExit?: (code: number | null, signal: NodeJS.Signals | null) => void, onError?: (error: Error) => void, ) { + let adapterControlBuffer = '' child.stdout.on('data', chunk => { + if (label.startsWith('claude-adapters:')) { + adapterControlBuffer = (adapterControlBuffer + String(chunk)).slice(-16_384) + const lines = adapterControlBuffer.split('\n') + adapterControlBuffer = lines.pop() ?? '' + for (const entry of lines) { + try { + if (JSON.parse(entry)?.type === 'migration_adapter_inactive') this.inactiveAdapters.add(child) + } catch { /* Platform log output is not a control marker. */ } + } + } const line = String(chunk).trimEnd() if (!line) return console.log(`[${label}] ${line}`) @@ -484,6 +595,10 @@ export class ElectronServerRuntime { const active = this.server?.child === child const starting = this.startingServer?.child === child if (!active && !starting) return + if (this.migrationActive) { + if (active) this.server = null + return + } this.onServerUnavailable?.() const failedServer = active ? this.server : null if (active) { @@ -546,7 +661,7 @@ export class ElectronServerRuntime { for (const child of children.splice(0)) { const index = this.adapters.indexOf(child) if (index >= 0) this.adapters.splice(index, 1) - killSidecar(child, sync) + this.deps.killSidecar(child, sync) } } @@ -602,6 +717,58 @@ export class ElectronServerRuntime { } } +async function quiesceAdapterForMigration(child: SidecarChild, token: string, isInactive: () => boolean): Promise { + if (child.exitCode != null || child.signalCode != null) return + if (!child.stdin) throw new Error('Adapter has no graceful migration control channel') + const requestId = randomBytes(16).toString('hex') + const exited = waitForSidecarExit(child, SERVER_SHUTDOWN_TIMEOUT_MS) + let buffer = '' + let remove = () => {} + let failControl = () => {} + const acknowledged = isInactive() ? Promise.resolve(true) : new Promise(resolve => { + const finish = (value: boolean) => { + remove() + resolve(value) + } + // A credential-gated sidecar can close stdin before its inactive marker + // reaches stdout. Keep waiting for that marker and positive process exit. + failControl = () => {} + const onData = (chunk: Buffer) => { + buffer = (buffer + chunk.toString()).slice(-16_384) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + try { + const message = JSON.parse(line) + if (message.type === 'migration_adapter_inactive') finish(true) + if (message.requestId === requestId && message.type === 'migration_quiesced') finish(true) + if (message.requestId === requestId && message.type === 'migration_quiesce_failed') finish(false) + } catch { /* Ordinary sidecar logs are not control acknowledgements. */ } + } + } + const timer = setTimeout(() => finish(false), SERVER_SHUTDOWN_TIMEOUT_MS) + remove = () => { + clearTimeout(timer) + child.stdout.removeListener('data', onData) + child.stdin?.removeListener('error', failControl) + } + child.stdout.on('data', onData) + child.stdin!.on('error', failControl) + }) + try { + if (!isInactive()) { + child.stdin.write(JSON.stringify({ type: 'migration_quiesce', requestId, token }) + '\n', error => { + if (error) failControl() + }) + } + } catch { + failControl() + } + const [ack, didExit] = await Promise.all([acknowledged, exited]) + remove() + if (!ack || !didExit || (child.exitCode != null && child.exitCode !== 0)) throw new Error('Adapter did not confirm a clean migration shutdown') +} + function waitForSidecarExit(child: SidecarChild, timeoutMs: number): Promise { if (child.exitCode != null || child.signalCode != null) return Promise.resolve(true) diff --git a/desktop/electron/services/sidecarManager.ts b/desktop/electron/services/sidecarManager.ts index b2944ed1..39c320c1 100644 --- a/desktop/electron/services/sidecarManager.ts +++ b/desktop/electron/services/sidecarManager.ts @@ -1,5 +1,6 @@ import { spawn, spawnSync, type ChildProcessByStdio } from 'node:child_process' import { randomUUID } from 'node:crypto' +import { areStorageWritesFrozen } from './storageMaintenance' import { constants as fsConstants, closeSync, @@ -17,7 +18,7 @@ import { statSync, writeFileSync, } from 'node:fs' -import type { Readable } from 'node:stream' +import type { Readable, Writable } from 'node:stream' import http from 'node:http' import net from 'node:net' import os from 'node:os' @@ -41,7 +42,7 @@ const MIN_FIXED_PORT = 1024 const MAX_FIXED_PORT = 65535 const MAX_PORT_RESERVATION_ATTEMPTS = 128 -export type SidecarChild = ChildProcessByStdio +export type SidecarChild = ChildProcessByStdio export type SidecarPlan = { command: string @@ -342,7 +343,7 @@ export function appendHostDiagnostic( line: string, { homeDir = os.homedir() }: { homeDir?: string } = {}, ): void { - if (!filePath) return + if (areStorageWritesFrozen() || !filePath) return const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp` let tempDescriptor: number | undefined try { @@ -709,9 +710,9 @@ export function spawnSidecar(plan: SidecarPlan, deps: SpawnSidecarDeps = {}): Si } return (deps.spawnFn ?? spawn)(plan.command, plan.args, { env: plan.env, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: [plan.env.CC_HAHA_MIGRATION_CONTROL === '1' && plan.args[0] === 'adapters' ? 'pipe' : 'ignore', 'pipe', 'pipe'], windowsHide: true, - }) + }) as SidecarChild } export type KillSidecarDeps = { diff --git a/desktop/electron/services/storageMaintenance.test.ts b/desktop/electron/services/storageMaintenance.test.ts new file mode 100644 index 00000000..f4bc7dda --- /dev/null +++ b/desktop/electron/services/storageMaintenance.test.ts @@ -0,0 +1,42 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { App } from 'electron' +import { afterEach, describe, expect, it } from 'vitest' +import { areStorageWritesFrozen, setStorageWritesFrozen } from './storageMaintenance' +import { writeWindowState } from './windows' +import { writeAppearanceState } from './nativeAppearance' +import { writePetWindowPosition } from './petWindow' +import { appendHostDiagnostic } from './sidecarManager' + +let root: string | undefined +afterEach(() => { + setStorageWritesFrozen(false) + if (root) fs.rmSync(root, { recursive: true, force: true }) + root = undefined +}) + +describe('host data migration write barrier', () => { + it('freezes event and quit-time root writers while leaving them available after rollback', () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'haha-host-maintenance-')) + const fakeApp = { getPath() { return root! } } as unknown as App + const configDir = path.join(root, 'data') + const env = { CLAUDE_CONFIG_DIR: configDir } + const writeAll = () => { + writeWindowState(fakeApp, { x: 1, y: 2, width: 1200, height: 800, maximized: false }, env) + writeAppearanceState(fakeApp, { isDark: false, background: '#ffffff', lightBackground: '#ffffff', followSystem: false }, env) + writePetWindowPosition({ x: 10, y: 20 }, env, root!) + appendHostDiagnostic(path.join(configDir, 'cc-haha', 'diagnostics', 'electron-host.log'), 'fixture', { homeDir: root! }) + } + setStorageWritesFrozen(true) + expect(areStorageWritesFrozen()).toBe(true) + writeAll() + expect(fs.existsSync(configDir)).toBe(false) + setStorageWritesFrozen(false) + writeAll() + expect(fs.existsSync(path.join(configDir, 'window-state.json'))).toBe(true) + expect(fs.existsSync(path.join(configDir, 'appearance-state.json'))).toBe(true) + expect(fs.existsSync(path.join(configDir, 'cc-haha', 'pet-window.json'))).toBe(true) + expect(fs.existsSync(path.join(configDir, 'cc-haha', 'diagnostics', 'electron-host.log'))).toBe(true) + }) +}) diff --git a/desktop/electron/services/storageMaintenance.ts b/desktop/electron/services/storageMaintenance.ts new file mode 100644 index 00000000..0ceec92a --- /dev/null +++ b/desktop/electron/services/storageMaintenance.ts @@ -0,0 +1,11 @@ +// Host writers share this barrier with the migration coordinator. Keep it +// process-local: the startup pointer and migration journal live in userData. +let frozen = false + +export function setStorageWritesFrozen(value: boolean): void { + frozen = value +} + +export function areStorageWritesFrozen(): boolean { + return frozen +} diff --git a/desktop/electron/services/windows.ts b/desktop/electron/services/windows.ts index 8e6d9d75..1303df80 100644 --- a/desktop/electron/services/windows.ts +++ b/desktop/electron/services/windows.ts @@ -1,5 +1,6 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' +import { areStorageWritesFrozen } from './storageMaintenance' import type { App, BrowserWindow, BrowserWindowConstructorOptions, Display } from 'electron' export const WINDOW_STATE_FILE = 'window-state.json' @@ -118,7 +119,7 @@ export function writeWindowState( state: StoredWindowState, env: NodeJS.ProcessEnv = process.env, ) { - if (!isPersistableWindowState(state)) return + if (areStorageWritesFrozen() || !isPersistableWindowState(state)) return const statePath = windowStatePath(app, env) try { mkdirSync(path.dirname(statePath), { recursive: true }) diff --git a/desktop/sidecars/adapterMigration.test.ts b/desktop/sidecars/adapterMigration.test.ts new file mode 100644 index 00000000..73a777ad --- /dev/null +++ b/desktop/sidecars/adapterMigration.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment node + +import { spawn } from 'node:child_process' +import { mkdir, mkdtemp, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { expect, it } from 'vitest' + +it('the real adapter launcher confirms inactivity when migration races credential gating', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'haha-adapter-inactive-')) + const data = path.join(root, 'data') + await mkdir(data) + const repo = path.resolve(import.meta.dirname, '../..') + const child = spawn('bun', [path.join(repo, 'desktop/sidecars/claude-sidecar.ts'), 'adapters', '--app-root', repo, '--feishu', '--telegram', '--wechat', '--dingtalk', '--whatsapp', '--wecom', '--qq', '--slack'], { + env: { + PATH: process.env.PATH, + SystemRoot: process.env.SystemRoot, + HOME: root, + USERPROFILE: root, + CLAUDE_CONFIG_DIR: data, + CC_HAHA_MIGRATION_CONTROL: '1', + CC_HAHA_LOCAL_ACCESS_TOKEN: 'isolated-migration-token', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', chunk => { stdout += String(chunk) }) + child.stderr.on('data', chunk => { stderr += String(chunk) }) + child.stdin.on('error', () => {}) + const closed = new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', resolve) + }) + try { + child.stdin.write(JSON.stringify({ type: 'migration_quiesce', requestId: 'early', token: 'isolated-migration-token' }) + '\n') + expect(await closed, stderr).toBe(0) + expect(stdout.trim()).toBe('{"type":"migration_adapter_inactive"}') + expect(stderr.match(/requested but/g)).toHaveLength(8) + expect(await readdir(data)).toEqual([]) + } finally { + if (child.exitCode === null) child.kill('SIGKILL') + await closed.catch(() => {}) + await rm(root, { recursive: true, force: true }) + } +}, 15_000) diff --git a/desktop/sidecars/claude-sidecar.ts b/desktop/sidecars/claude-sidecar.ts index 1c800661..b5c831e4 100644 --- a/desktop/sidecars/claude-sidecar.ts +++ b/desktop/sidecars/claude-sidecar.ts @@ -215,13 +215,22 @@ async function runAdapters(rawArgs: string[]): Promise { } if (started === 0) { + if (process.env.CC_HAHA_MIGRATION_CONTROL === '1') { + // No platform module was imported, so this process owns no adapter writes. + // Publish that fact before exit: the host may already be requesting drain. + process.stdout.write(JSON.stringify({ type: 'migration_adapter_inactive' }) + '\n', () => process.exit(0)) + return + } console.error( '[claude-sidecar] no adapter could be started — check credentials in env or ~/.claude/adapters.json', ) process.exit(1) } - // 让进程保持存活:每个 adapter 都通过 long-lived WebSocket(Lark WSClient - // / grammY long-polling / Socket Mode 等)持有 event loop,自然不会退出。 - // 这里不需要额外 setInterval 兜底。adapter 自己注册的 SIGINT handler 都会触发。 + if (process.env.CC_HAHA_MIGRATION_CONTROL === '1' && process.env.CC_HAHA_LOCAL_ACCESS_TOKEN) { + const { installAdapterMigrationControl } = await import('../../adapters/common/migration-control.js') + installAdapterMigrationControl({ token: process.env.CC_HAHA_LOCAL_ACCESS_TOKEN }) + } + + // 每个 adapter 的 WebSocket / long-polling 持有 event loop,自然保持进程存活。 } diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index dde34e30..f21cb17c 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -968,6 +968,33 @@ describe('Settings > General tab', () => { expect((webSearchHeading.compareDocumentPosition(storageHeading) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) expect(screen.getByText(/Windows, upgrades recover verified legacy app-adjacent data/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Migrate Existing Data…' })).toBeInTheDocument() + }) + + it('blocks the existing directory-switch controls while a restored migration is running', async () => { + const host = window.desktopHost! + host.appMode = { + ...host.appMode, + migration: { + ...host.appMode.migration, + status: vi.fn().mockResolvedValue({ + id: 'migration-fixture', sourceDir: '/fixture/original', targetDir: '/fixture/new', + stage: 'copying', files: 2, totalFiles: 4, bytes: 512, totalBytes: 1024, cancellable: true, + }), + }, + } + render() + fireEvent.click(screen.getByText('General')) + await screen.findByRole('dialog', { name: 'Data migration in progress' }) + // The child's status render precedes its effect that reports the busy state + // to General settings; observe the completed parent update as well. + await waitFor(() => { + expect(screen.getByRole('button', { name: /Use system directory/ })).toBeDisabled() + expect(screen.getByLabelText('Custom data directory')).toBeDisabled() + expect(screen.getByRole('button', { name: 'Choose Folder' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Use This Folder and Restart' })).toBeDisabled() + }) + expect(useSettingsStore.getState().setAppMode).not.toHaveBeenCalled() }) it('lets desktop users choose a custom data directory and relaunch immediately', async () => { diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 21e18411..88b3169c 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -2307,6 +2307,39 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le 'settings.general.storageSwitchDefaultBody': 'After switching back, the desktop app will use ~/.claude again. Data in the custom directory is not deleted or moved back automatically.', 'settings.general.storageSwitchRestartBody': 'The app will stop the local server and adapter processes, then relaunch. The new directory takes effect after restart.', + 'settings.general.migration.title': 'Migrate existing data', + 'settings.general.migration.description': 'Copy and verify your current sessions, settings, Skills, plugins and tasks, then switch directories and restart. The original data is kept.', + 'settings.general.migration.choose': 'Migrate Existing Data…', + 'settings.general.migration.chooseTitle': 'Choose a migration destination', + 'settings.general.migration.confirmTitle': 'Migrate data and restart?', + 'settings.general.migration.confirm': 'Migrate and Restart', + 'settings.general.migration.source': 'Original directory', + 'settings.general.migration.target': 'New directory', + 'settings.general.migration.preview': '{files} files · {size}', + 'settings.general.migration.activeTasks': '{count} running tasks will be stopped.', + 'settings.general.migration.stopWarning': 'Running conversations and background tasks will be interrupted and saved before copying. They will not resume automatically. The app restarts after verification succeeds.', + 'settings.general.migration.externalWarning': 'Close other Claude Code or cc-haha processes using the original directory before continuing. Choose an empty folder outside the app installation directory.', + 'settings.general.migration.running': 'Data migration in progress', + 'settings.general.migration.stagePreparing': 'Preparing migration…', + 'settings.general.migration.stageQuiescing': 'Stopping tasks and saving data…', + 'settings.general.migration.stageCopying': 'Copying data…', + 'settings.general.migration.stageVerifying': 'Verifying copied data…', + 'settings.general.migration.stageCommitting': 'Switching data directory…', + 'settings.general.migration.stageRestarting': 'Restarting the app…', + 'settings.general.migration.progress': '{files}/{totalFiles} files · {bytes}/{totalBytes}', + 'settings.general.migration.cancel': 'Cancel migration', + 'settings.general.migration.cancelling': 'Cancelling migration…', + 'settings.general.migration.completed': 'Data migration completed', + 'settings.general.migration.cancelled': 'Migration cancelled. The original data directory is unchanged.', + 'settings.general.migration.failed': 'Migration failed. The original data directory is kept.', + 'settings.general.migration.retained': 'The original directory is retained. Check your sessions and settings in the new directory before manually cleaning up the original data.', + 'settings.general.migration.cli': 'This changes the desktop app only. A separately launched CLI keeps its current directory unless you set CLAUDE_CONFIG_DIR to the new location.', + 'settings.general.migration.openSource': 'Open Original Directory', + 'settings.general.migration.openTarget': 'Open New Directory', + 'settings.general.migration.environment': 'Remove CLAUDE_CONFIG_DIR from the launch environment before using in-app migration.', + 'settings.general.migration.statusError': 'Could not read migration progress. Reconnecting automatically…', + 'settings.general.migration.error': 'Data migration could not continue.', + // Settings > General 'settings.general.appearanceTitle': 'Appearance', 'settings.general.proxyManagedSettingsWarning': 'Your user settings contain only a PROXY_MANAGED proxy placeholder, so another proxy tool may have replaced the remaining settings. If this was not intentional, disable CC Switch routing takeover and restore settings.json from a backup. Otherwise, you can ignore this notice.', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 82e0fea7..46e571df 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -2308,6 +2308,39 @@ export const jp: Record = { 'settings.general.storageSwitchDefaultBody': '元に戻すと、デスクトップアプリは再び ~/.claude を使用します。カスタムディレクトリ内のデータは削除も自動移動もされません。', 'settings.general.storageSwitchRestartBody': 'アプリはローカルサーバーとアダプタープロセスを停止してから再起動します。新しいディレクトリは再起動後に有効になります。', + 'settings.general.migration.title': '既存データを移行', + 'settings.general.migration.description': '現在のセッション、設定、Skills、プラグイン、タスクをコピーして検証し、保存先を切り替えて再起動します。元のデータは保持されます。', + 'settings.general.migration.choose': '既存データを移行…', + 'settings.general.migration.chooseTitle': 'データの移行先を選択', + 'settings.general.migration.confirmTitle': 'データを移行して再起動しますか?', + 'settings.general.migration.confirm': '移行して再起動', + 'settings.general.migration.source': '元のディレクトリ', + 'settings.general.migration.target': '新しいディレクトリ', + 'settings.general.migration.preview': '{files} ファイル · {size}', + 'settings.general.migration.activeTasks': '実行中のタスク {count} 件を停止します。', + 'settings.general.migration.stopWarning': 'コピー前に実行中の会話とバックグラウンドタスクを中断して現在の状態を保存します。自動再開はしません。検証成功後にアプリが再起動します。', + 'settings.general.migration.externalWarning': '続行前に、元のディレクトリを使用している他の Claude Code または cc-haha プロセスを終了してください。アプリのインストール先以外の空のフォルダーを選んでください。', + 'settings.general.migration.running': 'データを移行中', + 'settings.general.migration.stagePreparing': '移行を準備中…', + 'settings.general.migration.stageQuiescing': 'タスクを停止してデータを保存中…', + 'settings.general.migration.stageCopying': 'データをコピー中…', + 'settings.general.migration.stageVerifying': 'コピーしたデータを検証中…', + 'settings.general.migration.stageCommitting': 'データの保存先を切り替え中…', + 'settings.general.migration.stageRestarting': 'アプリを再起動中…', + 'settings.general.migration.progress': '{files}/{totalFiles} ファイル · {bytes}/{totalBytes}', + 'settings.general.migration.cancel': '移行をキャンセル', + 'settings.general.migration.cancelling': '移行をキャンセル中…', + 'settings.general.migration.completed': 'データ移行が完了しました', + 'settings.general.migration.cancelled': '移行をキャンセルしました。元の保存先は変更されていません。', + 'settings.general.migration.failed': '移行に失敗しました。元のデータディレクトリは保持されています。', + 'settings.general.migration.retained': '元のディレクトリは保持されます。新しい保存先のセッションと設定を確認してから、元のデータを手動で整理してください。', + 'settings.general.migration.cli': 'この変更はデスクトップアプリに適用されます。別途起動する CLI は、CLAUDE_CONFIG_DIR を新しい保存先に設定しない限り、従来の保存先を使います。', + 'settings.general.migration.openSource': '元のディレクトリを開く', + 'settings.general.migration.openTarget': '新しいディレクトリを開く', + 'settings.general.migration.environment': 'アプリ内の移行を使うには、起動環境から CLAUDE_CONFIG_DIR を削除してください。', + 'settings.general.migration.statusError': '移行の進行状況を取得できません。自動的に再接続しています…', + 'settings.general.migration.error': 'データ移行を続行できませんでした。', + // Settings > General 'settings.general.appearanceTitle': '外観', 'settings.general.proxyManagedSettingsWarning': 'ユーザー設定が PROXY_MANAGED プロキシのプレースホルダーだけになっており、他の設定がプロキシツールに上書きされた可能性があります。意図した設定でなければ、CC Switch のルーティング引き継ぎを無効にして、バックアップから settings.json を復元してください。意図した設定なら、この通知は無視できます。', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index fc66f621..c7d24c2f 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -2310,6 +2310,39 @@ export const kr: Record = { 'settings.general.storageSwitchDefaultBody': '다시 전환하면 데스크톱 앱은 ~/.claude를 사용합니다. 사용자 지정 디렉터리의 데이터는 삭제되거나 자동으로 이동되지 않습니다.', 'settings.general.storageSwitchRestartBody': '앱이 로컬 서버와 어댑터 프로세스를 중지한 후 다시 시작합니다. 새 디렉터리는 다시 시작 후에 적용됩니다.', + 'settings.general.migration.title': '기존 데이터 이전', + 'settings.general.migration.description': '현재 세션, 설정, Skills, 플러그인과 작업을 복사하고 검증한 후 디렉터리를 전환하고 다시 시작합니다. 기존 데이터는 유지됩니다.', + 'settings.general.migration.choose': '기존 데이터 이전…', + 'settings.general.migration.chooseTitle': '데이터 이전 대상 선택', + 'settings.general.migration.confirmTitle': '데이터를 이전하고 다시 시작할까요?', + 'settings.general.migration.confirm': '이전 후 다시 시작', + 'settings.general.migration.source': '기존 디렉터리', + 'settings.general.migration.target': '새 디렉터리', + 'settings.general.migration.preview': '파일 {files}개 · {size}', + 'settings.general.migration.activeTasks': '실행 중인 작업 {count}개가 중지됩니다.', + 'settings.general.migration.stopWarning': '복사 전에 실행 중인 대화와 백그라운드 작업을 중단하고 현재 상태를 저장합니다. 자동으로 재개하지 않습니다. 검증이 완료되면 앱이 다시 시작됩니다.', + 'settings.general.migration.externalWarning': '계속하기 전에 기존 디렉터리를 사용하는 다른 Claude Code 또는 cc-haha 프로세스를 종료하세요. 앱 설치 디렉터리 밖의 빈 폴더를 선택하세요.', + 'settings.general.migration.running': '데이터 이전 중', + 'settings.general.migration.stagePreparing': '이전 준비 중…', + 'settings.general.migration.stageQuiescing': '작업 중지 및 데이터 저장 중…', + 'settings.general.migration.stageCopying': '데이터 복사 중…', + 'settings.general.migration.stageVerifying': '복사된 데이터 검증 중…', + 'settings.general.migration.stageCommitting': '데이터 디렉터리 전환 중…', + 'settings.general.migration.stageRestarting': '앱 다시 시작 중…', + 'settings.general.migration.progress': '파일 {files}/{totalFiles}개 · {bytes}/{totalBytes}', + 'settings.general.migration.cancel': '이전 취소', + 'settings.general.migration.cancelling': '이전 취소 중…', + 'settings.general.migration.completed': '데이터 이전 완료', + 'settings.general.migration.cancelled': '이전이 취소되었습니다. 기존 데이터 디렉터리는 변경되지 않았습니다.', + 'settings.general.migration.failed': '이전에 실패했습니다. 기존 데이터 디렉터리는 유지됩니다.', + 'settings.general.migration.retained': '기존 디렉터리는 유지됩니다. 새 디렉터리의 세션과 설정을 확인한 후 기존 데이터를 수동으로 정리하세요.', + 'settings.general.migration.cli': '이 변경은 데스크톱 앱에만 적용됩니다. 별도로 실행하는 CLI는 CLAUDE_CONFIG_DIR을 새 위치로 설정하지 않으면 기존 디렉터리를 계속 사용합니다.', + 'settings.general.migration.openSource': '기존 디렉터리 열기', + 'settings.general.migration.openTarget': '새 디렉터리 열기', + 'settings.general.migration.environment': '앱 내 이전을 사용하려면 시작 환경에서 CLAUDE_CONFIG_DIR을 제거하세요.', + 'settings.general.migration.statusError': '이전 진행 상황을 읽을 수 없습니다. 자동으로 다시 연결하는 중…', + 'settings.general.migration.error': '데이터 이전을 계속할 수 없습니다.', + // Settings > General 'settings.general.appearanceTitle': '모양', 'settings.general.proxyManagedSettingsWarning': '사용자 설정에 PROXY_MANAGED 프록시 자리표시자만 남아 있어 다른 설정이 프록시 도구에 의해 덮어쓰였을 수 있습니다. 의도한 설정이 아니라면 CC Switch 라우팅 인계를 끄고 백업에서 settings.json을 복원하세요. 직접 설정한 경우에는 이 알림을 무시해도 됩니다.', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 708de84f..5d759a10 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -2307,6 +2307,39 @@ export const zh: Record = { 'settings.general.storageSwitchDefaultBody': '切回系統目錄後,桌面端會重新使用 ~/.claude。當前自訂目錄中的資料不會被刪除,也不會自動遷回。', 'settings.general.storageSwitchRestartBody': '應用將先關閉本地服務和介面卡程序,然後自動重啟。重啟後新目錄才會生效。', + 'settings.general.migration.title': '遷移現有資料', + 'settings.general.migration.description': '複製並驗證目前的對話、設定、Skills、外掛和任務,然後切換目錄並重啟。原目錄資料會保留。', + 'settings.general.migration.choose': '遷移現有資料…', + 'settings.general.migration.chooseTitle': '選擇資料遷移目標目錄', + 'settings.general.migration.confirmTitle': '遷移資料並重啟?', + 'settings.general.migration.confirm': '遷移並重啟', + 'settings.general.migration.source': '原目錄', + 'settings.general.migration.target': '目標目錄', + 'settings.general.migration.preview': '{files} 個檔案 · {size}', + 'settings.general.migration.activeTasks': '將停止 {count} 個正在執行的任務。', + 'settings.general.migration.stopWarning': '複製前會中斷正在執行的對話和背景任務並儲存目前狀態,不會自動繼續。驗證成功後應用會自動重啟。', + 'settings.general.migration.externalWarning': '請先關閉正在使用原目錄的其他 Claude Code 或 cc-haha 程序。目標應為空目錄,且不能位於應用安裝目錄內。', + 'settings.general.migration.running': '正在遷移資料', + 'settings.general.migration.stagePreparing': '正在準備遷移…', + 'settings.general.migration.stageQuiescing': '正在停止任務並儲存資料…', + 'settings.general.migration.stageCopying': '正在複製資料…', + 'settings.general.migration.stageVerifying': '正在驗證複製的資料…', + 'settings.general.migration.stageCommitting': '正在切換資料目錄…', + 'settings.general.migration.stageRestarting': '正在重啟應用…', + 'settings.general.migration.progress': '{files}/{totalFiles} 個檔案 · {bytes}/{totalBytes}', + 'settings.general.migration.cancel': '取消遷移', + 'settings.general.migration.cancelling': '正在取消遷移…', + 'settings.general.migration.completed': '資料遷移完成', + 'settings.general.migration.cancelled': '遷移已取消,原資料目錄未變。', + 'settings.general.migration.failed': '遷移失敗,已保留原資料目錄。', + 'settings.general.migration.retained': '原目錄已保留。請確認新目錄中的對話和設定正常,再手動清理原資料。', + 'settings.general.migration.cli': '此操作只切換桌面端。獨立啟動的 CLI 仍使用原來的目錄,需自行設定 CLAUDE_CONFIG_DIR 才會使用新位置。', + 'settings.general.migration.openSource': '開啟原目錄', + 'settings.general.migration.openTarget': '開啟目標目錄', + 'settings.general.migration.environment': '請先移除啟動環境中的 CLAUDE_CONFIG_DIR,再使用應用內遷移。', + 'settings.general.migration.statusError': '暫時無法讀取遷移進度,正在自動重連…', + 'settings.general.migration.error': '資料遷移未能繼續。', + // Settings > General 'settings.general.appearanceTitle': '配色主題', 'settings.general.proxyManagedSettingsWarning': '偵測到使用者設定只剩 PROXY_MANAGED 代理占位資訊,其他設定可能被代理工具覆寫。若這不是你的主動設定,請先關閉 CC Switch 路由接管並從備份還原 settings.json;主動使用此設定可忽略。', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index a06a810c..77461eda 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -2306,6 +2306,39 @@ export const zh: Record = { 'settings.general.storageSwitchDefaultBody': '切回系统目录后,桌面端会重新使用 ~/.claude。当前自定义目录中的数据不会被删除,也不会自动迁回。', 'settings.general.storageSwitchRestartBody': '应用将先关闭本地服务和适配器进程,然后自动重启。重启后新目录才会生效。', + 'settings.general.migration.title': '迁移现有数据', + 'settings.general.migration.description': '复制并校验当前会话、配置、Skills、插件和任务,然后切换目录并重启。原目录数据会保留。', + 'settings.general.migration.choose': '迁移现有数据…', + 'settings.general.migration.chooseTitle': '选择数据迁移目标目录', + 'settings.general.migration.confirmTitle': '迁移数据并重启?', + 'settings.general.migration.confirm': '迁移并重启', + 'settings.general.migration.source': '原目录', + 'settings.general.migration.target': '目标目录', + 'settings.general.migration.preview': '{files} 个文件 · {size}', + 'settings.general.migration.activeTasks': '将停止 {count} 个正在运行的任务。', + 'settings.general.migration.stopWarning': '复制前会中断正在运行的对话和后台任务并保存当前状态,不会自动续跑。校验成功后应用会自动重启。', + 'settings.general.migration.externalWarning': '请先关闭正在使用原目录的其他 Claude Code 或 cc-haha 进程。目标应为空目录,且不能位于应用安装目录内。', + 'settings.general.migration.running': '正在迁移数据', + 'settings.general.migration.stagePreparing': '正在准备迁移…', + 'settings.general.migration.stageQuiescing': '正在停止任务并保存数据…', + 'settings.general.migration.stageCopying': '正在复制数据…', + 'settings.general.migration.stageVerifying': '正在校验复制的数据…', + 'settings.general.migration.stageCommitting': '正在切换数据目录…', + 'settings.general.migration.stageRestarting': '正在重启应用…', + 'settings.general.migration.progress': '{files}/{totalFiles} 个文件 · {bytes}/{totalBytes}', + 'settings.general.migration.cancel': '取消迁移', + 'settings.general.migration.cancelling': '正在取消迁移…', + 'settings.general.migration.completed': '数据迁移完成', + 'settings.general.migration.cancelled': '迁移已取消,原数据目录未变。', + 'settings.general.migration.failed': '迁移失败,已保留原数据目录。', + 'settings.general.migration.retained': '原目录已保留。请确认新目录中的会话和配置正常,再手动清理原数据。', + 'settings.general.migration.cli': '此操作只切换桌面端。独立启动的 CLI 仍使用原来的目录,需自行设置 CLAUDE_CONFIG_DIR 才会使用新位置。', + 'settings.general.migration.openSource': '打开原目录', + 'settings.general.migration.openTarget': '打开目标目录', + 'settings.general.migration.environment': '请先移除启动环境中的 CLAUDE_CONFIG_DIR,再使用应用内迁移。', + 'settings.general.migration.statusError': '暂时无法读取迁移进度,正在自动重连…', + 'settings.general.migration.error': '数据迁移未能继续。', + // Settings > General 'settings.general.appearanceTitle': '配色主题', 'settings.general.proxyManagedSettingsWarning': '检测到用户配置仅剩 PROXY_MANAGED 代理占位信息,其他设置可能被代理工具覆盖。若这不是你的主动配置,请先关闭 CC Switch 路由接管并从备份恢复 settings.json;主动使用该配置可忽略。', diff --git a/desktop/src/lib/attachmentImages.test.ts b/desktop/src/lib/attachmentImages.test.ts index ab4d94bd..7dd7d088 100644 --- a/desktop/src/lib/attachmentImages.test.ts +++ b/desktop/src/lib/attachmentImages.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it } from 'vitest' import { setBaseUrl } from '../api/client' -import { attachmentImageSource, isInlineImagePath, localImageFileUrl } from './attachmentImages' +import { attachmentImageSource, isInlineImagePath, isManagedGeneratedImagePath, localImageFileUrl } from './attachmentImages' describe('attachment image sources', () => { + it('recognizes managed image results in a relocated data directory', () => { + expect(isManagedGeneratedImagePath('D:\\Haha Data\\cc-haha\\generated-images\\session\\image.png')).toBe(true) + expect(isManagedGeneratedImagePath('/Volumes/Data/haha/cc-haha/generated-images/session/image.png')).toBe(true) + expect(isManagedGeneratedImagePath('/project/generated-images/image.png')).toBe(false) + }) + it('recognizes the extensions the server inlines as images', () => { expect(isInlineImagePath('/Users/nanmi/Desktop/a.png')).toBe(true) expect(isInlineImagePath('/Users/nanmi/Desktop/a.JPG')).toBe(true) diff --git a/desktop/src/lib/attachmentImages.ts b/desktop/src/lib/attachmentImages.ts index a5d27566..5b166611 100644 --- a/desktop/src/lib/attachmentImages.ts +++ b/desktop/src/lib/attachmentImages.ts @@ -18,7 +18,7 @@ export function isInlineImagePath(pathOrName: string | undefined): boolean { /** Host-managed ImageGen results already render through their dedicated result card. */ export function isManagedGeneratedImagePath(filePath: string): boolean { - return filePath.replaceAll('\\', '/').includes('/.claude/cc-haha/generated-images/') + return /(?:^|\/)cc-haha\/generated-images\//.test(filePath.replaceAll('\\', '/')) } /** Serves a local absolute image path through the local server. */ diff --git a/desktop/src/lib/desktopHost/browserHost.ts b/desktop/src/lib/desktopHost/browserHost.ts index e63b27dd..009f66e7 100644 --- a/desktop/src/lib/desktopHost/browserHost.ts +++ b/desktop/src/lib/desktopHost/browserHost.ts @@ -355,6 +355,13 @@ export const browserHost: DesktopHost = { async restart() { unsupported('Desktop app restart') }, + migration: { + async prepare() { return unsupported('Data migration') }, + async start() { unsupported('Data migration') }, + async status() { return null }, + async cancel() { unsupported('Data migration') }, + async onProgress() { return noopUnlisten }, + }, }, adapters: { async restartSidecar() { diff --git a/desktop/src/lib/desktopHost/contract.test.ts b/desktop/src/lib/desktopHost/contract.test.ts index 1f3b35ac..95196ece 100644 --- a/desktop/src/lib/desktopHost/contract.test.ts +++ b/desktop/src/lib/desktopHost/contract.test.ts @@ -23,6 +23,11 @@ describe('desktop host contract', () => { }) it('rejects desktop-only browser calls with actionable errors', async () => { + await expect(browserHost.appMode.migration.prepare('/fixture/new')).rejects.toThrow('desktop app runtime') + await expect(browserHost.appMode.migration.start('migration-1')).rejects.toThrow('desktop app runtime') + await expect(browserHost.appMode.migration.cancel('migration-1')).rejects.toThrow('desktop app runtime') + await expect(browserHost.appMode.migration.status()).resolves.toBeNull() + await expect(browserHost.appMode.migration.onProgress(vi.fn())).resolves.toEqual(expect.any(Function)) await expect(browserHost.runtime.getServerUrl()).rejects.toThrow('desktop app runtime') await expect(browserHost.runtime.getLocalAccessToken()).rejects.toThrow('desktop app runtime') await expect(browserHost.dialogs.open({ directory: true })).rejects.toThrow('desktop app runtime') diff --git a/desktop/src/lib/desktopHost/electronHost.test.ts b/desktop/src/lib/desktopHost/electronHost.test.ts index fa1eb64c..b00afd4f 100644 --- a/desktop/src/lib/desktopHost/electronHost.test.ts +++ b/desktop/src/lib/desktopHost/electronHost.test.ts @@ -5,6 +5,29 @@ import { createElectronHost } from './electronHost' import { PUBLIC_ACCESS_CONSENT_VERSION, type WorkspaceBrowserMenuOptions } from './types' describe('electron desktop host', () => { + it('routes migration preview, background start, status, cancellation and progress over narrow IPC', async () => { + const invoke = vi.fn().mockResolvedValue(null) + const unlisten = vi.fn() + const subscribe = vi.fn().mockResolvedValue(unlisten) + const host = createElectronHost({ invoke, subscribe }) + await host.appMode.migration.prepare('D:\\cc-haha-data') + await host.appMode.migration.start('migration-1') + await host.appMode.migration.status() + await host.appMode.migration.cancel('migration-1') + expect(invoke.mock.calls).toEqual([ + [ELECTRON_IPC_CHANNELS.migrationPrepare, { targetDir: 'D:\\cc-haha-data' }], + [ELECTRON_IPC_CHANNELS.migrationStart, { id: 'migration-1' }], + [ELECTRON_IPC_CHANNELS.migrationStatus, undefined], + [ELECTRON_IPC_CHANNELS.migrationCancel, { id: 'migration-1' }], + ]) + const handler = vi.fn() + expect(await host.appMode.migration.onProgress(handler)).toBe(unlisten) + expect(subscribe).toHaveBeenCalledWith(ELECTRON_EVENT_CHANNELS.migrationProgress, handler) + await expect(host.appMode.migration.start('')).rejects.toThrow('Invalid Electron IPC payload') + await expect(host.appMode.migration.prepare('bad\u0000path')).rejects.toThrow('Invalid Electron IPC payload') + expect(invoke).toHaveBeenCalledTimes(4) + }) + it('routes public access through validated local IPC without exposing management in browsers', async () => { const invoke = vi.fn().mockResolvedValue({ hasCredential: true }) const host = createElectronHost({ invoke, subscribe: vi.fn() }) diff --git a/desktop/src/lib/desktopHost/electronHost.ts b/desktop/src/lib/desktopHost/electronHost.ts index a23e0f4a..d29e6fea 100644 --- a/desktop/src/lib/desktopHost/electronHost.ts +++ b/desktop/src/lib/desktopHost/electronHost.ts @@ -235,6 +235,13 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost { set: config => invoke(ELECTRON_IPC_CHANNELS.appModeSet, config), prepareRestart: () => invoke(ELECTRON_IPC_CHANNELS.appModePrepareRestart), restart: () => invoke(ELECTRON_IPC_CHANNELS.appModeRestart), + migration: { + prepare: targetDir => invoke(ELECTRON_IPC_CHANNELS.migrationPrepare, { targetDir }), + start: id => invoke(ELECTRON_IPC_CHANNELS.migrationStart, { id }), + status: () => invoke(ELECTRON_IPC_CHANNELS.migrationStatus), + cancel: id => invoke(ELECTRON_IPC_CHANNELS.migrationCancel, { id }), + onProgress: handler => subscribe(ELECTRON_EVENT_CHANNELS.migrationProgress, handler), + }, }, adapters: { restartSidecar: () => invoke(ELECTRON_IPC_CHANNELS.adaptersRestartSidecar), diff --git a/desktop/src/lib/desktopHost/types.ts b/desktop/src/lib/desktopHost/types.ts index 5b086f45..7fc8fe6b 100644 --- a/desktop/src/lib/desktopHost/types.ts +++ b/desktop/src/lib/desktopHost/types.ts @@ -370,6 +370,28 @@ export type AppModeSetInput = { portableDir: string | null } +export type MigrationPreview = { + id: string + sourceDir: string + targetDir: string + files: number + bytes: number + activeTasks: number +} + +export type MigrationStatus = { + id: string + sourceDir: string + targetDir: string + stage: 'preparing' | 'quiescing' | 'copying' | 'verifying' | 'committing' | 'restarting' | 'completed' | 'cancelled' | 'failed' + files: number + totalFiles: number + bytes: number + totalBytes: number + error?: string + cancellable: boolean +} + export type DesktopPublicAccessStatus = { state: 'unconfigured' | 'disabled' | 'connecting' | 'online' | 'reconnecting' | 'failed' hasCredential: boolean @@ -529,6 +551,13 @@ export type DesktopHost = { set(config: AppModeSetInput): Promise prepareRestart(): Promise restart(): Promise + migration: { + prepare(targetDir: string): Promise + start(id: string): Promise + status(): Promise + cancel(id: string): Promise + onProgress(handler: (status: MigrationStatus) => void): Promise + } } adapters: { restartSidecar(): Promise diff --git a/desktop/src/pages/settings/DataMigrationSettings.test.tsx b/desktop/src/pages/settings/DataMigrationSettings.test.tsx new file mode 100644 index 00000000..eacd1fb8 --- /dev/null +++ b/desktop/src/pages/settings/DataMigrationSettings.test.tsx @@ -0,0 +1,215 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import '@testing-library/jest-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { browserHost } from '@/lib/desktopHost/browserHost' +import type { DesktopHost, MigrationPreview, MigrationStatus } from '@/lib/desktopHost/types' +import { useSettingsStore } from '@/stores/settingsStore' +import { DataMigrationSettings } from './DataMigrationSettings' + +const preview: MigrationPreview = { + id: 'migration-fixture', sourceDir: '/fixture/original', targetDir: '/fixture/new', files: 4, bytes: 1024, activeTasks: 2, +} +const copying: MigrationStatus = { + id: preview.id, sourceDir: preview.sourceDir, targetDir: preview.targetDir, + stage: 'copying', files: 2, totalFiles: 4, bytes: 512, totalBytes: 1024, cancellable: true, +} + +let host: DesktopHost +let progressListener: ((status: MigrationStatus) => void) | undefined +const prepare = vi.fn() +const start = vi.fn() +const status = vi.fn() +const cancel = vi.fn() +const unlisten = vi.fn() +const open = vi.fn() +const openPath = vi.fn() +const setAppMode = vi.fn() +const onProgress = vi.fn() + +beforeEach(() => { + vi.resetAllMocks() + progressListener = undefined + useSettingsStore.setState({ locale: 'en' }) + prepare.mockResolvedValue(preview) + start.mockResolvedValue(undefined) + status.mockResolvedValue(null) + cancel.mockResolvedValue(undefined) + open.mockResolvedValue(preview.targetDir) + openPath.mockResolvedValue(undefined) + onProgress.mockImplementation(async (listener: (value: MigrationStatus) => void) => { + progressListener = listener + return unlisten + }) + host = { + ...browserHost, + kind: 'electron', + isDesktop: true, + capabilities: { ...browserHost.capabilities, appMode: true, dialogs: true, shell: true }, + dialogs: { ...browserHost.dialogs, open }, + shell: { ...browserHost.shell, openPath }, + appMode: { ...browserHost.appMode, set: setAppMode, migration: { prepare, start, status, cancel, onProgress } }, + } + window.desktopHost = host +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() + Reflect.deleteProperty(window, 'desktopHost') + useSettingsStore.setState(useSettingsStore.getInitialState(), true) +}) + +async function chooseAndPreview() { + fireEvent.click(screen.getByRole('button', { name: 'Migrate Existing Data…' })) + return screen.findByRole('dialog', { name: 'Migrate data and restart?' }) +} + +describe('Data migration settings', () => { + it('uses the native picker, previews exact paths and task impact, then starts only after confirmation', async () => { + const busy = vi.fn() + render() + const dialog = await chooseAndPreview() + expect(open).toHaveBeenCalledWith({ directory: true, multiple: false, title: 'Choose a migration destination' }) + expect(prepare).toHaveBeenCalledWith(preview.targetDir) + expect(within(dialog).getByText(preview.sourceDir)).toBeInTheDocument() + expect(within(dialog).getByText(preview.targetDir)).toBeInTheDocument() + expect(within(dialog).getByText('4 files · 1 KB')).toBeInTheDocument() + expect(within(dialog).getByText('2 running tasks will be stopped.')).toBeInTheDocument() + expect(within(dialog).getByText(/Close other Claude Code/)).toBeInTheDocument() + expect(start).not.toHaveBeenCalled() + expect(setAppMode).not.toHaveBeenCalled() + + fireEvent.click(within(dialog).getByRole('button', { name: 'Migrate and Restart' })) + await waitFor(() => expect(start).toHaveBeenCalledWith(preview.id)) + expect(screen.getByRole('dialog', { name: 'Data migration in progress' })).toBeInTheDocument() + expect(busy).toHaveBeenLastCalledWith(true) + expect(setAppMode).not.toHaveBeenCalled() + }) + + it('allows cancellation of the preview without stopping tasks or changing the mode', async () => { + render() + const dialog = await chooseAndPreview() + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(start).not.toHaveBeenCalled() + expect(cancel).not.toHaveBeenCalled() + expect(setAppMode).not.toHaveBeenCalled() + }) + + it('does not prepare a migration when the native picker is cancelled', async () => { + open.mockResolvedValue(null) + render() + fireEvent.click(screen.getByRole('button', { name: 'Migrate Existing Data…' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Migrate Existing Data…' })).not.toBeDisabled()) + expect(prepare).not.toHaveBeenCalled() + expect(start).not.toHaveBeenCalled() + }) + + it('shows preflight rejection and keeps migration and mode changes unstarted', async () => { + prepare.mockRejectedValue(new Error('Target directory must be empty')) + render() + fireEvent.click(screen.getByRole('button', { name: 'Migrate Existing Data…' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Target directory must be empty') + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(start).not.toHaveBeenCalled() + expect(setAppMode).not.toHaveBeenCalled() + }) + + it('disables migration for externally controlled roots and hides it in browsers', () => { + const { unmount } = render() + expect(screen.getByRole('button', { name: 'Migrate Existing Data…' })).toBeDisabled() + expect(screen.getByText(/Remove CLAUDE_CONFIG_DIR/)).toBeInTheDocument() + expect(open).not.toHaveBeenCalled() + unmount() + Reflect.deleteProperty(window, 'desktopHost') + render() + expect(screen.queryByText('Migrate existing data')).not.toBeInTheDocument() + }) + + it('restores a running job on mount, shows progress and blocks dismissal while committing', async () => { + status.mockResolvedValue(copying) + render() + const dialog = await screen.findByRole('dialog', { name: 'Data migration in progress' }) + expect(within(dialog).getByRole('progressbar', { name: 'Copying data…' })).toHaveAttribute('aria-valuenow', '50') + expect(screen.getByRole('button', { name: 'Migrate Existing Data…' })).toBeDisabled() + await act(async () => progressListener?.({ ...copying, stage: 'verifying', files: 4, bytes: 1024 })) + expect(within(dialog).getByRole('progressbar', { name: 'Verifying copied data…' })).not.toHaveAttribute('aria-valuenow') + await act(async () => progressListener?.({ ...copying, stage: 'committing', cancellable: false })) + expect(within(dialog).getByRole('button', { name: 'Cancel migration' })).toBeDisabled() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(dialog).toBeInTheDocument() + expect(cancel).not.toHaveBeenCalled() + }) + + it('requests cancellation once and waits for the host terminal status before restoring controls', async () => { + status.mockResolvedValue(copying) + const busy = vi.fn() + render() + const dialog = await screen.findByRole('dialog', { name: 'Data migration in progress' }) + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel migration' })) + await waitFor(() => expect(cancel).toHaveBeenCalledWith(copying.id)) + expect(within(dialog).getByRole('button', { name: 'Cancel migration' })).toBeDisabled() + expect(dialog).toBeInTheDocument() + await act(async () => progressListener?.({ ...copying, stage: 'cancelled', cancellable: false })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.getByText(/Migration cancelled/)).toBeInTheDocument() + expect(busy).toHaveBeenLastCalledWith(false) + }) + + it('ignores a stale initial poll after a newer progress event', async () => { + let resolveStatus!: (value: MigrationStatus | null) => void + status.mockImplementationOnce(() => new Promise(resolve => { resolveStatus = resolve })) + render() + await act(async () => progressListener?.(copying)) + await act(async () => resolveStatus(null)) + expect(screen.getByRole('dialog', { name: 'Data migration in progress' })).toBeInTheDocument() + }) + + it('keeps the migration barrier when the start response and status connection both disconnect', async () => { + start.mockRejectedValue(new Error('IPC connection lost')) + status.mockRejectedValue(new Error('IPC connection lost')) + render() + const dialog = await chooseAndPreview() + fireEvent.click(within(dialog).getByRole('button', { name: 'Migrate and Restart' })) + expect(await screen.findByRole('alert')).toHaveTextContent('IPC connection lost') + expect(screen.getByRole('dialog', { name: 'Data migration in progress' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Migrate Existing Data…' })).toBeDisabled() + expect(setAppMode).not.toHaveBeenCalled() + }) + + it('polls after the event connection fails and cleans up polling on unmount', async () => { + vi.useFakeTimers() + onProgress.mockRejectedValue(new Error('event transport unavailable')) + status.mockResolvedValueOnce(null).mockResolvedValue(copying) + const { unmount } = render() + await act(async () => {}) + await act(async () => { vi.advanceTimersByTime(1_000) }) + expect(screen.getByRole('dialog', { name: 'Data migration in progress' })).toBeInTheDocument() + expect(status).toHaveBeenCalledTimes(2) + unmount() + await act(async () => { vi.advanceTimersByTime(2_000) }) + expect(status).toHaveBeenCalledTimes(2) + }) + + it('recovers a completion receipt after restart and offers original/new folders and CLI guidance', async () => { + status.mockResolvedValue({ ...copying, stage: 'completed', cancellable: false }) + const { unmount } = render() + expect(await screen.findByText('Data migration completed')).toBeInTheDocument() + expect(screen.getByText(/before manually cleaning up/)).toBeInTheDocument() + expect(screen.getByText(/A separately launched CLI/)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Open Original Directory' })) + fireEvent.click(screen.getByRole('button', { name: 'Open New Directory' })) + await waitFor(() => expect(openPath.mock.calls).toEqual([[preview.sourceDir], [preview.targetDir]])) + unmount() + expect(unlisten).toHaveBeenCalledTimes(1) + }) + + it('shows a failed background job without changing the original mode', async () => { + status.mockResolvedValue({ ...copying, stage: 'failed', error: 'Source changed during copying', cancellable: false }) + render() + expect(await screen.findByRole('alert')).toHaveTextContent('Source changed during copying') + expect(screen.getByText('Migration failed. The original data directory is kept.')).toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(setAppMode).not.toHaveBeenCalled() + }) +}) diff --git a/desktop/src/pages/settings/DataMigrationSettings.tsx b/desktop/src/pages/settings/DataMigrationSettings.tsx new file mode 100644 index 00000000..1ea1e8e7 --- /dev/null +++ b/desktop/src/pages/settings/DataMigrationSettings.tsx @@ -0,0 +1,275 @@ +import { useEffect, useRef, useState } from 'react' +import { FolderInput } from 'lucide-react' +import { Button } from '@/components/ui/Button' +import { ConfirmDialog } from '@/components/ui/ConfirmDialog' +import { Modal } from '@/components/ui/Modal' +import { Progress } from '@/components/ui/Progress' +import { useTranslation, type TranslationKey } from '@/i18n' +import { getDesktopHost, type MigrationPreview, type MigrationStatus } from '@/lib/desktopHost' +import { formatBytes } from '@/lib/formatBytes' + +const STAGE_LABELS = { + preparing: 'settings.general.migration.stagePreparing', + quiescing: 'settings.general.migration.stageQuiescing', + copying: 'settings.general.migration.stageCopying', + verifying: 'settings.general.migration.stageVerifying', + committing: 'settings.general.migration.stageCommitting', + restarting: 'settings.general.migration.stageRestarting', + completed: 'settings.general.migration.completed', + cancelled: 'settings.general.migration.cancelled', + failed: 'settings.general.migration.failed', +} satisfies Record + +function isActive(status: MigrationStatus | null): boolean { + return status !== null && !['completed', 'cancelled', 'failed'].includes(status.stage) +} + +type DataMigrationSettingsProps = { + environmentControlled: boolean + disabled?: boolean + onBusyChange?: (busy: boolean) => void +} + +export function DataMigrationSettings({ environmentControlled, disabled = false, onBusyChange }: DataMigrationSettingsProps) { + const t = useTranslation() + const host = getDesktopHost() + const [preview, setPreview] = useState(null) + const [status, setStatus] = useState(null) + const [preparing, setPreparing] = useState(false) + const [starting, setStarting] = useState(false) + const [cancelling, setCancelling] = useState(false) + const [error, setError] = useState(null) + const [statusError, setStatusError] = useState(null) + const statusRef = useRef(status) + const revisionRef = useRef(0) + statusRef.current = status + const active = isActive(status) + const busy = preparing || starting || active + + useEffect(() => { + onBusyChange?.(busy) + }, [busy, onBusyChange]) + + useEffect(() => { + if (!host.isDesktop || !host.capabilities.appMode) return + let disposed = false + let polling = false + let unlisten: (() => void) | undefined + const accept = (next: MigrationStatus | null) => { + if (disposed) return + setStatus(previous => JSON.stringify(previous) === JSON.stringify(next) ? previous : next) + setStatusError(null) + if (next?.stage === 'completed') setError(null) + if (!isActive(next)) setCancelling(false) + } + const poll = async () => { + if (polling || disposed) return + polling = true + const before = revisionRef.current + try { + const next = await host.appMode.migration.status() + // A late poll must not overwrite a newer progress event. + if (before === revisionRef.current) accept(next) + } catch { + if (!disposed && isActive(statusRef.current)) { + setStatusError(t('settings.general.migration.statusError')) + } + } finally { + polling = false + } + } + void host.appMode.migration.onProgress(next => { + revisionRef.current += 1 + accept(next) + }).then(stop => { + if (disposed) stop() + else unlisten = stop + }).catch(() => { /* Polling also supports hosts whose event stream disconnected. */ }) + void poll() + const timer = window.setInterval(() => void poll(), 1_000) + return () => { + disposed = true + window.clearInterval(timer) + unlisten?.() + } + }, [host, t]) + + if (!host.isDesktop || !host.capabilities.appMode) return null + + const chooseTarget = async () => { + if (environmentControlled || disabled || busy) return + setError(null) + setPreparing(true) + try { + const selected = await host.dialogs.open({ + directory: true, + multiple: false, + title: t('settings.general.migration.chooseTitle'), + }) + if (typeof selected !== 'string') return + setPreview(await host.appMode.migration.prepare(selected)) + } catch (cause) { + setError(cause instanceof Error ? cause.message : t('settings.general.migration.error')) + } finally { + setPreparing(false) + } + } + + const start = async () => { + if (!preview || starting) return + const selected = preview + setStarting(true) + setError(null) + setPreview(null) + revisionRef.current += 1 + setStatus({ + id: selected.id, + sourceDir: selected.sourceDir, + targetDir: selected.targetDir, + stage: 'quiescing', + files: 0, + totalFiles: selected.files, + bytes: 0, + totalBytes: selected.bytes, + cancellable: true, + }) + try { + await host.appMode.migration.start(selected.id) + } catch (cause) { + setError(cause instanceof Error ? cause.message : t('settings.general.migration.error')) + try { + setStatus(await host.appMode.migration.status()) + } catch { + // The start request may have reached the host before IPC disconnected. + // Keep the progress barrier until polling establishes the actual result. + setStatusError(t('settings.general.migration.statusError')) + } + } finally { + setStarting(false) + } + } + + const cancel = async () => { + if (!status?.cancellable || cancelling) return + setCancelling(true) + setError(null) + try { + await host.appMode.migration.cancel(status.id) + } catch (cause) { + setError(cause instanceof Error ? cause.message : t('settings.general.migration.error')) + setCancelling(false) + } + } + + const openPath = async (directory: string) => { + try { + await host.shell.openPath(directory) + } catch (cause) { + setError(cause instanceof Error ? cause.message : t('settings.general.migration.error')) + } + } + + const directories = (sourceDir: string, targetDir: string) => ( +
+
+
{t('settings.general.migration.source')}
+
{sourceDir}
+
+
+
{t('settings.general.migration.target')}
+
{targetDir}
+
+
+ ) + const progress = status && status.totalBytes > 0 ? status.bytes / status.totalBytes * 100 : 0 + + return ( +
+
{t('settings.general.migration.title')}
+

{t('settings.general.migration.description')}

+ + {environmentControlled && ( +

{t('settings.general.migration.environment')}

+ )} + {error && !active &&

{error}

} + {status && !active && ( +
+

{t(STAGE_LABELS[status.stage])}

+ {status.error &&

{status.error}

} + {directories(status.sourceDir, status.targetDir)} + {status.stage === 'completed' && ( + <> +

{t('settings.general.migration.retained')}

+

{t('settings.general.migration.cli')}

+
+ + +
+ + )} +
+ )} + { if (!starting) setPreview(null) }} + onConfirm={start} + title={t('settings.general.migration.confirmTitle')} + confirmLabel={t('settings.general.migration.confirm')} + cancelLabel={t('common.cancel')} + confirmVariant="primary" + loading={starting} + body={preview && ( +
+ {directories(preview.sourceDir, preview.targetDir)} +

{t('settings.general.migration.preview', { files: preview.files, size: formatBytes(preview.bytes) })}

+ {preview.activeTasks > 0 &&

{t('settings.general.migration.activeTasks', { count: preview.activeTasks })}

} +

{t('settings.general.migration.stopWarning')}

+

{t('settings.general.migration.externalWarning')}

+

{t('settings.general.migration.retained')}

+
+ )} + /> + {}} + title={t('settings.general.migration.running')} + width={500} + footer={( + + )} + > + {status && ( +
+

{cancelling ? t('settings.general.migration.cancelling') : t(STAGE_LABELS[status.stage])}

+ +

{t('settings.general.migration.progress', { + files: status.files, + totalFiles: status.totalFiles, + bytes: formatBytes(status.bytes), + totalBytes: formatBytes(status.totalBytes), + })}

+ {directories(status.sourceDir, status.targetDir)} + {(error || statusError) &&

{error || statusError}

} +
+ )} +
+
+ ) +} diff --git a/desktop/src/pages/settings/GeneralSettings.tsx b/desktop/src/pages/settings/GeneralSettings.tsx index 33c4419d..9599c62c 100644 --- a/desktop/src/pages/settings/GeneralSettings.tsx +++ b/desktop/src/pages/settings/GeneralSettings.tsx @@ -36,6 +36,7 @@ import { isTouchH5Document } from '../../lib/touchH5' import { MODEL_REASONING_EFFORTS } from '../../../../src/shared/modelReasoning' import { AUTO_QUESTION_TIMEOUT_OPTIONS } from '../../../../src/shared/autoQuestionSettings' import { ChatAppearanceSettings } from './ChatAppearanceSettings' +import { DataMigrationSettings } from './DataMigrationSettings' /** * The General settings panel — the largest of the seven, and the one most often @@ -141,6 +142,7 @@ export function GeneralSettings() { const [pendingMode, setPendingMode] = useState(null) const [pendingPortableDir, setPendingPortableDir] = useState(null) const [portableDirDraft, setPortableDirDraft] = useState('') + const [migrationRunning, setMigrationRunning] = useState(false) const [modeActionRunning, setModeActionRunning] = useState(false) const [modeError, setModeError] = useState(null) const [uiZoomDraft, setUiZoomDraft] = useState(uiZoom) @@ -1662,6 +1664,7 @@ export function GeneralSettings() {
@@ -1729,7 +1734,7 @@ export function GeneralSettings() { type="button" size="sm" variant="secondary" - disabled={modeActionRunning || (appMode.mode === 'portable' && portableDirDraft.trim() === (appMode.portableDir ?? ''))} + disabled={modeActionRunning || migrationRunning || (appMode.mode === 'portable' && portableDirDraft.trim() === (appMode.portableDir ?? ''))} onClick={() => openModeSwitchConfirm('portable')} > {t('settings.general.storageApplyPortable')} @@ -1761,6 +1766,12 @@ export function GeneralSettings() { {t('settings.general.storageMoveHint')} + + {modeError && (
{modeError} diff --git a/scripts/quality-gate/persistence-upgrade.ts b/scripts/quality-gate/persistence-upgrade.ts index 5b4ff910..c574d229 100644 --- a/scripts/quality-gate/persistence-upgrade.ts +++ b/scripts/quality-gate/persistence-upgrade.ts @@ -8,6 +8,15 @@ type Check = { const rootDir = process.cwd() const checks: Check[] = [ + { + title: 'Data directory relocation metadata and legacy attachment aliases', + command: ['bun', 'test', './src/utils/storageMigrationMetadata.test.ts', './src/utils/storageRelocations.test.ts'], + }, + { + title: 'Desktop data migration journal recovery and credential namespace copy', + command: ['bun', 'run', 'test', '--', '--run', 'electron/services/dataMigration.test.ts', 'electron/services/migrationCredentials.test.ts'], + cwd: 'desktop', + }, { title: 'Agent Teams plan sidecar compatibility and approval recovery', command: ['bun', 'test', './src/utils/swarm/teamPlanStore.test.ts', './src/server/services/teamPlanService.test.ts'], diff --git a/src/server/__tests__/conversation-attachments.test.ts b/src/server/__tests__/conversation-attachments.test.ts index e514a129..e6ebf076 100644 --- a/src/server/__tests__/conversation-attachments.test.ts +++ b/src/server/__tests__/conversation-attachments.test.ts @@ -59,6 +59,19 @@ afterEach(async () => { }) describe('ConversationService attachment materialization', () => { + test('replays a managed image path after migration without needing the original root', async () => { + const originalRoot = path.join(tmpDir, 'absent-root') + const currentPath = path.join(tmpDir, 'uploads', 'old-session', 'fixture.png') + await fs.mkdir(path.dirname(currentPath), { recursive: true }) + await fs.writeFile(currentPath, Buffer.from('original-image')) + await fs.mkdir(path.join(tmpDir, 'cc-haha')) + await fs.writeFile(path.join(tmpDir, 'cc-haha/storage-relocations.json'), JSON.stringify({ version: 1, previousRoots: [originalRoot] })) + const svc = new ConversationService() + const blocks = await (svc as any).buildUserContent('use this image', 'current-session', [{ type: 'image', path: path.join(originalRoot, 'uploads', 'old-session', 'fixture.png'), name: 'fixture.png' }]) + expect(blocks.some((block: any) => block.type === 'image')).toBe(true) + expect(blocks.some((block: any) => block.type === 'text' && block.text.includes(path.join(tmpDir, 'uploads/current-session')))).toBe(true) + }) + test('inlines image data attachments without resizing when already within API limits', async () => { const svc = new ConversationService() const sent: unknown[] = [] diff --git a/src/server/__tests__/migration-quiescence.test.ts b/src/server/__tests__/migration-quiescence.test.ts new file mode 100644 index 00000000..f59c6ebb --- /dev/null +++ b/src/server/__tests__/migration-quiescence.test.ts @@ -0,0 +1,104 @@ +import { expect, test, spyOn, mock } from 'bun:test' +import { mkdtemp, mkdir, writeFile, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { startServer, quiesceServerRuntimeForMigration, quiesceServerRuntimeForRecovery } from '../index.js' +import { migrationMaintenance } from '../migrationMaintenance.js' +import { conversationService } from '../services/conversationService.js' +import { cronScheduler } from '../services/cronScheduler.js' +import { teamWatcher } from '../services/teamWatcher.js' +import { diagnosticsService } from '../services/diagnosticsService.js' +import { localIndexCoordinator } from '../services/localIndex/coordinator.js' +import { searchContentCoordinator } from '../services/localIndex/searchContentCoordinator.js' +import { getGlobalClaudeFile } from '../../utils/env.js' +import { sessionService } from '../services/sessionService.js' + +test('validation startup keeps business traffic and index writes disabled until activation', async () => { + const root = await mkdtemp(join(tmpdir(), 'migration-validation-')) + const old = { root: process.env.CLAUDE_CONFIG_DIR, token: process.env.CC_HAHA_LOCAL_ACCESS_TOKEN, validation: process.env.CC_HAHA_MIGRATION_VALIDATION } + process.env.CLAUDE_CONFIG_DIR = root + process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'isolated-token' + process.env.CC_HAHA_MIGRATION_VALIDATION = '1' + let server: ReturnType | undefined + try { + await mkdir(join(root, 'projects')) + await writeFile(join(root, 'settings.json'), '{"unknownOwnerSetting":true}\n') + await writeFile(join(root, '.config.json'), '{"unknownGlobalOwnerSetting":true}\n') + getGlobalClaudeFile.cache.clear() + const sessionId = 'e13ccaf8-c354-491d-a20e-7ce9f5802720' + await mkdir(join(root, 'projects', 'saved-project')) + await writeFile(join(root, 'projects', 'saved-project', `${sessionId}.jsonl`), JSON.stringify({ type: 'user', uuid: 'saved-message', sessionId, timestamp: '2026-01-01T00:00:00Z', message: { role: 'user', content: 'Saved conversation' } }) + '\n') + const readSession = spyOn(sessionService, 'getSessionHistoryPage') + const indexStart = spyOn(localIndexCoordinator, 'start') + const cronStart = spyOn(cronScheduler, 'start') + server = startServer(0) + const base = `http://127.0.0.1:${server.port}` + expect((await fetch(`${base}/health`)).status).toBe(200) + expect((await fetch(`${base}/api/sessions`)).status).toBe(503) + expect((await fetch(`${base}/proxy/anything`, { method: 'POST' })).status).toBe(503) + expect((await fetch(`${base}/sdk/anything`)).status).toBe(503) + expect((await fetch(`${base}/api/runtime/migration/validate`)).status).toBe(403) + const response = await fetch(`${base}/api/runtime/migration/validate`, { headers: { Authorization: 'Bearer isolated-token' } }) + expect(await response.json()).toEqual({ valid: true }) + expect(readSession).toHaveBeenCalledWith(sessionId, { limit: 1, projectContext: false }) + expect(indexStart).not.toHaveBeenCalled() + expect(cronStart).not.toHaveBeenCalled() + expect(await readFile(join(root, 'settings.json'), 'utf8')).toBe('{"unknownOwnerSetting":true}\n') + expect((await readdir(root)).sort()).toEqual(['.config.json', 'projects', 'settings.json']) + await writeFile(join(root, '.config.json'), '{"invalidGlobalConfig":') + expect((await fetch(`${base}/api/runtime/migration/validate`, { headers: { Authorization: 'Bearer isolated-token' } })).status).toBe(409) + await rm(join(root, '.config.json')) + await writeFile(join(root, '.claude.json'), '{"effectiveGlobalOwnerSetting":true}\n') + getGlobalClaudeFile.cache.clear() + expect((await fetch(`${base}/api/runtime/migration/validate`, { headers: { Authorization: 'Bearer isolated-token' } })).status).toBe(200) + } finally { + server?.stop(true) + migrationMaintenance.resetForTests() + mock.restore() + getGlobalClaudeFile.cache.clear() + for (const [key, value] of [['CLAUDE_CONFIG_DIR', old.root], ['CC_HAHA_LOCAL_ACCESS_TOKEN', old.token], ['CC_HAHA_MIGRATION_VALIDATION', old.validation]]) { + if (value === undefined) delete process.env[key!] + else process.env[key!] = value + } + await rm(root, { recursive: true, force: true }) + } +}) + +test('quiescence closes admission before graceful CLI drain and waits for disconnected handler writes', async () => { + const root = await mkdtemp(join(tmpdir(), 'migration-drain-')) + const oldRoot = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = root + const external = Bun.spawn([process.execPath, '-e', 'setInterval(() => {}, 1000)'], { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' }) + await mkdir(join(root, 'sessions')) + await writeFile(join(root, 'sessions', `${external.pid}.json`), JSON.stringify({ pid: external.pid })) + const calls: string[] = [] + let release!: () => void + migrationMaintenance.track(new Promise(resolve => { release = resolve }).then(() => { calls.push('pending-handler-saved') })) + spyOn(conversationService, 'getProcessIds').mockReturnValue([]) + spyOn(cronScheduler, 'getProcessIds').mockReturnValue([]) + spyOn(cronScheduler, 'stopAndWait').mockResolvedValue() + spyOn(teamWatcher, 'stopAndWait').mockResolvedValue() + spyOn(conversationService, 'stopForMigration').mockImplementation(async () => { + expect(migrationMaintenance.isActive).toBe(true) + calls.push('cli-drain') + release() + }) + spyOn(localIndexCoordinator, 'stop').mockImplementation(async () => { calls.push('index-closed') }) + spyOn(searchContentCoordinator, 'stop').mockResolvedValue() + spyOn(diagnosticsService, 'drainForMigration').mockImplementation(async () => { calls.push('diagnostics-drained') }) + try { + await expect(quiesceServerRuntimeForMigration()).rejects.toThrow('Close external CLI') + expect(calls).toEqual([]) + await quiesceServerRuntimeForRecovery() + expect(calls).toEqual(['cli-drain', 'pending-handler-saved', 'index-closed', 'diagnostics-drained']) + await expect(conversationService.startSession('blocked', root, 'ws://127.0.0.1')).rejects.toThrow('Data migration') + } finally { + external.kill() + await external.exited + mock.restore() + migrationMaintenance.resetForTests() + if (oldRoot === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = oldRoot + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/src/server/api/__tests__/localFile.test.ts b/src/server/api/__tests__/localFile.test.ts index 83227919..7fb5a222 100644 --- a/src/server/api/__tests__/localFile.test.ts +++ b/src/server/api/__tests__/localFile.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, it, spyOn } from 'bun:test' +import { afterAll, beforeAll, describe, expect, it, spyOn, test } from 'bun:test' import { mkdirSync, mkdtempSync, @@ -11,6 +11,7 @@ import * as os from 'node:os' import { homedir, tmpdir } from 'node:os' import * as path from 'node:path' import { handleLocalFile, reconstructAbsolutePath } from '../localFile' +import { handleFilesystemRoute } from '../filesystem' import { isAllowedFilesystemPath } from '../filesystem' // Deterministic 256-byte payload (bytes 0..255) so range slices are checkable. @@ -51,6 +52,32 @@ function setupFiles() { return root } +test('serves copied attachments from their historical path after the original root disappears', async () => { + const previous = process.env.CLAUDE_CONFIG_DIR + const current = mkdtempSync(path.join(tmpdir(), 'migrated-config-')) + const original = path.join(SANDBOX_ROOTS, 'missing-original') + const suffix = path.join('uploads', 'session', 'image.png') + mkdirSync(path.join(current, 'uploads', 'session'), { recursive: true }) + mkdirSync(path.join(current, 'cc-haha'), { recursive: true }) + writeFileSync(path.join(current, suffix), VIDEO_BYTES) + writeFileSync(path.join(current, 'cc-haha/storage-relocations.json'), JSON.stringify({ version: 1, previousRoots: [original] })) + process.env.CLAUDE_CONFIG_DIR = current + try { + expect(isAllowedFilesystemPath(current)).toBe(true) + const originalPath = path.join(original, suffix) + const response = await handleLocalFile(new URL(`http://localhost/local-file/${originalPath.replaceAll('\\', '/').replace(/^\//, '')}`)) + expect(response.status).toBe(200) + expect(new Uint8Array(await response.arrayBuffer())).toEqual(VIDEO_BYTES) + const imageResponse = await handleFilesystemRoute('/api/filesystem/file', new URL(`http://localhost/api/filesystem/file?path=${encodeURIComponent(originalPath)}`)) + expect(imageResponse.status).toBe(200) + expect(new Uint8Array(await imageResponse.arrayBuffer())).toEqual(VIDEO_BYTES) + } finally { + if (previous === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = previous + rmSync(current, { recursive: true, force: true }) + } +}) + function makeExternalFixtureDir(): string | null { const candidates = ['/var/tmp', '/private/var/tmp', '/Users/Shared'] for (const baseDir of candidates) { diff --git a/src/server/api/filesystem.ts b/src/server/api/filesystem.ts index b087bd99..7e905f16 100644 --- a/src/server/api/filesystem.ts +++ b/src/server/api/filesystem.ts @@ -13,6 +13,7 @@ import { findGitRoot, gitExe } from '../../utils/git.js' import { ripGrep } from '../../utils/ripgrep.js' import { expandTilde } from '../../utils/permissions/pathValidation.js' import { getInitialSettings } from '../../utils/settings/settings.js' +import { activeStorageRoot, resolveRelocatedAttachmentPath } from '../../utils/storageRelocations.js' import { canonicalizeFilesystemAccessPath, isWithinRegisteredFilesystemRoot, @@ -85,8 +86,9 @@ export function isAllowedFilesystemPath(targetPath: string): boolean { const resolvedPath = canonicalizeFilesystemAccessPath(targetPath) const homeDir = canonicalizeFilesystemAccessPath(os.homedir()) const temporaryDir = canonicalizeFilesystemAccessPath('/tmp') + const storageRoot = canonicalizeFilesystemAccessPath(activeStorageRoot()) - if (isWithinRoot(resolvedPath, homeDir) || isWithinRoot(resolvedPath, temporaryDir)) { + if (isWithinRoot(resolvedPath, homeDir) || isWithinRoot(resolvedPath, temporaryDir) || isWithinRoot(resolvedPath, storageRoot)) { return true } @@ -123,7 +125,7 @@ async function handleServeFile(url: URL): Promise { // A model writes `~/Pictures/chart.png` as readily as an absolute path; the // other local file routes expand the alias, and the allow-list below is applied // to the expanded path. - const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(expandTilde(filePath))) + const resolvedPath = resolveRelocatedAttachmentPath(path.resolve(normalizeDriveRootPathForPlatform(expandTilde(filePath)))) const canonicalPath = await canonicalizeExistingFilesystemPath(resolvedPath) if (!canonicalPath) { if (!isAllowedFilesystemPath(resolvedPath)) { diff --git a/src/server/api/localFile.ts b/src/server/api/localFile.ts index c24e66fa..76c09094 100644 --- a/src/server/api/localFile.ts +++ b/src/server/api/localFile.ts @@ -4,6 +4,7 @@ import { isAllowedFilesystemPath } from './filesystem.js' import { serveFileWithRange } from './previewFs.js' import { canonicalizeExistingFilesystemPath } from '../services/filesystemPathSecurity.js' import { normalizeDriveRootPathForPlatform } from '../services/windowsDrivePath.js' +import { resolveRelocatedAttachmentPath } from '../../utils/storageRelocations.js' const PREFIX = '/local-file/' @@ -47,7 +48,7 @@ export function reconstructAbsolutePath(rest: string): string | null { if (!decoded) return null if (decoded === '~' || decoded.startsWith('~/')) { - return expandTilde(decoded) + return path.normalize(expandTilde(decoded)) } // Windows drive form: `C:/...` or `C:\...` is already absolute. @@ -90,7 +91,7 @@ export async function handleLocalFile( const absPath = reconstructAbsolutePath(rest) if (!absPath) return new Response('bad request', { status: 400 }) - const resolved = path.resolve(normalizeDriveRootPathForPlatform(absPath)) + const resolved = resolveRelocatedAttachmentPath(path.resolve(normalizeDriveRootPathForPlatform(absPath))) const canonicalPath = await canonicalizeExistingFilesystemPath(resolved) if (!canonicalPath) { if (!isAllowedFilesystemPath(resolved)) { diff --git a/src/server/index.ts b/src/server/index.ts index 9a8188ab..073e4b98 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -9,7 +9,12 @@ import { handleSessionCollaborationApi } from './api/sessionCollaboration.js' */ import { handleApiRequest } from './router.js' -import { handleWebSocket, type WebSocketData } from './ws/handler.js' +import { handleWebSocket, revokeSessionAdmissionsForMigration, drainSessionWritesForMigration, type WebSocketData } from './ws/handler.js' +import { migrationMaintenance, migrationUnavailableResponse } from './migrationMaintenance.js' +import { countExternalMigrationProcesses } from './migrationInventory.js' +import { handleMigrationRuntimeRequest, isMigrationRuntimePath } from './migrationRuntimeApi.js' +import { quiesceTeamPlanRuntimesForMigration } from './services/teamPlanRuntime.js' +import { ManagedSettingsService } from './services/managedSettingsService.js' import { resolveCors, withCors, type CorsResolution } from './middleware/cors.js' import { requireAuth, requireH5Token } from './middleware/auth.js' import { teamWatcher } from './services/teamWatcher.js' @@ -229,15 +234,16 @@ function originFromUrl(value: string | null): string | null { } export function startServer(port = PORT, host = HOST) { - enableConfigs() + if (process.env.CC_HAHA_MIGRATION_VALIDATION === '1') migrationMaintenance.beginValidation() + if (!migrationMaintenance.isActive) enableConfigs() const trustedRendererOrigin = resolveTrustedRendererOrigin(process.env.CC_HAHA_TRUSTED_RENDERER_ORIGIN) // Warm the synchronous disconnect-grace cache from managed settings so the // first client disconnect honors the configured value (issue #764). - void refreshDisconnectGraceMs() + if (!migrationMaintenance.isActive) void refreshDisconnectGraceMs() // Don't hijack the global console / process handlers under `bun test`: // a test that boots the server would otherwise route every test-side // console.error/warn into the user's real diagnostics file. - if (process.env.NODE_ENV !== 'test') { + if (process.env.NODE_ENV !== 'test' && !migrationMaintenance.isActive) { diagnosticsService.installConsoleCapture() diagnosticsService.installProcessCapture() } @@ -261,7 +267,9 @@ export function startServer(port = PORT, host = HOST) { const h5AccessService = new H5AccessService() const publicAccess = new PublicAccessServer({ - handleApiRequest, + handleApiRequest: (request, url, context) => migrationMaintenance.isActive + ? Promise.resolve(migrationUnavailableResponse()) + : migrationMaintenance.track(handleApiRequest(request, url, context)), handleStatic: handleStaticH5Request, websocket: handleWebSocket, serverPort: () => serverPort, @@ -272,7 +280,7 @@ export function startServer(port = PORT, host = HOST) { // Open SQLite before the first REST request. Discovery still runs in the // background; without this, getPublicStatus() reports `off` and the sidebar // falls through to a full JSONL scan that can exceed the 120s client timeout. - void localIndexCoordinator.start().catch(() => undefined) + if (!migrationMaintenance.isActive) void localIndexCoordinator.start().catch(() => undefined) try { server = Bun.serve({ @@ -281,6 +289,30 @@ export function startServer(port = PORT, host = HOST) { idleTimeout: HTTP_CONNECTION_IDLE_TIMEOUT_SECONDS, async fetch(req, server) { + const pathname = new URL(req.url).pathname + if (isMigrationRuntimePath(pathname)) { + return handleMigrationRuntimeRequest(req, server.requestIP(req)?.address ?? null, { + preview: migrationRuntimePreview, + quiesce: quiesceServerRuntimeForMigration, + recover: quiesceServerRuntimeForRecovery, + activate: async () => { + migrationMaintenance.activateValidation() + delete process.env.CC_HAHA_MIGRATION_VALIDATION + enableConfigs() + void refreshDisconnectGraceMs() + if (process.env.NODE_ENV !== 'test') { + diagnosticsService.installConsoleCapture() + diagnosticsService.installProcessCapture() + } + beginBackgroundIndexStartup() + teamWatcher.start() + cronScheduler.start() + }, + }) + } + if (migrationMaintenance.isActive && pathname !== '/health' && + (migrationMaintenance.isValidation || !pathname.startsWith('/sdk/'))) return migrationUnavailableResponse() + return migrationMaintenance.track((async () => { const url = new URL(req.url) if (isPublicAccessControlPath(url.pathname)) return publicAccess.control(req) @@ -299,8 +331,10 @@ export function startServer(port = PORT, host = HOST) { ) } - await localIndexCoordinator.start().catch(() => undefined) - await ensurePersistentStorageUpgraded() + if (!migrationMaintenance.isActive) { + await localIndexCoordinator.start().catch(() => undefined) + await ensurePersistentStorageUpgraded() + } const collaborationAction = collaborationToolAction(url.pathname) if (collaborationAction) { const caller = authenticateCollaborationCaller(req, (id, token) => conversationService.authorizeSdkConnection(id, token)) @@ -623,6 +657,7 @@ export function startServer(port = PORT, host = HOST) { } return new Response('Not Found', { status: 404 }) + })()) }, websocket: handleWebSocket, @@ -651,15 +686,15 @@ export function startServer(port = PORT, host = HOST) { // Bun.serve is already accepting requests. Both projections remain // background work; session-list metadata gets priority on a cold start so // full-text backfill cannot make the sidebar slower on low-memory machines. - beginBackgroundIndexStartup() + if (!migrationMaintenance.isActive) beginBackgroundIndexStartup() // Start watching ~/.claude/teams/ for real-time WebSocket push - teamWatcher.start() + if (!migrationMaintenance.isActive) teamWatcher.start() // Start the cron scheduler to execute scheduled tasks - cronScheduler.start() + if (!migrationMaintenance.isActive) cronScheduler.start() - void ensureDesktopCliLauncherInstalled().catch((error) => { + if (!migrationMaintenance.isActive) void ensureDesktopCliLauncherInstalled().catch((error) => { console.error( '[desktop-cli-launcher] failed to install bundled launcher:', error instanceof Error ? error.message : error, @@ -673,6 +708,48 @@ export function startServer(port = PORT, host = HOST) { // ─── Graceful shutdown: kill all CLI subprocesses on exit ──────────────────── let shutdownInProgress: Promise | null = null +let migrationQuiescence: Promise | undefined + +async function migrationRuntimePreview() { + const owned = [...conversationService.getProcessIds(), ...cronScheduler.getProcessIds()] + return { activeTasks: owned.length, externalProcesses: await countExternalMigrationProcesses(owned) } +} + +export function quiesceServerRuntimeForMigration(ignoreExternalProcesses = false): Promise { + if (migrationQuiescence) return migrationQuiescence + migrationMaintenance.begin() + revokeSessionAdmissionsForMigration() + const operation = (async () => { + const inventory = await migrationRuntimePreview() + if (!ignoreExternalProcesses && inventory.externalProcesses > 0) throw new Error('Close external CLI sessions before migrating data') + for (const remote of publicAccessServers) remote.disable() + await quiesceTeamPlanRuntimesForMigration() + await Promise.all([teamWatcher.stopAndWait(), cronScheduler.stopAndWait(), conversationService.stopForMigration()]) + await migrationMaintenance.drain() + await drainSessionWritesForMigration() + backgroundIndexStartupController?.abort() + await Promise.all([localIndexCoordinator.stop(), searchContentCoordinator.stop(), backgroundIndexStartup]) + await sessionService.drainForMigration() + await ManagedSettingsService.drainForMigration() + await diagnosticsService.drainForMigration() + })() + migrationQuiescence = operation + return operation +} + +export async function quiesceServerRuntimeForRecovery(): Promise { + if (migrationQuiescence) { + try { + await migrationQuiescence + return + } catch { + migrationQuiescence = undefined + } + } + // Recovery restarts the source runtime; it never authorizes copying while + // another process still owns that directory. + await quiesceServerRuntimeForMigration(true) +} export async function stopServerRuntimeForShutdown( options: { waitForCli?: boolean } = {}, diff --git a/src/server/migrationInventory.test.ts b/src/server/migrationInventory.test.ts new file mode 100644 index 00000000..b4a15ee6 --- /dev/null +++ b/src/server/migrationInventory.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from 'bun:test' +import { mkdtemp, mkdir, writeFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { countExternalMigrationProcesses } from './migrationInventory.js' + +test('migration inventory preserves stale and unknown registrations and excludes owned processes', async () => { + const root = await mkdtemp(join(tmpdir(), 'migration-inventory-')) + try { + await mkdir(join(root, 'sessions')) + for (const pid of [123, 456, 789]) await writeFile(join(root, 'sessions', `${pid}.json`), JSON.stringify({ pid })) + await writeFile(join(root, 'sessions', 'notes.json'), '{}') + expect(await countExternalMigrationProcesses([123], root, pid => pid !== 456)).toBe(1) + expect((await readdir(join(root, 'sessions'))).sort()).toEqual(['123.json', '456.json', '789.json', 'notes.json']) + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/src/server/migrationInventory.ts b/src/server/migrationInventory.ts new file mode 100644 index 00000000..e74acdde --- /dev/null +++ b/src/server/migrationInventory.ts @@ -0,0 +1,40 @@ +import { readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { getClaudeConfigHomeDir } from '../utils/envUtils.js' + +/** Never sweeps PID files or assumes an inaccessible process is dead. */ +export async function countExternalMigrationProcesses( + ownedPids: readonly number[], + configDir = getClaudeConfigHomeDir(), + isAlive: (pid: number) => boolean = pid => { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ESRCH' + } + }, +): Promise { + const owned = new Set([process.pid, ...ownedPids]) + let files: string[] + try { files = await readdir(join(configDir, 'sessions')) } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 0 + throw error + } + let count = 0 + for (const file of files) { + if (!/^\d+\.json$/.test(file)) continue + const pid = Number(file.slice(0, -5)) + if (!Number.isSafeInteger(pid) || pid <= 0 || owned.has(pid) || !isAlive(pid)) continue + // An unreadable/malformed live registration cannot safely be ignored. + try { + const entry = JSON.parse(await readFile(join(configDir, 'sessions', file), 'utf8')) + if (entry.pid !== pid) throw new Error('Invalid session process registration') + } catch { + count += 1 + continue + } + count += 1 + } + return count +} diff --git a/src/server/migrationMaintenance.test.ts b/src/server/migrationMaintenance.test.ts new file mode 100644 index 00000000..7fda211b --- /dev/null +++ b/src/server/migrationMaintenance.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from 'bun:test' +import { MigrationMaintenance } from './migrationMaintenance.js' + +test('migration closes admission immediately and waits for work beyond a disconnected response', async () => { + const maintenance = new MigrationMaintenance() + let finish!: () => void + maintenance.track(new Promise(resolve => { finish = resolve })) + maintenance.begin() + expect(() => maintenance.assertAvailable()).toThrow('Data migration') + let drained = false + const drain = maintenance.drain().then(() => { drained = true }) + await Promise.resolve() + expect(drained).toBe(false) + finish() + await drain + expect(drained).toBe(true) +}) + +test('migration drains follow-up writes started by existing work', async () => { + const maintenance = new MigrationMaintenance() + let finish!: () => void + const write = new Promise(resolve => { finish = resolve }) + maintenance.track(Promise.resolve().then(() => { maintenance.track(write) })) + const drain = maintenance.drain() + finish() + await drain +}) diff --git a/src/server/migrationMaintenance.ts b/src/server/migrationMaintenance.ts new file mode 100644 index 00000000..5bc6b958 --- /dev/null +++ b/src/server/migrationMaintenance.ts @@ -0,0 +1,63 @@ +import { ApiError } from './middleware/errorHandler.js' + +/** Tracks the work itself, including work whose HTTP client has disconnected. */ +export class MigrationMaintenance { + private active = false + private validation = false + private operations = new Set>() + + get isActive(): boolean { return this.active } + get isValidation(): boolean { return this.validation } + + begin(): void { this.active = true } + + beginValidation(): void { + this.validation = true + this.active = true + } + + activateValidation(): void { + if (!this.validation) throw new Error('Runtime is not awaiting migration validation') + this.validation = false + this.active = false + } + + assertAvailable(): void { + if (this.active) throw new ApiError(503, 'Data migration is in progress', 'MIGRATION_IN_PROGRESS') + } + + track(operation: Promise): Promise { + this.operations.add(operation) + void operation.then(() => this.operations.delete(operation), () => this.operations.delete(operation)) + return operation + } + + async drain(): Promise { + while (this.operations.size > 0) await Promise.allSettled([...this.operations]) + } + + resetForTests(): void { + this.active = false + this.validation = false + this.operations.clear() + } +} + +export const migrationMaintenance = new MigrationMaintenance() + +export function waitForMigrationExit(exited: Promise, timeoutMs = 15_000): Promise { + return new Promise(resolve => { + const timer = setTimeout(() => resolve(false), timeoutMs) + void exited.then(() => { + clearTimeout(timer) + resolve(true) + }, () => { + clearTimeout(timer) + resolve(false) + }) + }) +} + +export function migrationUnavailableResponse(): Response { + return Response.json({ error: 'Data migration is in progress', code: 'MIGRATION_IN_PROGRESS' }, { status: 503 }) +} diff --git a/src/server/migrationRuntimeApi.test.ts b/src/server/migrationRuntimeApi.test.ts new file mode 100644 index 00000000..4f7359e8 --- /dev/null +++ b/src/server/migrationRuntimeApi.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from 'bun:test' +import { handleMigrationRuntimeRequest } from './migrationRuntimeApi.js' + +test('migration control rejects H5, forwarded, external and tokenless requests', async () => { + const oldToken = process.env.CC_HAHA_LOCAL_ACCESS_TOKEN + process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'isolated-token' + let calls = 0 + const control = { preview: async () => ({ activeTasks: 2, externalProcesses: 0 }), quiesce: async () => { calls++ }, activate: async () => {} } + try { + for (const [address, headers] of [ + ['127.0.0.1', {}], + ['192.168.0.4', { Authorization: 'Bearer isolated-token' }], + ['127.0.0.1', { Authorization: 'Bearer isolated-token', Origin: 'https://remote.test' }], + ['127.0.0.1', { Authorization: 'Bearer isolated-token', 'x-forwarded-for': '8.8.8.8' }], + ] as [string, Record][]) { + const response = await handleMigrationRuntimeRequest(new Request('http://localhost/api/runtime/migration/quiesce', { method: 'POST', headers }), address, control) + expect(response.status).toBe(403) + } + expect(calls).toBe(0) + const response = await handleMigrationRuntimeRequest(new Request('http://localhost/api/runtime/migration/quiesce', { method: 'POST', headers: { Authorization: 'Bearer isolated-token' } }), '127.0.0.1', control) + expect(await response.json()).toEqual({ quiesced: true }) + expect(calls).toBe(1) + } finally { + if (oldToken === undefined) delete process.env.CC_HAHA_LOCAL_ACCESS_TOKEN + else process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = oldToken + } +}) diff --git a/src/server/migrationRuntimeApi.ts b/src/server/migrationRuntimeApi.ts new file mode 100644 index 00000000..d68bae8e --- /dev/null +++ b/src/server/migrationRuntimeApi.ts @@ -0,0 +1,81 @@ +import { open, readdir, readFile, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { isLocalAccessAuthorized } from './localAccessAuth.js' +import { isLoopbackHost } from './h5AccessPolicy.js' +import { getClaudeConfigHomeDir } from '../utils/envUtils.js' +import { stripBOM } from '../utils/jsonRead.js' +import { getGlobalClaudeFile } from '../utils/env.js' +import { sessionService } from './services/sessionService.js' + +export type MigrationPreview = { activeTasks: number; externalProcesses: number } +export type MigrationRuntimeControl = { + preview(): Promise + quiesce(): Promise + activate(): Promise + recover?(): Promise +} + +export function isMigrationRuntimePath(pathname: string): boolean { + return pathname.startsWith('/api/runtime/migration/') +} + +export async function handleMigrationRuntimeRequest( + request: Request, + clientAddress: string | null, + control: MigrationRuntimeControl, +): Promise { + if (!clientAddress || !isLoopbackHost(clientAddress) || request.headers.has('Origin') || + ['forwarded', 'x-forwarded-for', 'x-forwarded-host', 'via'].some(header => request.headers.has(header)) || + !isLocalAccessAuthorized(request)) { + return Response.json({ error: 'Local desktop credential required' }, { status: 403 }) + } + const pathname = new URL(request.url).pathname + try { + if (request.method === 'GET' && pathname.endsWith('/preview')) return Response.json(await control.preview()) + if (request.method === 'POST' && pathname.endsWith('/quiesce')) { + await control.quiesce() + return Response.json({ quiesced: true }) + } + if (request.method === 'POST' && pathname.endsWith('/recover') && control.recover) { + await control.recover() + return Response.json({ quiesced: true }) + } + if (request.method === 'GET' && pathname.endsWith('/validate')) { + const root = getClaudeConfigHomeDir() + if (!(await stat(root)).isDirectory()) throw new Error('Data directory is unavailable') + for (const configFile of [join(root, 'settings.json'), join(root, 'cc-haha', 'settings.json'), getGlobalClaudeFile()]) { + try { + const config = JSON.parse(stripBOM(await readFile(configFile, 'utf8'))) + if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('Invalid migrated configuration') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + } + let representativeSession: string | undefined + try { + const projects = join(root, 'projects') + for (const project of await readdir(projects, { withFileTypes: true })) { + if (!project.isDirectory()) continue + const directory = join(projects, project.name) + for (const file of await readdir(directory, { withFileTypes: true })) { + if (!file.isFile() || !file.name.endsWith('.jsonl')) continue + if (!representativeSession && /^[a-f0-9-]{36}\.jsonl$/i.test(file.name)) representativeSession = file.name.slice(0, -6) + const transcript = await open(join(directory, file.name), 'r') + try { await transcript.read(Buffer.alloc(1), 0, 1, 0) } finally { await transcript.close() } + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + if (representativeSession) await sessionService.getSessionHistoryPage(representativeSession, { limit: 1, projectContext: false }) + return Response.json({ valid: true }) + } + if (request.method === 'POST' && pathname.endsWith('/activate')) { + await control.activate() + return Response.json({ activated: true }) + } + return Response.json({ error: 'Unknown migration control' }, { status: 404 }) + } catch (error) { + return Response.json({ error: error instanceof Error ? error.message : 'Migration runtime control failed', code: 'MIGRATION_RUNTIME_UNSAFE' }, { status: 409 }) + } +} diff --git a/src/server/services/conversationService.migration.test.ts b/src/server/services/conversationService.migration.test.ts new file mode 100644 index 00000000..7c10a613 --- /dev/null +++ b/src/server/services/conversationService.migration.test.ts @@ -0,0 +1,70 @@ +import { expect, spyOn, test } from 'bun:test' +import { ConversationService } from './conversationService.js' +import { migrationMaintenance } from '../migrationMaintenance.js' + +test('migration ends the CLI over its control stream, then waits for exit and output writes', async () => { + const service = new ConversationService() as any + let releaseExit!: (code: number) => void + let releaseWrites!: () => void + const exited = new Promise(resolve => { releaseExit = resolve }) + const outputDrain = new Promise(resolve => { releaseWrites = resolve }) + service.sessions.set('active', { + proc: { exitCode: null, exited, kill: () => { throw new Error('Unsafe force termination') } }, + outputDrain, + }) + const control = spyOn(service, 'requestControl').mockResolvedValue({}) + let stopped = false + const stopping = service.stopForMigration().then(() => { stopped = true }) + try { + await Promise.resolve() + expect(control).toHaveBeenCalledWith('active', { subtype: 'end_session', reason: 'data_migration' }, 10_000) + expect(stopped).toBe(false) + releaseExit(0) + await Promise.resolve() + expect(stopped).toBe(false) + releaseWrites() + await stopping + expect(service.getActiveSessions()).toEqual([]) + } finally { + releaseExit(0) + releaseWrites() + control.mockRestore() + } +}) + +test('migration refuses to finish when a CLI graceful-control request fails', async () => { + const service = new ConversationService() as any + service.sessions.set('active', { proc: { exitCode: null }, outputDrain: Promise.resolve() }) + const control = spyOn(service, 'requestControl').mockRejectedValue(new Error('CLI control unavailable')) + try { + await expect(service.stopForMigration()).rejects.toThrow('CLI control unavailable') + expect(service.getActiveSessions()).toEqual(['active']) + } finally { + control.mockRestore() + } +}) + +test('migration rejects new sends and revokes a send already waiting for attachment preparation', async () => { + const service = new ConversationService() as any + let release!: () => void + const prepared = new Promise(resolve => { release = resolve }) + const content = spyOn(service, 'buildUserContent').mockImplementation(async () => { + await prepared + return [{ type: 'text', text: 'Fixture prompt' }] + }) + const send = spyOn(service, 'sendSdkMessage').mockReturnValue(true) + try { + const pending = service.sendMessage('fixture', 'Fixture prompt') + migrationMaintenance.begin() + expect(await service.sendMessage('fixture', 'Should never prepare')).toBe(false) + expect(content).toHaveBeenCalledTimes(1) + release() + expect(await pending).toBe(false) + expect(send).not.toHaveBeenCalled() + } finally { + release() + migrationMaintenance.resetForTests() + content.mockRestore() + send.mockRestore() + } +}) diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index bbf17e8e..82529d5e 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -1,4 +1,5 @@ import { closeSideChatsForParent, getSideChat, isSideChatId, SIDE_CHAT_BOUNDARY } from './sideChatRegistry.js' +import { migrationMaintenance, waitForMigrationExit } from '../migrationMaintenance.js' /** * ConversationService — CLI subprocess manager * @@ -53,6 +54,7 @@ import { REJECT_MESSAGE_WITH_REASON_PREFIX, } from '../../constants/messages.js' import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' +import { resolveRelocatedAttachmentPath } from '../../utils/storageRelocations.js' import { findCanonicalGitRoot } from '../../utils/git.js' import { sanitizePath } from '../../utils/path.js' import { getProcessEnvWithTerminalShellEnvironment } from '../../utils/terminalShellEnvironment.js' @@ -606,6 +608,7 @@ export class ConversationService { sdkUrl: string, options?: SessionStartOptions, ): Promise { + migrationMaintenance.assertAvailable() if (this.deletedSessions.has(sessionId)) { throw new ConversationStartupError( `Session was deleted before startup completed: ${sessionId}`, @@ -742,6 +745,7 @@ export class ConversationService { const usesOfficialOAuth = this.shouldMarkManagedOAuth(options?.providerId) let proc: ReturnType + migrationMaintenance.assertAvailable() try { proc = Bun.spawn(args, buildConversationCliSpawnOptions(launchWorkDir, childEnv)) if (side) side.started = true @@ -934,6 +938,7 @@ export class ConversationService { attachments?: AttachmentRef[], options?: SendMessageOptions, ): Promise { + if (migrationMaintenance.isActive) return false const userContent = await this.buildUserContent(content, sessionId, attachments) let session = this.sessions.get(sessionId) if (session && !await this.refreshNetworkEnvironmentBeforeTurn(sessionId, session)) { @@ -947,7 +952,7 @@ export class ConversationService { // can all suspend this call. Stop may revoke the owning desktop turn while // one of those awaits is pending, so check ownership at the last possible // point before writing the user message to the SDK socket. - if (options?.canSend && !options.canSend()) return false + if (migrationMaintenance.isActive || (options?.canSend && !options.canSend())) return false const sent = this.sendSdkMessage(sessionId, { type: 'user', ...(options?.messageUuid ? { uuid: options.messageUuid } : {}), @@ -1801,6 +1806,24 @@ export class ConversationService { return Array.from(this.sessions.keys()) } + getProcessIds(): number[] { + return [...this.sessions.values()].map(session => session.proc.pid).filter(pid => pid > 0) + } + + async stopForMigration(): Promise { + const sessions = [...this.sessions.entries()] + await Promise.all(sessions.map(async ([sessionId, session]) => { + if (session.proc.exitCode == null) { + await this.requestControl(sessionId, { subtype: 'end_session', reason: 'data_migration' }, 10_000) + } + const exited = await waitForMigrationExit(session.proc.exited) + if (!exited) throw new Error(`CLI process did not exit: ${sessionId}`) + await session.outputDrain + this.sessions.delete(sessionId) + })) + await Promise.all([...this.teamStopOperations.values()]) + } + private async readProcessOutputStream( sessionId: string, stream: ReadableStream | null | undefined, @@ -2313,7 +2336,7 @@ export class ConversationService { } })() - session.officialOAuthRefreshPromise = recovery + session.officialOAuthRefreshPromise = migrationMaintenance.track(recovery) void recovery.finally(() => { if (session.officialOAuthRefreshPromise === recovery) { session.officialOAuthRefreshPromise = undefined @@ -2731,7 +2754,10 @@ export class ConversationService { const savedPaths: string[] = [] const imageBlocks: UserContentBlock[] = [] const imageMetadataTexts: string[] = [] - for (const attachment of attachments) { + for (const originalAttachment of attachments) { + const attachment = originalAttachment.path + ? { ...originalAttachment, path: resolveRelocatedAttachmentPath(originalAttachment.path) } + : originalAttachment if (this.shouldInlineImageAttachment(attachment)) { const image = await this.materializeImageAttachment(attachment, uploadDir) if (image) { diff --git a/src/server/services/cronScheduler.migration.test.ts b/src/server/services/cronScheduler.migration.test.ts new file mode 100644 index 00000000..fcf7cf60 --- /dev/null +++ b/src/server/services/cronScheduler.migration.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from 'bun:test' +import { CronScheduler } from './cronScheduler.js' +import { migrationMaintenance } from '../migrationMaintenance.js' +import { mkdtemp, writeFile, stat, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spyOn } from 'bun:test' +import { CronService } from './cronService.js' +import { resetScheduledRunReadModelForTests } from './localIndex/scheduledRunReadModel.js' + +test('migration uses the CLI control stream and waits for scheduled-task exit', async () => { + const scheduler = new CronScheduler() as any + let release!: (code: number) => void + const exited = new Promise(resolve => { release = resolve }) + const messages: any[] = [] + let ended = false + scheduler.runningTasks.set('task', { proc: { + stdin: { write: (line: string) => { messages.push(JSON.parse(line)) }, end: () => { ended = true } }, + exited, + kill: () => { throw new Error('Force termination is unsafe before flush') }, + } }) + let completed = false + const stopped = scheduler.stopAndWait().then(() => { completed = true }) + await Promise.resolve() + expect(messages[0].request).toEqual({ subtype: 'end_session', reason: 'data_migration' }) + expect(ended).toBe(true) + expect(completed).toBe(false) + release(0) + await stopped +}) + +test('maintenance rejects scheduled execution before any provider or filesystem work', async () => { + migrationMaintenance.begin() + try { + await expect(new CronScheduler().executeTask({ id: 'blocked' } as any)).rejects.toThrow('Data migration') + } finally { migrationMaintenance.resetForTests() } +}) + +test('a gracefully interrupted scheduled turn is saved as failed and remains available for its next schedule', async () => { + const root = await mkdtemp(join(tmpdir(), 'migration-cron-process-')) + const oldRoot = process.env.CLAUDE_CONFIG_DIR + const oldCli = process.env.CLAUDE_CLI_PATH + process.env.CLAUDE_CONFIG_DIR = root + const cli = join(root, 'fixture.ts') + const ready = join(root, 'ready') + await writeFile(cli, ` + import { writeFileSync } from 'node:fs' + writeFileSync(${JSON.stringify(ready)}, 'ready') + let buffer = '' + process.stdin.on('data', chunk => { + buffer += chunk.toString() + const lines = buffer.split('\\n') + buffer = lines.pop() || '' + for (const line of lines) { + if (JSON.parse(line).request?.subtype !== 'end_session') continue + process.stdout.write(JSON.stringify({ type: 'result', is_error: true }) + '\\n', () => process.exit(0)) + } + }) + setInterval(() => {}, 1000) + `) + process.env.CLAUDE_CLI_PATH = cli + const cron = new CronService() + const scheduler = new CronScheduler(cron) as any + const childEnv = spyOn(scheduler, 'buildTaskChildEnv').mockResolvedValue({ CLAUDE_CONFIG_DIR: root }) + try { + const task = await cron.createTask({ name: 'Migration fixture', prompt: 'Fixture only', cron: '* * * * *', workDir: root, enabled: true, recurring: false }) + const execution = scheduler.executeTask(task) + for (let attempt = 0; attempt < 300; attempt++) { + if (await stat(ready).then(() => true, () => false)) break + await Bun.sleep(5) + } + await stat(ready) + await scheduler.stopAndWait() + expect(await execution).toMatchObject({ status: 'failed', error: 'Interrupted for data migration', exitCode: 0 }) + expect((await cron.listTasks()).find(candidate => candidate.id === task.id)?.enabled).toBe(true) + } finally { + scheduler.stop() + await resetScheduledRunReadModelForTests() + childEnv.mockRestore() + if (oldRoot === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = oldRoot + if (oldCli === undefined) delete process.env.CLAUDE_CLI_PATH + else process.env.CLAUDE_CLI_PATH = oldCli + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/src/server/services/cronScheduler.ts b/src/server/services/cronScheduler.ts index 1e930763..3206f82e 100644 --- a/src/server/services/cronScheduler.ts +++ b/src/server/services/cronScheduler.ts @@ -8,6 +8,7 @@ */ import * as fs from 'fs/promises' +import { migrationMaintenance, waitForMigrationExit } from '../migrationMaintenance.js' import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs' import * as path from 'path' import * as os from 'os' @@ -537,7 +538,7 @@ export class CronScheduler { private intervalId: Timer | null = null private runningTasks = new Map< string, - { proc: ReturnType; startedAt: number; runId: string } + { proc: ReturnType; startedAt: number; runId: string; resultReceived?: boolean; migrationInterrupted?: boolean } >() /** Track which minute each task last fired (prevents same-process duplicate within a minute). */ private lastFiredMinuteKey = new Map() @@ -557,10 +558,11 @@ export class CronScheduler { /** Start the scheduler (called on server boot). */ start(): void { + if (migrationMaintenance.isActive) return if (this.intervalId) return // already running console.log('[CronScheduler] Starting — checking every 60 s') // Clean up stale "running" entries left by previously crashed processes - this.cleanupStaleRuns().catch((err) => + migrationMaintenance.track(this.cleanupStaleRuns()).catch((err) => console.error('[CronScheduler] Error cleaning up stale runs:', err), ) this.intervalId = setInterval(() => this.tick(), 60_000) @@ -590,12 +592,14 @@ export class CronScheduler { /** One tick of the scheduler — evaluate all tasks against the current time. */ async tick(): Promise { + if (migrationMaintenance.isActive) return try { const tasks = await this.cronService.listTasks() const now = new Date() const currentKey = CronScheduler.minuteKey(now) for (const task of tasks) { + if (migrationMaintenance.isActive) return // Skip disabled tasks if (task.enabled === false) continue @@ -634,7 +638,38 @@ export class CronScheduler { * @param task The task to execute * @param options.createSession When true, creates a Session for rich output viewing (used for manual "Run Now") */ - async executeTask(task: CronTask, options?: { createSession?: boolean }): Promise { + executeTask(task: CronTask, options?: { createSession?: boolean }): Promise { + if (migrationMaintenance.isActive) return Promise.reject(new Error('Data migration is in progress')) + return migrationMaintenance.track(this.executeTaskOnce(task, options)) + } + + getProcessIds(): number[] { + return [...this.runningTasks.values()].map(entry => entry.proc.pid).filter(pid => pid > 0) + } + + async stopAndWait(): Promise { + const entries = [...this.runningTasks.values()] + for (const entry of entries) { + if (!entry.resultReceived) entry.migrationInterrupted = true + } + const processes = entries.map(entry => entry.proc) + if (this.intervalId) clearInterval(this.intervalId) + this.intervalId = null + for (const proc of processes) { + try { + proc.stdin.write(JSON.stringify({ type: 'control_request', request_id: crypto.randomUUID(), request: { subtype: 'end_session', reason: 'data_migration' } }) + '\n') + proc.stdin.end() + } catch { + // A completed run may already have closed stdin; exit still must be confirmed. + } + } + for (const proc of processes) { + const exited = await waitForMigrationExit(proc.exited) + if (!exited) throw new Error('Scheduled task process did not exit') + } + } + + private async executeTaskOnce(task: CronTask, options?: { createSession?: boolean }): Promise { const runLogTarget = captureRunsFileMutationTarget() // Prevent concurrent executions of the same task @@ -721,17 +756,19 @@ export class CronScheduler { const childEnv = await this.buildTaskChildEnv(workDir, task) const taskTimeoutMs = resolveCronTaskTimeoutMs() + migrationMaintenance.assertAvailable() const proc = Bun.spawn( cliArgs, buildCronTaskSpawnOptions(workDir, childEnv), ) - this.runningTasks.set(task.id, { proc, startedAt: Date.now(), runId }) + const runningEntry = { proc, startedAt: Date.now(), runId, resultReceived: false, migrationInterrupted: false } + this.runningTasks.set(task.id, runningEntry) - // Write prompt to stdin then close it + // Keep the control stream open until the terminal result so migration can + // request a graceful stop even while this scheduled turn is running. try { proc.stdin.write(inputPayload) - proc.stdin.end() } catch { // If writing fails, the process may have already exited } @@ -750,6 +787,7 @@ export class CronScheduler { try { // Collect stdout const stdoutChunks: string[] = [] + let pendingLine = '' if (proc.stdout) { const reader = proc.stdout.getReader() const decoder = new TextDecoder() @@ -757,7 +795,22 @@ export class CronScheduler { while (true) { const { done, value } = await reader.read() if (done) break - stdoutChunks.push(decoder.decode(value, { stream: true })) + const chunk = decoder.decode(value, { stream: true }) + stdoutChunks.push(chunk) + pendingLine += chunk + const lines = pendingLine.split('\n') + pendingLine = lines.pop() ?? '' + for (const line of lines) { + try { + const message = JSON.parse(line) + if (message.type === 'result' && !message.parent_tool_use_id) { + runningEntry.resultReceived = true + proc.stdin.end() + } + } catch { + // Non-JSON diagnostic output does not affect the run. + } + } } } catch { // stream may be interrupted on kill @@ -787,7 +840,8 @@ export class CronScheduler { const completedRun: TaskRun = { ...run, completedAt, - status: wasTimeout ? 'timeout' : exitCode === 0 ? 'completed' : 'failed', + status: runningEntry.migrationInterrupted ? 'failed' : wasTimeout ? 'timeout' : exitCode === 0 ? 'completed' : 'failed', + ...(runningEntry.migrationInterrupted ? { error: 'Interrupted for data migration' } : {}), output: output.slice(0, 50_000), // cap after extraction exitCode, durationMs, @@ -808,13 +862,13 @@ export class CronScheduler { // Send IM notification if configured if (task.notification?.enabled && task.notification.channels.length > 0) { - sendTaskNotification(completedRun, task.notification).catch((err) => { + migrationMaintenance.track(sendTaskNotification(completedRun, task.notification)).catch((err) => { console.error(`[CronScheduler] Notification error for task ${task.id}:`, err) }) } // If non-recurring, disable after first run - if (!task.recurring) { + if (!task.recurring && !runningEntry.migrationInterrupted) { await this.cronService.updateTask(task.id, { enabled: false }).catch(() => { // Task may have been deleted }) diff --git a/src/server/services/diagnosticsService.ts b/src/server/services/diagnosticsService.ts index e8b1b182..56499fe9 100644 --- a/src/server/services/diagnosticsService.ts +++ b/src/server/services/diagnosticsService.ts @@ -100,6 +100,10 @@ export class DiagnosticsService { private lastRetentionSweepAt = 0 private writeQueue: Promise = Promise.resolve() + async drainForMigration(): Promise { + await this.writeQueue + } + getLogDir(): string { return path.join(this.getConfigDir(), 'cc-haha', 'diagnostics') } diff --git a/src/server/services/localIndex/coordinator.ts b/src/server/services/localIndex/coordinator.ts index 80512b1b..b73cb42c 100644 --- a/src/server/services/localIndex/coordinator.ts +++ b/src/server/services/localIndex/coordinator.ts @@ -1,4 +1,5 @@ import { lstat, readdir, stat } from 'node:fs/promises' +import { migrationMaintenance } from '../../migrationMaintenance.js' import { basename, join, relative, resolve, sep } from 'node:path' import { getClaudeConfigHomeDir } from '../../../utils/envUtils.js' import { @@ -1428,6 +1429,7 @@ export function createLocalIndexCoordinator( const coordinator: LocalIndexCoordinator = { async start(): Promise { + if (migrationMaintenance.isActive) return if (started) { if (synchronizeRuntimeConfiguration()) return await runtimeReconfigurePromise diff --git a/src/server/services/localIndex/searchContentCoordinator.ts b/src/server/services/localIndex/searchContentCoordinator.ts index 4cfc7a80..d880b34a 100644 --- a/src/server/services/localIndex/searchContentCoordinator.ts +++ b/src/server/services/localIndex/searchContentCoordinator.ts @@ -1,4 +1,5 @@ import { lstat, readdir, rm } from 'node:fs/promises' +import { migrationMaintenance } from '../../migrationMaintenance.js' import { basename, join, relative, resolve, sep } from 'node:path' import { getClaudeConfigHomeDir } from '../../../utils/envUtils.js' import { isConfirmedLocalIndexCorruption } from './recovery.js' @@ -635,6 +636,7 @@ export function createSearchContentCoordinator( const coordinator: SearchContentCoordinator = { async start() { + if (migrationMaintenance.isActive) return const pendingStop = stopPromise if (pendingStop) await pendingStop if (startPromise) return startPromise diff --git a/src/server/services/managedSettingsService.ts b/src/server/services/managedSettingsService.ts index cc267aa1..d3272a9e 100644 --- a/src/server/services/managedSettingsService.ts +++ b/src/server/services/managedSettingsService.ts @@ -9,6 +9,10 @@ import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.j export class ManagedSettingsService { private static writeLocks = new Map>() + static async drainForMigration(): Promise { + await Promise.all([...this.writeLocks.values()]) + } + private getConfigDir(): string { return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') } diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 3de58403..26630ac4 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -725,6 +725,11 @@ export class SessionService { }> >() private readonly taskNotificationMutationEpochs = new Map() + + async drainForMigration(): Promise { + const writes = [...this.pendingTaskNotificationWrites.values()].flatMap(entries => [...entries].map(entry => entry.promise)) + await Promise.all(writes) + } private readonly clearingTaskNotificationSessions = new Set() private readonly localIndexGateway: LocalIndexGateway diff --git a/src/server/services/sessionTurnEvents.ts b/src/server/services/sessionTurnEvents.ts index bc3f652c..5b0f489a 100644 --- a/src/server/services/sessionTurnEvents.ts +++ b/src/server/services/sessionTurnEvents.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'node:events' +import { migrationMaintenance } from '../migrationMaintenance.js' export type SessionTurnEvent = | { type: 'user-input' | 'input-committed' | 'stopped'; sessionId: string } @@ -27,6 +28,7 @@ export function registerSessionTurnAdmissionGuard(guard: AdmissionGuard): () => } export async function admitSessionUserTurn(sessionId: string, canAdmit: () => boolean): Promise { + migrationMaintenance.assertAvailable() if (admissionGuard) return admissionGuard(sessionId, canAdmit) return { release: async () => {} } } diff --git a/src/server/services/teamPlanRuntime.ts b/src/server/services/teamPlanRuntime.ts index 2e6ba875..85b1ad43 100644 --- a/src/server/services/teamPlanRuntime.ts +++ b/src/server/services/teamPlanRuntime.ts @@ -1,4 +1,5 @@ import { createHash, randomUUID } from 'node:crypto' +import { migrationMaintenance } from '../migrationMaintenance.js' import { readdir, readFile, stat } from 'node:fs/promises' import { join } from 'node:path' import { isValidTeamMemberName, teamPlanRecordSchema, type TeamPlanRecord, type TeamPlanMember } from '../../shared/teamPlan.js' @@ -260,7 +261,7 @@ async function startWorkerProcess(launch: TeamLaunch, worker: WorkerRuntime, res } conversationService.onOutput(worker.sessionId, message => { if (message?.type !== 'result') return - void handleWorkerResult(launch, worker, message).catch(error => console.error(`[TeamPlanRuntime] cannot record ${member.name}'s turn`, error)) + void migrationMaintenance.track(handleWorkerResult(launch, worker, message)).catch(error => console.error(`[TeamPlanRuntime] cannot record ${member.name}'s turn`, error)) }) } @@ -318,6 +319,7 @@ function autoContinuePrompt(reason: string, attempt: number): string { } async function autoContinueWorker(launch: TeamLaunch, worker: WorkerRuntime, reason: string, attempt: number): Promise { + if (migrationMaintenance.isActive) return worker.autoContinueTimer = undefined if (launch.stopped || launch.pausedAt || !conversationService.hasSession(worker.sessionId)) return // Queued mail resumes the member anyway, with the newer context. @@ -361,7 +363,7 @@ async function handleWorkerResult(launch: TeamLaunch, worker: WorkerRuntime, mes const delay = timing.autoContinueDelaysMs[attempt - 1]! cancelAutoContinue(worker) worker.autoContinueTimer = setTimeout(() => { - void autoContinueWorker(launch, worker, reason, attempt).catch(error => console.error(`[TeamPlanRuntime] cannot continue ${worker.member.name}`, error)) + void migrationMaintenance.track(autoContinueWorker(launch, worker, reason, attempt)).catch(error => console.error(`[TeamPlanRuntime] cannot continue ${worker.member.name}`, error)) }, delay) worker.autoContinueTimer.unref?.() await updateWorkerEntry(launch, worker, entry => ({ ...entry, isActive: false, lastError: reason, autoRetry: { attempt, max: timing.autoContinueDelaysMs.length, nextAt: Date.now() + delay } })) @@ -422,6 +424,7 @@ async function wakeForReadyTask(launch: TeamLaunch, worker: WorkerRuntime, entry } async function superviseLaunch(launch: TeamLaunch): Promise { + if (migrationMaintenance.isActive) return const team = readTeamFile(launch.plan.teamName) if (!team || team.createdAt !== launch.createdAt) { await stopTeamPlanRuntime(launch.plan.planId) @@ -457,7 +460,7 @@ async function superviseLaunch(launch: TeamLaunch): Promise { // Without a lead nobody coordinates the restarted member; a direct user // message is the exception. if (!leadAlive && !fromUser) continue - void restartWorker(launch, worker, fromUser ? 'user' : 'message') + void migrationMaintenance.track(restartWorker(launch, worker, fromUser ? 'user' : 'message')) continue } await deliverToWorker(launch, worker, messages) @@ -465,11 +468,11 @@ async function superviseLaunch(launch: TeamLaunch): Promise { } function startSupervisor(launch: TeamLaunch): void { - if (launch.timer) return + if (launch.timer || migrationMaintenance.isActive) return launch.timer = setInterval(() => { - if (launch.supervising || launch.stopped) return + if (launch.supervising || launch.stopped || migrationMaintenance.isActive) return launch.supervising = true - void superviseLaunch(launch) + void migrationMaintenance.track(superviseLaunch(launch)) .catch(error => { console.error('[TeamPlanRuntime] team supervision failed', error) }) .finally(() => { launch.supervising = false }) }, SUPERVISOR_INTERVAL_MS) @@ -495,11 +498,11 @@ function pauseLaunchesForLead(parentSessionId: string): void { // No notice goes to the lead's mailbox: delivering it would start a lead // turn right after the user stopped everything. Messaging a stopped member // restarts it, so the lead needs no special knowledge to continue later. - void mutateTeamFileAsync(launch.plan.teamName, team => { + void migrationMaintenance.track(mutateTeamFileAsync(launch.plan.teamName, team => { if (team.createdAt !== launch.createdAt) return const sessions = new Set([...launch.workers.values()].map(worker => worker.sessionId)) return { ...team, members: team.members.map(entry => entry.sessionId && sessions.has(entry.sessionId) ? { ...entry, isActive: false, terminated: true } : entry) } - }).catch(error => console.error('[TeamPlanRuntime] cannot record the paused team', error)) + })).catch(error => console.error('[TeamPlanRuntime] cannot record the paused team', error)) } } @@ -544,11 +547,11 @@ function ensureRuntimeHooks(): void { const ownedLaunch = () => [...launches.values()].find(item => item.parentId === sessionId && item.running && !item.stopped) // Workers never lead a team; skip the disk scan a re-own needs. if (!ownedLaunch() && isTeamWorker) return - void (async () => { + void migrationMaintenance.track((async () => { if (!ownedLaunch()) await rehydrateTeamPlanRuntimesForSession(sessionId) const launch = ownedLaunch() if (launch) await sendTeamSnapshot(sessionId, launch.plan.teamName, launch.createdAt) - })().catch(error => console.error('[TeamPlanRuntime] cannot restore the team after a lead start', error)) + })()).catch(error => console.error('[TeamPlanRuntime] cannot restore the team after a lead start', error)) }, }) } @@ -582,6 +585,7 @@ export async function stopTeamPlanRuntime(planId: string): Promise { } export async function launchTeamPlanRuntime(plan: TeamPlanRecord): Promise<{ memberIds: Record }> { + migrationMaintenance.assertAvailable() const approved = plan.approvedSnapshot if (!approved || approved.revision !== plan.revision - 1 || plan.state !== 'launching') throw new Error('Team plan is not approved for launch') ensureRuntimeHooks() @@ -661,12 +665,33 @@ export async function launchTeamPlanRuntime(plan: TeamPlanRecord): Promise<{ mem } } +/** Keep released teams resumable while preventing every supervisor wake. */ +export async function quiesceTeamPlanRuntimesForMigration(): Promise { + const parents = new Set([...launches.values()].map(launch => launch.parentId)) + for (const parent of parents) pauseLaunchesForLead(parent) + for (const launch of launches.values()) { + if (launch.timer) clearInterval(launch.timer) + launch.timer = undefined + for (const worker of launch.workers.values()) cancelAutoContinue(worker) + if (launch.running) continue + launch.stopped = true + const current = await readTeamPlan(launch.plan.teamName) + if (current?.planId === launch.plan.planId && current.state === 'launching') { + await mutateTeamPlan(current.teamName, { ...current, expectedRevision: current.revision }, plan => ({ + ...plan, state: launch.released ? 'interrupted' : 'cancelled', + launch: { ...plan.launch, status: 'failed', executionStarted: launch.released, error: 'Team startup stopped for data migration.' }, + })) + } + } +} + /** * Rebuild the supervisor for an approved team whose server-side owner was lost * (server or app restart). Members start dormant and restart from their own * transcripts as soon as the lead or the user messages them. */ export async function rehydrateTeamPlanRuntime(plan: TeamPlanRecord): Promise { + if (migrationMaintenance.isActive) return false if (plan.state !== 'running' || !plan.approvedSnapshot || launches.has(plan.planId)) return launches.has(plan.planId) const team = readTeamFile(plan.teamName) if (!team || team.leadSessionId !== plan.sessionId || incarnationOf(team) !== plan.incarnationId) return false diff --git a/src/server/services/teamWatcher.ts b/src/server/services/teamWatcher.ts index b58bccea..39661b09 100644 --- a/src/server/services/teamWatcher.ts +++ b/src/server/services/teamWatcher.ts @@ -79,6 +79,11 @@ export class TeamWatcher { } } + async stopAndWait(): Promise { + this.stop() + await this.checkPromise + } + /** Visible for testing -- force a single poll cycle. */ checkNow(): Promise { return this.scheduleCheck() diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index e019274b..35f330ac 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -1,4 +1,5 @@ import { getSideChat, isSideChatId } from '../services/sideChatRegistry.js' +import { migrationMaintenance } from '../migrationMaintenance.js' /** * WebSocket connection handler * @@ -669,6 +670,11 @@ export const handleWebSocket = { typeof rawMessage === 'string' ? rawMessage : rawMessage.toString() ) as ClientMessage + if (migrationMaintenance.isActive && message.type !== 'ping' && message.type !== 'sync_state') { + sendError(ws, 'Data migration is in progress', 'MIGRATION_IN_PROGRESS') + return + } + if (ws.data.clientKind === 'pet' && !isPetClientMessageAllowed(message)) { sendError( ws, @@ -681,7 +687,7 @@ export const handleWebSocket = { switch (message.type) { case 'user_message': { const activeTurn: ActiveUserTurnState = { messageSent: false } - handleUserMessage(ws, message, activeTurn).catch((err) => { + migrationMaintenance.track(handleUserMessage(ws, message, activeTurn)).catch((err) => { const sessionId = ws.data.sessionId void diagnosticsService.recordEvent({ type: 'ws_user_message_failed', @@ -728,15 +734,15 @@ export const handleWebSocket = { break case 'set_permission_mode': - void handleSetPermissionMode(ws, message) + void migrationMaintenance.track(handleSetPermissionMode(ws, message)) break case 'set_runtime_config': - void handleSetRuntimeConfig(ws, message) + void migrationMaintenance.track(handleSetRuntimeConfig(ws, message)) break case 'prewarm_session': - void handlePrewarmSession(ws) + void migrationMaintenance.track(handlePrewarmSession(ws)) break case 'sync_state': @@ -756,7 +762,7 @@ export const handleWebSocket = { break case 'stop_background_task': - void handleStopBackgroundTask(ws, message) + void migrationMaintenance.track(handleStopBackgroundTask(ws, message)) break case 'ping': @@ -1139,6 +1145,24 @@ export function stopSessionTurn(sessionId: string): void { handleStopGeneration(sessionTurnConnection(sessionId, { serverHost: '127.0.0.1', serverPort: 0 })) } +export function revokeSessionAdmissionsForMigration(): void { + for (const turn of activeUserTurns.values()) turn.cancelled = true + for (const timer of prewarmIdleTimers.values()) clearTimeout(timer) + for (const timer of sessionCleanupTimers.values()) clearTimeout(timer) + prewarmIdleTimers.clear() + sessionCleanupTimers.clear() + for (const remove of sessionDisconnectWatchers.values()) remove() + sessionDisconnectWatchers.clear() + for (const tasks of activeAgentTasks.values()) { + for (const task of tasks.values()) clearAgentStopFinalizationRetry(task) + } +} + +export async function drainSessionWritesForMigration(): Promise { + await Promise.allSettled([...runtimeTransitionPromises.values(), ...sessionStartupPromises.values()]) + await Promise.all([...taskNotificationPersistence.values()].flatMap(writes => [...writes.values()])) +} + export function isSessionTurnStopped(sessionId: string): boolean { return interruptedSessionChats.has(sessionId) || activeUserTurns.get(sessionId)?.cancelled === true } @@ -2559,6 +2583,7 @@ function scheduleAgentStopFinalizationRetry( sessionId: string, task: ActiveAgentTaskState, ): void { + if (migrationMaintenance.isActive) return if (!task.localStopConfirmed || task.finalizationRetryTimer !== undefined) return const delayMs = AGENT_STOP_FINALIZATION_RETRY_DELAYS_MS[task.finalizationRetryCount] if (delayMs !== undefined) { @@ -2575,7 +2600,7 @@ function scheduleAgentStopFinalizationRetry( return } current.stopFailureMessage = undefined - void emitAuthoritativeAgentStopped(sessionId, current) + void migrationMaintenance.track(emitAuthoritativeAgentStopped(sessionId, current)) }, delayMs) if (typeof task.finalizationRetryTimer === 'object') { task.finalizationRetryTimer.unref?.() @@ -2628,7 +2653,7 @@ function emitAuthoritativeAgentStopped( if (current.bookendPending) return Promise.resolve(false) current.bookendPending = true - const finalization = (async (): Promise => { + const finalization = migrationMaintenance.track((async (): Promise => { const remoteArchiveAttempt = current.taskType === 'remote_agent' ? ensureRemoteAgentArchive(sessionId, current) : undefined @@ -2725,7 +2750,7 @@ function emitAuthoritativeAgentStopped( forwardCliMessageToSessionClients(sessionId, cliMsg) scheduleDisconnectedSessionCleanupIfIdle(sessionId) return true - })().catch((error): boolean => { + })()).catch((error): boolean => { if (activeAgentTasks.get(sessionId)?.get(current.taskId) !== current) return false current.bookendPending = false current.stopRequested = false @@ -2747,7 +2772,7 @@ function resumeAgentFinalizationAfterFailedClear( ): void { const pendingFinalizations = tasks.flatMap((task) => task.finalization ? [task.finalization] : []) - void Promise.allSettled(pendingFinalizations).then(() => { + void migrationMaintenance.track(Promise.allSettled(pendingFinalizations).then(() => { for (const task of tasks) { const current = activeAgentTasks.get(sessionId)?.get(task.taskId) if (current !== task) continue @@ -2759,7 +2784,7 @@ function resumeAgentFinalizationAfterFailedClear( current.stopFailureMessage = undefined void emitAuthoritativeAgentStopped(sessionId, current) } - }) + })) } function emitAuthoritativeStoppedForActiveAgents(sessionId: string): Promise { @@ -2852,6 +2877,7 @@ function triggerTitleGeneration( ): void { const state = sessionTitleState.get(sessionId) if (!state || state.hasCustomTitle || state.hasExistingTranscript) return + if (migrationMaintenance.isActive) return // Titles summarize cumulative input. Once it includes a private turn, later // refreshes must remain in memory even if retention is enabled again. state.persistTitleSource &&= sessionService.shouldPersistSession() @@ -2867,7 +2893,7 @@ function triggerTitleGeneration( if (state.startedGenerationKeys.has(key)) return state.startedGenerationKeys.add(key) - void (async () => { + void migrationMaintenance.track((async () => { try { const text = state.firstUserMessage const placeholder = deriveTitle(text) @@ -2882,7 +2908,7 @@ function triggerTitleGeneration( } catch (err) { console.error(`[Title] Failed to derive title for ${sessionId}:`, err) } - })() + })()) return } @@ -2896,7 +2922,7 @@ function triggerTitleGeneration( const runtimeProviderId = runtimeOverrides.get(sessionId)?.providerId const generationSeq = ++state.generationSeq - void (async () => { + void migrationMaintenance.track((async () => { try { const responseLanguage = await getResponseLanguageSetting() const titleLanguagePreference = resolveTitleLanguagePreference( @@ -2921,7 +2947,7 @@ function triggerTitleGeneration( } catch (err) { console.error(`[Title] Failed to generate title for ${sessionId}:`, err) } - })() + })()) } async function getResponseLanguageSetting(): Promise { @@ -3215,6 +3241,7 @@ function clearPrewarmState(sessionId: string) { } function markPrewarmed(sessionId: string) { + if (migrationMaintenance.isActive) return prewarmedSessions.add(sessionId) const timeoutMs = getPrewarmIdleTimeoutMs() if (timeoutMs === 0) return @@ -3247,12 +3274,12 @@ function cacheSessionInitMetadata(sessionId: string, cliMsg: any) { if (cliMsg?.type !== 'system' || cliMsg.subtype !== 'init') return if (typeof cliMsg.cwd === 'string' && cliMsg.cwd.trim()) { conversationService.updateSessionWorkDir(sessionId, cliMsg.cwd) - void (async () => { + void migrationMaintenance.track((async () => { await sessionService.appendSessionMetadata(sessionId, { workDir: cliMsg.cwd, }) await sessionService.deletePlaceholderSessionFiles(sessionId, cliMsg.cwd) - })() + })()) } if (cliMsg.slash_commands && Array.isArray(cliMsg.slash_commands)) { updateSessionSlashCommands(sessionId, cliMsg.slash_commands, { notifyClient: false }) @@ -4095,6 +4122,7 @@ function hasLiveUserTurnForClient(sessionId: string): boolean { * reconnects before it fires, the CLI subprocess is stopped. */ function scheduleDisconnectCleanup(sessionId: string): void { + if (migrationMaintenance.isActive) return computerUseApprovalService.cancelSession(sessionId) const existing = sessionCleanupTimers.get(sessionId) @@ -4103,7 +4131,7 @@ function scheduleDisconnectCleanup(sessionId: string): void { const cleanupDelayMs = getDisconnectCleanupDelayMs(sessionId) const cleanupTimer = setTimeout(() => { sessionCleanupTimers.delete(sessionId) - if (hasActiveClients(sessionId)) return + if (migrationMaintenance.isActive || hasActiveClients(sessionId)) return const permissionBoundExpired = conversationService .getPendingPermissionRequests(sessionId).length > 0 @@ -4142,6 +4170,7 @@ function scheduleDisconnectedSessionCleanupIfIdle(sessionId: string): void { * (issue #764). If a client reconnects first, the watcher is torn down. */ function watchTurnCompletionForCleanup(sessionId: string): void { + if (migrationMaintenance.isActive) return cancelSessionDisconnectWatcher(sessionId) const onComplete = (cliMsg: any) => { @@ -4643,7 +4672,7 @@ function handleCliPermissionModeBroadcast(sessionId: string, cliMsg: any): void if (currentMode === mode) return if (!conversationService.recordSessionPermissionMode(sessionId, mode)) return - void persistSessionPermissionMode(sessionId, mode).catch((err) => { + void migrationMaintenance.track(persistSessionPermissionMode(sessionId, mode)).catch((err) => { console.warn(`[WS] Failed to persist CLI permission mode broadcast for ${sessionId}:`, err) }) } diff --git a/src/services/connectors/nativeConnectorSkill.test.ts b/src/services/connectors/nativeConnectorSkill.test.ts new file mode 100644 index 00000000..41a82e45 --- /dev/null +++ b/src/services/connectors/nativeConnectorSkill.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test' +import { renderNativeConnectorSkill } from './nativeConnectorSkill.js' +import type { ConnectorDefinition } from './types.js' + +const definition: ConnectorDefinition = { + id: 'dingtalk', pluginId: 'office-dingtalk@haha-connectors', packageName: 'dingtalk-workspace-cli', + version: '1.0.50', homepage: 'https://example.test/official', credentialMode: 'isolated', platforms: ['win32-x64'], +} + +describe('native connector skill regeneration', () => { + test('renders relocated invocation commands safely and includes only owned environment entries', () => { + const skill = renderNativeConnectorSkill(definition, { + directory: "D:/Haha's data/connectors/runtime/dingtalk/1.0.50-win32-x64", + command: "D:/Haha's data/connectors/runtime/dingtalk/1.0.50-win32-x64/dws.exe", + args: ['--fixture'], + env: { DWS_CONFIG_DIR: "D:/Haha's data/connectors/accounts/dingtalk/dws", DWS_DISABLE_KEYCHAIN: '1', SECRET: 'never-render' }, + }) + expect(skill).toContain('D:/Haha\'\'s data/connectors') + expect(skill).toContain('D:/Haha\'"\'"\'s data/connectors') + expect(skill).toContain('dingtalk-workspace-cli@1.0.50') + expect(skill).not.toContain('never-render') + expect(skill).not.toContain('SECRET') + }) + + test('rejects unknown native identities instead of regenerating a user plugin', () => { + expect(() => renderNativeConnectorSkill({ ...definition, pluginId: 'user-plugin@market' }, { directory: '', command: '', args: [], env: {} })) + .toThrow('Invalid managed native connector identity') + }) +}) diff --git a/src/services/connectors/nativeConnectorSkill.ts b/src/services/connectors/nativeConnectorSkill.ts new file mode 100644 index 00000000..40511afe --- /dev/null +++ b/src/services/connectors/nativeConnectorSkill.ts @@ -0,0 +1,63 @@ +import type { ConnectorDefinition, ConnectorId, ConnectorInstallation } from './types.js' + +export const nativeConnectorRecipes: Record = { + feishu: { name: '飞书 / Feishu', help: 'calendar --help', task: 'Read the user’s calendar agenda with calendar +agenda. Discover document commands with docs --help.' }, + dingtalk: { name: '钉钉 / DingTalk', help: 'calendar +agenda --help', task: 'For today’s agenda use calendar +agenda --format json. The account needs calendar access and any required organization approval. Discover other domains with --help before use.' }, + wecom: { name: '企业微信 / WeCom', help: 'calendar schedules list --help', task: 'List calendar schedules with calendar schedules list --begin-time --end-time , using the requested time window and this version’s documented time format. Omitted dates default to the next 30 days, not today. This command already returns JSON; do not append an unsupported --format flag. Discover other domains with --help before use.' }, +} + +function shellQuote(value: string) { + return `'${value.replace(/'/g, `'"'"'`)}'` +} + +function powershellQuote(value: string) { + return `'${value.replace(/'/g, "''")}'` +} + + +export function renderNativeConnectorSkill(definition: ConnectorDefinition, installation: ConnectorInstallation): string { + const expectedName = `office-${definition.id}` + if (definition.pluginId !== `${expectedName}@haha-connectors` || !nativeConnectorRecipes[definition.id]) { + throw new Error('Invalid managed native connector identity') + } + const recipe = nativeConnectorRecipes[definition.id]! + const command = [installation.command, ...installation.args] + // Only adapter-owned non-secret environment entries belong in skill text. + const environment = Object.entries(installation.env).filter(([key]) => ['DWS_CONFIG_DIR', 'DWS_KEYCHAIN_DIR', 'DWS_DISABLE_KEYCHAIN'].includes(key)) + const bashCommand = [...environment.map(([key, value]) => `${key}=${shellQuote(value)}`), ...command.map(shellQuote)].join(' ') + const windowsCommand = `${environment.map(([key, value]) => `$env:${key} = ${powershellQuote(value)}; `).join('')}& ${command.map(powershellQuote).join(' ')}` + return `--- +name: ${expectedName} +description: Use ${recipe.name} through the desktop-managed connector when the user requests its documents, calendar, or collaboration services. +--- + +# ${recipe.name} + +This skill belongs to the desktop connector. The desktop manages installation and account connection. Use the pinned executable below; do not install a global CLI, change accounts, or delete configuration directories. + +## Invocation + +On macOS / a POSIX shell: + +\`\`\`sh +${bashCommand} ${recipe.help} +\`\`\` + +On Windows PowerShell: + +\`\`\`powershell +${windowsCommand} ${recipe.help} +\`\`\` + +Keep the executable path, environment and prefix arguments when appending a command. Request JSON output where supported and inspect this version's --help instead of guessing parameters. ${recipe.task} + +## Account and permissions + +Use the connected account only. A successful login does not grant every business permission. If authentication expires or required scopes are missing, explain the service's error and direct the user to the desktop connector's connection flow. Do not print tokens, perform login from a task, or read credential files. + +Prefer read-only discovery. Send messages, edit documents, create events, or perform other writes only when the user's request authorizes that action. Show errors accurately; never claim a business operation succeeded from login status alone. + +Official documentation: ${definition.homepage} +Pinned package: ${definition.packageName}@${definition.version} +` +} diff --git a/src/services/connectors/pluginBridge.ts b/src/services/connectors/pluginBridge.ts index a3a2997d..6cc37b46 100644 --- a/src/services/connectors/pluginBridge.ts +++ b/src/services/connectors/pluginBridge.ts @@ -13,14 +13,12 @@ import { getRemoteRecipe } from './remoteCatalog.js' import { buildRemotePlugin } from './remoteConnector.js' import { getSkillRecipe } from './skillCatalog.js' import { readSkillBundleFiles } from './skillAdapter.js' -import type { ConnectorDefinition, ConnectorId, ConnectorInstallation } from './types.js' +import type { ConnectorDefinition, ConnectorInstallation } from './types.js' + +import { nativeConnectorRecipes as recipes, renderNativeConnectorSkill } from './nativeConnectorSkill.js' const MARKETPLACE = 'haha-connectors' -const recipes: Record = { - feishu: { name: '飞书 / Feishu', help: 'calendar --help', task: 'Read the user’s calendar agenda with calendar +agenda. Discover document commands with docs --help.' }, - dingtalk: { name: '钉钉 / DingTalk', help: 'calendar +agenda --help', task: 'For today’s agenda use calendar +agenda --format json. The account needs calendar access and any required organization approval. Discover other domains with --help before use.' }, - wecom: { name: '企业微信 / WeCom', help: 'calendar schedules list --help', task: 'List calendar schedules with calendar schedules list --begin-time --end-time , using the requested time window and this version’s documented time format. Omitted dates default to the next 30 days, not today. This command already returns JSON; do not append an unsupported --format flag. Discover other domains with --help before use.' }, -} + // All three connectors share one local marketplace and user settings entry. // Serialize bridge writes so simultaneous preparation does not lose an entry. @@ -43,13 +41,6 @@ function pluginName(definition: ConnectorDefinition): string { return expected } -function shellQuote(value: string) { - return `'${value.replace(/'/g, `'"'"'`)}'` -} - -function powershellQuote(value: string) { - return `'${value.replace(/'/g, "''")}'` -} export function renderConnectorSkill(definition: ConnectorDefinition, installation: ConnectorInstallation): string { const bundle = getSkillRecipe(definition.id, definition.version) @@ -89,46 +80,7 @@ Use read-only discovery first. Business writes (messages, documents, transaction Official documentation: ${definition.homepage} ` - const recipe = recipes[definition.id]! - const command = [installation.command, ...installation.args] - // Only adapter-owned non-secret environment entries belong in skill text. - const environment = Object.entries(installation.env).filter(([key]) => ['DWS_CONFIG_DIR', 'DWS_KEYCHAIN_DIR', 'DWS_DISABLE_KEYCHAIN'].includes(key)) - const bashCommand = [...environment.map(([key, value]) => `${key}=${shellQuote(value)}`), ...command.map(shellQuote)].join(' ') - const windowsCommand = `${environment.map(([key, value]) => `$env:${key} = ${powershellQuote(value)}; `).join('')}& ${command.map(powershellQuote).join(' ')}` - return `--- -name: ${pluginName(definition)} -description: Use ${recipe.name} through the desktop-managed connector when the user requests its documents, calendar, or collaboration services. ---- - -# ${recipe.name} - -This skill belongs to the desktop connector. The desktop manages installation and account connection. Use the pinned executable below; do not install a global CLI, change accounts, or delete configuration directories. - -## Invocation - -On macOS / a POSIX shell: - -\`\`\`sh -${bashCommand} ${recipe.help} -\`\`\` - -On Windows PowerShell: - -\`\`\`powershell -${windowsCommand} ${recipe.help} -\`\`\` - -Keep the executable path, environment and prefix arguments when appending a command. Request JSON output where supported and inspect this version's --help instead of guessing parameters. ${recipe.task} - -## Account and permissions - -Use the connected account only. A successful login does not grant every business permission. If authentication expires or required scopes are missing, explain the service's error and direct the user to the desktop connector's connection flow. Do not print tokens, perform login from a task, or read credential files. - -Prefer read-only discovery. Send messages, edit documents, create events, or perform other writes only when the user's request authorizes that action. Show errors accurately; never claim a business operation succeeded from login status alone. - -Official documentation: ${definition.homepage} -Pinned package: ${definition.packageName}@${definition.version} -` + return renderNativeConnectorSkill(definition, installation) } async function writeAtomic(file: string, content: string | Uint8Array) { diff --git a/src/tools/FileReadTool/FileReadTool.test.ts b/src/tools/FileReadTool/FileReadTool.test.ts index ec25c2e6..ff15ab5c 100644 --- a/src/tools/FileReadTool/FileReadTool.test.ts +++ b/src/tools/FileReadTool/FileReadTool.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, spyOn, test } from 'bun:test' +import { afterAll, beforeAll, afterEach, describe, expect, spyOn, test } from 'bun:test' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -20,6 +20,43 @@ function makeToolUseContext(): ToolUseContext { } const temporaryDirectories: string[] = [] +let isolatedConfig: string +const originalConfigDir = process.env.CLAUDE_CONFIG_DIR +beforeAll(async () => { + isolatedConfig = await mkdtemp(join(tmpdir(), 'file-read-test-config-')) + process.env.CLAUDE_CONFIG_DIR = isolatedConfig +}) +afterAll(async () => { + if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = originalConfigDir + await rm(isolatedConfig, { recursive: true, force: true }) +}) + +test('uses the relocated managed upload for permissions and reading, preserving the original path input', async () => { + const current = await mkdtemp(join(tmpdir(), 'file-read-relocated-')) + temporaryDirectories.push(current) + const previous = process.env.CLAUDE_CONFIG_DIR + const previousSimple = process.env.CLAUDE_CODE_SIMPLE + process.env.CLAUDE_CONFIG_DIR = current + process.env.CLAUDE_CODE_SIMPLE = '1' + try { + const oldRoot = join(current, 'absent-original') + const oldPath = join(oldRoot, 'uploads', 'session', 'fixture.txt') + const newPath = join(current, 'uploads', 'session', 'fixture.txt') + await mkdir(join(current, 'uploads/session'), { recursive: true }) + await mkdir(join(current, 'cc-haha')) + await writeFile(newPath, 'copied upload') + await writeFile(join(current, 'cc-haha/storage-relocations.json'), JSON.stringify({ version: 1, previousRoots: [oldRoot] })) + expect(FileReadTool.getPath({ file_path: oldPath })).toBe(newPath) + const result = await FileReadTool.call({ file_path: oldPath }, makeToolUseContext()) + expect(result.data).toMatchObject({ type: 'text', file: { content: 'copied upload' } }) + } finally { + if (previous === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = previous + if (previousSimple === undefined) delete process.env.CLAUDE_CODE_SIMPLE + else process.env.CLAUDE_CODE_SIMPLE = previousSimple + } +}) test('uses the shared image processor for the final image compression fallback', async () => { const root = await mkdtemp(join(tmpdir(), 'cc-haha-read-image-fallback-')) diff --git a/src/tools/FileReadTool/FileReadTool.ts b/src/tools/FileReadTool/FileReadTool.ts index 4450ab55..e1774d45 100644 --- a/src/tools/FileReadTool/FileReadTool.ts +++ b/src/tools/FileReadTool/FileReadTool.ts @@ -29,6 +29,7 @@ import type { ToolUseContext } from '../../Tool.js' import { buildTool, type ToolDef } from '../../Tool.js' import { getCwd } from '../../utils/cwd.js' import { getClaudeConfigHomeDir, isEnvTruthy } from '../../utils/envUtils.js' +import { resolveRelocatedAttachmentPath } from '../../utils/storageRelocations.js' import { getErrnoCode, isENOENT } from '../../utils/errors.js' import { addLineNumbers, @@ -385,13 +386,13 @@ export const FileReadTool = buildTool({ return { isSearch: false, isRead: true } }, getPath({ file_path }): string { - return file_path || getCwd() + return file_path ? resolveRelocatedAttachmentPath(expandPath(file_path)) : getCwd() }, backfillObservableInput(input) { // hooks.mdx documents file_path as absolute; expand so hook allowlists // can't be bypassed via ~ or relative paths. if (typeof input.file_path === 'string') { - input.file_path = expandPath(input.file_path) + input.file_path = resolveRelocatedAttachmentPath(expandPath(input.file_path)) } }, async preparePermissionMatcher({ file_path }) { @@ -418,9 +419,9 @@ export const FileReadTool = buildTool({ }, renderToolUseErrorMessage, async validateInput({ file_path, pages }, toolUseContext: ToolUseContext) { - // Path expansion + extension checks are string-only and avoid I/O before - // permission evaluation. - const fullFilePath = expandPath(file_path) + // Resolve trusted relocation metadata before evaluating permissions; the + // attachment itself is not opened here. + const fullFilePath = resolveRelocatedAttachmentPath(expandPath(file_path)) const ext = path.extname(fullFilePath).toLowerCase() // Validate pages parameter only for PDF files. Models sometimes send @@ -524,7 +525,7 @@ export const FileReadTool = buildTool({ const effectivePages = isPDFExtension(ext) ? pages : undefined // Use expandPath for consistent path normalization with FileEditTool/FileWriteTool // (especially handles whitespace trimming and Windows path separators) - const fullFilePath = expandPath(file_path) + const fullFilePath = resolveRelocatedAttachmentPath(expandPath(file_path)) // Dedup: if we've already read this exact range and the file hasn't // changed on disk, return a stub instead of re-sending the full content. diff --git a/src/tools/ImageGenTool/backend.test.ts b/src/tools/ImageGenTool/backend.test.ts index 3779bb41..37be815c 100644 --- a/src/tools/ImageGenTool/backend.test.ts +++ b/src/tools/ImageGenTool/backend.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'bun:test' +import { afterAll, beforeAll, afterEach, describe, expect, test } from 'bun:test' import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { join } from 'path' @@ -36,6 +36,17 @@ const customConfig: ImageGenerationRuntimeConfig = { } let outputDir: string | undefined +let isolatedConfig: string +const originalConfigDir = process.env.CLAUDE_CONFIG_DIR +beforeAll(async () => { + isolatedConfig = await mkdtemp(join(tmpdir(), 'imagegen-test-config-')) + process.env.CLAUDE_CONFIG_DIR = isolatedConfig +}) +afterAll(async () => { + if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = originalConfigDir + await rm(isolatedConfig, { recursive: true, force: true }) +}) afterEach(async () => { if (outputDir) await rm(outputDir, { recursive: true, force: true }) @@ -46,6 +57,28 @@ afterEach(async () => { }) describe('ImageGen backend', () => { + test('edits a migrated session upload referenced by its historical path', async () => { + outputDir = await mkdtemp(join(tmpdir(), 'imagegen-relocated-')) + const previous = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = outputDir + try { + const originalRoot = join(outputDir, 'absent-original') + const uploadDir = join(outputDir, 'uploads', getSessionId()) + await mkdir(uploadDir, { recursive: true }) + await mkdir(join(outputDir, 'cc-haha')) + await writeFile(join(uploadDir, 'source.png'), PNG_BYTES) + await writeFile(join(outputDir, 'cc-haha/storage-relocations.json'), JSON.stringify({ version: 1, previousRoots: [originalRoot] })) + const result = await generateImages({ prompt: 'edit my image', count: 1, referenced_image_paths: [join(originalRoot, 'uploads', getSessionId(), 'source.png')] }, customConfig, { + outputDir, + fetchImpl: async () => Response.json({ data: [{ b64_json: PNG_BYTES.toString('base64') }] }), + }) + expect(result.inputImageCount).toBe(1) + } finally { + if (previous === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = previous + } + }) + test('builds the ChatGPT Responses image tool contract', () => { expect(buildChatGPTRequestBody({ prompt: 'A geometric fox poster', diff --git a/src/tools/ImageGenTool/backend.ts b/src/tools/ImageGenTool/backend.ts index 4f153b9d..e6f3467e 100644 --- a/src/tools/ImageGenTool/backend.ts +++ b/src/tools/ImageGenTool/backend.ts @@ -24,6 +24,7 @@ import { getCcHahaDir, getClaudeConfigHomeDir } from '../../utils/envUtils.js' import { getImageStoreDir } from '../../utils/imageStore.js' import { getProxyFetchOptions } from '../../utils/proxy.js' import { isUserProvidedImage } from '../../utils/userProvidedImages.js' +import { resolveRelocatedAttachmentPath } from '../../utils/storageRelocations.js' import { buildApiSmartImageBodies, isApiSmartImageConfig } from './apiSmart.js' import { downloadGeneratedImage } from './imageDownload.js' @@ -623,7 +624,7 @@ async function prepareInputImages( ) return Promise.all(requested.map(async (inputPath) => { - const resolvedPath = await realpath(inputPath).catch(() => null) + const resolvedPath = await realpath(resolveRelocatedAttachmentPath(inputPath)).catch(() => null) // Two ways an image earns the right to be uploaded to the image provider: // it sits in a per-session directory we own (pasted, staged, or generated), // or the user named it explicitly with @. Anything else — a path the model diff --git a/src/utils/storageMigrationMetadata.test.ts b/src/utils/storageMigrationMetadata.test.ts new file mode 100644 index 00000000..35acc1a8 --- /dev/null +++ b/src/utils/storageMigrationMetadata.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { cp, link, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { relocateManagedData } from './storageMigrationMetadata.js' + +const roots: string[] = [] +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'storage-metadata-')) + roots.push(root) + const source = join(root, 'source') + const target = join(root, 'target') + const stage = join(root, 'stage') + await mkdir(source) + const put = async (relativePath: string, value: unknown) => { + const file = join(source, relativePath) + await mkdir(join(file, '..'), { recursive: true }) + await writeFile(file, typeof value === 'string' ? value : JSON.stringify(value)) + } + return { root, source, target, stage, put } +} + +describe('copied storage metadata migration', () => { + test('preserves both runtime and canonical roots and relocates canonical connector paths from an alias', async () => { + const { root, source, target, stage, put } = await fixture() + const alias = join(root, 'runtime-alias') + await symlink(source, alias, 'junction') + const directory = join(source, 'connectors', 'runtime', 'dingtalk', '1.0.50-win32-x64') + await put('connectors/state.json', { connectors: { dingtalk: { enabled: true, installation: { + directory, command: join(directory, 'dws.exe'), args: [], env: {}, + } } } }) + await cp(source, stage, { recursive: true }) + await relocateManagedData(alias, target, stage) + const state = JSON.parse(await readFile(join(stage, 'connectors/state.json'), 'utf8')) + expect(state.connectors.dingtalk).toMatchObject({ enabled: true, installation: { directory: join(target, 'connectors/runtime/dingtalk/1.0.50-win32-x64') } }) + expect(JSON.parse(await readFile(join(stage, 'cc-haha/storage-relocations.json'), 'utf8')).previousRoots).toEqual([alias, source]) + }) + + test('rebases old connector/plugin fixtures, regenerates owned skills and preserves shared state and transcript bytes', async () => { + const { root, source, target, stage, put } = await fixture() + const older = join(root, 'older') + const nativeDir = join(source, 'connectors', 'runtime', 'dingtalk', '1.0.50-win32-x64') + const cache = join(source, 'plugins', 'cache', 'haha-connectors', 'office-dingtalk', '1.0.50-connector.1') + await put('cc-haha/storage-relocations.json', { version: 1, previousRoots: [older, source], futureField: { keep: true } }) + await put('connectors/state.json', { unknown: 123, connectors: { dingtalk: { installed: true, enabled: true, installedVersion: '1.0.50', unknownRecord: 'keep', installation: { + directory: nativeDir, command: join(nativeDir, 'dws.exe'), args: [], env: { DWS_CONFIG_DIR: join(source, 'connectors', 'accounts', 'dingtalk', 'dws'), DWS_KEYCHAIN_DIR: join(source, 'connectors', 'accounts', 'dingtalk', 'keychain'), DWS_DISABLE_KEYCHAIN: '1', UNKNOWN: 'keep' }, unknownInstall: true, + } } } }) + await put('plugins/known_marketplaces.json', { + 'haha-connectors': { source: { source: 'directory', path: join(source, 'connectors', 'marketplace'), unknown: true }, installLocation: join(source, 'connectors', 'marketplace'), extra: true }, + external: { source: { source: 'file', path: join(root, 'external.json') }, installLocation: join(root, 'external.json') }, + }) + await put('plugins/installed_plugins.json', { version: 2, extra: true, plugins: { 'office-dingtalk@haha-connectors': [{ installPath: cache, projectPath: '/external/project', scope: 'user', version: '1.0.50-connector.1', unknown: 'keep' }] } }) + await put('plugins/cache/haha-connectors/office-dingtalk/1.0.50-connector.1/skills/office-dingtalk/SKILL.md', 'old generated skill') + await put('connectors/marketplace/plugins/office-dingtalk/skills/office-dingtalk/SKILL.md', 'old generated skill') + const settings = JSON.stringify({ enabledPlugins: { 'office-dingtalk@haha-connectors': true }, unknown: true }) + const transcript = JSON.stringify({ type: 'user', message: { content: `@"${join(source, 'uploads', 'session', 'image.png')}" historical text` } }) + '\n' + await put('settings.json', settings) + await put('projects/repo/session.jsonl', transcript) + await cp(source, stage, { recursive: true }) + await relocateManagedData(source, target, stage) + const state = JSON.parse(await readFile(join(stage, 'connectors', 'state.json'), 'utf8')) + expect(state).toMatchObject({ unknown: 123, connectors: { dingtalk: { enabled: true, unknownRecord: 'keep', installation: { directory: nativeDir.replace(source, target), unknownInstall: true, env: { UNKNOWN: 'keep', DWS_CONFIG_DIR: join(target, 'connectors', 'accounts', 'dingtalk', 'dws') } } } } }) + const marketplaces = JSON.parse(await readFile(join(stage, 'plugins', 'known_marketplaces.json'), 'utf8')) + expect(marketplaces['haha-connectors']).toMatchObject({ extra: true, source: { path: join(target, 'connectors', 'marketplace'), unknown: true } }) + expect(marketplaces.external.installLocation).toBe(join(root, 'external.json')) + const plugins = JSON.parse(await readFile(join(stage, 'plugins', 'installed_plugins.json'), 'utf8')) + expect(plugins.plugins['office-dingtalk@haha-connectors'][0]).toMatchObject({ installPath: cache.replace(source, target), projectPath: '/external/project', unknown: 'keep' }) + for (const file of ['plugins/cache/haha-connectors/office-dingtalk/1.0.50-connector.1/skills/office-dingtalk/SKILL.md', 'connectors/marketplace/plugins/office-dingtalk/skills/office-dingtalk/SKILL.md']) { + const skill = await readFile(join(stage, file), 'utf8') + expect(skill).toContain(nativeDir.replace(source, target)) + expect(skill).toContain('@1.0.50') + expect(skill).not.toContain(source) + } + expect(await readFile(join(stage, 'settings.json'), 'utf8')).toBe(settings) + expect(await readFile(join(stage, 'projects/repo/session.jsonl'), 'utf8')).toBe(transcript) + expect(await readFile(join(source, 'connectors/marketplace/plugins/office-dingtalk/skills/office-dingtalk/SKILL.md'), 'utf8')).toBe('old generated skill') + expect(JSON.parse(await readFile(join(stage, 'cc-haha/storage-relocations.json'), 'utf8'))) + .toEqual({ version: 1, previousRoots: [older, source], futureField: { keep: true } }) + }) + + test('supports old plugin V1/cowork fixtures even when the old source no longer exists', async () => { + const { source, target, stage, put } = await fixture() + await put('cowork_plugins/installed_plugins.json', { version: 1, plugins: { 'plugin@market': { installPath: join(source, 'cowork_plugins/cache/plugin'), version: '1' } } }) + await cp(source, stage, { recursive: true }) + await rm(source, { recursive: true }) + await relocateManagedData(source, target, stage) + expect(JSON.parse(await readFile(join(stage, 'cowork_plugins/installed_plugins.json'), 'utf8')).plugins['plugin@market'].installPath) + .toBe(join(target, 'cowork_plugins/cache/plugin')) + }) + + test('rejects unsupported schemas and overlapping previous roots', async () => { + const { source, target, stage, put } = await fixture() + await put('cc-haha/storage-relocations.json', { version: 2, previousRoots: [] }) + await cp(source, stage, { recursive: true }) + await expect(relocateManagedData(source, target, stage)).rejects.toThrow('Unsupported') + await writeFile(join(stage, 'cc-haha/storage-relocations.json'), JSON.stringify({ version: 1, previousRoots: [join(target, 'nested')] })) + await expect(relocateManagedData(source, target, stage)).rejects.toThrow('overlaps') + }) + + test('rejects linked metadata instead of modifying a protected outside file', async () => { + const { root, source, target, stage } = await fixture() + await mkdir(join(stage, 'connectors'), { recursive: true }) + const outside = join(root, 'outside.json') + const content = '{"connectors":{}}' + await writeFile(outside, content) + await link(outside, join(stage, 'connectors/state.json')) + await expect(relocateManagedData(source, target, stage)).rejects.toThrow('independent') + expect(await readFile(outside, 'utf8')).toBe(content) + }) +}) diff --git a/src/utils/storageMigrationMetadata.ts b/src/utils/storageMigrationMetadata.ts new file mode 100644 index 00000000..8d2ae068 --- /dev/null +++ b/src/utils/storageMigrationMetadata.ts @@ -0,0 +1,182 @@ +import { lstat, mkdir, readFile, realpath, rename, writeFile } from 'node:fs/promises' +import { dirname, isAbsolute, join, relative, resolve, sep, basename } from 'node:path' +import { randomUUID } from 'node:crypto' +import { CONNECTORS } from '../services/connectors/catalog.js' +import type { ConnectorInstallation } from '../services/connectors/types.js' +import { renderNativeConnectorSkill } from '../services/connectors/nativeConnectorSkill.js' + +type JsonObject = Record + +function object(value: unknown, label: string): JsonObject { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Invalid ${label}`) + return value as JsonObject +} + +function inside(root: string, candidate: string): boolean { + const child = relative(root, candidate) + return child === '' || (child !== '..' && !child.startsWith(`..${sep}`) && !isAbsolute(child)) +} + +/** Never follow a staged descendant link when editing the copied metadata. */ +async function assertStagedPath(stage: string, file: string): Promise { + const root = resolve(stage) + const target = resolve(file) + if (!inside(root, target)) throw new Error('Storage metadata path escaped staging directory') + let current = root + for (const segment of relative(root, target).split(sep).filter(Boolean)) { + current = join(current, segment) + try { + const info = await lstat(current) + if (info.isSymbolicLink()) throw new Error('Linked storage metadata cannot be migrated') + if (current === target && (!info.isFile() || info.nlink !== 1)) throw new Error('Storage metadata must be a regular independent file') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + } +} + +async function optionalJson(stage: string, file: string): Promise { + await assertStagedPath(stage, file) + try { + return object(JSON.parse(await readFile(file, 'utf8')), 'storage metadata') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null + throw error + } +} + +async function saveFile(stage: string, file: string, content: string): Promise { + await assertStagedPath(stage, file) + await mkdir(dirname(file), { recursive: true, mode: 0o700 }) + const temporary = `${file}.${randomUUID()}.tmp` + await writeFile(temporary, content, { mode: 0o600, flush: true }) + await rename(temporary, file) +} + +/** Transform only the private staged copy. Stored conversation bytes are never rewritten. */ +export async function relocateManagedData(sourceDir: string, targetDir: string, stagedDir: string): Promise { + const source = resolve(sourceDir) + const target = resolve(targetDir) + const stage = resolve(stagedDir) + const sourceRoots = [source] + try { + const canonical = await realpath(source) + if (relative(source, canonical) !== '') sourceRoots.push(canonical) + } catch (error) { + // Offline recovery fixtures and metadata-only upgrades may no longer have + // the old directory. The stored root still supplies its original mapping. + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + const rebase = (value: unknown): unknown => { + if (typeof value !== 'string' || !isAbsolute(value)) return value + const root = sourceRoots.find(candidate => inside(candidate, resolve(value))) + return root ? join(target, relative(root, resolve(value))) : value + } + const stagedPath = (finalPath: string): string => { + if (!inside(target, resolve(finalPath))) throw new Error('Managed connector path escaped migration target') + return join(stage, relative(target, resolve(finalPath))) + } + const mappingPath = join(stage, 'cc-haha', 'storage-relocations.json') + const mapping = await optionalJson(stage, mappingPath) ?? { version: 1, previousRoots: [] } + if (mapping.version !== 1 || !Array.isArray(mapping.previousRoots) || + !mapping.previousRoots.every(root => typeof root === 'string' && isAbsolute(root) && !root.includes('\0'))) { + throw new Error('Unsupported or invalid storage relocation metadata') + } + const roots = [...mapping.previousRoots as string[], ...sourceRoots] + const previousRoots: string[] = [] + for (const root of roots) { + const normalized = resolve(root) + if (inside(target, normalized) || inside(normalized, target)) { + throw new Error('Previous storage root overlaps the migration target') + } + const comparable = process.platform === 'win32' ? normalized.toLowerCase() : normalized + if (!previousRoots.some(existing => (process.platform === 'win32' ? existing.toLowerCase() : existing) === comparable)) previousRoots.push(normalized) + } + + const installations = new Map() + const connectorPath = join(stage, 'connectors', 'state.json') + const connectorState = await optionalJson(stage, connectorPath) + if (connectorState) { + if (connectorState.schemaVersion !== undefined && connectorState.schemaVersion !== 0 && connectorState.schemaVersion !== 1) { + throw new Error('Unsupported connector state version') + } + const records = object(connectorState.connectors, 'connector records') + for (const [id, value] of Object.entries(records)) { + const record = object(value, 'connector record') + if (!record.installation) continue + const installation = object(record.installation, 'connector installation') + if (typeof installation.directory !== 'string' || typeof installation.command !== 'string' || + !Array.isArray(installation.args) || !installation.args.every(arg => typeof arg === 'string')) { + throw new Error('Invalid connector installation paths') + } + const env = object(installation.env, 'connector installation environment') + installation.directory = rebase(installation.directory) + installation.command = rebase(installation.command) + installation.args = installation.args.map(rebase) + for (const key of ['DWS_CONFIG_DIR', 'DWS_KEYCHAIN_DIR']) { + if (env[key] !== undefined && typeof env[key] !== 'string') throw new Error('Invalid connector account path') + if (env[key] !== undefined) env[key] = rebase(env[key]) + } + installations.set(id, { + installation: installation as ConnectorInstallation, + version: typeof record.installedVersion === 'string' ? record.installedVersion : undefined, + }) + } + await saveFile(stage, connectorPath, JSON.stringify(connectorState, null, 2) + '\n') + } + + const ownedSkills = new Map() + for (const folder of ['plugins', 'cowork_plugins']) { + const marketplacesPath = join(stage, folder, 'known_marketplaces.json') + const marketplaces = await optionalJson(stage, marketplacesPath) + if (marketplaces) { + for (const value of Object.values(marketplaces)) { + const entry = object(value, 'marketplace') + if (typeof entry.installLocation !== 'string') throw new Error('Invalid marketplace install location') + entry.installLocation = rebase(entry.installLocation) + const location = object(entry.source, 'marketplace source') + if (location.source === 'file' || location.source === 'directory') { + if (typeof location.path !== 'string') throw new Error('Invalid local marketplace source') + location.path = rebase(location.path) + } + } + await saveFile(stage, marketplacesPath, JSON.stringify(marketplaces, null, 2) + '\n') + } + for (const filename of ['installed_plugins.json', 'installed_plugins_v2.json']) { + const pluginsPath = join(stage, folder, filename) + const plugins = await optionalJson(stage, pluginsPath) + if (!plugins) continue + if (plugins.version !== undefined && plugins.version !== 1 && plugins.version !== 2) throw new Error('Unsupported installed plugins version') + for (const [id, value] of Object.entries(object(plugins.plugins, 'installed plugins'))) { + for (const item of Array.isArray(value) ? value : [value]) { + const entry = object(item, 'installed plugin') + if (typeof entry.installPath !== 'string') throw new Error('Invalid installed plugin path') + entry.installPath = rebase(entry.installPath) + const definition = CONNECTORS.find(def => def.pluginId === id) + if (definition && inside(target, resolve(entry.installPath as string))) { + ownedSkills.set(join(entry.installPath as string, 'skills', `office-${definition.id}`, 'SKILL.md'), definition.id) + } + } + } + await saveFile(stage, pluginsPath, JSON.stringify(plugins, null, 2) + '\n') + } + } + for (const definition of CONNECTORS) { + ownedSkills.set(join(target, 'connectors', 'marketplace', 'plugins', `office-${definition.id}`, 'skills', `office-${definition.id}`, 'SKILL.md'), definition.id) + } + for (const [finalPath, id] of ownedSkills) { + const managed = installations.get(id) + if (!managed || !inside(join(target, 'connectors'), resolve(managed.installation.directory))) continue + const file = stagedPath(finalPath) + await assertStagedPath(stage, file) + try { await lstat(file) } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue + throw error + } + const definition = CONNECTORS.find(def => def.id === id)! + const version = managed.version ?? basename(managed.installation.directory).match(/^(.+)-(?:darwin|win32|linux)-(?:x64|arm64)$/)?.[1] ?? definition.version + await saveFile(stage, file, renderNativeConnectorSkill({ ...definition, version }, managed.installation)) + } + await saveFile(stage, mappingPath, JSON.stringify({ ...mapping, version: 1, previousRoots }, null, 2) + '\n') +} diff --git a/src/utils/storageRelocations.test.ts b/src/utils/storageRelocations.test.ts new file mode 100644 index 00000000..2d742dbf --- /dev/null +++ b/src/utils/storageRelocations.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { mapRelocatedAttachmentPath, readStorageRelocations, resolveRelocatedAttachmentPath } from './storageRelocations.js' + +const directories: string[] = [] +afterEach(async () => { + await Promise.all(directories.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +describe('managed attachment relocation', () => { + test('maps attachment prefixes across multiple migrations without changing project references', () => { + for (const prefix of ['uploads', 'image-cache', 'im-downloads', 'cc-haha/generated-images']) { + expect(mapRelocatedAttachmentPath(`/old/${prefix}/session/file.png`, '/new', ['/older', '/old'], 'darwin')) + .toBe(`/new/${prefix}/session/file.png`) + } + for (const file of ['/old/projects/repo/file.txt', '/old/settings.json', '/oldish/uploads/a.png', '/old/uploads/../settings.json', '/project/image.png', 'uploads/image.png']) { + expect(mapRelocatedAttachmentPath(file, '/new', ['/old'], 'darwin')).toBe(file) + } + }) + + test('handles Windows roots case-insensitively, cross-drive moves and separator aliases', () => { + expect(mapRelocatedAttachmentPath('c:/OLD/Uploads/session/a.png', 'D:\\data', ['C:\\old'], 'win32')) + .toBe('D:\\data\\Uploads\\session\\a.png') + expect(mapRelocatedAttachmentPath('C:\\old2\\uploads\\a.png', 'D:\\data', ['C:\\old'], 'win32')) + .toBe('C:\\old2\\uploads\\a.png') + }) + + test('resolves a copied upload with its original root absent, and defaults to no mapping for old installations', async () => { + const root = await mkdtemp(join(tmpdir(), 'storage-relocation-')) + directories.push(root) + const oldRoot = join(root, 'absent-original') + const currentRoot = join(root, 'current') + const oldPath = join(oldRoot, 'uploads', 'session', 'a.png') + expect(resolveRelocatedAttachmentPath(oldPath, currentRoot)).toBe(oldPath) + await mkdir(join(currentRoot, 'cc-haha'), { recursive: true }) + await writeFile(join(currentRoot, 'cc-haha', 'storage-relocations.json'), JSON.stringify({ version: 1, previousRoots: [oldRoot], futureField: true })) + expect(resolveRelocatedAttachmentPath(oldPath, currentRoot)).toBe(join(currentRoot, 'uploads', 'session', 'a.png')) + expect(readStorageRelocations(currentRoot).futureField).toBe(true) + }) + + test('does not interpret malformed or future metadata as permission to fall back to old storage', async () => { + const root = await mkdtemp(join(tmpdir(), 'storage-relocation-invalid-')) + directories.push(root) + await mkdir(join(root, 'cc-haha')) + for (const data of ['{', JSON.stringify({ version: 2, previousRoots: [] }), JSON.stringify({ version: 1, previousRoots: ['relative'] })]) { + await writeFile(join(root, 'cc-haha', 'storage-relocations.json'), data) + expect(() => readStorageRelocations(root)).toThrow() + } + }) +}) diff --git a/src/utils/storageRelocations.ts b/src/utils/storageRelocations.ts new file mode 100644 index 00000000..34045085 --- /dev/null +++ b/src/utils/storageRelocations.ts @@ -0,0 +1,66 @@ +import { readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join, posix, win32 } from 'node:path' + +export type StorageRelocations = { + version: 1 + previousRoots: string[] + [key: string]: unknown +} + +export const MANAGED_ATTACHMENT_PREFIXES = [ + 'uploads', + 'image-cache', + 'im-downloads', + 'cc-haha/generated-images', +] as const + +export function activeStorageRoot(): string { + return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude') +} + +export function readStorageRelocations(configDir = activeStorageRoot()): StorageRelocations { + let value: unknown + try { + value = JSON.parse(readFileSync(join(configDir, 'cc-haha', 'storage-relocations.json'), 'utf8')) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { version: 1, previousRoots: [] } + throw new Error('Cannot read storage relocation metadata', { cause: error }) + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Invalid storage relocation metadata') + } + const record = value as Record + if (record.version !== 1 || !Array.isArray(record.previousRoots) || + !record.previousRoots.every(root => typeof root === 'string' && root.length > 0 && + !root.includes('\0') && (process.platform === 'win32' ? win32 : posix).isAbsolute(root))) { + throw new Error('Unsupported or invalid storage relocation metadata') + } + return record as StorageRelocations +} + +/** Resolve only app-owned attachment locations. Project files and transcript text stay unchanged. */ +export function mapRelocatedAttachmentPath( + inputPath: string, + configDir: string, + previousRoots: readonly string[], + platform: string = process.platform, +): string { + const paths = platform === 'win32' ? win32 : posix + if (!paths.isAbsolute(inputPath) || inputPath.includes('\0')) return inputPath + const normalized = paths.normalize(inputPath).normalize('NFC') + for (const root of previousRoots) { + const relativePath = paths.relative(paths.normalize(root).normalize('NFC'), normalized) + if (!relativePath || relativePath === '..' || relativePath.startsWith(`..${paths.sep}`) || paths.isAbsolute(relativePath)) continue + const comparable = relativePath.replaceAll('\\', '/') + const matchingPath = platform === 'win32' ? comparable.toLowerCase() : comparable + if (MANAGED_ATTACHMENT_PREFIXES.some(prefix => matchingPath.startsWith(`${prefix}/`))) { + return paths.join(configDir, relativePath).normalize('NFC') + } + } + return inputPath +} + +export function resolveRelocatedAttachmentPath(inputPath: string, configDir = activeStorageRoot()): string { + return mapRelocatedAttachmentPath(inputPath, configDir, readStorageRelocations(configDir).previousRoots) +}