feat(desktop): add safe data directory migration

This commit is contained in:
Relakkes Yang
2026-10-04 00:35:17 +08:00
committed by 程序员阿江-Relakkes
parent c144148d20
commit 1485db1f5e
101 changed files with 5024 additions and 226 deletions
@@ -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<number>(resolve => { didExit = resolve })
const write = new Promise<void>(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<number>(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()
})
@@ -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<void>(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<void>((_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<void>(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)
})
})
@@ -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')
@@ -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)')
+12 -3
View File
@@ -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<string> {
write(target: string, data: Buffer): Promise<string> {
return adapterMigrationLifecycle.track(this.writeOnce(target, data))
}
private async writeOnce(target: string, data: Buffer): Promise<string> {
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()
@@ -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
+5 -1
View File
@@ -6,11 +6,15 @@
* 参考 openclaw-lark chat-queue.ts 的 Promise 链设计。
*/
import { adapterMigrationLifecycle } from './migration-lifecycle.js'
const queues = new Map<string, Promise<void>>()
export async function enqueue(chatId: string, fn: () => Promise<void>): Promise<void> {
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)
+2 -1
View File
@@ -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))
}
}
}
+41
View File
@@ -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<void>
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))
})
}
})
}
+51
View File
@@ -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<Promise<unknown>>()
private shutdown = new Set<() => Promise<void> | void>()
private failures: unknown[] = []
private stopping: Promise<void> | null = null
get isQuiescing(): boolean { return this.quiescing }
registerShutdown(cleanup: () => Promise<void> | void): void { this.shutdown.add(cleanup) }
track<T>(operation: Promise<T>): Promise<T> {
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<void> {
if (this.stopping) return this.stopping
this.quiescing = true
this.stopping = this.drain()
return this.stopping
}
private async drain(): Promise<void> {
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): 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)
}
+3 -2
View File
@@ -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)
})
+11 -16
View File
@@ -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<void> {
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<void> {
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<void> {
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)
})
+7 -5
View File
@@ -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<void> {
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<void> {
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<void> {
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 }
+2 -1
View File
@@ -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) {
+4 -4
View File
@@ -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)
})
+9 -8
View File
@@ -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)
})
@@ -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> | void
let release!: () => void
const pendingStop = new Promise<void>(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()
}
})
})
+10 -7
View File
@@ -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()
})
}
+3 -2
View File
@@ -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<void> {
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 }
+2 -2
View File
@@ -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)
})
+102 -2
View File
@@ -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<void>(resolve => { releaseCreds = resolve })
const pendingKeys = new Promise<void>(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<void>(resolve => { first = resolve }), new Promise<void>(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<void> | 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 })
}
})
})
+13 -5
View File
@@ -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<void> {
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<void> {
}
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 }
+5 -2
View File
@@ -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): 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)
})
+18
View File
@@ -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]) {
+20
View File
@@ -13,6 +13,22 @@ const booleanPayload: Validator = value => typeof value === 'boolean'
const hasOnlyKeys = (value: Record<string, unknown>, 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,
+5
View File
@@ -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 = {
+99 -6
View File
@@ -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<Promise<unknown>>()
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<unknown> = Promise.resolve()
@@ -471,6 +509,13 @@ function registerHandler<T>(
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<T>(
) {
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<typeof setAppMode>[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
@@ -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')
+13 -2
View File
@@ -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<string, unknown> = {}
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<string, unknown>
} 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')
@@ -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()
})
})
+404
View File
@@ -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<string>()
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<void>
resume(): Promise<void>
restart(): Promise<void> | void
progress(status: MigrationStatus): void
credentialStore?: MigrationCredentialStore
permissions?: {
restrictStaging(directory: string, signal: AbortSignal): Promise<void>
preserve(entries: { source: string; target: string }[], signal: AbortSignal): Promise<void>
}
platform?: NodeJS.Platform
}
async function exists(file: string): Promise<boolean> {
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<void> {
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<void> | 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<void> { return this.operation ?? Promise.resolve() }
async prepare(targetDir: string): Promise<MigrationPreview> {
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<void> {
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<void> {
await atomicWrite(this.journalPath, JSON.stringify(this.journal))
}
private async stage(stage: MigrationStatus['stage']): Promise<void> {
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<Journal['directories']>, source: string, target: string): Promise<void> {
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<Journal['directories']>, signal: AbortSignal): Promise<void> {
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<void> {
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<void> {
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<void> { await this.stage('completed') }
async failValidation(error: unknown): Promise<void> {
await this.restoreMode()
this.journal!.status.error = error instanceof Error ? error.message : 'New directory startup failed'
await this.stage('failed')
}
}
@@ -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<string> {
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<MigrationDirectoryIdentity> {
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<void> {
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<void> {
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<MigrationFile[]> {
const files: MigrationFile[] = []
async function visit(source: string, relative: string): Promise<void> {
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<string> {
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<void>,
): Promise<void> {
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<void> {
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<void> {
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<VerifiedMigrationFile[]> {
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<string>()
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<void> {
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<string>()
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}`)
}
}
@@ -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<string, string>([['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)
})
})
@@ -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<string | null>
write(service: string, value: string, signal?: AbortSignal): Promise<void>
remove(service: string, signal?: AbortSignal): Promise<void>
}
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<void>,
signal?: AbortSignal,
): Promise<MigrationCredentialReceipt[]> {
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<void> {
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<void> {
for (const receipt of receipts) {
const value = await store.read(receipt.service)
if (value !== null && digest(value) === receipt.digest) await store.remove(receipt.service)
}
}
@@ -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<string> {
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<MigrationPermissionsRunner>(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<MigrationPermissionsRunner>(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<MigrationPermissionsRunner>(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<MigrationPermissionsRunner>(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 spawn>) => 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<MigrationPermissionsRunner>(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<MigrationPermissionsRunner>(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<MigrationPermissionsRunner>(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)
})
@@ -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<void>
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<void>((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<import('node:fs').BigIntStats> {
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<void> {
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<void> {
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<void> {
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()
}
@@ -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')
})
})
@@ -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 })
+2 -1
View File
@@ -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 {
@@ -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<void> } | 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<void>(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<void>(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()
+163 -4
View File
@@ -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<string>
sleep?: (delayMs: number) => Promise<void>
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<typeof vi.fn>, 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<void>(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
+181 -14
View File
@@ -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<void> | null = null
private migrationActive = false
private migrationQuiescence: Promise<void> | null = null
private migrationQuiesced = false
private migrationSourceDir: string | null = null
private readonly inactiveAdapters = new WeakSet<SidecarChild>()
constructor(options: ServerRuntimeOptions) {
this.onServerUnavailable = options.onServerUnavailable
@@ -151,6 +162,7 @@ export class ElectronServerRuntime {
}
async startServer(): Promise<string> {
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<string> {
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<void> {
const result = await this.requestMigrationControl('validate', 'GET')
if (result.valid !== true) throw new Error('Migrated data failed runtime validation')
}
async activateAfterMigrationValidation(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<Record<string, unknown>> {
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<string, unknown>
if (!response.ok) throw new Error(typeof result.error === 'string' ? result.error : 'Migration runtime control failed')
return result
}
restartAdaptersSidecars(): Promise<void> {
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<void> {
const serverChildren = new Set<SidecarChild>()
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<string> {
@@ -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<void> {
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<void> {
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<boolean>(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<boolean> {
if (child.exitCode != null || child.signalCode != null) return Promise.resolve(true)
+6 -5
View File
@@ -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<null, Readable, Readable>
export type SidecarChild = ChildProcessByStdio<Writable | null, Readable, Readable>
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 = {
@@ -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)
})
})
@@ -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
}
+2 -1
View File
@@ -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 })
+46
View File
@@ -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<number | null>((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)
+12 -3
View File
@@ -215,13 +215,22 @@ async function runAdapters(rawArgs: string[]): Promise<void> {
}
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,自然保持进程存活。
}
@@ -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(<Settings />)
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 () => {
+33
View File
@@ -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.',
+33
View File
@@ -2308,6 +2308,39 @@ export const jp: Record<TranslationKey, string> = {
'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 を復元してください。意図した設定なら、この通知は無視できます。',
+33
View File
@@ -2310,6 +2310,39 @@ export const kr: Record<TranslationKey, string> = {
'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을 복원하세요. 직접 설정한 경우에는 이 알림을 무시해도 됩니다.',
+33
View File
@@ -2307,6 +2307,39 @@ export const zh: Record<TranslationKey, string> = {
'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;主動使用此設定可忽略。',
+33
View File
@@ -2306,6 +2306,39 @@ export const zh: Record<TranslationKey, string> = {
'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;主动使用该配置可忽略。',
+7 -1
View File
@@ -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)
+1 -1
View File
@@ -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. */
@@ -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() {
@@ -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')
@@ -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() })
@@ -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),
+29
View File
@@ -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<void>
prepareRestart(): Promise<void>
restart(): Promise<void>
migration: {
prepare(targetDir: string): Promise<MigrationPreview>
start(id: string): Promise<void>
status(): Promise<MigrationStatus | null>
cancel(id: string): Promise<void>
onProgress(handler: (status: MigrationStatus) => void): Promise<DesktopHostUnlisten>
}
}
adapters: {
restartSidecar(): Promise<void>
@@ -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(<DataMigrationSettings environmentControlled={false} onBusyChange={busy} />)
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(<DataMigrationSettings environmentControlled={false} />)
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(<DataMigrationSettings environmentControlled={false} />)
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(<DataMigrationSettings environmentControlled={false} />)
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(<DataMigrationSettings environmentControlled />)
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(<DataMigrationSettings environmentControlled={false} />)
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(<DataMigrationSettings environmentControlled={false} />)
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(<DataMigrationSettings environmentControlled={false} onBusyChange={busy} />)
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<MigrationStatus | null>(resolve => { resolveStatus = resolve }))
render(<DataMigrationSettings environmentControlled={false} />)
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(<DataMigrationSettings environmentControlled={false} />)
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(<DataMigrationSettings environmentControlled={false} />)
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(<DataMigrationSettings environmentControlled={false} />)
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(<DataMigrationSettings environmentControlled={false} />)
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()
})
})
@@ -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<MigrationStatus['stage'], TranslationKey>
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<MigrationPreview | null>(null)
const [status, setStatus] = useState<MigrationStatus | null>(null)
const [preparing, setPreparing] = useState(false)
const [starting, setStarting] = useState(false)
const [cancelling, setCancelling] = useState(false)
const [error, setError] = useState<string | null>(null)
const [statusError, setStatusError] = useState<string | null>(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) => (
<dl className="space-y-2 text-xs">
<div>
<dt className="text-[var(--color-text-tertiary)]">{t('settings.general.migration.source')}</dt>
<dd className="mt-1 break-all font-mono text-[var(--color-text-secondary)]">{sourceDir}</dd>
</div>
<div>
<dt className="text-[var(--color-text-tertiary)]">{t('settings.general.migration.target')}</dt>
<dd className="mt-1 break-all font-mono text-[var(--color-text-secondary)]">{targetDir}</dd>
</div>
</dl>
)
const progress = status && status.totalBytes > 0 ? status.bytes / status.totalBytes * 100 : 0
return (
<div className="mt-4 border-t border-[var(--color-border-separator)] pt-4">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.general.migration.title')}</div>
<p className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">{t('settings.general.migration.description')}</p>
<Button
type="button"
size="sm"
variant="secondary"
className="mt-3"
disabled={environmentControlled || disabled || busy || !!preview}
loading={preparing}
onClick={() => void chooseTarget()}
>
<FolderInput size={14} aria-hidden="true" />
{t('settings.general.migration.choose')}
</Button>
{environmentControlled && (
<p className="mt-2 text-xs leading-5 text-[var(--color-text-tertiary)]">{t('settings.general.migration.environment')}</p>
)}
{error && !active && <p className="mt-2 text-xs text-[var(--color-error)]" role="alert">{error}</p>}
{status && !active && (
<div className="mt-3 space-y-3 rounded-[var(--radius-lg)] bg-[var(--color-surface)] p-3" role="status">
<p className="text-sm font-medium text-[var(--color-text-primary)]">{t(STAGE_LABELS[status.stage])}</p>
{status.error && <p className="text-xs text-[var(--color-error)]" role="alert">{status.error}</p>}
{directories(status.sourceDir, status.targetDir)}
{status.stage === 'completed' && (
<>
<p className="text-xs leading-5 text-[var(--color-text-secondary)]">{t('settings.general.migration.retained')}</p>
<p className="text-xs leading-5 text-[var(--color-text-tertiary)]">{t('settings.general.migration.cli')}</p>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="secondary" onClick={() => void openPath(status.sourceDir)}>{t('settings.general.migration.openSource')}</Button>
<Button size="sm" variant="secondary" onClick={() => void openPath(status.targetDir)}>{t('settings.general.migration.openTarget')}</Button>
</div>
</>
)}
</div>
)}
<ConfirmDialog
open={!!preview}
onClose={() => { 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 && (
<div className="space-y-3 text-sm leading-6 text-[var(--color-text-secondary)]">
{directories(preview.sourceDir, preview.targetDir)}
<p>{t('settings.general.migration.preview', { files: preview.files, size: formatBytes(preview.bytes) })}</p>
{preview.activeTasks > 0 && <p>{t('settings.general.migration.activeTasks', { count: preview.activeTasks })}</p>}
<p>{t('settings.general.migration.stopWarning')}</p>
<p>{t('settings.general.migration.externalWarning')}</p>
<p>{t('settings.general.migration.retained')}</p>
</div>
)}
/>
<Modal
open={active}
onClose={() => {}}
title={t('settings.general.migration.running')}
width={500}
footer={(
<Button variant="secondary" disabled={!status?.cancellable || cancelling} loading={cancelling} onClick={() => void cancel()}>
{t('settings.general.migration.cancel')}
</Button>
)}
>
{status && (
<div className="space-y-3 text-sm text-[var(--color-text-secondary)]" aria-live="polite">
<p>{cancelling ? t('settings.general.migration.cancelling') : t(STAGE_LABELS[status.stage])}</p>
<Progress
label={t(STAGE_LABELS[status.stage])}
value={['committing', 'restarting'].includes(status.stage) ? 100 : progress}
indeterminate={['preparing', 'quiescing', 'verifying'].includes(status.stage)}
/>
<p className="text-xs">{t('settings.general.migration.progress', {
files: status.files,
totalFiles: status.totalFiles,
bytes: formatBytes(status.bytes),
totalBytes: formatBytes(status.totalBytes),
})}</p>
{directories(status.sourceDir, status.targetDir)}
{(error || statusError) && <p className="text-xs text-[var(--color-error)]" role="alert">{error || statusError}</p>}
</div>
)}
</Modal>
</div>
)
}
+12 -1
View File
@@ -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<AppMode | null>(null)
const [pendingPortableDir, setPendingPortableDir] = useState<string | null>(null)
const [portableDirDraft, setPortableDirDraft] = useState('')
const [migrationRunning, setMigrationRunning] = useState(false)
const [modeActionRunning, setModeActionRunning] = useState(false)
const [modeError, setModeError] = useState<string | null>(null)
const [uiZoomDraft, setUiZoomDraft] = useState(uiZoom)
@@ -1662,6 +1664,7 @@ export function GeneralSettings() {
<div className="flex flex-col gap-3">
<button
type="button"
disabled={migrationRunning}
onClick={() => {
if (isEnvironmentConfigDir) {
setModeError(t('settings.general.storageEnvironmentSwitchBlocked'))
@@ -1712,6 +1715,7 @@ export function GeneralSettings() {
setModeError(null)
}}
className="w-full font-mono text-xs"
disabled={migrationRunning}
/>
</div>
<Button
@@ -1719,6 +1723,7 @@ export function GeneralSettings() {
variant="secondary"
className="h-10 flex-shrink-0 px-3 whitespace-nowrap"
onClick={() => void openPortableDirPicker()}
disabled={migrationRunning}
>
{t('settings.general.storageChooseDir')}
</Button>
@@ -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')}
</div>
<DataMigrationSettings
environmentControlled={isEnvironmentConfigDir}
disabled={modeActionRunning || appModeRequiresRestart}
onBusyChange={setMigrationRunning}
/>
{modeError && (
<div className="mt-3 text-xs text-[var(--color-error)]">
{modeError}
@@ -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'],
@@ -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[] = []
@@ -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<typeof startServer> | 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<void>(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 })
}
})
+28 -1
View File
@@ -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) {
+4 -2
View File
@@ -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<Response> {
// 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)) {
+3 -2
View File
@@ -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)) {
+89 -12
View File
@@ -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<WebSocketData>({
@@ -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<void> | null = null
let migrationQuiescence: Promise<void> | undefined
async function migrationRuntimePreview() {
const owned = [...conversationService.getProcessIds(), ...cronScheduler.getProcessIds()]
return { activeTasks: owned.length, externalProcesses: await countExternalMigrationProcesses(owned) }
}
export function quiesceServerRuntimeForMigration(ignoreExternalProcesses = false): Promise<void> {
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<void> {
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 } = {},
+16
View File
@@ -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 }) }
})
+40
View File
@@ -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<number> {
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
}
+27
View File
@@ -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<void>(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<void>(resolve => { finish = resolve })
maintenance.track(Promise.resolve().then(() => { maintenance.track(write) }))
const drain = maintenance.drain()
finish()
await drain
})
+63
View File
@@ -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<Promise<unknown>>()
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<T>(operation: Promise<T>): Promise<T> {
this.operations.add(operation)
void operation.then(() => this.operations.delete(operation), () => this.operations.delete(operation))
return operation
}
async drain(): Promise<void> {
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<unknown>, timeoutMs = 15_000): Promise<boolean> {
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 })
}
+27
View File
@@ -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<string, string>][]) {
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
}
})
+81
View File
@@ -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<MigrationPreview>
quiesce(): Promise<void>
activate(): Promise<void>
recover?(): Promise<void>
}
export function isMigrationRuntimePath(pathname: string): boolean {
return pathname.startsWith('/api/runtime/migration/')
}
export async function handleMigrationRuntimeRequest(
request: Request,
clientAddress: string | null,
control: MigrationRuntimeControl,
): Promise<Response> {
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 })
}
}
@@ -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<number>(resolve => { releaseExit = resolve })
const outputDrain = new Promise<void>(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<void>(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()
}
})
+29 -3
View File
@@ -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<void> {
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<typeof Bun.spawn>
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<boolean> {
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<void> {
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) {
@@ -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<number>(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 })
}
})
+64 -10
View File
@@ -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<typeof Bun.spawn>; startedAt: number; runId: string }
{ proc: ReturnType<typeof Bun.spawn>; 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<string, string>()
@@ -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<void> {
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<TaskRun> {
executeTask(task: CronTask, options?: { createSession?: boolean }): Promise<TaskRun> {
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<void> {
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<TaskRun> {
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
})
@@ -100,6 +100,10 @@ export class DiagnosticsService {
private lastRetentionSweepAt = 0
private writeQueue: Promise<void> = Promise.resolve()
async drainForMigration(): Promise<void> {
await this.writeQueue
}
getLogDir(): string {
return path.join(this.getConfigDir(), 'cc-haha', 'diagnostics')
}
@@ -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<void> {
if (migrationMaintenance.isActive) return
if (started) {
if (synchronizeRuntimeConfiguration()) return
await runtimeReconfigurePromise
@@ -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
@@ -9,6 +9,10 @@ import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.j
export class ManagedSettingsService {
private static writeLocks = new Map<string, Promise<void>>()
static async drainForMigration(): Promise<void> {
await Promise.all([...this.writeLocks.values()])
}
private getConfigDir(): string {
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
}
+5
View File
@@ -725,6 +725,11 @@ export class SessionService {
}>
>()
private readonly taskNotificationMutationEpochs = new Map<string, number>()
async drainForMigration(): Promise<void> {
const writes = [...this.pendingTaskNotificationWrites.values()].flatMap(entries => [...entries].map(entry => entry.promise))
await Promise.all(writes)
}
private readonly clearingTaskNotificationSessions = new Set<string>()
private readonly localIndexGateway: LocalIndexGateway
+2
View File
@@ -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<SessionTurnAdmissionLease> {
migrationMaintenance.assertAvailable()
if (admissionGuard) return admissionGuard(sessionId, canAdmit)
return { release: async () => {} }
}
+35 -10
View File
@@ -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<void> {
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<void> {
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<void> {
// 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<void> {
}
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<void> {
}
export async function launchTeamPlanRuntime(plan: TeamPlanRecord): Promise<{ memberIds: Record<string, string> }> {
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<void> {
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<boolean> {
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
+5
View File
@@ -79,6 +79,11 @@ export class TeamWatcher {
}
}
async stopAndWait(): Promise<void> {
this.stop()
await this.checkPromise
}
/** Visible for testing -- force a single poll cycle. */
checkNow(): Promise<void> {
return this.scheduleCheck()
+47 -18
View File
@@ -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<void> {
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<boolean> => {
const finalization = migrationMaintenance.track((async (): Promise<boolean> => {
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<boolean[]> {
@@ -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<string | undefined> {
@@ -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)
})
}
@@ -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')
})
})
@@ -0,0 +1,63 @@
import type { ConnectorDefinition, ConnectorId, ConnectorInstallation } from './types.js'
export const nativeConnectorRecipes: Record<ConnectorId, { name: string; help: string; task: string }> = {
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 <start> --end-time <end>, 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}
`
}
+5 -53
View File
@@ -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<ConnectorId, { name: string; help: string; task: string }> = {
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 <start> --end-time <end>, 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) {
+38 -1
View File
@@ -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-'))
+7 -6
View File
@@ -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.
+34 -1
View File
@@ -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',
+2 -1
View File
@@ -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
+114
View File
@@ -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)
})
})
+182
View File
@@ -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<string, unknown>
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<void> {
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<JsonObject | null> {
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<void> {
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<void> {
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<string, { installation: ConnectorInstallation, version?: string }>()
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<string, string>()
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')
}
+52
View File
@@ -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()
}
})
})

Some files were not shown because too many files have changed in this diff Show More