refactor: store sandbox registry in sqlite

Move sandbox registry runtime state to SQLite and migrate legacy JSON registry files/directories via doctor.
This commit is contained in:
Peter Steinberger 2026-06-07 02:05:28 -07:00 committed by GitHub
parent e06f6ffc3e
commit ce015cef57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 586 additions and 226 deletions

View file

@ -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.<skill>.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.

View file

@ -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

View file

@ -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<void>;
};
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<typeof import("../../infra/json-files.js")>(
"../../infra/json-files.js",
);
return {
...actual,
writeJson: async (
filePath: string,
value: unknown,
options?: Parameters<typeof actual.writeJson>[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<ReturnType<typeof migrateLegacySandboxRegistryFiles>>[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<void>; release: () => void } {
let markStarted = () => {};
const waitForStart = new Promise<void>((resolve) => {
markStarted = resolve;
});
let resolveRelease = () => {};
const waitForRelease = new Promise<void>((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);
});
});

View file

@ -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<T extends RegistryEntry> = {
entries: T[];
validFiles: string[];
invalidFiles: string[];
};
type LegacyRegistryKind = "containers" | "browsers";
type SandboxRegistryKind = "container" | "browser";
type SandboxRegistryTable = OpenClawStateKyselyDatabase["sandbox_registry_entries"];
type SandboxRegistryDatabase = Pick<OpenClawStateKyselyDatabase, "sandbox_registry_entries">;
type SandboxRegistryRow = Selectable<SandboxRegistryTable>;
type SandboxRegistryInsert = Insertable<SandboxRegistryTable>;
type SandboxRegistryUpdate = Updateable<SandboxRegistryTable>;
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<SandboxRegistryDatabase>(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<RegistryFil
}
}
/** Reads all registered sandbox runtime containers from the sharded registry. */
/** Reads all registered sandbox runtime containers from SQLite. */
export async function readRegistry(): Promise<SandboxRegistry> {
const entries = await readShardedEntries<SandboxRegistryEntry>(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<T>(
async function readShardedEntriesDetailed<T extends RegistryEntry>(
dir: string,
containerName: string,
fn: () => Promise<T>,
): Promise<T> {
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<T extends RegistryEntry>(
dir: string,
containerName: string,
): Promise<T | null> {
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<void> {
await fs.mkdir(dir, { recursive: true });
await writeJson(shardedEntryFilePath(dir, entry.containerName), entry, {
trailingNewline: true,
});
}
async function removeShardedEntry(dir: string, containerName: string): Promise<void> {
await fs.rm(shardedEntryFilePath(dir, containerName), { force: true });
}
async function readShardedEntries<T extends RegistryEntry>(dir: string): Promise<T[]> {
): Promise<ShardedRegistryRead<T>> {
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<T extends RegistryEntry>(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<string> {
@ -235,16 +433,39 @@ async function quarantineLegacyRegistry(registryPath: string): Promise<string> {
return quarantinePath;
}
async function quarantineInvalidShards(
dir: string,
invalidFiles: readonly string[],
): Promise<string> {
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<void> {
await Promise.all(files.map((file) => fs.rm(file, { force: true })));
}
async function migrateMonolithicIfNeeded(
target: LegacyRegistryTarget,
): Promise<LegacySandboxRegistryMigrationResult> {
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<LegacySandboxRegistryMigrationResult> {
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<RegistryEntryPayload>(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<RegistryEntryPayload>(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<SandboxRegistryEntry | null> {
const entry = await readShardedEntry<SandboxRegistryEntry>(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<SandboxRegistryEntry>(
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<SandboxBrowserRegistry> {
return { entries: await readShardedEntries<SandboxBrowserRegistryEntry>(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<SandboxBrowserRegistryEntry>(
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);
}

View file

@ -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<void> {
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",
);

View file

@ -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",
);
});