diff --git a/package.json b/package.json index 4c545dc2a58a..633cd90f58ae 100644 --- a/package.json +++ b/package.json @@ -1839,7 +1839,7 @@ "test:unit:fast:audit": "node scripts/test-unit-fast-audit.mjs", "test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs", "test:watch": "node scripts/test-projects.mjs --watch", - "test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/daemon/schtasks.startup-fallback.test.ts extensions/lobster/src/lobster-runner.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts", + "test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/infra/sqlite-snapshot.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/daemon/schtasks.startup-fallback.test.ts extensions/lobster/src/lobster-runner.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts", "tool-display:check": "node --import tsx scripts/tool-display.ts --check", "tool-display:write": "node --import tsx scripts/tool-display.ts --write", "ts-topology": "node --import tsx scripts/ts-topology.ts", diff --git a/src/infra/sqlite-snapshot.test.ts b/src/infra/sqlite-snapshot.test.ts index c508e7284e90..fefc97cb284a 100644 --- a/src/infra/sqlite-snapshot.test.ts +++ b/src/infra/sqlite-snapshot.test.ts @@ -72,6 +72,42 @@ describe("createVerifiedSqliteSnapshot", () => { }, ); + it.runIf(process.platform === "win32")( + "snapshots when its private staging path exceeds MAX_PATH", + async () => { + const tempDir = await createTempDir(); + let targetDirectory = tempDir; + while (targetDirectory.length < 205) { + targetDirectory = path.join(targetDirectory, `segment-${"x".repeat(24)}`); + } + await fs.mkdir(targetDirectory, { recursive: true }); + const sourcePath = path.join(tempDir, "source.sqlite"); + const targetPath = path.join(targetDirectory, "snapshot.sqlite"); + const longestStagingPath = path.join( + targetDirectory, + `.sqlite-publish-${"0".repeat(36)}-${"0".repeat(36)}`, + "database.sqlite", + ); + expect(targetPath.length).toBeLessThan(260); + expect(longestStagingPath.length).toBeGreaterThan(260); + const sqlite = requireNodeSqlite(); + const source = new sqlite.DatabaseSync(sourcePath); + source.exec("CREATE TABLE records (value TEXT NOT NULL); INSERT INTO records VALUES ('ok');"); + source.close(); + + await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).resolves.toEqual({ + path: targetPath, + userVersion: 0, + }); + const snapshot = new sqlite.DatabaseSync(targetPath, { readOnly: true }); + try { + expect(snapshot.prepare("SELECT value FROM records").get()).toEqual({ value: "ok" }); + } finally { + snapshot.close(); + } + }, + ); + it("captures committed WAL state and removes deleted page contents", async () => { const tempDir = await createTempDir(); const sourcePath = path.join(tempDir, "source.sqlite"); diff --git a/src/infra/sqlite-snapshot.ts b/src/infra/sqlite-snapshot.ts index ca8bd2191738..80304261ee31 100644 --- a/src/infra/sqlite-snapshot.ts +++ b/src/infra/sqlite-snapshot.ts @@ -16,6 +16,16 @@ import { readSqliteUserVersion } from "./sqlite-user-version.js"; const SQLITE_DIRECTORY_MODE = 0o700; const WINDOWS_DIRECTORY_EXISTS_MARKER = "OPENCLAW_SQLITE_DIRECTORY_EXISTS"; + +function resolveSqliteFilesystemPath(pathname: string): string { + if (process.platform !== "win32") { + return pathname; + } + // Node normalizes long paths for fs, but DatabaseSync and VACUUM INTO pass + // filesystem names directly to SQLite's Windows VFS. + return path.toNamespacedPath(path.resolve(pathname)); +} + // Managed directory creation accepts existing paths. CreateDirectoryW applies the // protected DACL atomically while preserving fail-if-exists semantics. const WINDOWS_PRIVATE_DIRECTORY_NATIVE_SOURCE = ` @@ -122,7 +132,9 @@ export async function createPrivateSqliteDirectory(directoryPath: string): Promi await fs.mkdir(directoryPath, { mode: SQLITE_DIRECTORY_MODE }); return; } - const encodedPath = Buffer.from(directoryPath, "utf8").toString("base64"); + // This raw Win32 call bypasses Node's automatic long-path normalization. + const nativeDirectoryPath = path.toNamespacedPath(path.resolve(directoryPath)); + const encodedPath = Buffer.from(nativeDirectoryPath, "utf8").toString("base64"); const encodedNativeSource = Buffer.from(WINDOWS_PRIVATE_DIRECTORY_NATIVE_SOURCE, "utf8").toString( "base64", ); @@ -787,7 +799,7 @@ export async function createVerifiedSqliteSnapshot( const sqlite = requireNodeSqlite(); let stagedIdentity: Stats | undefined; try { - const source = new sqlite.DatabaseSync(options.sourcePath, { + const source = new sqlite.DatabaseSync(resolveSqliteFilesystemPath(options.sourcePath), { allowExtension: true, readOnly: true, }); @@ -796,13 +808,15 @@ export async function createVerifiedSqliteSnapshot( await loadSqliteVecExtension({ db: source }); assertSqliteIntegrity(source, options.sourcePath); options.validate?.(source, options.sourcePath); - source.prepare("VACUUM INTO ?").run(stagedPath); + source.prepare("VACUUM INTO ?").run(resolveSqliteFilesystemPath(stagedPath)); } finally { source.close(); } await fs.chmod(stagedPath, 0o600); - const snapshot = new sqlite.DatabaseSync(stagedPath, { allowExtension: true }); + const snapshot = new sqlite.DatabaseSync(resolveSqliteFilesystemPath(stagedPath), { + allowExtension: true, + }); try { snapshot.exec("PRAGMA busy_timeout = 30000; PRAGMA trusted_schema = OFF;"); await loadSqliteVecExtension({ db: snapshot }); @@ -827,7 +841,7 @@ export async function createVerifiedSqliteSnapshot( beforePublish: options.beforePublish, afterPublish: options.afterPublish, validatePublished: async (publishedPath) => { - const published = new sqlite.DatabaseSync(publishedPath, { + const published = new sqlite.DatabaseSync(resolveSqliteFilesystemPath(publishedPath), { allowExtension: true, readOnly: true, }); diff --git a/test/package-scripts.test.ts b/test/package-scripts.test.ts index acec2cecd921..11e152b6c482 100644 --- a/test/package-scripts.test.ts +++ b/test/package-scripts.test.ts @@ -175,6 +175,12 @@ describe("package scripts", () => { ); }); + it("runs SQLite snapshot path coverage in Windows CI", () => { + expect(readPackageJson().scripts["test:windows:ci"]).toContain( + "src/infra/sqlite-snapshot.test.ts", + ); + }); + it("runs cross-OS installer behavior coverage in Windows CI", () => { expect(readPackageJson().scripts["test:windows:ci"]).toContain( "test/scripts/openclaw-cross-os-installer.windows.test.ts",