From 39a5712ae72c7f4e21958f7c24dbdfe3e6d585bd Mon Sep 17 00:00:00 2001 From: musistudio Date: Fri, 31 Jul 2026 11:55:10 +0800 Subject: [PATCH] Prepare Claude App VM storage before launch --- packages/core/src/agents/claude-app/launch.ts | 5 + .../core/src/agents/claude-app/vm-storage.ts | 408 ++++++++++++++++++ .../agents/claude-app-vm-storage.test.mjs | 123 ++++++ 3 files changed, 536 insertions(+) create mode 100644 packages/core/src/agents/claude-app/vm-storage.ts create mode 100644 packages/core/test/unit/agents/claude-app-vm-storage.test.mjs diff --git a/packages/core/src/agents/claude-app/launch.ts b/packages/core/src/agents/claude-app/launch.ts index 17094c73..5f8ae26d 100644 --- a/packages/core/src/agents/claude-app/launch.ts +++ b/packages/core/src/agents/claude-app/launch.ts @@ -5,6 +5,7 @@ import path from "node:path"; import type { AppConfig, ProfileConfig } from "@ccr/core/contracts/app"; import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env"; import { prepareClaudeAppCdpUserDataDir, reserveClaudeAppCdpPort, scheduleClaudeAppDesignCdp } from "@ccr/core/agents/claude-app/cdp"; +import { prepareClaudeAppVmStorage } from "@ccr/core/agents/claude-app/vm-storage"; import { claudeCodeModelEnv as claudeCodeProfileModelEnv, claudeCodeUtcTimezoneEnvOverride, isClaudeCodeManagedModelEnvKey } from "@ccr/core/agents/claude-code/environment"; import { resolveClaudeCodeSettingsFile } from "@ccr/core/profiles/launch-core"; import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "@ccr/core/platform/windows-app-discovery"; @@ -52,6 +53,10 @@ export async function launchClaudeAppProfile(configDir: string, profile: Profile const settingsDir = path.dirname(settingsFile); const userDataDir = resolveClaudeAppProfileUserDataDir(configDir, profile); mkdirSync(userDataDir, { recursive: true }); + const vmStorage = prepareClaudeAppVmStorage(configDir, userDataDir); + if (vmStorage.action === "skipped" && vmStorage.reason === "clone-failed") { + console.warn(`[profile] Failed to clone Claude App VM seed for ${profile.name || profile.id}. Claude App may rebuild its VM in ${vmStorage.targetBundleDir}.`); + } prepareClaudeAppCdpUserDataDir(userDataDir); const shouldOpenDesign = shouldOpenClaudeAppDesign(config); const cdpPort = await reserveClaudeAppCdpPort(console, shouldOpenDesign); diff --git a/packages/core/src/agents/claude-app/vm-storage.ts b/packages/core/src/agents/claude-app/vm-storage.ts new file mode 100644 index 00000000..510ca3ee --- /dev/null +++ b/packages/core/src/agents/claude-app/vm-storage.ts @@ -0,0 +1,408 @@ +import { constants, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, rmSync, statSync, symlinkSync, chmodSync, renameSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { resolveRuntimeAppPath } from "@ccr/core/runtime/app-paths"; + +const claudeAppVmBundlesDir = "vm_bundles"; +const claudeAppVmBundleName = "claudevm.bundle"; +const claudeAppVmSeedEnv = "CCR_CLAUDE_APP_VM_SEED_DIR"; +const claudeAppVmSeedDisabledEnv = "CCR_CLAUDE_APP_VM_SEED_DISABLED"; +const maxSmallFileCopyBytes = 64 * 1024 * 1024; +const lockRetryIntervalMs = 100; +const lockTimeoutMs = 15_000; +const staleLockMs = 10 * 60_000; + +export type ClaudeAppVmStoragePrepareResult = + | { + action: "prepared"; + seedBundleDir: string; + sourceBundleDir: string; + targetBundleDir: string; + } + | { + action: "skipped"; + reason: string; + targetBundleDir: string; + }; + +export function prepareClaudeAppVmStorage(configDir: string, userDataDir: string): ClaudeAppVmStoragePrepareResult { + const targetBundleDir = resolveClaudeAppVmBundleDir(userDataDir); + if (isDisabledEnv(process.env[claudeAppVmSeedDisabledEnv])) { + return { action: "skipped", reason: "disabled", targetBundleDir }; + } + return withVmStorageLock(configDir, targetBundleDir, () => prepareClaudeAppVmStorageLocked(configDir, userDataDir)); +} + +function prepareClaudeAppVmStorageLocked(configDir: string, userDataDir: string): ClaudeAppVmStoragePrepareResult { + const targetBundleDir = resolveClaudeAppVmBundleDir(userDataDir); + if (isUsableClaudeAppVmBundle(targetBundleDir)) { + return { action: "skipped", reason: "target-present", targetBundleDir }; + } + + const sourceBundleDir = findClaudeAppVmSeedBundle(configDir, userDataDir); + if (!sourceBundleDir) { + return { action: "skipped", reason: "no-seed", targetBundleDir }; + } + + const seedBundleDir = ensureSharedClaudeAppVmSeed(configDir, sourceBundleDir, targetBundleDir); + if (!seedBundleDir || samePath(seedBundleDir, targetBundleDir)) { + return { action: "skipped", reason: "seed-unavailable", targetBundleDir }; + } + + if (!prepareTargetBundlePath(targetBundleDir)) { + return { action: "skipped", reason: "target-not-replaceable", targetBundleDir }; + } + + try { + cloneDirectory(seedBundleDir, targetBundleDir); + return { + action: "prepared", + seedBundleDir, + sourceBundleDir, + targetBundleDir + }; + } catch { + rmSync(targetBundleDir, { force: true, recursive: true }); + return { action: "skipped", reason: "clone-failed", targetBundleDir }; + } +} + +function withVmStorageLock( + configDir: string, + targetBundleDir: string, + run: () => ClaudeAppVmStoragePrepareResult +): ClaudeAppVmStoragePrepareResult { + const lockDir = path.join(configDir, "app-cache", "claude-app", ".vm-storage.lock"); + if (!acquireDirectoryLock(lockDir)) { + return { action: "skipped", reason: "lock-timeout", targetBundleDir }; + } + try { + return run(); + } finally { + rmSync(lockDir, { force: true, recursive: true }); + } +} + +function acquireDirectoryLock(lockDir: string): boolean { + const deadline = Date.now() + lockTimeoutMs; + mkdirSync(path.dirname(lockDir), { mode: 0o700, recursive: true }); + while (Date.now() < deadline) { + try { + mkdirSync(lockDir, { mode: 0o700 }); + return true; + } catch { + removeStaleLock(lockDir); + sleepSync(lockRetryIntervalMs); + } + } + return false; +} + +function removeStaleLock(lockDir: string): void { + try { + const stat = statSync(lockDir); + if (Date.now() - stat.mtimeMs > staleLockMs) { + rmSync(lockDir, { force: true, recursive: true }); + } + } catch { + // Another process may have released the lock between attempts. + } +} + +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +export function resolveClaudeAppVmBundleDir(userDataDir: string): string { + return path.join(userDataDir, claudeAppVmBundlesDir, claudeAppVmBundleName); +} + +export function resolveSharedClaudeAppVmSeedBundleDir(configDir: string): string { + return path.join(configDir, "app-cache", "claude-app", "vm-seeds", claudeAppVmBundleName); +} + +export function resolveClaudeAppDefaultUserDataDirs(): string[] { + if (process.platform === "darwin") { + const applicationSupport = path.join(resolveRuntimeAppPath("home"), "Library", "Application Support"); + return [ + path.join(applicationSupport, "Claude"), + path.join(applicationSupport, "Claude Desktop"), + path.join(applicationSupport, "Claude-3p") + ]; + } + if (process.platform === "win32") { + const roaming = resolveRuntimeAppPath("appData"); + const local = process.env.LOCALAPPDATA || path.join(roaming, "..", "Local"); + return [ + path.join(roaming, "Claude"), + path.join(roaming, "Claude Desktop"), + path.join(local, "Claude"), + path.join(local, "Claude Desktop"), + path.join(local, "Claude-3p") + ]; + } + const configHome = resolveRuntimeAppPath("appData"); + return [ + path.join(configHome, "Claude"), + path.join(configHome, "claude"), + path.join(configHome, "Claude Desktop"), + path.join(configHome, "claude-desktop"), + path.join(configHome, "Claude-3p") + ]; +} + +function findClaudeAppVmSeedBundle(configDir: string, userDataDir: string): string | undefined { + const targetBundleDir = resolveClaudeAppVmBundleDir(userDataDir); + return uniqueStrings([ + ...configuredSeedBundleCandidates(), + resolveSharedClaudeAppVmSeedBundleDir(configDir), + ...resolveClaudeAppDefaultUserDataDirs().map(resolveClaudeAppVmBundleDir), + ...existingCcrProfileVmBundleCandidates(configDir) + ]).find((candidate) => + !samePath(candidate, targetBundleDir) && + isUsableClaudeAppVmBundle(candidate) + ); +} + +function configuredSeedBundleCandidates(): string[] { + const configured = process.env[claudeAppVmSeedEnv]?.trim(); + if (!configured) { + return []; + } + return configured + .split(path.delimiter) + .map((entry) => entry.trim()) + .filter(Boolean) + .flatMap((entry) => { + const resolved = resolveUserPath(entry); + return path.basename(resolved) === claudeAppVmBundleName + ? [resolved] + : [resolved, resolveClaudeAppVmBundleDir(resolved)]; + }); +} + +function existingCcrProfileVmBundleCandidates(configDir: string): string[] { + const profilesDir = path.join(configDir, "profiles"); + if (!isDirectory(profilesDir)) { + return []; + } + const results: string[] = []; + const pending: Array<{ depth: number; dir: string }> = [{ depth: 0, dir: profilesDir }]; + while (pending.length > 0) { + const current = pending.shift(); + if (!current || current.depth > 8) { + continue; + } + for (const entry of readDirEntries(current.dir)) { + const file = path.join(current.dir, entry); + if (entry === claudeAppVmBundleName && isDirectory(file) && file.includes(`${path.sep}${claudeAppVmBundlesDir}${path.sep}`)) { + results.push(file); + continue; + } + if (isDirectory(file)) { + pending.push({ depth: current.depth + 1, dir: file }); + } + } + } + return results; +} + +function ensureSharedClaudeAppVmSeed( + configDir: string, + sourceBundleDir: string, + targetBundleDir: string +): string | undefined { + const seedBundleDir = resolveSharedClaudeAppVmSeedBundleDir(configDir); + if (isUsableClaudeAppVmBundle(seedBundleDir)) { + return seedBundleDir; + } + if (samePath(sourceBundleDir, seedBundleDir)) { + return isUsableClaudeAppVmBundle(sourceBundleDir) ? sourceBundleDir : undefined; + } + + if (!prepareTargetBundlePath(seedBundleDir)) { + return sourceBundleDir; + } + + try { + cloneDirectory(sourceBundleDir, seedBundleDir); + return seedBundleDir; + } catch { + rmSync(seedBundleDir, { force: true, recursive: true }); + return samePath(sourceBundleDir, targetBundleDir) ? undefined : sourceBundleDir; + } +} + +function prepareTargetBundlePath(bundleDir: string): boolean { + if (!pathEntryExists(bundleDir)) { + mkdirSync(path.dirname(bundleDir), { mode: 0o700, recursive: true }); + return true; + } + if (!isReplaceableIncompleteBundle(bundleDir)) { + return false; + } + rmSync(bundleDir, { force: true, recursive: true }); + mkdirSync(path.dirname(bundleDir), { mode: 0o700, recursive: true }); + return true; +} + +function isUsableClaudeAppVmBundle(bundleDir: string): boolean { + return isDirectory(bundleDir) && ( + existsSync(path.join(bundleDir, "rootfs.img")) || + existsSync(path.join(bundleDir, "rootfs.img.zst")) + ); +} + +function isReplaceableIncompleteBundle(bundleDir: string): boolean { + if (!isDirectory(bundleDir)) { + return isSymlink(bundleDir); + } + const entries = readDirEntries(bundleDir); + if (entries.length === 0) { + return true; + } + return entries.every((entry) => + entry === ".cowork-adopted" || + entry === ".DS_Store" || + entry.startsWith(".wvm-tmp-") + ); +} + +function cloneDirectory(sourceDir: string, targetDir: string): void { + const parentDir = path.dirname(targetDir); + const tempDir = path.join(parentDir, `.${path.basename(targetDir)}.ccr-clone-${process.pid}-${Date.now()}`); + rmSync(tempDir, { force: true, recursive: true }); + try { + copyDirectoryContents(sourceDir, tempDir); + rmSync(targetDir, { force: true, recursive: true }); + renameSync(tempDir, targetDir); + } catch (error) { + rmSync(tempDir, { force: true, recursive: true }); + throw error; + } +} + +function copyDirectoryContents(sourceDir: string, targetDir: string): void { + const sourceStat = statSync(sourceDir); + mkdirSync(targetDir, { mode: sourceStat.mode & 0o777, recursive: true }); + chmodBestEffort(targetDir, sourceStat.mode & 0o777); + for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { + const source = path.join(sourceDir, entry.name); + const target = path.join(targetDir, entry.name); + const sourceEntryStat = lstatSync(source); + if (sourceEntryStat.isSymbolicLink()) { + symlinkSync(readlinkSync(source), target); + continue; + } + if (sourceEntryStat.isDirectory()) { + copyDirectoryContents(source, target); + continue; + } + if (sourceEntryStat.isFile()) { + copyFileWithClone(source, target, sourceEntryStat.size); + chmodBestEffort(target, sourceEntryStat.mode & 0o777); + } + } +} + +function copyFileWithClone(source: string, target: string, size: number): void { + if (process.platform === "darwin" && cloneFileWithMacCp(source, target)) { + return; + } + try { + copyFileSync(source, target, constants.COPYFILE_FICLONE_FORCE); + return; + } catch { + if (size > maxSmallFileCopyBytes) { + throw new Error(`Copy-on-write clone is not available for ${source}.`); + } + } + copyFileSync(source, target); +} + +function cloneFileWithMacCp(source: string, target: string): boolean { + const result = spawnSync("/bin/cp", ["-c", source, target], { + stdio: "ignore" + }); + return result.status === 0; +} + +function pathEntryExists(file: string): boolean { + try { + lstatSync(file); + return true; + } catch { + return false; + } +} + +function isDirectory(file: string): boolean { + try { + return statSync(file).isDirectory(); + } catch { + return false; + } +} + +function isSymlink(file: string): boolean { + try { + return lstatSync(file).isSymbolicLink(); + } catch { + return false; + } +} + +function readDirEntries(dir: string): string[] { + try { + return readdirSync(dir); + } catch { + return []; + } +} + +function samePath(left: string, right: string): boolean { + return normalizeComparablePath(left) === normalizeComparablePath(right); +} + +function normalizeComparablePath(value: string): string { + const normalized = path.resolve(value); + return process.platform === "win32" ? normalized.replace(/\\/g, "/").toLowerCase() : normalized; +} + +function uniqueStrings(values: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const normalized = normalizeComparablePath(value); + if (seen.has(normalized)) { + continue; + } + seen.add(normalized); + result.push(value); + } + return result; +} + +function resolveUserPath(value: string): string { + if (value === "~") { + return os.homedir(); + } + if (value.startsWith(`~${path.sep}`) || value.startsWith("~/")) { + return path.join(os.homedir(), value.slice(2)); + } + return path.resolve(value); +} + +function chmodBestEffort(file: string, mode: number): void { + try { + chmodSync(file, mode); + } catch { + // File permissions are best-effort across platforms. + } +} + +function isDisabledEnv(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; +} diff --git a/packages/core/test/unit/agents/claude-app-vm-storage.test.mjs b/packages/core/test/unit/agents/claude-app-vm-storage.test.mjs new file mode 100644 index 00000000..7dcf4561 --- /dev/null +++ b/packages/core/test/unit/agents/claude-app-vm-storage.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + prepareClaudeAppVmStorage, + resolveClaudeAppDefaultUserDataDirs, + resolveClaudeAppVmBundleDir, + resolveSharedClaudeAppVmSeedBundleDir +} from "@ccr/core/agents/claude-app/vm-storage.ts"; + +test("Claude App VM storage prepares profile bundle from the default Claude App data dir", () => { + withRuntimeEnv((root) => { + const configDir = path.join(root, "ccr"); + const sourceBundle = resolveClaudeAppVmBundleDir(resolveClaudeAppDefaultUserDataDirs()[0]); + const targetUserDataDir = path.join(configDir, "profiles", "target", "claude", ".claude-code-router", "claude-app-user-data", "target"); + writeVmBundle(sourceBundle, "default-vm"); + + const result = prepareClaudeAppVmStorage(configDir, targetUserDataDir); + + assert.equal(result.action, "prepared"); + assert.equal(readRootfs(resolveSharedClaudeAppVmSeedBundleDir(configDir)), "default-vm"); + assert.equal(readRootfs(resolveClaudeAppVmBundleDir(targetUserDataDir)), "default-vm"); + }); +}); + +test("Claude App VM storage can seed new profiles from an existing CCR profile", () => { + withRuntimeEnv((root) => { + const configDir = path.join(root, "ccr"); + const sourceUserDataDir = path.join(configDir, "profiles", "source", "claude", ".claude-code-router", "claude-app-user-data", "source"); + const targetUserDataDir = path.join(configDir, "profiles", "target", "claude", ".claude-code-router", "claude-app-user-data", "target"); + const sourceBundle = resolveClaudeAppVmBundleDir(sourceUserDataDir); + writeVmBundle(sourceBundle, "profile-vm"); + + const result = prepareClaudeAppVmStorage(configDir, targetUserDataDir); + + assert.equal(result.action, "prepared"); + assert.equal(result.sourceBundleDir, sourceBundle); + assert.equal(readRootfs(resolveSharedClaudeAppVmSeedBundleDir(configDir)), "profile-vm"); + assert.equal(readRootfs(resolveClaudeAppVmBundleDir(targetUserDataDir)), "profile-vm"); + }); +}); + +test("Claude App VM storage leaves an existing profile VM untouched", () => { + withRuntimeEnv((root) => { + const configDir = path.join(root, "ccr"); + const sourceBundle = resolveClaudeAppVmBundleDir(resolveClaudeAppDefaultUserDataDirs()[0]); + const targetUserDataDir = path.join(configDir, "profiles", "target", "claude", ".claude-code-router", "claude-app-user-data", "target"); + const targetBundle = resolveClaudeAppVmBundleDir(targetUserDataDir); + writeVmBundle(sourceBundle, "source-vm"); + writeVmBundle(targetBundle, "target-vm"); + + const result = prepareClaudeAppVmStorage(configDir, targetUserDataDir); + + assert.deepEqual(result, { + action: "skipped", + reason: "target-present", + targetBundleDir: targetBundle + }); + assert.equal(readRootfs(targetBundle), "target-vm"); + }); +}); + +test("Claude App VM storage replaces incomplete temporary VM bundles", () => { + withRuntimeEnv((root) => { + const configDir = path.join(root, "ccr"); + const sourceBundle = resolveClaudeAppVmBundleDir(resolveClaudeAppDefaultUserDataDirs()[0]); + const targetUserDataDir = path.join(configDir, "profiles", "target", "claude", ".claude-code-router", "claude-app-user-data", "target"); + const targetBundle = resolveClaudeAppVmBundleDir(targetUserDataDir); + writeVmBundle(sourceBundle, "source-vm"); + mkdirSync(path.join(targetBundle, ".wvm-tmp-123"), { recursive: true }); + writeFileSync(path.join(targetBundle, ".wvm-tmp-123", "rootfs.img"), "partial-vm"); + writeFileSync(path.join(targetBundle, ".cowork-adopted"), "marker"); + + const result = prepareClaudeAppVmStorage(configDir, targetUserDataDir); + + assert.equal(result.action, "prepared"); + assert.equal(readRootfs(targetBundle), "source-vm"); + assert.equal(existsSync(path.join(targetBundle, ".wvm-tmp-123")), false); + }); +}); + +function writeVmBundle(bundleDir, content) { + mkdirSync(bundleDir, { recursive: true }); + writeFileSync(path.join(bundleDir, "rootfs.img"), content); + writeFileSync(path.join(bundleDir, "machineIdentifier"), "machine"); +} + +function readRootfs(bundleDir) { + return readFileSync(path.join(bundleDir, "rootfs.img"), "utf8"); +} + +function withRuntimeEnv(run) { + const root = mkdtempSync(path.join(os.tmpdir(), "ccr-claude-app-vm-storage-")); + const previous = { + appData: process.env.CCR_INTERNAL_APP_DATA_DIR, + home: process.env.CCR_INTERNAL_HOME_DIR, + seed: process.env.CCR_CLAUDE_APP_VM_SEED_DIR, + seedDisabled: process.env.CCR_CLAUDE_APP_VM_SEED_DISABLED + }; + try { + process.env.CCR_INTERNAL_APP_DATA_DIR = path.join(root, "app-data"); + process.env.CCR_INTERNAL_HOME_DIR = path.join(root, "home"); + delete process.env.CCR_CLAUDE_APP_VM_SEED_DIR; + delete process.env.CCR_CLAUDE_APP_VM_SEED_DISABLED; + run(root); + } finally { + setOptionalEnv("CCR_INTERNAL_APP_DATA_DIR", previous.appData); + setOptionalEnv("CCR_INTERNAL_HOME_DIR", previous.home); + setOptionalEnv("CCR_CLAUDE_APP_VM_SEED_DIR", previous.seed); + setOptionalEnv("CCR_CLAUDE_APP_VM_SEED_DISABLED", previous.seedDisabled); + rmSync(root, { force: true, recursive: true }); + } +} + +function setOptionalEnv(name, value) { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +}