diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index 28973cb6943..43ba30d7d72 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -230,7 +230,7 @@ Notes: - Doctor removes retired `plugins.entries.codex.config.codexDynamicToolsProfile`; Codex app-server always keeps Codex-native workspace tools native. - Doctor warns when skills allowed for the default agent are unavailable in the current runtime environment because bins, env vars, config, or OS requirements are missing. `doctor --fix` can disable those unavailable skills with `skills.entries..enabled=false`; install/configure the missing requirement instead when you want to keep the skill active. - If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`). -- If legacy sandbox registry files (`~/.openclaw/sandbox/containers.json` or `~/.openclaw/sandbox/browsers.json`) are present, doctor reports them; `openclaw doctor --fix` migrates valid entries into sharded registry directories and quarantines invalid legacy files. +- If legacy sandbox registry files or shard directories are present (`~/.openclaw/sandbox/containers.json`, `~/.openclaw/sandbox/browsers.json`, `~/.openclaw/sandbox/containers/`, or `~/.openclaw/sandbox/browsers/`), doctor reports them; `openclaw doctor --fix` migrates valid entries into SQLite and quarantines invalid legacy files. - If `gateway.auth.token`/`gateway.auth.password` are SecretRef-managed and unavailable in the current command path, doctor reports a read-only warning and does not write plaintext fallback credentials. For exec-backed SecretRefs, doctor skips execution unless `--allow-exec` is present. - If channel SecretRef inspection fails in a fix path, doctor continues and reports a warning instead of exiting early. - After state-directory migrations, doctor warns when enabled default Telegram or Discord accounts depend on env fallback and `TELEGRAM_BOT_TOKEN` or `DISCORD_BOT_TOKEN` is unavailable to the doctor process. diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index b16f1576b3a..50f14647841 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -166,12 +166,12 @@ Prefer `openclaw sandbox recreate` over manual backend-specific cleanup. It uses ## Registry migration -OpenClaw stores sandbox runtime metadata as one JSON shard per container/browser entry under the sandbox state directory. Older installs may still have monolithic legacy files: +OpenClaw stores sandbox runtime metadata in the shared SQLite state database. Older installs may still have legacy sandbox registry files: - `~/.openclaw/sandbox/containers.json` - `~/.openclaw/sandbox/browsers.json` -Regular sandbox runtime reads do not rewrite those files. Run `openclaw doctor --fix` to migrate valid legacy entries into the sharded registry directories. Invalid legacy files are quarantined so one bad old registry cannot hide current runtime entries. +Some upgrades may also have one JSON shard per container/browser under `~/.openclaw/sandbox/containers/` or `~/.openclaw/sandbox/browsers/`. Regular sandbox runtime reads do not rewrite those legacy sources. Run `openclaw doctor --fix` to migrate valid legacy entries into SQLite. Invalid legacy files are quarantined so one bad old registry cannot hide current runtime entries. ## Configuration diff --git a/src/agents/sandbox/registry.test.ts b/src/agents/sandbox/registry.test.ts index 94b33ca2113..85bcf990bdd 100644 --- a/src/agents/sandbox/registry.test.ts +++ b/src/agents/sandbox/registry.test.ts @@ -1,36 +1,31 @@ -// Sandbox registry tests cover sharded registry migration, locking, ordering, +// Sandbox registry tests cover legacy registry migration, ordering, // and race-safety for container/browser runtime records. import fs from "node:fs/promises"; -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -type WriteDelayConfig = { - targetFile: "containers.json" | "browsers.json" | null; - containerName: string; - started: boolean; - markStarted: () => void; - waitForRelease: Promise; -}; +import path from "node:path"; +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; const { TEST_STATE_DIR, + PREVIOUS_OPENCLAW_STATE_DIR, SANDBOX_REGISTRY_PATH, SANDBOX_BROWSER_REGISTRY_PATH, SANDBOX_CONTAINERS_DIR, SANDBOX_BROWSERS_DIR, - writeGateState, } = vi.hoisted(() => { const path = require("node:path"); const { mkdtempSync } = require("node:fs"); const { tmpdir } = require("node:os"); const baseDir = mkdtempSync(path.join(tmpdir(), "openclaw-sandbox-registry-")); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = baseDir; return { TEST_STATE_DIR: baseDir, + PREVIOUS_OPENCLAW_STATE_DIR: previousStateDir, SANDBOX_REGISTRY_PATH: path.join(baseDir, "containers.json"), SANDBOX_BROWSER_REGISTRY_PATH: path.join(baseDir, "browsers.json"), SANDBOX_CONTAINERS_DIR: path.join(baseDir, "containers"), SANDBOX_BROWSERS_DIR: path.join(baseDir, "browsers"), - writeGateState: { active: null as WriteDelayConfig | null }, }; }); @@ -42,37 +37,8 @@ vi.mock("./constants.js", () => ({ SANDBOX_BROWSERS_DIR, })); -vi.mock("../../infra/json-files.js", async () => { - const actual = await vi.importActual( - "../../infra/json-files.js", - ); - return { - ...actual, - writeJson: async ( - filePath: string, - value: unknown, - options?: Parameters[2], - ) => { - // Gate selected writes to model a concurrent update/remove interleave at - // the JSON-file boundary. - const payload = JSON.stringify(value); - const gate = writeGateState.active; - if ( - gate && - (!gate.targetFile || filePath.includes(gate.targetFile)) && - payloadMentionsContainer(payload, gate.containerName) - ) { - if (!gate.started) { - gate.started = true; - gate.markStarted(); - } - await gate.waitForRelease; - } - await actual.writeJson(filePath, value, options); - }, - }; -}); - +import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js"; +import { hashTextSha256 } from "./hash.js"; import { migrateLegacySandboxRegistryFiles, readBrowserRegistry, @@ -88,13 +54,6 @@ type SandboxBrowserRegistryEntry = import("./registry.js").SandboxBrowserRegistr type SandboxRegistryEntry = import("./registry.js").SandboxRegistryEntry; type MigrationResult = Awaited>[number]; -function payloadMentionsContainer(payload: string, containerName: string): boolean { - return ( - payload.includes(`"containerName":"${containerName}"`) || - payload.includes(`"containerName": "${containerName}"`) - ); -} - async function seedMalformedContainerRegistry(payload: string) { await fs.writeFile(SANDBOX_REGISTRY_PATH, payload, "utf-8"); } @@ -103,39 +62,9 @@ async function seedMalformedBrowserRegistry(payload: string) { await fs.writeFile(SANDBOX_BROWSER_REGISTRY_PATH, payload, "utf-8"); } -function installWriteGate( - targetFile: "containers.json" | "browsers.json" | null, - containerName: string, -): { waitForStart: Promise; release: () => void } { - let markStarted = () => {}; - const waitForStart = new Promise((resolve) => { - markStarted = resolve; - }); - let resolveRelease = () => {}; - const waitForRelease = new Promise((resolve) => { - resolveRelease = resolve; - }); - writeGateState.active = { - targetFile, - containerName, - started: false, - markStarted, - waitForRelease, - }; - return { - waitForStart, - release: () => { - resolveRelease(); - writeGateState.active = null; - }, - }; -} - -beforeEach(() => { - writeGateState.active = null; -}); - afterEach(async () => { + closeOpenClawStateDatabaseForTest(); + await fs.rm(path.join(TEST_STATE_DIR, "state"), { recursive: true, force: true }); await fs.rm(SANDBOX_CONTAINERS_DIR, { recursive: true, force: true }); await fs.rm(SANDBOX_BROWSERS_DIR, { recursive: true, force: true }); await fs.rm(SANDBOX_REGISTRY_PATH, { force: true }); @@ -145,7 +74,13 @@ afterEach(async () => { }); afterAll(async () => { + closeOpenClawStateDatabaseForTest(); await fs.rm(TEST_STATE_DIR, { recursive: true, force: true }); + if (PREVIOUS_OPENCLAW_STATE_DIR === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = PREVIOUS_OPENCLAW_STATE_DIR; + } }); function browserEntry( @@ -185,6 +120,28 @@ async function seedBrowserRegistry(entries: SandboxBrowserRegistryEntry[]) { ); } +async function seedShardedContainerRegistry(entries: SandboxRegistryEntry[]) { + await fs.mkdir(SANDBOX_CONTAINERS_DIR, { recursive: true }); + for (const entry of entries) { + await fs.writeFile( + path.join(SANDBOX_CONTAINERS_DIR, `${hashTextSha256(entry.containerName)}.json`), + `${JSON.stringify(entry, null, 2)}\n`, + "utf-8", + ); + } +} + +async function seedShardedBrowserRegistry(entries: SandboxBrowserRegistryEntry[]) { + await fs.mkdir(SANDBOX_BROWSERS_DIR, { recursive: true }); + for (const entry of entries) { + await fs.writeFile( + path.join(SANDBOX_BROWSERS_DIR, `${hashTextSha256(entry.containerName)}.json`), + `${JSON.stringify(entry, null, 2)}\n`, + "utf-8", + ); + } +} + async function seedStaleLock(lockPath: string) { await fs.writeFile( lockPath, @@ -246,7 +203,7 @@ describe("registry race safety", () => { expect(entry?.configLabelKind).toBe("Image"); }); - it("migrates legacy container and browser registry files after explicit repair", async () => { + it("migrates legacy monolithic container and browser registry files after explicit repair", async () => { await seedContainerRegistry([ containerEntry({ containerName: "legacy-container", @@ -297,7 +254,35 @@ describe("registry race safety", () => { expect(browser?.configHash).toBe("legacy-browser-hash"); }); - it("does not overwrite newer sharded entries during legacy migration", async () => { + it("migrates legacy sharded container and browser registry files after explicit repair", async () => { + await seedShardedContainerRegistry([ + containerEntry({ + containerName: "legacy-container", + sessionKey: "agent:legacy", + lastUsedAtMs: 7, + configHash: "legacy-container-hash", + }), + ]); + await seedShardedBrowserRegistry([ + browserEntry({ + containerName: "legacy-browser", + sessionKey: "agent:legacy", + cdpPort: 9333, + noVncPort: 6081, + configHash: "legacy-browser-hash", + }), + ]); + + const migrationResults = await migrateLegacySandboxRegistryFiles(); + expect(requireMigrationResult(migrationResults, "containers").status).toBe("migrated"); + expect(requireMigrationResult(migrationResults, "browsers").status).toBe("migrated"); + await expectPathMissing(SANDBOX_CONTAINERS_DIR); + await expectPathMissing(SANDBOX_BROWSERS_DIR); + expect((await readRegistry()).entries[0]?.containerName).toBe("legacy-container"); + expect((await readBrowserRegistry()).entries[0]?.containerName).toBe("legacy-browser"); + }); + + it("does not overwrite newer SQLite entries during legacy migration", async () => { await updateRegistry( containerEntry({ containerName: "container-a", @@ -320,7 +305,30 @@ describe("registry race safety", () => { expect(entry?.lastUsedAtMs).toBe(10); }); - it("reads a single sharded entry without scanning the full registry", async () => { + it("prefers newer sharded entries over stale monolithic entries during legacy migration", async () => { + await seedContainerRegistry([ + containerEntry({ + containerName: "container-a", + sessionKey: "legacy-session", + lastUsedAtMs: 1, + }), + ]); + await seedShardedContainerRegistry([ + containerEntry({ + containerName: "container-a", + sessionKey: "sharded-session", + lastUsedAtMs: 10, + }), + ]); + + await migrateLegacySandboxRegistryFiles(); + + const entry = await readRegistryEntry("container-a"); + expect(entry?.sessionKey).toBe("sharded-session"); + expect(entry?.lastUsedAtMs).toBe(10); + }); + + it("reads a single SQLite entry without scanning the full registry", async () => { await updateRegistry(containerEntry({ containerName: "container-x", sessionKey: "sess:x" })); await updateRegistry(containerEntry({ containerName: "container-y", sessionKey: "sess:y" })); @@ -347,24 +355,19 @@ describe("registry race safety", () => { }); it("prevents concurrent container remove/update from resurrecting deleted entries", async () => { - // Delete wins over an in-flight stale update so prune/remove cannot be - // undone by a delayed writer. await updateRegistry(containerEntry({ containerName: "container-x" })); - const writeGate = installWriteGate(null, "container-x"); const updatePromise = updateRegistry( containerEntry({ containerName: "container-x", configHash: "updated" }), ); - await writeGate.waitForStart; const removePromise = removeRegistryEntry("container-x"); - writeGate.release(); await Promise.all([updatePromise, removePromise]); const registry = await readRegistry(); expect(registry.entries).toHaveLength(0); }); - it("stores unsafe container names as encoded shard filenames", async () => { + it("stores unsafe container names without writing path-derived files", async () => { await updateRegistry(containerEntry({ containerName: "../escape" })); const registry = await readRegistry(); @@ -406,14 +409,11 @@ describe("registry race safety", () => { it("prevents concurrent browser remove/update from resurrecting deleted entries", async () => { await updateBrowserRegistry(browserEntry({ containerName: "browser-x" })); - const writeGate = installWriteGate(null, "browser-x"); const updatePromise = updateBrowserRegistry( browserEntry({ containerName: "browser-x", configHash: "updated" }), ); - await writeGate.waitForStart; const removePromise = removeBrowserRegistryEntry("browser-x"); - writeGate.release(); await Promise.all([updatePromise, removePromise]); const registry = await readBrowserRegistry(); @@ -443,4 +443,28 @@ describe("registry race safety", () => { ); expect(requireMigrationResult(migrationResults, "browsers").status).toBe("quarantined-invalid"); }); + + it("quarantines malformed sharded registry directories during migration", async () => { + await fs.mkdir(SANDBOX_CONTAINERS_DIR, { recursive: true }); + await fs.mkdir(SANDBOX_BROWSERS_DIR, { recursive: true }); + await seedShardedContainerRegistry([ + containerEntry({ containerName: "valid-container", sessionKey: "agent:valid" }), + ]); + await seedShardedBrowserRegistry([ + browserEntry({ containerName: "valid-browser", sessionKey: "agent:valid" }), + ]); + await fs.writeFile(path.join(SANDBOX_CONTAINERS_DIR, "bad.json"), "{bad json", "utf-8"); + await fs.writeFile(path.join(SANDBOX_BROWSERS_DIR, "bad.json"), "{bad json", "utf-8"); + + const migrationResults = await migrateLegacySandboxRegistryFiles(); + + expect(requireMigrationResult(migrationResults, "containers").status).toBe( + "quarantined-invalid", + ); + expect(requireMigrationResult(migrationResults, "browsers").status).toBe("quarantined-invalid"); + expect((await readRegistry()).entries[0]?.containerName).toBe("valid-container"); + expect((await readBrowserRegistry()).entries[0]?.containerName).toBe("valid-browser"); + await expectPathMissing(SANDBOX_CONTAINERS_DIR); + await expectPathMissing(SANDBOX_BROWSERS_DIR); + }); }); diff --git a/src/agents/sandbox/registry.ts b/src/agents/sandbox/registry.ts index 8cb64472b7f..48641989b41 100644 --- a/src/agents/sandbox/registry.ts +++ b/src/agents/sandbox/registry.ts @@ -1,12 +1,18 @@ /** * Persistent sandbox registry storage. * - * Tracks runtime and browser containers with sharded JSON files plus migration support for legacy registries. + * Tracks runtime and browser containers in the shared state DB plus migration support for legacy registries. */ import fs from "node:fs/promises"; import path from "node:path"; +import type { Insertable, Selectable, Updateable } from "kysely"; import { z } from "zod"; -import { writeJson } from "../../infra/json-files.js"; +import { executeSqliteQuerySync, getNodeSqliteKysely } from "../../infra/kysely-sync.js"; +import type { DB as OpenClawStateKyselyDatabase } from "../../state/openclaw-state-db.generated.js"; +import { + openOpenClawStateDatabase, + runOpenClawStateWriteTransaction, +} from "../../state/openclaw-state-db.js"; import { safeParseJsonWithSchema } from "../../utils/zod-parse.js"; import { acquireSessionWriteLock } from "../session-write-lock.js"; import { @@ -15,7 +21,6 @@ import { SANDBOX_CONTAINERS_DIR, SANDBOX_REGISTRY_PATH, } from "./constants.js"; -import { hashTextSha256 } from "./hash.js"; export type SandboxRegistryEntry = { containerName: string; @@ -58,7 +63,19 @@ type RegistryFile = { entries: RegistryEntryPayload[]; }; +type ShardedRegistryRead = { + entries: T[]; + validFiles: string[]; + invalidFiles: string[]; +}; + type LegacyRegistryKind = "containers" | "browsers"; +type SandboxRegistryKind = "container" | "browser"; +type SandboxRegistryTable = OpenClawStateKyselyDatabase["sandbox_registry_entries"]; +type SandboxRegistryDatabase = Pick; +type SandboxRegistryRow = Selectable; +type SandboxRegistryInsert = Insertable; +type SandboxRegistryUpdate = Updateable; type LegacyRegistryTarget = { kind: LegacyRegistryKind; @@ -70,11 +87,13 @@ export type LegacySandboxRegistryInspection = LegacyRegistryTarget & { exists: boolean; valid: boolean; entries: number; + source: "monolithic" | "sharded"; }; export type LegacySandboxRegistryMigrationResult = LegacyRegistryTarget & { status: "missing" | "migrated" | "removed-empty" | "quarantined-invalid"; entries: number; + source?: "monolithic" | "sharded"; quarantinePath?: string; }; @@ -88,6 +107,220 @@ const RegistryFileSchema = z.object({ entries: z.array(RegistryEntrySchema), }); +function getSandboxRegistryKysely(db: import("node:sqlite").DatabaseSync) { + return getNodeSqliteKysely(db); +} + +function parseRegistryEntryJson(row: SandboxRegistryRow): RegistryEntryPayload | null { + try { + const parsed = JSON.parse(row.entry_json) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as RegistryEntryPayload) + : null; + } catch { + return null; + } +} + +function rowToContainerEntry(row: SandboxRegistryRow): SandboxRegistryEntry | null { + if (row.registry_kind !== "container") { + return null; + } + const payload = parseRegistryEntryJson(row); + if (!payload) { + return null; + } + return normalizeSandboxRegistryEntry({ + ...payload, + containerName: row.container_name, + sessionKey: row.session_key ?? String(payload.sessionKey ?? ""), + createdAtMs: row.created_at_ms ?? Number(payload.createdAtMs ?? 0), + lastUsedAtMs: row.last_used_at_ms ?? Number(payload.lastUsedAtMs ?? 0), + image: row.image ?? String(payload.image ?? ""), + ...(row.backend_id != null ? { backendId: row.backend_id } : {}), + ...(row.runtime_label != null ? { runtimeLabel: row.runtime_label } : {}), + ...(row.config_label_kind != null ? { configLabelKind: row.config_label_kind } : {}), + ...(row.config_hash != null ? { configHash: row.config_hash } : {}), + } as SandboxRegistryEntry); +} + +function rowToBrowserEntry(row: SandboxRegistryRow): SandboxBrowserRegistryEntry | null { + if (row.registry_kind !== "browser") { + return null; + } + const payload = parseRegistryEntryJson(row); + if (!payload) { + return null; + } + return { + ...payload, + containerName: row.container_name, + sessionKey: row.session_key ?? String(payload.sessionKey ?? ""), + createdAtMs: row.created_at_ms ?? Number(payload.createdAtMs ?? 0), + lastUsedAtMs: row.last_used_at_ms ?? Number(payload.lastUsedAtMs ?? 0), + image: row.image ?? String(payload.image ?? ""), + cdpPort: row.cdp_port ?? Number(payload.cdpPort ?? 0), + ...(row.no_vnc_port != null ? { noVncPort: row.no_vnc_port } : {}), + ...(row.config_hash != null ? { configHash: row.config_hash } : {}), + } as SandboxBrowserRegistryEntry; +} + +function containerEntryToRow(entry: SandboxRegistryEntry, existing?: SandboxRegistryEntry | null) { + const next: SandboxRegistryEntry = { + ...entry, + backendId: entry.backendId ?? existing?.backendId, + runtimeLabel: entry.runtimeLabel ?? existing?.runtimeLabel, + createdAtMs: existing?.createdAtMs ?? entry.createdAtMs, + image: existing?.image ?? entry.image, + configLabelKind: entry.configLabelKind ?? existing?.configLabelKind, + configHash: entry.configHash ?? existing?.configHash, + }; + return { + registry_kind: "container", + container_name: next.containerName, + session_key: next.sessionKey, + backend_id: next.backendId ?? null, + runtime_label: next.runtimeLabel ?? null, + image: next.image, + created_at_ms: next.createdAtMs, + last_used_at_ms: next.lastUsedAtMs, + config_label_kind: next.configLabelKind ?? null, + config_hash: next.configHash ?? null, + cdp_port: null, + no_vnc_port: null, + entry_json: JSON.stringify(next), + updated_at: Date.now(), + } satisfies SandboxRegistryInsert; +} + +function browserEntryToRow( + entry: SandboxBrowserRegistryEntry, + existing?: SandboxBrowserRegistryEntry | null, +) { + const next: SandboxBrowserRegistryEntry = { + ...entry, + createdAtMs: existing?.createdAtMs ?? entry.createdAtMs, + image: existing?.image ?? entry.image, + configHash: entry.configHash ?? existing?.configHash, + }; + return { + registry_kind: "browser", + container_name: next.containerName, + session_key: next.sessionKey, + backend_id: null, + runtime_label: null, + image: next.image, + created_at_ms: next.createdAtMs, + last_used_at_ms: next.lastUsedAtMs, + config_label_kind: null, + config_hash: next.configHash ?? null, + cdp_port: next.cdpPort, + no_vnc_port: next.noVncPort ?? null, + entry_json: JSON.stringify(next), + updated_at: Date.now(), + } satisfies SandboxRegistryInsert; +} + +function rowToUpdate(row: SandboxRegistryInsert): SandboxRegistryUpdate { + const { registry_kind: _registryKind, container_name: _containerName, ...update } = row; + return update; +} + +function readRegistryRows(kind: SandboxRegistryKind): SandboxRegistryRow[] { + const { db } = openOpenClawStateDatabase(); + const stateDb = getSandboxRegistryKysely(db); + return executeSqliteQuerySync( + db, + stateDb + .selectFrom("sandbox_registry_entries") + .selectAll() + .where("registry_kind", "=", kind) + .orderBy("container_name", "asc"), + ).rows; +} + +function readRegistryRow( + kind: SandboxRegistryKind, + containerName: string, +): SandboxRegistryRow | null { + const { db } = openOpenClawStateDatabase(); + const stateDb = getSandboxRegistryKysely(db); + return ( + executeSqliteQuerySync( + db, + stateDb + .selectFrom("sandbox_registry_entries") + .selectAll() + .where("registry_kind", "=", kind) + .where("container_name", "=", containerName) + .limit(1), + ).rows[0] ?? null + ); +} + +function insertRegistryRowIfMissing(row: SandboxRegistryInsert): void { + runOpenClawStateWriteTransaction(({ db }) => { + const stateDb = getSandboxRegistryKysely(db); + executeSqliteQuerySync( + db, + stateDb + .insertInto("sandbox_registry_entries") + .values(row) + .onConflict((conflict) => + conflict.columns(["registry_kind", "container_name"]).doNothing(), + ), + ); + }); +} + +function insertRegistryRow( + db: import("node:sqlite").DatabaseSync, + row: SandboxRegistryInsert, +): void { + const stateDb = getSandboxRegistryKysely(db); + executeSqliteQuerySync( + db, + stateDb + .insertInto("sandbox_registry_entries") + .values(row) + .onConflict((conflict) => + conflict.columns(["registry_kind", "container_name"]).doUpdateSet(rowToUpdate(row)), + ), + ); +} + +function readRegistryRowFromDb( + db: import("node:sqlite").DatabaseSync, + kind: SandboxRegistryKind, + containerName: string, +): SandboxRegistryRow | null { + const stateDb = getSandboxRegistryKysely(db); + return ( + executeSqliteQuerySync( + db, + stateDb + .selectFrom("sandbox_registry_entries") + .selectAll() + .where("registry_kind", "=", kind) + .where("container_name", "=", containerName) + .limit(1), + ).rows[0] ?? null + ); +} + +function removeRegistryRow(kind: SandboxRegistryKind, containerName: string): void { + runOpenClawStateWriteTransaction(({ db }) => { + const stateDb = getSandboxRegistryKysely(db); + executeSqliteQuerySync( + db, + stateDb + .deleteFrom("sandbox_registry_entries") + .where("registry_kind", "=", kind) + .where("container_name", "=", containerName), + ); + }); +} + function normalizeSandboxRegistryEntry(entry: SandboxRegistryEntry): SandboxRegistryEntry { return { ...entry, @@ -127,88 +360,49 @@ async function readLegacyRegistryFile(registryPath: string): Promise { - const entries = await readShardedEntries(SANDBOX_CONTAINERS_DIR); + const entries = readRegistryRows("container") + .map((row) => rowToContainerEntry(row)) + .filter((entry): entry is SandboxRegistryEntry => entry != null); return { entries: entries.map((entry) => normalizeSandboxRegistryEntry(entry)), }; } -function shardedEntryFilePath(dir: string, containerName: string): string { - return path.join(dir, `${hashTextSha256(containerName)}.json`); -} - -async function withEntryLock( +async function readShardedEntriesDetailed( dir: string, - containerName: string, - fn: () => Promise, -): Promise { - const entryPath = shardedEntryFilePath(dir, containerName); - // Entry-level locks keep independent container updates concurrent while serializing writes for - // the same container hash path. - const lock = await acquireSessionWriteLock({ - sessionFile: entryPath, - allowReentrant: false, - timeoutMs: 60_000, - }); - try { - return await fn(); - } finally { - await lock.release(); - } -} - -async function readShardedEntry( - dir: string, - containerName: string, -): Promise { - let raw: string; - try { - raw = await fs.readFile(shardedEntryFilePath(dir, containerName), "utf-8"); - } catch (error) { - const code = (error as { code?: string } | null)?.code; - if (code === "ENOENT") { - return null; - } - throw error; - } - const parsed = safeParseJsonWithSchema(RegistryEntrySchema, raw) as T | null; - return parsed?.containerName === containerName ? parsed : null; -} - -async function writeShardedEntry(dir: string, entry: RegistryEntryPayload): Promise { - await fs.mkdir(dir, { recursive: true }); - await writeJson(shardedEntryFilePath(dir, entry.containerName), entry, { - trailingNewline: true, - }); -} - -async function removeShardedEntry(dir: string, containerName: string): Promise { - await fs.rm(shardedEntryFilePath(dir, containerName), { force: true }); -} - -async function readShardedEntries(dir: string): Promise { +): Promise> { let files: string[]; try { files = await fs.readdir(dir); } catch (error) { const code = (error as { code?: string } | null)?.code; if (code === "ENOENT") { - return []; + return { entries: [], validFiles: [], invalidFiles: [] }; } throw error; } + const invalidFiles: string[] = []; + const validFiles: string[] = []; const entries = await Promise.all( files .filter((name) => name.endsWith(".json")) .toSorted() .map(async (name) => { + const filePath = path.join(dir, name); try { - const raw = await fs.readFile(path.join(dir, name), "utf-8"); - return safeParseJsonWithSchema(RegistryEntrySchema, raw) as T | null; + const raw = await fs.readFile(filePath, "utf-8"); + const entry = safeParseJsonWithSchema(RegistryEntrySchema, raw) as T | null; + if (!entry) { + invalidFiles.push(filePath); + } else { + validFiles.push(filePath); + } + return entry; } catch { + invalidFiles.push(filePath); return null; } }), @@ -219,9 +413,13 @@ async function readShardedEntries(dir: string): Promise validEntries.push(entry); } } - return validEntries.toSorted((left, right) => - left.containerName.localeCompare(right.containerName), - ); + return { + entries: validEntries.toSorted((left, right) => + left.containerName.localeCompare(right.containerName), + ), + validFiles: validFiles.toSorted(), + invalidFiles: invalidFiles.toSorted(), + }; } async function quarantineLegacyRegistry(registryPath: string): Promise { @@ -235,16 +433,39 @@ async function quarantineLegacyRegistry(registryPath: string): Promise { return quarantinePath; } +async function quarantineInvalidShards( + dir: string, + invalidFiles: readonly string[], +): Promise { + const quarantineDir = `${dir}.invalid-${Date.now()}`; + await fs.mkdir(quarantineDir, { recursive: true }); + for (const invalidFile of invalidFiles) { + await fs + .rename(invalidFile, path.join(quarantineDir, path.basename(invalidFile))) + .catch(async (error: unknown) => { + const code = (error as { code?: string } | null)?.code; + if (code !== "ENOENT") { + throw error; + } + }); + } + return quarantineDir; +} + +async function removeFiles(files: readonly string[]): Promise { + await Promise.all(files.map((file) => fs.rm(file, { force: true }))); +} + async function migrateMonolithicIfNeeded( target: LegacyRegistryTarget, ): Promise { - const { registryPath, shardedDir } = target; + const { registryPath } = target; try { await fs.access(registryPath); } catch (error) { const code = (error as { code?: string } | null)?.code; if (code === "ENOENT") { - return { ...target, status: "missing", entries: 0 }; + return { ...target, source: "monolithic", status: "missing", entries: 0 }; } throw error; } @@ -253,26 +474,124 @@ async function migrateMonolithicIfNeeded( const registry = await readLegacyRegistryFile(registryPath); if (!registry) { const quarantinePath = await quarantineLegacyRegistry(registryPath); - return { ...target, status: "quarantined-invalid", entries: 0, quarantinePath }; + return { + ...target, + source: "monolithic", + status: "quarantined-invalid", + entries: 0, + quarantinePath, + }; } if (registry.entries.length === 0) { await fs.rm(registryPath, { force: true }); - return { ...target, status: "removed-empty", entries: 0 }; + return { ...target, source: "monolithic", status: "removed-empty", entries: 0 }; } - await fs.mkdir(shardedDir, { recursive: true }); for (const entry of registry.entries) { - await withEntryLock(shardedDir, entry.containerName, async () => { - const existing = await readShardedEntry(shardedDir, entry.containerName); - if (!existing) { - await writeShardedEntry(shardedDir, entry); - } - }); + writeLegacyEntryIfMissing(target.kind, entry); } await fs.rm(registryPath, { force: true }); - return { ...target, status: "migrated", entries: registry.entries.length }; + return { + ...target, + source: "monolithic", + status: "migrated", + entries: registry.entries.length, + }; }); } +function writeLegacyEntryIfMissing(kind: LegacyRegistryKind, entry: RegistryEntryPayload): boolean { + if (kind === "containers") { + insertRegistryRowIfMissing( + containerEntryToRow({ + ...entry, + containerName: entry.containerName, + sessionKey: typeof entry.sessionKey === "string" ? entry.sessionKey : "", + createdAtMs: typeof entry.createdAtMs === "number" ? entry.createdAtMs : 0, + lastUsedAtMs: typeof entry.lastUsedAtMs === "number" ? entry.lastUsedAtMs : 0, + image: typeof entry.image === "string" ? entry.image : "", + }), + ); + return true; + } + insertRegistryRowIfMissing( + browserEntryToRow({ + ...entry, + containerName: entry.containerName, + sessionKey: typeof entry.sessionKey === "string" ? entry.sessionKey : "", + createdAtMs: typeof entry.createdAtMs === "number" ? entry.createdAtMs : 0, + lastUsedAtMs: typeof entry.lastUsedAtMs === "number" ? entry.lastUsedAtMs : 0, + image: typeof entry.image === "string" ? entry.image : "", + cdpPort: typeof entry.cdpPort === "number" ? entry.cdpPort : 0, + }), + ); + return true; +} + +async function migrateShardedIfNeeded( + target: LegacyRegistryTarget, +): Promise { + let dirExists = false; + try { + const stat = await fs.stat(target.shardedDir); + dirExists = stat.isDirectory(); + } catch (error) { + const code = (error as { code?: string } | null)?.code; + if (code !== "ENOENT") { + throw error; + } + } + if (!dirExists) { + return { ...target, source: "sharded", status: "missing", entries: 0 }; + } + const { entries, validFiles, invalidFiles } = + await readShardedEntriesDetailed(target.shardedDir); + if (invalidFiles.length > 0) { + for (const entry of entries) { + writeLegacyEntryIfMissing(target.kind, entry); + } + await removeFiles(validFiles); + const quarantinePath = await quarantineInvalidShards(target.shardedDir, invalidFiles); + await fs.rm(target.shardedDir, { recursive: true, force: true }); + return { + ...target, + source: "sharded", + status: "quarantined-invalid", + entries: entries.length, + quarantinePath, + }; + } + if (entries.length === 0) { + await fs.rm(target.shardedDir, { recursive: true, force: true }); + return { ...target, source: "sharded", status: "removed-empty", entries: 0 }; + } + for (const entry of entries) { + writeLegacyEntryIfMissing(target.kind, entry); + } + await fs.rm(target.shardedDir, { recursive: true, force: true }); + return { ...target, source: "sharded", status: "migrated", entries: entries.length }; +} + +function combineMigrationResults( + target: LegacyRegistryTarget, + monolithic: LegacySandboxRegistryMigrationResult, + sharded: LegacySandboxRegistryMigrationResult, +): LegacySandboxRegistryMigrationResult { + if (monolithic.status === "quarantined-invalid") { + return monolithic; + } + if (sharded.status === "quarantined-invalid") { + return sharded; + } + const entries = monolithic.entries + sharded.entries; + if (entries > 0) { + return { ...target, status: "migrated", entries }; + } + if (monolithic.status === "removed-empty" || sharded.status === "removed-empty") { + return { ...target, status: "removed-empty", entries: 0 }; + } + return { ...target, status: "missing", entries: 0 }; +} + function legacyRegistryTargets(): LegacyRegistryTarget[] { return [ { @@ -288,7 +607,7 @@ function legacyRegistryTargets(): LegacyRegistryTarget[] { ]; } -/** Inspects old monolithic registry files without mutating them. */ +/** Inspects old registry files without mutating them. */ export async function inspectLegacySandboxRegistryFiles(): Promise< LegacySandboxRegistryInspection[] > { @@ -299,30 +618,59 @@ export async function inspectLegacySandboxRegistryFiles(): Promise< } catch (error) { const code = (error as { code?: string } | null)?.code; if (code === "ENOENT") { - inspections.push({ ...target, exists: false, valid: true, entries: 0 }); - continue; + inspections.push({ + ...target, + source: "monolithic", + exists: false, + valid: true, + entries: 0, + }); + } else { + throw error; } - throw error; } - const registry = await readLegacyRegistryFile(target.registryPath); + if (!inspections.some((entry) => entry.kind === target.kind && entry.source === "monolithic")) { + const registry = await readLegacyRegistryFile(target.registryPath); + inspections.push({ + ...target, + source: "monolithic", + exists: true, + valid: Boolean(registry), + entries: registry?.entries.length ?? 0, + }); + } + + const sharded = await readShardedEntriesDetailed(target.shardedDir); + let shardedExists = false; + try { + shardedExists = (await fs.stat(target.shardedDir)).isDirectory(); + } catch (error) { + const code = (error as { code?: string } | null)?.code; + if (code !== "ENOENT") { + throw error; + } + } inspections.push({ ...target, - exists: true, - valid: Boolean(registry), - entries: registry?.entries.length ?? 0, + source: "sharded", + exists: shardedExists, + valid: sharded.invalidFiles.length === 0, + entries: sharded.entries.length, }); } return inspections; } -/** Migrates old monolithic registry files into sharded entry files when present. */ +/** Migrates old registry files into SQLite when present. */ export async function migrateLegacySandboxRegistryFiles(): Promise< LegacySandboxRegistryMigrationResult[] > { const results: LegacySandboxRegistryMigrationResult[] = []; for (const target of legacyRegistryTargets()) { - results.push(await migrateMonolithicIfNeeded(target)); + const sharded = await migrateShardedIfNeeded(target); + const monolithic = await migrateMonolithicIfNeeded(target); + results.push(combineMigrationResults(target, monolithic, sharded)); } return results; } @@ -331,60 +679,44 @@ export async function migrateLegacySandboxRegistryFiles(): Promise< export async function readRegistryEntry( containerName: string, ): Promise { - const entry = await readShardedEntry(SANDBOX_CONTAINERS_DIR, containerName); + const row = readRegistryRow("container", containerName); + const entry = row ? rowToContainerEntry(row) : null; return entry ? normalizeSandboxRegistryEntry(entry) : null; } /** Creates or updates one sandbox runtime registry entry, preserving immutable creation fields. */ export async function updateRegistry(entry: SandboxRegistryEntry) { - await withEntryLock(SANDBOX_CONTAINERS_DIR, entry.containerName, async () => { - const existing = await readShardedEntry( - SANDBOX_CONTAINERS_DIR, - entry.containerName, - ); - await writeShardedEntry(SANDBOX_CONTAINERS_DIR, { - ...entry, - backendId: entry.backendId ?? existing?.backendId, - runtimeLabel: entry.runtimeLabel ?? existing?.runtimeLabel, - createdAtMs: existing?.createdAtMs ?? entry.createdAtMs, - image: existing?.image ?? entry.image, - configLabelKind: entry.configLabelKind ?? existing?.configLabelKind, - configHash: entry.configHash ?? existing?.configHash, - }); + runOpenClawStateWriteTransaction(({ db }) => { + const existingRow = readRegistryRowFromDb(db, "container", entry.containerName); + const existing = existingRow ? rowToContainerEntry(existingRow) : null; + insertRegistryRow(db, containerEntryToRow(entry, existing)); }); } /** Removes one sandbox runtime registry entry by container name. */ export async function removeRegistryEntry(containerName: string) { - await withEntryLock(SANDBOX_CONTAINERS_DIR, containerName, async () => { - await removeShardedEntry(SANDBOX_CONTAINERS_DIR, containerName); - }); + removeRegistryRow("container", containerName); } -/** Reads all registered browser sandbox containers from the sharded registry. */ +/** Reads all registered browser sandbox containers from SQLite. */ export async function readBrowserRegistry(): Promise { - return { entries: await readShardedEntries(SANDBOX_BROWSERS_DIR) }; + return { + entries: readRegistryRows("browser") + .map((row) => rowToBrowserEntry(row)) + .filter((entry): entry is SandboxBrowserRegistryEntry => entry != null), + }; } /** Creates or updates one browser sandbox registry entry, preserving immutable creation fields. */ export async function updateBrowserRegistry(entry: SandboxBrowserRegistryEntry) { - await withEntryLock(SANDBOX_BROWSERS_DIR, entry.containerName, async () => { - const existing = await readShardedEntry( - SANDBOX_BROWSERS_DIR, - entry.containerName, - ); - await writeShardedEntry(SANDBOX_BROWSERS_DIR, { - ...entry, - createdAtMs: existing?.createdAtMs ?? entry.createdAtMs, - image: existing?.image ?? entry.image, - configHash: entry.configHash ?? existing?.configHash, - }); + runOpenClawStateWriteTransaction(({ db }) => { + const existingRow = readRegistryRowFromDb(db, "browser", entry.containerName); + const existing = existingRow ? rowToBrowserEntry(existingRow) : null; + insertRegistryRow(db, browserEntryToRow(entry, existing)); }); } /** Removes one browser sandbox registry entry by container name. */ export async function removeBrowserRegistryEntry(containerName: string) { - await withEntryLock(SANDBOX_BROWSERS_DIR, containerName, async () => { - await removeShardedEntry(SANDBOX_BROWSERS_DIR, containerName); - }); + removeRegistryRow("browser", containerName); } diff --git a/src/commands/doctor-sandbox.ts b/src/commands/doctor-sandbox.ts index d792dda6635..5bb3cfff5af 100644 --- a/src/commands/doctor-sandbox.ts +++ b/src/commands/doctor-sandbox.ts @@ -358,25 +358,27 @@ export async function maybeRepairSandboxImages( function formatLegacyRegistryInspectionLine(file: LegacySandboxRegistryInspection): string { const status = file.valid ? `${file.entries} entr${file.entries === 1 ? "y" : "ies"}` : "invalid"; - return `- ${file.kind}: ${shortenHomePath(file.registryPath)} (${status})`; + const sourcePath = file.source === "sharded" ? file.shardedDir : file.registryPath; + return `- ${file.kind} ${file.source}: ${shortenHomePath(sourcePath)} (${status})`; } function formatLegacyRegistryMigrationLine(result: LegacySandboxRegistryMigrationResult): string { - const file = shortenHomePath(result.registryPath); if (result.status === "migrated") { - return `- Migrated ${result.kind} registry from ${file} into ${result.entries} shard${result.entries === 1 ? "" : "s"}.`; + return `- Migrated ${result.kind} registry into ${result.entries} SQLite row${result.entries === 1 ? "" : "s"}.`; } if (result.status === "removed-empty") { - return `- Removed empty legacy ${result.kind} registry ${file}.`; + return `- Removed empty legacy ${result.kind} registry files.`; } if (result.status === "quarantined-invalid") { + const sourcePath = result.source === "sharded" ? result.shardedDir : result.registryPath; + const file = shortenHomePath(sourcePath); const quarantine = result.quarantinePath ? ` to ${shortenHomePath(result.quarantinePath)}` : ""; return `- Quarantined invalid legacy ${result.kind} registry ${file}${quarantine}.`; } return ""; } -/** Migrates legacy sandbox registry files into the current sharded registry layout. */ +/** Migrates legacy sandbox registry files and directories into SQLite. */ export async function maybeRepairSandboxRegistryFiles(prompter: DoctorPrompter): Promise { const legacyFiles = (await inspectLegacySandboxRegistryFiles()).filter((file) => file.exists); if (legacyFiles.length === 0) { @@ -388,7 +390,7 @@ export async function maybeRepairSandboxRegistryFiles(prompter: DoctorPrompter): [ "Legacy sandbox registry files detected.", ...legacyFiles.map(formatLegacyRegistryInspectionLine), - `Run ${formatCliCommand("openclaw doctor --fix")} to migrate them to sharded registry files.`, + `Run ${formatCliCommand("openclaw doctor --fix")} to migrate them to SQLite.`, ].join("\n"), "Sandbox", ); diff --git a/src/commands/doctor-sandbox.warns-sandbox-enabled-without-docker.test.ts b/src/commands/doctor-sandbox.warns-sandbox-enabled-without-docker.test.ts index ef880881d5c..fb71382393a 100644 --- a/src/commands/doctor-sandbox.warns-sandbox-enabled-without-docker.test.ts +++ b/src/commands/doctor-sandbox.warns-sandbox-enabled-without-docker.test.ts @@ -258,6 +258,7 @@ describe("maybeRepairSandboxRegistryFiles", () => { kind: "containers", registryPath: "/tmp/openclaw/sandbox/containers.json", shardedDir: "/tmp/openclaw/sandbox/containers", + source: "monolithic", exists: true, valid: true, entries: 2, @@ -270,8 +271,8 @@ describe("maybeRepairSandboxRegistryFiles", () => { expect(note).toHaveBeenCalledWith( [ "Legacy sandbox registry files detected.", - "- containers: /tmp/openclaw/sandbox/containers.json (2 entries)", - "Run openclaw doctor --fix to migrate them to sharded registry files.", + "- containers monolithic: /tmp/openclaw/sandbox/containers.json (2 entries)", + "Run openclaw doctor --fix to migrate them to SQLite.", ].join("\n"), "Sandbox", ); @@ -283,6 +284,7 @@ describe("maybeRepairSandboxRegistryFiles", () => { kind: "containers", registryPath: "/tmp/openclaw/sandbox/containers.json", shardedDir: "/tmp/openclaw/sandbox/containers", + source: "monolithic", exists: true, valid: true, entries: 2, @@ -305,7 +307,7 @@ describe("maybeRepairSandboxRegistryFiles", () => { expect(migrateLegacySandboxRegistryFiles).toHaveBeenCalledTimes(1); expect(note).toHaveBeenCalledWith( - "- Migrated containers registry from /tmp/openclaw/sandbox/containers.json into 2 shards.", + "- Migrated containers registry into 2 SQLite rows.", "Doctor changes", ); });