refactor: store workspace setup state in SQLite (#109147)

* refactor(state): move workspace setup state to SQLite

* test(state): track workspace cleanup temp dirs

* fix(state): satisfy workspace migration gates

* docs(state): fix migration list indentation

* fix(state): verify sibling migration claims

* fix(state): remove obsolete claim probe
This commit is contained in:
Peter Steinberger
2026-07-16 10:28:36 -07:00
committed by GitHub
parent 2db77fb3fb
commit 2cd3ee43ee
39 changed files with 5560 additions and 748 deletions

View File

@@ -27,17 +27,18 @@ openclaw reset --scope full --yes --non-interactive
## Scopes
| Scope | Removes | Stops gateway first |
| ----------------------- | ----------------------------------------------------------------------------------------------------- | ------------------- |
| `config` | config file only | no |
| `config+creds+sessions` | config file, OAuth/credentials dir, per-agent session directories | yes |
| `full` | state dir (including config/creds if nested inside it) plus workspace dirs and workspace attestations | yes |
| Scope | Removes | Stops gateway first |
| ----------------------- | --------------------------------------------------------------------------- | ------------------- |
| `config` | config file only | no |
| `config+creds+sessions` | config file, OAuth/credentials dir, per-agent session directories | yes |
| `full` | state dir (including the shared SQLite database) plus workspace directories | yes |
`config+creds+sessions` and `full` stop a running managed gateway service before deleting state.
## Notes
- Run `openclaw backup create` first for a restorable snapshot before removing local state.
- Workspace setup state and attestations are rows in the shared SQLite database, so `full` removes them with the state directory; there are no current attestation sidecar files to remove separately.
- Without `--scope`, `openclaw reset` prompts interactively for the scope to remove.
- `--non-interactive` is only valid when both `--scope` and `--yes` are set.
- `config+creds+sessions` and `full` print `Next: openclaw onboard --install-daemon` when done.

View File

@@ -111,6 +111,7 @@ If a bootstrap file is missing, OpenClaw injects a "missing file" marker into th
These live under `~/.openclaw/` and should NOT be committed to the workspace repo:
- `~/.openclaw/openclaw.json` (config)
- `~/.openclaw/state/openclaw.sqlite` (shared workspace setup state and attestations)
- `~/.openclaw/agents/<agentId>/agent/auth-profiles.json` (model auth profiles: OAuth + API keys)
- `~/.openclaw/agents/<agentId>/agent/openclaw-agent.sqlite` (session rows, transcripts, and per-agent runtime state)
- `~/.openclaw/agents/<agentId>/agent/codex-home/` (per-agent Codex runtime account, config, skills, plugins, and native thread state)
@@ -120,6 +121,12 @@ These live under `~/.openclaw/` and should NOT be committed to the workspace rep
If you need to migrate sessions or config, copy them separately and keep them out of version control.
Older OpenClaw releases wrote `openclaw-workspace-state.json`,
`.openclaw/workspace-state.json`, and `.attested` workspace sidecars. Current
runtime uses only the shared SQLite database for that state. If Doctor reports
one of these files, run `openclaw doctor --fix`; Doctor imports valid legacy
state and deletes a source only after verifying the database rows.
## Git backup (recommended, private)
Treat the workspace as private memory. Put it in a **private** git repo so it is backed up and recoverable.

View File

@@ -47,7 +47,16 @@ Blank files are skipped. Large files are trimmed and truncated with a marker so
`BOOTSTRAP.md` is only created for a **brand new workspace** (no other bootstrap files present). While it is pending, OpenClaw keeps it in Project Context and adds system-prompt bootstrap guidance for the initial ritual instead of copying it into the user message. If you delete it after completing the ritual, it is not recreated on later restarts.
After a workspace has been observed, OpenClaw also keeps a state-dir attestation marker for the workspace path. If a recently attested workspace disappears or is wiped, startup refuses to silently reseed `BOOTSTRAP.md`; restore the workspace or use a full onboard reset so the workspace and marker are cleared together.
After a workspace has been observed, OpenClaw stores its setup state and
attestation in the shared SQLite database at
`~/.openclaw/state/openclaw.sqlite`. If a recently attested workspace
disappears or is wiped, startup refuses to silently reseed `BOOTSTRAP.md`;
restore the workspace or use a full onboard reset so the workspace and its
database state are cleared together.
Older releases used workspace JSON and `.attested` sidecar files. Runtime does
not read those files. Run `openclaw doctor --fix` to validate them, import their
state into SQLite, and remove each source after the imported rows are verified.
To disable bootstrap file creation entirely (for pre-seeded workspaces), set:

View File

@@ -146,6 +146,10 @@ without exceptions outside doctor/import/export/debug boundaries.
- Backup: `sqlite-runtime`. Backup stages compact SQLite snapshots, omits live
WAL/SHM sidecars, verifies SQLite integrity, and records backup runs in the
global database.
- Workspace setup: `sqlite-runtime`. Setup completion, workspace attestations,
and generated bootstrap hashes live in typed shared SQLite tables. Runtime
does not read or write the retired workspace JSON and `.attested` sidecars;
Doctor owns their validated import and verified removal.
- Doctor migration: `migrating`, intentionally. Doctor imports legacy JSON,
JSONL, and retired sidecar stores into SQLite, records migration runs/sources,
and removes successful sources.
@@ -315,7 +319,8 @@ The branch already has a real shared SQLite base:
`skill_uploads`, `capture_sessions`, `capture_events`, `capture_blobs`,
`sandbox_registry_entries`, `cron_jobs`, `commitments`,
`delivery_queue_entries`, `model_capability_cache`,
`workspace_setup_state`, `native_hook_relay_bridges`,
`workspace_setup_state`, `workspace_path_aliases`, `workspace_attestations`,
`workspace_generated_bootstrap_hashes`, `native_hook_relay_bridges`,
`current_conversation_bindings`, `plugin_binding_approvals`,
`tui_last_sessions`, `acp_sessions`, `acp_replay_sessions`,
`acp_replay_events`, `task_runs`, `task_delivery_state`, `flow_runs`,
@@ -455,11 +460,18 @@ The branch already has a real shared SQLite base:
instead of scanning a whole namespace or relying on `LIKE` path matching.
- `src/agents/runtime-worker.entry.ts` creates per-run SQLite VFS, tool artifact,
run artifact, and scoped cache stores for workers.
- Workspace bootstrap completion markers now live in typed shared
`workspace_setup_state` rows keyed by resolved workspace path instead of
`.openclaw/workspace-state.json`; runtime no longer reads or rewrites the
legacy workspace marker, and helper APIs no longer pass around a fake
`.openclaw/setup-state` path just to derive storage identity.
- Workspace bootstrap completion, attestation recency, and generated bootstrap
hashes now live in typed shared `workspace_setup_state`,
`workspace_path_aliases`, `workspace_attestations`, and
`workspace_generated_bootstrap_hashes` rows keyed by canonical workspace
identity. Persisted lexical and real-path aliases keep vanished-workspace
protection stable after a configured symlink disappears; repointed aliases
fail closed. Runtime no longer reads or writes
`openclaw-workspace-state.json`, `.openclaw/workspace-state.json`, state-dir
`workspace-attestations/*.attested`, or sibling `<workspace>.attested`
sidecars. `openclaw doctor --fix` validates and claims legacy sources,
imports them into SQLite with migration receipts, verifies the canonical
rows, and only then removes the claimed files.
- The shared schema reserves an `exec_approvals_config` singleton row, but the
runtime cutover remains pending. TypeScript and the macOS companion still use
the state-scoped JSON file and must move to SQLite together.
@@ -1503,6 +1515,9 @@ device_identities(identity_key, device_id, public_key_pem, private_key_pem, crea
device_auth_tokens(device_id, role, token, scopes_json, updated_at_ms)
macos_port_guardian_records(pid, port, command, mode, timestamp)
workspace_setup_state(workspace_key, workspace_path, version, bootstrap_seeded_at, setup_completed_at, updated_at)
workspace_path_aliases(alias_key, alias_path, workspace_key, workspace_path, updated_at_ms)
workspace_attestations(workspace_key, attested_at_ms, updated_at_ms)
workspace_generated_bootstrap_hashes(workspace_key, filename, sha256)
native_hook_relay_bridges(relay_id, pid, hostname, port, token, expires_at_ms, updated_at_ms)
model_capability_cache(provider_id, model_id, name, input_text, input_image, reasoning, supports_tools, context_window, max_tokens, cost_input, cost_output, cost_cache_read, cost_cache_write, updated_at_ms)
agent_model_catalogs(catalog_key, agent_dir, raw_json, updated_at)
@@ -2194,7 +2209,10 @@ Add a repo check that fails new runtime writes to legacy state paths:
- `auth-profiles.json`
- `auth-state.json`
- `exec-approvals.json`
- `openclaw-workspace-state.json`
- `workspace-state.json`
- `workspace-attestations/*.attested`
- sibling `<workspace>.attested`
- Matrix `credentials*.json` and `recovery-key.json`
- `cron/runs/*.jsonl`
- `cron/jobs.json`

View File

@@ -89,7 +89,10 @@ const legacyStorePatterns = [
/\bacp\/event-ledger\.json\b/u,
/\bcache\/[^"'`]*\.json\b/u,
/\bagents\/[^"'`]+\/agent\/(?:auth|models)\.json\b/u,
/\b(?:credentials\/oauth|github-copilot\.token|openrouter-models|auth-profiles|auth-state|exec-approvals|workspace-state)\.json\b/u,
/\b(?:credentials\/oauth|github-copilot\.token|openrouter-models|auth-profiles|auth-state|exec-approvals|(?:openclaw-)?workspace-state)\.json\b/u,
// Dynamic template spans resolve to `*`, so the start alternative also
// catches `${workspaceKey}.attested` and `${workspaceDir}.attested`.
/(?:^|[/\\])[^/\\"'`]+\.attested\b/u,
/\btui\/last-session\.json\b/u,
/\bcommitments\/commitments\.json\b/u,
/\bmedia\/outgoing\/records\/[^"'`]*\.json\b/u,
@@ -116,6 +119,7 @@ const allowedRuntimeMigrationPaths = [
"src/infra/state-migrations.commitments.ts",
"src/infra/state-migrations.managed-outgoing-images.ts",
"src/infra/state-migrations.apns.ts",
"src/infra/state-migrations.workspace-setup.ts",
"src/infra/state-migrations.web-push.ts",
"src/infra/state-migrations.node-host.ts",
"src/infra/state-migrations.subagent-registry.ts",

View File

@@ -9,8 +9,13 @@ import {
registerInternalHook,
type AgentBootstrapHookContext,
} from "../hooks/internal-hooks.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { makeTempWorkspace } from "../test-helpers/workspace.js";
import { withEnvAsync } from "../test-utils/env.js";
import {
createOpenClawTestState,
type OpenClawTestState,
} from "../test-utils/openclaw-test-state.js";
import {
FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE,
hasCompletedBootstrapTurn,
@@ -19,8 +24,12 @@ import {
resolveBootstrapFilesForRun,
resolveContextInjectionMode,
} from "./bootstrap-files.js";
import { resetLegacyWorkspaceStateCheckForTest } from "./workspace-legacy-state.test-support.js";
import { mergeWorkspaceSetupState } from "./workspace-state-store.js";
import type { WorkspaceBootstrapFile } from "./workspace.js";
let testState: OpenClawTestState | undefined;
function registerExtraBootstrapFileHook() {
registerInternalHook("agent:bootstrap", (event) => {
const context = event.context as AgentBootstrapHookContext;
@@ -111,15 +120,10 @@ async function createHeartbeatAgentsWorkspace() {
}
async function writeCompletedWorkspaceState(workspaceDir: string): Promise<void> {
await fs.writeFile(
path.join(workspaceDir, "openclaw-workspace-state.json"),
`${JSON.stringify({
version: 1,
bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
setupCompletedAt: "2026-05-16T00:00:01.000Z",
})}\n`,
"utf8",
);
mergeWorkspaceSetupState(workspaceDir, {
bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
setupCompletedAt: "2026-05-16T00:00:01.000Z",
});
}
async function writeLegacyCompletedWorkspaceState(workspaceDir: string): Promise<void> {
@@ -144,8 +148,21 @@ function expectHeartbeatExcludedAndAgentsKept(files: WorkspaceBootstrapFile[]) {
}
describe("resolveBootstrapFilesForRun", () => {
beforeEach(() => clearInternalHooks());
afterEach(() => clearInternalHooks());
beforeEach(async () => {
clearInternalHooks();
resetLegacyWorkspaceStateCheckForTest();
testState = await createOpenClawTestState({
layout: "state-only",
prefix: "openclaw-bootstrap-state-",
});
});
afterEach(async () => {
clearInternalHooks();
closeOpenClawStateDatabaseForTest();
resetLegacyWorkspaceStateCheckForTest();
await testState?.cleanup();
testState = undefined;
});
it("applies bootstrap hook overrides", async () => {
registerExtraBootstrapFileHook();
@@ -211,7 +228,7 @@ describe("resolveBootstrapFilesForRun", () => {
expect(files.map((file) => file.name)).not.toContain("BOOTSTRAP.md");
});
it("ignores stale workspace BOOTSTRAP.md when legacy setup state is completed", async () => {
it("keeps BOOTSTRAP.md until Doctor migrates legacy setup state", async () => {
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
await writeLegacyCompletedWorkspaceState(workspaceDir);
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
@@ -220,7 +237,7 @@ describe("resolveBootstrapFilesForRun", () => {
const files = await resolveBootstrapFilesForRun({ workspaceDir });
expect(files.map((file) => file.name)).toContain("AGENTS.md");
expect(files.map((file) => file.name)).not.toContain("BOOTSTRAP.md");
expect(files.map((file) => file.name)).toContain("BOOTSTRAP.md");
});
it("keeps BOOTSTRAP.md when current setup state cannot be read", async () => {

View File

@@ -0,0 +1,19 @@
import "./workspace-legacy-state.js";
type WorkspaceLegacyStateTestApi = {
resetLegacyWorkspaceStateCheckForTest(): void;
};
function getTestApi(): WorkspaceLegacyStateTestApi {
const api = (globalThis as Record<PropertyKey, unknown>)[
Symbol.for("openclaw.workspaceLegacyStateTestApi")
];
if (!api) {
throw new Error("workspace legacy state test API is unavailable");
}
return api as WorkspaceLegacyStateTestApi;
}
export function resetLegacyWorkspaceStateCheckForTest(): void {
getTestApi().resetLegacyWorkspaceStateCheckForTest();
}

View File

@@ -0,0 +1,258 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import {
assertNoUnmigratedWorkspaceState,
LEGACY_WORKSPACE_ATTESTATION_HEADER,
prepareLegacyWorkspaceStateReset,
removeLegacyWorkspaceStateForReset,
resolveLegacyWorkspaceSourcePaths,
} from "./workspace-legacy-state.js";
import { resetLegacyWorkspaceStateCheckForTest } from "./workspace-legacy-state.test-support.js";
import { resolveWorkspaceStateIdentity } from "./workspace-state-store.js";
describe("legacy workspace reset cleanup", () => {
const tempDirs = useAutoCleanupTempDirTracker((cleanup) => afterEach(cleanup));
afterEach(() => resetLegacyWorkspaceStateCheckForTest());
function setup() {
const homeDir = tempDirs.make("openclaw-workspace-legacy-cleanup-");
const stateDir = path.join(homeDir, "state");
const workspaceDir = path.join(homeDir, "workspace");
const env = { ...process.env, HOME: homeDir, OPENCLAW_STATE_DIR: stateDir };
const homedir = () => homeDir;
return {
env,
homeDir,
homedir,
stateDir,
workspaceDir,
paths: resolveLegacyWorkspaceSourcePaths(workspaceDir, { env, homedir }),
};
}
function prepare(context: ReturnType<typeof setup>) {
return prepareLegacyWorkspaceStateReset(context.workspaceDir, {
env: context.env,
homedir: context.homedir,
});
}
it("removes retired setup files, claims, and owned attestations", async () => {
const context = setup();
await fs.mkdir(context.workspaceDir, { recursive: true });
const marker = `${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n2026-07-15T11:00:00.000Z\n`;
const candidates = [
context.paths.setupStatePaths[0]!,
`${context.paths.setupStatePaths[1]!}.doctor-importing`,
context.paths.stateDirAttestationPaths[0]!,
`${context.paths.stateDirAttestationPaths.at(-1)!}.doctor-importing`,
context.paths.siblingAttestationPaths[0]!,
`${context.paths.siblingAttestationPaths[0]!}.doctor-importing`,
];
for (const candidate of candidates) {
await fs.mkdir(path.dirname(candidate), { recursive: true });
await fs.writeFile(
candidate,
candidate.includes("workspace-state") ? '{"version":1}\n' : marker,
"utf8",
);
}
const result = await removeLegacyWorkspaceStateForReset(prepare(context));
expect(result.warnings).toEqual([]);
expect(new Set(result.removedPaths)).toEqual(new Set(candidates));
for (const candidate of candidates) {
await expect(fs.lstat(candidate)).rejects.toHaveProperty("code", "ENOENT");
}
});
it("previews retired state cleanup without deleting files", async () => {
const context = setup();
const marker = `${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n2026-07-15T11:00:00.000Z\n`;
const candidates = [
context.paths.setupStatePaths[0]!,
context.paths.siblingAttestationPaths[0]!,
];
for (const candidate of candidates) {
await fs.mkdir(path.dirname(candidate), { recursive: true });
await fs.writeFile(
candidate,
candidate.includes("workspace-state") ? '{"version":1}\n' : marker,
"utf8",
);
}
const result = await removeLegacyWorkspaceStateForReset(prepare(context), { dryRun: true });
expect(result.warnings).toEqual([]);
expect(new Set(result.removedPaths)).toEqual(new Set(candidates));
for (const candidate of candidates) {
await expect(fs.lstat(candidate)).resolves.toBeDefined();
}
});
it("preserves a foreign sibling attestation", async () => {
const context = setup();
await fs.mkdir(context.workspaceDir, { recursive: true });
const siblingPath = context.paths.siblingAttestationPaths[0]!;
await fs.writeFile(siblingPath, "foreign marker\n", "utf8");
const result = await removeLegacyWorkspaceStateForReset(prepare(context));
expect(result.warnings).toEqual([]);
expect(result.removedPaths).toEqual([]);
await expect(fs.readFile(siblingPath, "utf8")).resolves.toBe("foreign marker\n");
});
it("preserves a malformed sibling claim and foreign marker", async () => {
const context = setup();
const siblingPath = context.paths.siblingAttestationPaths[0]!;
const claimPath = `${siblingPath}.doctor-importing`;
await fs.mkdir(context.workspaceDir, { recursive: true });
await fs.writeFile(siblingPath, "foreign marker\n", "utf8");
await fs.writeFile(claimPath, "truncated claim\n", "utf8");
const result = await removeLegacyWorkspaceStateForReset(prepare(context));
expect(result.warnings).toEqual([]);
expect(result.removedPaths).toEqual([]);
await expect(fs.readFile(siblingPath, "utf8")).resolves.toBe("foreign marker\n");
await expect(fs.readFile(claimPath, "utf8")).resolves.toBe("truncated claim\n");
expect(() =>
assertNoUnmigratedWorkspaceState({ workspaceDir: context.workspaceDir }),
).not.toThrow();
});
it("checks lexical legacy markers separately for aliases of one workspace", async () => {
const context = setup();
const targetDir = path.join(context.homeDir, "workspace-target");
await fs.mkdir(targetDir, { recursive: true });
await fs.symlink(
targetDir,
context.workspaceDir,
process.platform === "win32" ? "junction" : "dir",
);
assertNoUnmigratedWorkspaceState({ workspaceDir: targetDir });
await fs.writeFile(
`${context.workspaceDir}.attested`,
`${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n2026-07-15T11:00:00.000Z\n`,
"utf8",
);
expect(() => assertNoUnmigratedWorkspaceState({ workspaceDir: context.workspaceDir })).toThrow(
/run openclaw doctor --fix/u,
);
});
it("checks canonical legacy markers when configuration uses a symlink alias", async () => {
const context = setup();
const targetDir = path.join(context.homeDir, "workspace-target");
await fs.mkdir(targetDir, { recursive: true });
await fs.symlink(
targetDir,
context.workspaceDir,
process.platform === "win32" ? "junction" : "dir",
);
const identity = resolveWorkspaceStateIdentity(targetDir);
const canonicalSiblingPath = `${identity.workspacePath}.attested`;
const sources = resolveLegacyWorkspaceSourcePaths(context.workspaceDir, {
env: context.env,
homedir: context.homedir,
});
await fs.writeFile(
canonicalSiblingPath,
`${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n2026-07-15T11:00:00.000Z\n`,
"utf8",
);
expect(sources.siblingAttestationPaths).toContain(canonicalSiblingPath);
expect(sources.stateDirAttestationPaths).toContain(
path.join(context.stateDir, "workspace-attestations", `${identity.workspaceKey}.attested`),
);
expect(() => assertNoUnmigratedWorkspaceState({ workspaceDir: context.workspaceDir })).toThrow(
/run openclaw doctor --fix/u,
);
const cleanup = await removeLegacyWorkspaceStateForReset(prepare(context));
expect(cleanup.removedPaths).toContain(canonicalSiblingPath);
await expect(fs.lstat(canonicalSiblingPath)).rejects.toHaveProperty("code", "ENOENT");
});
it("removes canonical legacy paths after the configured symlink is removed", async () => {
const context = setup();
const targetDir = path.join(context.homeDir, "workspace-target");
await fs.mkdir(targetDir, { recursive: true });
await fs.symlink(
targetDir,
context.workspaceDir,
process.platform === "win32" ? "junction" : "dir",
);
const identity = resolveWorkspaceStateIdentity(targetDir);
const setupPath = path.join(targetDir, "openclaw-workspace-state.json");
const stateAttestationPath = path.join(
context.stateDir,
"workspace-attestations",
`${identity.workspaceKey}.attested`,
);
const siblingPath = `${identity.workspacePath}.attested`;
const marker = `${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n2026-07-15T11:00:00.000Z\n`;
await fs.writeFile(setupPath, '{"version":1}\n', "utf8");
await fs.mkdir(path.dirname(stateAttestationPath), { recursive: true });
await fs.writeFile(stateAttestationPath, marker, "utf8");
await fs.writeFile(siblingPath, marker, "utf8");
const plan = prepare(context);
await fs.unlink(context.workspaceDir);
const result = await removeLegacyWorkspaceStateForReset(plan);
expect(result.warnings).toEqual([]);
for (const candidate of [setupPath, stateAttestationPath, siblingPath]) {
await expect(fs.lstat(candidate)).rejects.toHaveProperty("code", "ENOENT");
}
});
it("removes malformed markers from reserved state-directory paths", async () => {
const context = setup();
const candidates = [
context.paths.stateDirAttestationPaths[0]!,
`${context.paths.stateDirAttestationPaths.at(-1)!}.doctor-importing`,
];
for (const candidate of candidates) {
await fs.mkdir(path.dirname(candidate), { recursive: true });
await fs.writeFile(candidate, "truncated marker\n", "utf8");
}
const result = await removeLegacyWorkspaceStateForReset(prepare(context));
expect(result.warnings).toEqual([]);
expect(new Set(result.removedPaths)).toEqual(new Set(candidates));
for (const candidate of candidates) {
await expect(fs.lstat(candidate)).rejects.toHaveProperty("code", "ENOENT");
}
});
it("does not follow a symlinked attestation directory during reset", async () => {
const context = setup();
const markerPath = context.paths.stateDirAttestationPaths[0]!;
const externalDir = path.join(context.homeDir, "external-attestations");
const externalMarker = path.join(externalDir, path.basename(markerPath));
await fs.mkdir(path.dirname(markerPath), { recursive: true });
await fs.rm(path.dirname(markerPath), { recursive: true, force: true });
await fs.mkdir(externalDir, { recursive: true });
await fs.writeFile(
externalMarker,
`${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n2026-07-15T11:00:00.000Z\n`,
"utf8",
);
await fs.symlink(externalDir, path.dirname(markerPath));
const result = await removeLegacyWorkspaceStateForReset(prepare(context));
expect(result.warnings.length).toBeGreaterThan(0);
await expect(fs.readFile(externalMarker, "utf8")).resolves.toContain(
LEGACY_WORKSPACE_ATTESTATION_HEADER,
);
});
});

View File

@@ -0,0 +1,283 @@
// Legacy workspace state paths remain here solely for Doctor discovery and a
// presence-only runtime upgrade gate. Runtime state never parses these files.
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveLegacyStateDirs, resolveStateDir } from "../config/paths.js";
import { root } from "../infra/fs-safe.js";
import { resolveUserPath } from "../utils.js";
import { resolveWorkspaceStateIdentity } from "./workspace-state-store.js";
export const LEGACY_WORKSPACE_STATE_DIRNAME = ".openclaw";
const LEGACY_WORKSPACE_STATE_FILENAME = "workspace-state.json";
export const LEGACY_WORKSPACE_STATE_CURRENT_FILENAME = "openclaw-workspace-state.json";
export const LEGACY_WORKSPACE_ATTESTATION_DIRNAME = "workspace-attestations";
const LEGACY_WORKSPACE_ATTESTATION_SUFFIX = ".attested";
export const LEGACY_WORKSPACE_ATTESTATION_HEADER = "openclaw-workspace-attestation:v1";
export const LEGACY_WORKSPACE_ATTESTATION_MAX_BYTES = 2048;
export const WORKSPACE_DOCTOR_CLAIM_SUFFIX = ".doctor-importing";
// Legacy files are upgrade-time inputs. Cache only verified absence so every
// agent turn does not poll retired paths; Doctor/restart owns later changes.
const checkedWorkspaceSourceSets = new Set<string>();
type LegacyWorkspaceSourcePaths = {
workspacePath: string;
setupStatePaths: string[];
stateDirAttestationPaths: string[];
siblingAttestationPaths: string[];
};
type LegacyWorkspaceResetCleanup = {
removedPaths: string[];
warnings: string[];
};
type LegacyWorkspaceResetCandidate = {
rootDir: string;
sourcePath: string;
requireAttestationHeader: boolean;
};
type LegacyWorkspaceResetPlan = {
candidates: LegacyWorkspaceResetCandidate[];
};
function uniqueSiblingPaths(paths: readonly string[]): string[] {
const seen = new Set<string>();
return paths.filter((candidate) => {
let key = path.resolve(candidate);
try {
key = path.join(fs.realpathSync.native(path.dirname(candidate)), path.basename(candidate));
} catch {
// Missing parents stay distinct lexical migration inputs.
}
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
export function resolveLegacyWorkspaceSourcePaths(
workspaceDir: string,
options?: { env?: NodeJS.ProcessEnv; homedir?: () => string },
): LegacyWorkspaceSourcePaths {
// Hashed and sibling legacy filenames used the lexical configured path.
// Setup files live inside the workspace, so bind them to the canonical root
// while it still exists; destructive cleanup may remove the alias first.
const workspacePath = path.resolve(resolveUserPath(workspaceDir));
const canonicalIdentity = resolveWorkspaceStateIdentity(workspaceDir);
const workspaceKeys = [
createHash("sha256").update(workspacePath).digest("hex"),
canonicalIdentity.workspaceKey,
];
const workspacePaths = [workspacePath, canonicalIdentity.workspacePath];
const env = options?.env ?? process.env;
const stateDirs = [
resolveStateDir(env, options?.homedir),
...resolveLegacyStateDirs(options?.homedir),
];
return {
workspacePath,
setupStatePaths: [
path.join(canonicalIdentity.workspacePath, LEGACY_WORKSPACE_STATE_CURRENT_FILENAME),
path.join(
canonicalIdentity.workspacePath,
LEGACY_WORKSPACE_STATE_DIRNAME,
LEGACY_WORKSPACE_STATE_FILENAME,
),
],
stateDirAttestationPaths: [...new Set(stateDirs)].flatMap((stateDir) =>
[...new Set(workspaceKeys)].map((workspaceKey) =>
path.join(
stateDir,
LEGACY_WORKSPACE_ATTESTATION_DIRNAME,
`${workspaceKey}${LEGACY_WORKSPACE_ATTESTATION_SUFFIX}`,
),
),
),
siblingAttestationPaths: uniqueSiblingPaths(
[...new Set(workspacePaths)].map(
(candidate) => `${candidate}${LEGACY_WORKSPACE_ATTESTATION_SUFFIX}`,
),
),
};
}
function pathOrClaimExists(filePath: string): boolean {
for (const candidate of [filePath, `${filePath}${WORKSPACE_DOCTOR_CLAIM_SUFFIX}`]) {
try {
fs.lstatSync(candidate);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
return true;
}
}
}
return false;
}
function siblingPathIsOwnedMarker(filePath: string): boolean {
let stat: fs.Stats;
try {
stat = fs.lstatSync(filePath);
} catch {
return false;
}
if (!stat.isFile()) {
return false;
}
try {
const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
const fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow);
try {
const buffer = Buffer.alloc(
Math.min(stat.size, LEGACY_WORKSPACE_ATTESTATION_HEADER.length + 1),
);
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
return (
buffer.subarray(0, bytesRead).toString("utf8") ===
`${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n`
);
} finally {
fs.closeSync(fd);
}
} catch {
// A regular file at the exact retired path is ambiguous when it cannot be
// inspected. Doctor owns the safe decision; runtime must not assume absence.
return true;
}
}
/** Fail closed on unmigrated owned state without reading it as runtime data. */
export function assertNoUnmigratedWorkspaceState(params: { workspaceDir: string }): void {
const identity = resolveWorkspaceStateIdentity(params.workspaceDir);
const sources = resolveLegacyWorkspaceSourcePaths(params.workspaceDir);
const sourceSetKey = JSON.stringify([
identity.workspaceKey,
...sources.setupStatePaths,
...sources.stateDirAttestationPaths,
...sources.siblingAttestationPaths,
]);
if (checkedWorkspaceSourceSets.has(sourceSetKey)) {
return;
}
const hasLegacy =
sources.setupStatePaths.some(pathOrClaimExists) ||
sources.stateDirAttestationPaths.some(pathOrClaimExists) ||
sources.siblingAttestationPaths.some(
(sourcePath) =>
siblingPathIsOwnedMarker(`${sourcePath}${WORKSPACE_DOCTOR_CLAIM_SUFFIX}`) ||
siblingPathIsOwnedMarker(sourcePath),
);
if (hasLegacy) {
throw new Error(
`Legacy workspace setup state requires migration for ${identity.workspacePath}; run openclaw doctor --fix.`,
);
}
checkedWorkspaceSourceSets.add(sourceSetKey);
}
function resetLegacyWorkspaceStateCheckForTest(): void {
checkedWorkspaceSourceSets.clear();
}
if (process.env.VITEST || process.env.NODE_ENV === "test") {
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.workspaceLegacyStateTestApi")] =
{ resetLegacyWorkspaceStateCheckForTest };
}
function isOwnedAttestationBuffer(buffer: Buffer): boolean {
return (
buffer.subarray(0, LEGACY_WORKSPACE_ATTESTATION_HEADER.length + 1).toString("utf8") ===
`${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n`
);
}
/** Capture canonical legacy paths before a destructive workspace removal. */
export function prepareLegacyWorkspaceStateReset(
workspaceDir: string,
options?: { env?: NodeJS.ProcessEnv; homedir?: () => string },
): LegacyWorkspaceResetPlan {
const sources = resolveLegacyWorkspaceSourcePaths(workspaceDir, options);
const candidates = [
...sources.setupStatePaths.map((sourcePath) => ({
rootDir: sourcePath.endsWith(LEGACY_WORKSPACE_STATE_CURRENT_FILENAME)
? path.dirname(sourcePath)
: path.dirname(path.dirname(sourcePath)),
sourcePath,
requireAttestationHeader: false,
})),
...sources.stateDirAttestationPaths.map((sourcePath) => ({
rootDir: path.dirname(path.dirname(sourcePath)),
sourcePath,
// Hashed paths inside OpenClaw-owned attestation directories are
// reserved state. Explicit reset must remove malformed blockers too.
requireAttestationHeader: false,
})),
...sources.siblingAttestationPaths.map((sourcePath) => ({
rootDir: path.dirname(sourcePath),
sourcePath,
requireAttestationHeader: true,
})),
].flatMap((candidate) => [
candidate,
{
...candidate,
sourcePath: `${candidate.sourcePath}${WORKSPACE_DOCTOR_CLAIM_SUFFIX}`,
// Sibling claims remain outside OpenClaw-owned roots. Renaming a claimed
// marker preserves its header, so require that ownership proof there too.
requireAttestationHeader: candidate.requireAttestationHeader,
},
]);
return { candidates };
}
/** Discard retired workspace files from a pre-removal reset plan. */
export async function removeLegacyWorkspaceStateForReset(
plan: LegacyWorkspaceResetPlan,
options?: { dryRun?: boolean },
): Promise<LegacyWorkspaceResetCleanup> {
const removedPaths: string[] = [];
const warnings: string[] = [];
for (const candidate of plan.candidates) {
const rootDir = path.resolve(candidate.rootDir);
const sourcePath = path.resolve(candidate.sourcePath);
const relativePath = path.relative(rootDir, sourcePath);
try {
fs.lstatSync(rootDir);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
continue;
}
warnings.push(`Could not inspect retired workspace state at ${sourcePath}: ${String(error)}`);
continue;
}
try {
const sourceRoot = await root(rootDir, {
hardlinks: "reject",
maxBytes: LEGACY_WORKSPACE_ATTESTATION_MAX_BYTES,
symlinks: "reject",
});
if (!(await sourceRoot.exists(relativePath))) {
continue;
}
if (candidate.requireAttestationHeader) {
const snapshot = await sourceRoot.read(relativePath);
if (!isOwnedAttestationBuffer(snapshot.buffer)) {
continue;
}
}
if (!options?.dryRun) {
await sourceRoot.remove(relativePath);
}
removedPaths.push(sourcePath);
} catch (error) {
warnings.push(`Could not remove retired workspace state at ${sourcePath}: ${String(error)}`);
}
}
return { removedPaths, warnings };
}

View File

@@ -0,0 +1,190 @@
// Setup-only SQLite safety tests cover attestation-write failure and
// independently migrated setup state.
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../state/openclaw-state-db.js";
import { makeTempWorkspace } from "../test-helpers/workspace.js";
import {
createOpenClawTestState,
type OpenClawTestState,
} from "../test-utils/openclaw-test-state.js";
import { resetLegacyWorkspaceStateCheckForTest } from "./workspace-legacy-state.test-support.js";
import {
mergeWorkspaceSetupState,
readWorkspaceStateSnapshot,
resolveWorkspaceStateIdentity,
} from "./workspace-state-store.js";
import {
DEFAULT_AGENTS_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
ensureAgentWorkspace,
WORKSPACE_VANISHED_ERROR_CODE,
} from "./workspace.js";
let testState: OpenClawTestState | undefined;
beforeEach(async () => {
resetLegacyWorkspaceStateCheckForTest();
testState = await createOpenClawTestState({
layout: "state-only",
prefix: "openclaw-workspace-sqlite-safety-",
});
});
afterEach(async () => {
closeOpenClawStateDatabaseForTest();
resetLegacyWorkspaceStateCheckForTest();
await testState?.cleanup();
testState = undefined;
});
function deleteWorkspaceAttestation(workspaceDir: string): void {
const identity = resolveWorkspaceStateIdentity(workspaceDir);
openOpenClawStateDatabase()
.db.prepare("DELETE FROM workspace_attestations WHERE workspace_key = ?")
.run(identity.workspaceKey);
}
describe("workspace setup-only SQLite safety", () => {
it("clears expired setup-only state when one generated remnant survives", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
const generatedAgents = await fs.readFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), "utf8");
const identity = resolveWorkspaceStateIdentity(tempDir);
const expiredAtMs = Date.now() - 25 * 60 * 60 * 1000;
const db = openOpenClawStateDatabase().db;
deleteWorkspaceAttestation(tempDir);
db.prepare("UPDATE workspace_setup_state SET updated_at = ? WHERE workspace_key = ?").run(
expiredAtMs,
identity.workspaceKey,
);
await fs.rm(tempDir, { recursive: true, force: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), generatedAgents);
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expect(
fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
).resolves.toBeUndefined();
expect(readWorkspaceStateSnapshot(tempDir).setup.setupCompletedAt).toBeUndefined();
});
it("clears expired state when only one generated bootstrap file survives", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
const generatedAgents = await fs.readFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), "utf-8");
const identity = resolveWorkspaceStateIdentity(tempDir);
const expiredAtMs = Date.now() - 25 * 60 * 60 * 1000;
const db = openOpenClawStateDatabase().db;
db.prepare(
"UPDATE workspace_attestations SET attested_at_ms = ?, updated_at_ms = ? WHERE workspace_key = ?",
).run(expiredAtMs, expiredAtMs, identity.workspaceKey);
db.prepare("UPDATE workspace_setup_state SET updated_at = ? WHERE workspace_key = ?").run(
expiredAtMs,
identity.workspaceKey,
);
await fs.rm(tempDir, { recursive: true, force: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), generatedAgents);
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expect(
fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
).resolves.toBeUndefined();
expect(readWorkspaceStateSnapshot(tempDir).setup.setupCompletedAt).toBeUndefined();
});
it("refuses an empty recent setup-only workspace when bootstrap creation is disabled", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
mergeWorkspaceSetupState(tempDir, {
bootstrapSeededAt: new Date().toISOString(),
});
await expect(
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: false }),
).rejects.toMatchObject({
code: WORKSPACE_VANISHED_ERROR_CODE,
name: "WorkspaceVanishedError",
});
});
it("does not mistake an old generated template for setup-only customization", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await fs.writeFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), "old generated agents\n");
mergeWorkspaceSetupState(tempDir, {
bootstrapSeededAt: "2026-07-15T10:00:00.000Z",
setupCompletedAt: "2026-07-15T10:01:00.000Z",
});
await expect(
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
).rejects.toMatchObject({
code: WORKSPACE_VANISHED_ERROR_CODE,
name: "WorkspaceVanishedError",
});
await expect(fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME))).rejects.toHaveProperty(
"code",
"ENOENT",
);
});
it("refuses to reseed a missing workspace with recent setup-only state", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
mergeWorkspaceSetupState(tempDir, {
bootstrapSeededAt: new Date().toISOString(),
});
await fs.rm(tempDir, { recursive: true, force: true });
await expect(
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
).rejects.toMatchObject({
code: WORKSPACE_VANISHED_ERROR_CODE,
name: "WorkspaceVanishedError",
});
await expect(fs.access(tempDir)).rejects.toHaveProperty("code", "ENOENT");
});
it("refuses to trust setup-only state after only generated remnants survive", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
const generatedAgents = await fs.readFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), "utf-8");
deleteWorkspaceAttestation(tempDir);
await fs.rm(tempDir, { recursive: true, force: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), generatedAgents);
await expect(
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
).rejects.toMatchObject({
code: WORKSPACE_VANISHED_ERROR_CODE,
name: "WorkspaceVanishedError",
});
await expect(fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME))).rejects.toHaveProperty(
"code",
"ENOENT",
);
});
it("accepts an intact generated workspace with setup-only state", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await fs.rm(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
deleteWorkspaceAttestation(tempDir);
await expect(
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
).resolves.toMatchObject({ dir: tempDir });
await expect(fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME))).rejects.toHaveProperty(
"code",
"ENOENT",
);
});
});

View File

@@ -0,0 +1,399 @@
// SQLite workspace state tests cover persistence, monotonic setup completion,
// and atomic attestation hash replacement.
import fs from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
import {
createOpenClawTestState,
type OpenClawTestState,
} from "../test-utils/openclaw-test-state.js";
import {
clearExpiredWorkspaceStateForVanishedWorkspace,
deleteWorkspaceState,
mergeWorkspaceSetupState,
prepareWorkspaceStateDeletion,
readWorkspaceStateSnapshot,
replaceWorkspaceAttestation,
resolveWorkspaceStateIdentity,
WORKSPACE_LEGACY_STATE_MIGRATION_KIND,
} from "./workspace-state-store.js";
let testState: OpenClawTestState | undefined;
beforeEach(async () => {
testState = await createOpenClawTestState({
layout: "state-only",
prefix: "openclaw-workspace-store-",
});
});
afterEach(async () => {
closeOpenClawStateDatabaseForTest();
await testState?.cleanup();
testState = undefined;
});
function workspaceDir(): string {
if (!testState) {
throw new Error("test state unavailable");
}
return testState.workspaceDir;
}
function deleteState(targetDir: string): void {
deleteWorkspaceState(prepareWorkspaceStateDeletion(targetDir));
}
describe("workspace state store", () => {
it("round-trips setup and attestation state after a database restart", () => {
const dir = workspaceDir();
mergeWorkspaceSetupState(dir, {
bootstrapSeededAt: "2026-07-16T01:00:00.000Z",
setupCompletedAt: "2026-07-16T02:00:00.000Z",
});
replaceWorkspaceAttestation({
workspaceDir: dir,
attestedAtMs: 1_752_628_800_000,
generatedHashes: new Map([
["AGENTS.md", "a".repeat(64)],
["TOOLS.md", "b".repeat(64)],
]),
});
closeOpenClawStateDatabaseForTest();
const snapshot = readWorkspaceStateSnapshot(dir);
expect(snapshot.setupExists).toBe(true);
expect(snapshot.setup).toStrictEqual({
version: 1,
bootstrapSeededAt: "2026-07-16T01:00:00.000Z",
setupCompletedAt: "2026-07-16T02:00:00.000Z",
});
expect(snapshot.attestation?.attestedAtMs).toBe(1_752_628_800_000);
expect([...snapshot.attestation!.generatedHashes.entries()]).toStrictEqual([
["AGENTS.md", "a".repeat(64)],
["TOOLS.md", "b".repeat(64)],
]);
});
it("never regresses persisted setup milestones", () => {
const dir = workspaceDir();
mergeWorkspaceSetupState(dir, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
mergeWorkspaceSetupState(dir, { setupCompletedAt: "2026-07-16T02:00:00.000Z" }, 2_000);
const state = mergeWorkspaceSetupState(
dir,
{
bootstrapSeededAt: "2026-07-16T03:00:00.000Z",
setupCompletedAt: "2026-07-16T04:00:00.000Z",
},
3_000,
);
expect(state).toStrictEqual({
version: 1,
bootstrapSeededAt: "2026-07-16T01:00:00.000Z",
setupCompletedAt: "2026-07-16T02:00:00.000Z",
});
expect(readWorkspaceStateSnapshot(dir).setup).toStrictEqual(state);
});
it("replaces generated hashes atomically and ignores older attestations", () => {
const dir = workspaceDir();
replaceWorkspaceAttestation({
workspaceDir: dir,
attestedAtMs: 2_000,
generatedHashes: new Map([
["AGENTS.md", "a".repeat(64)],
["TOOLS.md", "b".repeat(64)],
]),
nowMs: 2_000,
});
replaceWorkspaceAttestation({
workspaceDir: dir,
attestedAtMs: 3_000,
generatedHashes: new Map([["SOUL.md", "c".repeat(64)]]),
nowMs: 3_000,
});
replaceWorkspaceAttestation({
workspaceDir: dir,
attestedAtMs: 1_000,
generatedHashes: new Map([["USER.md", "d".repeat(64)]]),
nowMs: 4_000,
});
const attestation = readWorkspaceStateSnapshot(dir).attestation;
expect(attestation?.attestedAtMs).toBe(3_000);
expect([...attestation!.generatedHashes.entries()]).toStrictEqual([
["SOUL.md", "c".repeat(64)],
]);
});
it("replaces a future-dated attestation with a live refresh", () => {
const dir = workspaceDir();
replaceWorkspaceAttestation({
workspaceDir: dir,
attestedAtMs: 100_000,
generatedHashes: new Map([["AGENTS.md", "a".repeat(64)]]),
nowMs: 100_000,
});
replaceWorkspaceAttestation({
workspaceDir: dir,
attestedAtMs: 2_000,
generatedHashes: new Map([["TOOLS.md", "b".repeat(64)]]),
nowMs: 2_000,
});
const attestation = readWorkspaceStateSnapshot(dir).attestation;
expect(attestation?.attestedAtMs).toBe(2_000);
expect([...attestation!.generatedHashes.entries()]).toStrictEqual([
["TOOLS.md", "b".repeat(64)],
]);
});
it("preserves future-dated state for a vanished workspace", () => {
const dir = workspaceDir();
mergeWorkspaceSetupState(dir, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
replaceWorkspaceAttestation({
workspaceDir: dir,
attestedAtMs: 100_000,
generatedHashes: new Map(),
nowMs: 100_000,
});
expect(clearExpiredWorkspaceStateForVanishedWorkspace(dir, 2_000)).toBe(false);
expect(readWorkspaceStateSnapshot(dir).setupExists).toBe(true);
expect(readWorkspaceStateSnapshot(dir).attestation?.attestedAtMs).toBe(100_000);
});
it("preserves recent setup-only state for a vanished workspace", () => {
const dir = workspaceDir();
mergeWorkspaceSetupState(dir, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
expect(clearExpiredWorkspaceStateForVanishedWorkspace(dir, 2_000)).toBe(false);
expect(readWorkspaceStateSnapshot(dir).setupExists).toBe(true);
});
it("keeps symlink aliases on one identity after the workspace target vanishes", () => {
const dir = workspaceDir();
const alias = testState!.path("workspace-link");
fs.symlinkSync(dir, alias, process.platform === "win32" ? "junction" : "dir");
const identity = resolveWorkspaceStateIdentity(dir);
expect(resolveWorkspaceStateIdentity(alias)).toStrictEqual(identity);
mergeWorkspaceSetupState(dir, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
fs.rmSync(dir, { recursive: true, force: true });
expect(resolveWorkspaceStateIdentity(alias)).toStrictEqual(identity);
expect(clearExpiredWorkspaceStateForVanishedWorkspace(alias, 2_000)).toBe(false);
expect(readWorkspaceStateSnapshot(alias).setupExists).toBe(true);
});
it("uses a persisted alias after the configured symlink itself disappears", () => {
const dir = workspaceDir();
const alias = testState!.path("workspace-link");
fs.symlinkSync(dir, alias, process.platform === "win32" ? "junction" : "dir");
const identity = resolveWorkspaceStateIdentity(dir);
mergeWorkspaceSetupState(alias, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
fs.unlinkSync(alias);
expect(resolveWorkspaceStateIdentity(alias)).not.toStrictEqual(identity);
expect(readWorkspaceStateSnapshot(alias).identity).toStrictEqual(identity);
expect(clearExpiredWorkspaceStateForVanishedWorkspace(alias, 2_000)).toBe(false);
});
it("fails closed when a persisted symlink alias is repointed", () => {
const dir = workspaceDir();
const alias = testState!.path("workspace-link");
const replacement = testState!.path("replacement-workspace");
fs.mkdirSync(replacement, { recursive: true });
fs.symlinkSync(dir, alias, process.platform === "win32" ? "junction" : "dir");
mergeWorkspaceSetupState(alias, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
fs.unlinkSync(alias);
fs.symlinkSync(replacement, alias, process.platform === "win32" ? "junction" : "dir");
expect(() => readWorkspaceStateSnapshot(alias)).toThrow(/different current target/u);
});
it("cleans current state and only the stale association for a repointed alias", () => {
const dir = workspaceDir();
const alias = testState!.path("workspace-link");
const replacement = testState!.path("replacement-workspace");
fs.mkdirSync(replacement, { recursive: true });
fs.symlinkSync(dir, alias, process.platform === "win32" ? "junction" : "dir");
mergeWorkspaceSetupState(alias, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
mergeWorkspaceSetupState(replacement, { bootstrapSeededAt: "2026-07-16T02:00:00.000Z" }, 2_000);
fs.unlinkSync(alias);
fs.symlinkSync(replacement, alias, process.platform === "win32" ? "junction" : "dir");
const deletion = prepareWorkspaceStateDeletion(alias);
fs.unlinkSync(alias);
deleteWorkspaceState(deletion);
expect(readWorkspaceStateSnapshot(dir).setupExists).toBe(true);
expect(readWorkspaceStateSnapshot(replacement).setupExists).toBe(false);
const staleAlias = openOpenClawStateDatabase()
.db.prepare("SELECT alias_key FROM workspace_path_aliases WHERE alias_path = ?")
.get(alias);
expect(staleAlias).toBeUndefined();
});
it("deletes canonical state through a missing persisted alias", () => {
const dir = workspaceDir();
const alias = testState!.path("workspace-link");
fs.symlinkSync(dir, alias, process.platform === "win32" ? "junction" : "dir");
mergeWorkspaceSetupState(alias, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
fs.unlinkSync(alias);
deleteState(alias);
expect(readWorkspaceStateSnapshot(dir).setupExists).toBe(false);
const aliases = openOpenClawStateDatabase()
.db.prepare("SELECT alias_key FROM workspace_path_aliases")
.all();
expect(aliases).toEqual([]);
});
it("clears expired setup-only state for a vanished workspace", () => {
const dir = workspaceDir();
mergeWorkspaceSetupState(dir, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
expect(clearExpiredWorkspaceStateForVanishedWorkspace(dir, 86_401_001)).toBe(true);
expect(readWorkspaceStateSnapshot(dir).setupExists).toBe(false);
});
it("does not protect a markerless setup row", () => {
const dir = workspaceDir();
mergeWorkspaceSetupState(dir, {}, 1_000);
expect(clearExpiredWorkspaceStateForVanishedWorkspace(dir, 2_000)).toBe(true);
expect(readWorkspaceStateSnapshot(dir).setupExists).toBe(false);
});
it("preserves recent setup state when its attestation is stale", () => {
const dir = workspaceDir();
mergeWorkspaceSetupState(dir, { setupCompletedAt: "2026-07-16T01:00:00.000Z" }, 100_000_000);
replaceWorkspaceAttestation({
workspaceDir: dir,
attestedAtMs: 1_000,
generatedHashes: new Map(),
nowMs: 1_000,
});
expect(clearExpiredWorkspaceStateForVanishedWorkspace(dir, 100_001_000)).toBe(false);
expect(readWorkspaceStateSnapshot(dir).setupExists).toBe(true);
});
it("deletes future-version state without parsing it", () => {
const dir = workspaceDir();
const identity = resolveWorkspaceStateIdentity(dir);
const db = openOpenClawStateDatabase().db;
db.prepare(
`INSERT INTO workspace_setup_state (
workspace_key,
workspace_path,
version,
bootstrap_seeded_at,
setup_completed_at,
updated_at
) VALUES (?, ?, 99, NULL, NULL, 1)`,
).run(identity.workspaceKey, identity.workspacePath);
expect(() => readWorkspaceStateSnapshot(dir)).toThrow(/version requires openclaw doctor/u);
expect(() => deleteState(dir)).not.toThrow();
const row = db
.prepare("SELECT workspace_key FROM workspace_setup_state WHERE workspace_key = ?")
.get(identity.workspaceKey);
expect(row).toBeUndefined();
});
it("does not recreate a missing database during delete-only cleanup", () => {
const dir = workspaceDir();
const databasePath = resolveOpenClawStateSqlitePath();
closeOpenClawStateDatabaseForTest();
fs.rmSync(path.dirname(databasePath), { recursive: true, force: true });
deleteState(dir);
expect(fs.existsSync(databasePath)).toBe(false);
expect(fs.existsSync(path.dirname(databasePath))).toBe(false);
});
it("deletes migration receipts owned by the workspace", () => {
const dir = workspaceDir();
const identity = resolveWorkspaceStateIdentity(dir);
const db = openOpenClawStateDatabase().db;
mergeWorkspaceSetupState(dir, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" });
const insertRun = db.prepare(
"INSERT INTO migration_runs (id, started_at, finished_at, status, report_json) VALUES (?, 1, 1, 'completed', '{}')",
);
insertRun.run("owned-run");
insertRun.run("unrelated-run");
const insertReceipt = db.prepare(
`INSERT INTO migration_sources (
source_key,
migration_kind,
source_path,
target_table,
last_run_id,
status,
imported_at,
report_json
) VALUES (?, ?, ?, 'workspace_setup_state', ?, 'completed', 1, ?)`,
);
insertReceipt.run(
"owned-receipt",
WORKSPACE_LEGACY_STATE_MIGRATION_KIND,
path.join(dir, ".openclaw", "workspace-state.json"),
"owned-run",
JSON.stringify({ workspaceKey: identity.workspaceKey }),
);
insertReceipt.run(
"unrelated-receipt",
WORKSPACE_LEGACY_STATE_MIGRATION_KIND,
"/other/workspace-state.json",
"unrelated-run",
JSON.stringify({ workspaceKey: "other-workspace" }),
);
deleteState(dir);
const receipts = db
.prepare("SELECT source_key FROM migration_sources ORDER BY source_key")
.all();
expect(receipts).toEqual([{ source_key: "unrelated-receipt" }]);
const runs = db.prepare("SELECT id FROM migration_runs ORDER BY id").all();
expect(runs).toEqual([{ id: "unrelated-run" }]);
});
it("clears expired missing-workspace state but preserves a concurrent refresh", () => {
const dir = workspaceDir();
mergeWorkspaceSetupState(dir, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
replaceWorkspaceAttestation({
workspaceDir: dir,
attestedAtMs: 1_000,
generatedHashes: new Map(),
nowMs: 1_000,
});
expect(clearExpiredWorkspaceStateForVanishedWorkspace(dir, 86_401_001)).toBe(true);
expect(readWorkspaceStateSnapshot(dir)).toMatchObject({ setupExists: false });
expect(readWorkspaceStateSnapshot(dir).attestation).toBeUndefined();
mergeWorkspaceSetupState(dir, { bootstrapSeededAt: "2026-07-16T02:00:00.000Z" }, 86_401_000);
replaceWorkspaceAttestation({
workspaceDir: dir,
attestedAtMs: 86_401_000,
generatedHashes: new Map(),
nowMs: 86_401_000,
});
expect(clearExpiredWorkspaceStateForVanishedWorkspace(dir, 86_401_001)).toBe(false);
expect(readWorkspaceStateSnapshot(dir).setupExists).toBe(true);
});
});

View File

@@ -0,0 +1,715 @@
import { createHash } from "node:crypto";
import fs, { existsSync } from "node:fs";
import path from "node:path";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../infra/kysely-sync.js";
import { runSqliteDeferredTransactionSync } from "../infra/sqlite-transaction.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
} from "../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
import { resolveUserPath } from "../utils.js";
export const WORKSPACE_SETUP_STATE_VERSION = 1 as const;
export const WORKSPACE_ATTESTATION_RECENT_MS = 24 * 60 * 60 * 1000;
export const WORKSPACE_LEGACY_STATE_MIGRATION_KIND = "legacy-workspace-setup-files";
export const WORKSPACE_ATTESTED_BOOTSTRAP_FILENAMES: ReadonlySet<string> = new Set([
"AGENTS.md",
"SOUL.md",
"TOOLS.md",
"IDENTITY.md",
"USER.md",
"HEARTBEAT.md",
]);
const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/u;
function isCanonicalIsoTimestamp(value: string): boolean {
const timestamp = new Date(value);
return Number.isFinite(timestamp.getTime()) && timestamp.toISOString() === value;
}
function assertCanonicalTimestamp(value: string | null, label: string): void {
if (value !== null && !isCanonicalIsoTimestamp(value)) {
throw new Error(`workspace ${label} timestamp is invalid`);
}
}
function assertCanonicalIntegerTimestamp(value: number, label: string): void {
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error(`workspace ${label} timestamp is invalid`);
}
}
export type WorkspaceSetupState = {
version: typeof WORKSPACE_SETUP_STATE_VERSION;
bootstrapSeededAt?: string;
setupCompletedAt?: string;
};
export type WorkspaceAttestation = {
attestedAtMs: number;
generatedHashes: ReadonlyMap<string, string>;
};
export type WorkspaceStateSnapshot = {
identity: WorkspaceStateIdentity;
setupExists: boolean;
setupUpdatedAtMs?: number;
setup: WorkspaceSetupState;
attestation?: WorkspaceAttestation;
};
type WorkspaceStateIdentity = {
workspaceKey: string;
workspacePath: string;
};
type WorkspaceStateDeletionPlan = {
lexicalAlias: WorkspaceStateIdentity;
currentCanonicalIdentity: WorkspaceStateIdentity;
pathEntryExisted: boolean;
};
type WorkspaceStateDatabase = Pick<
OpenClawStateKyselyDatabase,
| "workspace_setup_state"
| "workspace_path_aliases"
| "workspace_attestations"
| "workspace_generated_bootstrap_hashes"
| "migration_runs"
| "migration_sources"
>;
const MAX_WORKSPACE_IDENTITY_SYMLINKS = 40;
type WorkspaceIdentityResolution = {
identity: WorkspaceStateIdentity;
aliases: WorkspaceStateIdentity[];
missingAliasKeys: string[];
};
function normalizeWorkspaceIdentityPath(value: string): string {
const normalized = path.normalize(value).normalize("NFC");
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
}
function canonicalizeWorkspaceIdentityPath(workspaceDir: string): string {
const fallback = normalizeWorkspaceIdentityPath(path.resolve(resolveUserPath(workspaceDir)));
let candidate = fallback;
const followedSymlinks = new Set<string>();
for (let redirectCount = 0; redirectCount < MAX_WORKSPACE_IDENTITY_SYMLINKS; redirectCount += 1) {
const missingSegments: string[] = [];
let current = candidate;
while (true) {
try {
return normalizeWorkspaceIdentityPath(
path.join(fs.realpathSync.native(current), ...missingSegments.toReversed()),
);
} catch {
// A dangling symlink still carries the stable target identity. Resolve
// it lexically so vanished-workspace protection cannot be bypassed.
}
try {
if (fs.lstatSync(current).isSymbolicLink()) {
const normalizedLink = normalizeWorkspaceIdentityPath(current);
if (followedSymlinks.has(normalizedLink)) {
return fallback;
}
followedSymlinks.add(normalizedLink);
candidate = path.resolve(
path.dirname(current),
fs.readlinkSync(current),
...missingSegments.toReversed(),
);
break;
}
} catch {
// Keep walking to a real existing ancestor.
}
const parent = path.dirname(current);
if (parent === current) {
return fallback;
}
missingSegments.push(path.basename(current));
current = parent;
}
}
return fallback;
}
function createWorkspaceStateIdentity(workspacePath: string): WorkspaceStateIdentity {
return {
workspacePath,
workspaceKey: createHash("sha256").update(workspacePath).digest("hex"),
};
}
function resolveWorkspaceStateAliases(workspaceDir: string): WorkspaceStateIdentity[] {
const lexicalPath = normalizeWorkspaceIdentityPath(path.resolve(resolveUserPath(workspaceDir)));
const canonicalPath = canonicalizeWorkspaceIdentityPath(workspaceDir);
return [...new Set([lexicalPath, canonicalPath])].map(createWorkspaceStateIdentity);
}
function workspacePathEntryExists(workspaceDir: string): boolean {
try {
fs.lstatSync(path.resolve(resolveUserPath(workspaceDir)));
return true;
} catch {
return false;
}
}
export function resolveWorkspaceStateIdentity(workspaceDir: string): WorkspaceStateIdentity {
return createWorkspaceStateIdentity(canonicalizeWorkspaceIdentityPath(workspaceDir));
}
function resolveWorkspaceIdentityFromDatabase(params: {
workspaceDir: string;
database: ReturnType<typeof openOpenClawStateDatabase>;
}): WorkspaceIdentityResolution {
const aliases = resolveWorkspaceStateAliases(params.workspaceDir);
const canonicalIdentity = aliases.at(-1)!;
const kysely = getNodeSqliteKysely<WorkspaceStateDatabase>(params.database.db);
const rows = executeSqliteQuerySync(
params.database.db,
kysely
.selectFrom("workspace_path_aliases")
.selectAll()
.where(
"alias_key",
"in",
aliases.map((alias) => alias.workspaceKey),
),
).rows;
const aliasesByKey = new Map(aliases.map((alias) => [alias.workspaceKey, alias]));
let storedIdentity: WorkspaceStateIdentity | undefined;
for (const row of rows) {
const alias = aliasesByKey.get(row.alias_key);
if (!alias || alias.workspacePath !== row.alias_path) {
throw new Error("workspace path alias key collision");
}
const rowIdentity = createWorkspaceStateIdentity(row.workspace_path);
if (rowIdentity.workspaceKey !== row.workspace_key) {
throw new Error("workspace path alias target is invalid");
}
if (storedIdentity && storedIdentity.workspaceKey !== rowIdentity.workspaceKey) {
throw new Error("workspace path aliases resolve to conflicting state");
}
storedIdentity = rowIdentity;
}
if (
storedIdentity &&
workspacePathEntryExists(params.workspaceDir) &&
storedIdentity.workspaceKey !== canonicalIdentity.workspaceKey
) {
throw new Error("workspace path alias points to a different current target");
}
const existingAliasKeys = new Set(rows.map((row) => row.alias_key));
return {
identity: storedIdentity ?? canonicalIdentity,
aliases,
missingAliasKeys: aliases
.map((alias) => alias.workspaceKey)
.filter((aliasKey) => !existingAliasKeys.has(aliasKey)),
};
}
function registerWorkspacePathAliases(params: {
database: ReturnType<typeof openOpenClawStateDatabase>;
identity: WorkspaceStateIdentity;
aliases: readonly WorkspaceStateIdentity[];
updatedAtMs: number;
}): void {
assertCanonicalIntegerTimestamp(params.updatedAtMs, "path alias update");
const kysely = getNodeSqliteKysely<WorkspaceStateDatabase>(params.database.db);
for (const alias of params.aliases) {
const existing = executeSqliteQueryTakeFirstSync(
params.database.db,
kysely
.selectFrom("workspace_path_aliases")
.selectAll()
.where("alias_key", "=", alias.workspaceKey),
);
if (existing) {
if (
existing.alias_path !== alias.workspacePath ||
existing.workspace_key !== params.identity.workspaceKey ||
existing.workspace_path !== params.identity.workspacePath
) {
throw new Error("workspace path alias conflicts with canonical state");
}
continue;
}
executeSqliteQuerySync(
params.database.db,
kysely.insertInto("workspace_path_aliases").values({
alias_key: alias.workspaceKey,
alias_path: alias.workspacePath,
workspace_key: params.identity.workspaceKey,
workspace_path: params.identity.workspacePath,
updated_at_ms: params.updatedAtMs,
}),
);
}
}
export function registerWorkspaceStateAliasesInTransaction(params: {
database: ReturnType<typeof openOpenClawStateDatabase>;
workspaceDirs: readonly string[];
identity: WorkspaceStateIdentity;
updatedAtMs: number;
}): void {
const aliases = new Map<string, WorkspaceStateIdentity>();
for (const workspaceDir of params.workspaceDirs) {
for (const alias of resolveWorkspaceStateAliases(workspaceDir)) {
aliases.set(alias.workspaceKey, alias);
}
}
registerWorkspacePathAliases({
database: params.database,
identity: params.identity,
aliases: [...aliases.values()],
updatedAtMs: params.updatedAtMs,
});
}
function readSnapshotFromDatabase(params: {
identity: WorkspaceStateIdentity;
database: ReturnType<typeof openOpenClawStateDatabase>;
}): WorkspaceStateSnapshot {
const identity = params.identity;
const kysely = getNodeSqliteKysely<WorkspaceStateDatabase>(params.database.db);
const setupRow = executeSqliteQueryTakeFirstSync(
params.database.db,
kysely
.selectFrom("workspace_setup_state")
.selectAll()
.where("workspace_key", "=", identity.workspaceKey),
);
if (setupRow && setupRow.workspace_path !== identity.workspacePath) {
throw new Error("workspace state key collision");
}
if (setupRow && setupRow.version !== WORKSPACE_SETUP_STATE_VERSION) {
throw new Error("workspace setup state version requires openclaw doctor --fix");
}
if (setupRow) {
assertCanonicalTimestamp(setupRow.bootstrap_seeded_at, "bootstrap seeded");
assertCanonicalTimestamp(setupRow.setup_completed_at, "setup completed");
assertCanonicalIntegerTimestamp(setupRow.updated_at, "setup update");
}
const attestationRow = executeSqliteQueryTakeFirstSync(
params.database.db,
kysely
.selectFrom("workspace_attestations")
.selectAll()
.where("workspace_key", "=", identity.workspaceKey),
);
const generatedHashes = new Map<string, string>();
if (attestationRow) {
assertCanonicalIntegerTimestamp(attestationRow.attested_at_ms, "attestation");
const hashRows = executeSqliteQuerySync(
params.database.db,
kysely
.selectFrom("workspace_generated_bootstrap_hashes")
.select(["filename", "sha256"])
.where("workspace_key", "=", identity.workspaceKey)
.orderBy("filename", "asc"),
).rows;
for (const row of hashRows) {
if (
!WORKSPACE_ATTESTED_BOOTSTRAP_FILENAMES.has(row.filename) ||
!SHA256_HEX_PATTERN.test(row.sha256)
) {
throw new Error("workspace attestation hash row is invalid");
}
generatedHashes.set(row.filename, row.sha256);
}
}
return {
identity,
setupExists: Boolean(setupRow),
...(setupRow ? { setupUpdatedAtMs: setupRow.updated_at } : {}),
setup: {
version: WORKSPACE_SETUP_STATE_VERSION,
...(setupRow?.bootstrap_seeded_at ? { bootstrapSeededAt: setupRow.bootstrap_seeded_at } : {}),
...(setupRow?.setup_completed_at ? { setupCompletedAt: setupRow.setup_completed_at } : {}),
},
...(attestationRow
? {
attestation: {
attestedAtMs: attestationRow.attested_at_ms,
generatedHashes,
},
}
: {}),
};
}
export function readWorkspaceStateSnapshot(workspaceDir: string): WorkspaceStateSnapshot {
const database = openOpenClawStateDatabase();
const initial = runSqliteDeferredTransactionSync(database.db, () => {
const resolution = resolveWorkspaceIdentityFromDatabase({ workspaceDir, database });
return {
resolution,
snapshot: readSnapshotFromDatabase({ identity: resolution.identity, database }),
};
});
if (
initial.resolution.missingAliasKeys.length === 0 ||
(!initial.snapshot.setupExists && !initial.snapshot.attestation)
) {
return initial.snapshot;
}
// Register a newly observed configured spelling once state proves the target
// identity. Later disappearance must still find the same safety evidence.
return runOpenClawStateWriteTransaction((writeDatabase) => {
const currentAliases = resolveWorkspaceStateAliases(workspaceDir);
const currentCanonicalIdentity = currentAliases.at(-1)!;
if (
workspacePathEntryExists(workspaceDir) &&
currentCanonicalIdentity.workspaceKey !== initial.resolution.identity.workspaceKey
) {
throw new Error("workspace path alias points to a different current target");
}
const snapshot = readSnapshotFromDatabase({
identity: initial.resolution.identity,
database: writeDatabase,
});
if (snapshot.setupExists || snapshot.attestation) {
const aliases = new Map(
[...initial.resolution.aliases, ...currentAliases].map((alias) => [
alias.workspaceKey,
alias,
]),
);
registerWorkspacePathAliases({
database: writeDatabase,
identity: initial.resolution.identity,
aliases: [...aliases.values()],
updatedAtMs: Date.now(),
});
}
return snapshot;
});
}
export function mergeWorkspaceSetupState(
workspaceDir: string,
next: Partial<Omit<WorkspaceSetupState, "version">>,
nowMs = Date.now(),
): WorkspaceSetupState {
assertCanonicalIntegerTimestamp(nowMs, "setup update");
if (next.bootstrapSeededAt) {
assertCanonicalTimestamp(next.bootstrapSeededAt, "bootstrap seeded");
}
if (next.setupCompletedAt) {
assertCanonicalTimestamp(next.setupCompletedAt, "setup completed");
}
return runOpenClawStateWriteTransaction((database) => {
const resolution = resolveWorkspaceIdentityFromDatabase({ workspaceDir, database });
const identity = resolution.identity;
const snapshot = readSnapshotFromDatabase({ identity, database });
const bootstrapSeededAt = snapshot.setup.bootstrapSeededAt ?? next.bootstrapSeededAt;
const setupCompletedAt = snapshot.setup.setupCompletedAt ?? next.setupCompletedAt;
const merged: WorkspaceSetupState = {
version: WORKSPACE_SETUP_STATE_VERSION,
...(bootstrapSeededAt ? { bootstrapSeededAt } : {}),
...(setupCompletedAt ? { setupCompletedAt } : {}),
};
const kysely = getNodeSqliteKysely<WorkspaceStateDatabase>(database.db);
executeSqliteQuerySync(
database.db,
kysely
.insertInto("workspace_setup_state")
.values({
workspace_key: identity.workspaceKey,
workspace_path: identity.workspacePath,
version: WORKSPACE_SETUP_STATE_VERSION,
bootstrap_seeded_at: merged.bootstrapSeededAt ?? null,
setup_completed_at: merged.setupCompletedAt ?? null,
updated_at: nowMs,
})
.onConflict((conflict) =>
conflict.column("workspace_key").doUpdateSet({
workspace_path: identity.workspacePath,
version: WORKSPACE_SETUP_STATE_VERSION,
bootstrap_seeded_at: merged.bootstrapSeededAt ?? null,
setup_completed_at: merged.setupCompletedAt ?? null,
updated_at: nowMs,
}),
),
);
registerWorkspacePathAliases({
database,
identity,
aliases: resolution.aliases,
updatedAtMs: nowMs,
});
return merged;
});
}
export function replaceWorkspaceAttestation(params: {
workspaceDir: string;
attestedAtMs: number;
generatedHashes: ReadonlyMap<string, string>;
nowMs?: number;
}): WorkspaceAttestation {
assertCanonicalIntegerTimestamp(params.attestedAtMs, "attestation");
if (params.nowMs !== undefined) {
assertCanonicalIntegerTimestamp(params.nowMs, "attestation update");
}
for (const [filename, sha256] of params.generatedHashes) {
if (!WORKSPACE_ATTESTED_BOOTSTRAP_FILENAMES.has(filename) || !SHA256_HEX_PATTERN.test(sha256)) {
throw new Error("workspace attestation hash is invalid");
}
}
const sortedHashes = [...params.generatedHashes.entries()].toSorted(([left], [right]) =>
left.localeCompare(right),
);
return runOpenClawStateWriteTransaction((database) => {
// Capture the comparison clock only after BEGIN IMMEDIATE acquires the
// writer lock, so a newer committed row cannot look future-dated.
const updatedAtMs = params.nowMs ?? Date.now();
assertCanonicalIntegerTimestamp(updatedAtMs, "attestation update");
const resolution = resolveWorkspaceIdentityFromDatabase({
workspaceDir: params.workspaceDir,
database,
});
const identity = resolution.identity;
const snapshot = readSnapshotFromDatabase({ identity, database });
if (
snapshot.attestation &&
snapshot.attestation.attestedAtMs > params.attestedAtMs &&
snapshot.attestation.attestedAtMs <= updatedAtMs
) {
registerWorkspacePathAliases({
database,
identity,
aliases: resolution.aliases,
updatedAtMs,
});
return snapshot.attestation;
}
const kysely = getNodeSqliteKysely<WorkspaceStateDatabase>(database.db);
executeSqliteQuerySync(
database.db,
kysely
.insertInto("workspace_attestations")
.values({
workspace_key: identity.workspaceKey,
attested_at_ms: params.attestedAtMs,
updated_at_ms: updatedAtMs,
})
.onConflict((conflict) =>
conflict.column("workspace_key").doUpdateSet({
attested_at_ms: params.attestedAtMs,
updated_at_ms: updatedAtMs,
}),
),
);
executeSqliteQuerySync(
database.db,
kysely
.deleteFrom("workspace_generated_bootstrap_hashes")
.where("workspace_key", "=", identity.workspaceKey),
);
if (sortedHashes.length > 0) {
executeSqliteQuerySync(
database.db,
kysely.insertInto("workspace_generated_bootstrap_hashes").values(
sortedHashes.map(([filename, sha256]) => ({
workspace_key: identity.workspaceKey,
filename,
sha256,
})),
),
);
}
registerWorkspacePathAliases({
database,
identity,
aliases: resolution.aliases,
updatedAtMs,
});
return {
attestedAtMs: params.attestedAtMs,
generatedHashes: new Map(sortedHashes),
};
});
}
function deleteWorkspaceRows(
database: ReturnType<typeof openOpenClawStateDatabase>,
workspaceKey: string,
): void {
const kysely = getNodeSqliteKysely<WorkspaceStateDatabase>(database.db);
const receiptRows = executeSqliteQuerySync(
database.db,
kysely
.selectFrom("migration_sources")
.select(["source_key", "last_run_id", "report_json"])
.where("migration_kind", "=", WORKSPACE_LEGACY_STATE_MIGRATION_KIND),
).rows.filter((row) => {
try {
const report = JSON.parse(row.report_json) as Record<string, unknown>;
return report.workspaceKey === workspaceKey;
} catch {
return false;
}
});
if (receiptRows.length > 0) {
const receiptKeys = receiptRows.map((row) => row.source_key);
executeSqliteQuerySync(
database.db,
kysely.deleteFrom("migration_sources").where("source_key", "in", receiptKeys),
);
const runIds = [...new Set(receiptRows.map((row) => row.last_run_id))];
const referencedRunIds = new Set(
executeSqliteQuerySync(
database.db,
kysely
.selectFrom("migration_sources")
.select("last_run_id")
.where("last_run_id", "in", runIds),
).rows.map((row) => row.last_run_id),
);
const orphanedRunIds = runIds.filter((runId) => !referencedRunIds.has(runId));
if (orphanedRunIds.length > 0) {
executeSqliteQuerySync(
database.db,
kysely.deleteFrom("migration_runs").where("id", "in", orphanedRunIds),
);
}
}
executeSqliteQuerySync(
database.db,
kysely
.deleteFrom("workspace_generated_bootstrap_hashes")
.where("workspace_key", "=", workspaceKey),
);
executeSqliteQuerySync(
database.db,
kysely.deleteFrom("workspace_attestations").where("workspace_key", "=", workspaceKey),
);
executeSqliteQuerySync(
database.db,
kysely.deleteFrom("workspace_setup_state").where("workspace_key", "=", workspaceKey),
);
executeSqliteQuerySync(
database.db,
kysely.deleteFrom("workspace_path_aliases").where("workspace_key", "=", workspaceKey),
);
}
/** Clear expired state only when no concurrent writer refreshed the vanished workspace. */
export function clearExpiredWorkspaceStateForVanishedWorkspace(
workspaceDir: string,
nowMs = Date.now(),
): boolean {
assertCanonicalIntegerTimestamp(nowMs, "workspace expiry check");
return runOpenClawStateWriteTransaction((database) => {
const resolution = resolveWorkspaceIdentityFromDatabase({ workspaceDir, database });
const identity = resolution.identity;
const snapshot = readSnapshotFromDatabase({ identity, database });
const preserveRecentState = () => {
registerWorkspacePathAliases({
database,
identity,
aliases: resolution.aliases,
updatedAtMs: nowMs,
});
return false;
};
if (snapshot.attestation) {
const ageMs = nowMs - snapshot.attestation.attestedAtMs;
if (ageMs <= WORKSPACE_ATTESTATION_RECENT_MS) {
return preserveRecentState();
}
}
if (
(snapshot.setup.bootstrapSeededAt || snapshot.setup.setupCompletedAt) &&
snapshot.setupUpdatedAtMs !== undefined
) {
const ageMs = nowMs - snapshot.setupUpdatedAtMs;
if (ageMs <= WORKSPACE_ATTESTATION_RECENT_MS) {
return preserveRecentState();
}
}
deleteWorkspaceRows(database, identity.workspaceKey);
return true;
});
}
/** Capture workspace identity before the filesystem entry is removed. */
export function prepareWorkspaceStateDeletion(workspaceDir: string): WorkspaceStateDeletionPlan {
const aliases = resolveWorkspaceStateAliases(workspaceDir);
return {
lexicalAlias: aliases[0]!,
currentCanonicalIdentity: aliases.at(-1)!,
pathEntryExisted: workspacePathEntryExists(workspaceDir),
};
}
export function deleteWorkspaceState(plan: WorkspaceStateDeletionPlan): void {
// Delete-only cleanup must not recreate state after reset/uninstall removed
// the canonical database successfully or partially.
if (!existsSync(resolveOpenClawStateSqlitePath())) {
return;
}
runOpenClawStateWriteTransaction((database) => {
const { lexicalAlias, currentCanonicalIdentity } = plan;
const kysely = getNodeSqliteKysely<WorkspaceStateDatabase>(database.db);
const storedAlias = executeSqliteQueryTakeFirstSync(
database.db,
kysely
.selectFrom("workspace_path_aliases")
.selectAll()
.where("alias_key", "=", lexicalAlias.workspaceKey),
);
if (storedAlias && storedAlias.alias_path !== lexicalAlias.workspacePath) {
throw new Error("workspace path alias key collision");
}
const storedIdentity = storedAlias
? createWorkspaceStateIdentity(storedAlias.workspace_path)
: undefined;
if (storedIdentity && storedIdentity.workspaceKey !== storedAlias?.workspace_key) {
throw new Error("workspace path alias target is invalid");
}
if (
storedIdentity &&
plan.pathEntryExisted &&
storedIdentity.workspaceKey !== currentCanonicalIdentity.workspaceKey
) {
// A repointed configured alias no longer owns its former canonical
// workspace. Remove only that stale association, then clean current state.
executeSqliteQuerySync(
database.db,
kysely
.deleteFrom("workspace_path_aliases")
.where("alias_key", "=", lexicalAlias.workspaceKey),
);
const currentResolution = resolveWorkspaceIdentityFromDatabase({
workspaceDir: currentCanonicalIdentity.workspacePath,
database,
});
deleteWorkspaceRows(database, currentResolution.identity.workspaceKey);
return;
}
if (storedIdentity) {
deleteWorkspaceRows(database, storedIdentity.workspaceKey);
return;
}
const resolution = resolveWorkspaceIdentityFromDatabase({
workspaceDir: currentCanonicalIdentity.workspacePath,
database,
});
deleteWorkspaceRows(database, resolution.identity.workspaceKey);
});
}

View File

@@ -6,11 +6,24 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js";
import {
createOpenClawTestState,
type OpenClawTestState,
} from "../test-utils/openclaw-test-state.js";
import {
LEGACY_WORKSPACE_ATTESTATION_HEADER,
LEGACY_WORKSPACE_STATE_CURRENT_FILENAME,
LEGACY_WORKSPACE_STATE_DIRNAME,
} from "./workspace-legacy-state.js";
import { resetLegacyWorkspaceStateCheckForTest } from "./workspace-legacy-state.test-support.js";
import {
mergeWorkspaceSetupState,
readWorkspaceStateSnapshot,
replaceWorkspaceAttestation,
resolveWorkspaceStateIdentity,
} from "./workspace-state-store.js";
import {
DEFAULT_AGENTS_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
@@ -26,7 +39,6 @@ import {
loadWorkspaceBootstrapFiles,
resolveWorkspaceBootstrapStatus,
resolveDefaultAgentWorkspaceDir,
resolveWorkspaceAttestationPaths,
WORKSPACE_VANISHED_ERROR_CODE,
type WorkspaceBootstrapFile,
} from "./workspace.js";
@@ -34,6 +46,7 @@ import {
let testState: OpenClawTestState | undefined;
beforeEach(async () => {
resetLegacyWorkspaceStateCheckForTest();
testState = await createOpenClawTestState({
layout: "state-only",
prefix: "openclaw-workspace-state-",
@@ -41,6 +54,8 @@ beforeEach(async () => {
});
afterEach(async () => {
closeOpenClawStateDatabaseForTest();
resetLegacyWorkspaceStateCheckForTest();
await testState?.cleanup();
testState = undefined;
});
@@ -66,28 +81,17 @@ describe("resolveDefaultAgentWorkspaceDir", () => {
});
});
const WORKSPACE_STATE_PATH_SEGMENTS = ["openclaw-workspace-state.json"] as const;
const LEGACY_WORKSPACE_STATE_PATH_SEGMENTS = [".openclaw", "workspace-state.json"] as const;
function resolveCurrentWorkspaceAttestationPath(dir: string): string {
const [attestationPath] = resolveWorkspaceAttestationPaths(dir);
if (!attestationPath) {
throw new Error("expected current workspace attestation path");
}
return attestationPath;
}
const LEGACY_WORKSPACE_STATE_PATH_SEGMENTS = [
LEGACY_WORKSPACE_STATE_DIRNAME,
"workspace-state.json",
] as const;
async function readWorkspaceState(dir: string): Promise<{
version: number;
bootstrapSeededAt?: string;
setupCompletedAt?: string;
}> {
const raw = await fs.readFile(path.join(dir, ...WORKSPACE_STATE_PATH_SEGMENTS), "utf-8");
return JSON.parse(raw) as {
version: number;
bootstrapSeededAt?: string;
setupCompletedAt?: string;
};
return readWorkspaceStateSnapshot(dir).setup;
}
async function writeLegacyWorkspaceState(dir: string, state: unknown): Promise<void> {
@@ -108,19 +112,28 @@ async function expectPathMissing(filePath: string): Promise<void> {
await expect(fs.access(filePath)).rejects.toHaveProperty("code", "ENOENT");
}
async function expectWorkspaceVanished(
action: Promise<unknown>,
expected?: { attestationPath?: string },
): Promise<void> {
async function expectWorkspaceVanished(action: Promise<unknown>): Promise<void> {
// Recently attested generated workspaces must not be silently recreated after
// deletion or wipe; that could hide user data loss.
await expect(action).rejects.toMatchObject({
code: WORKSPACE_VANISHED_ERROR_CODE,
name: "WorkspaceVanishedError",
...expected,
});
}
async function expectNoLegacyWorkspaceStateWrites(dir: string): Promise<void> {
const { workspaceKey } = resolveWorkspaceStateIdentity(dir);
const paths = [
path.join(dir, LEGACY_WORKSPACE_STATE_CURRENT_FILENAME),
path.join(dir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS),
`${dir}.attested`,
path.join(testState?.stateDir ?? "", "workspace-attestations", `${workspaceKey}.attested`),
];
for (const filePath of paths) {
await expectPathMissing(filePath);
}
}
async function expectCompletedWithoutBootstrap(dir: string) {
await expect(fs.access(path.join(dir, DEFAULT_IDENTITY_FILENAME))).resolves.toBeUndefined();
await expectPathMissing(path.join(dir, DEFAULT_BOOTSTRAP_FILENAME));
@@ -145,8 +158,9 @@ describe("ensureAgentWorkspace", () => {
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expectBootstrapSeeded(tempDir);
await expectPathMissing(path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS));
await expectNoLegacyWorkspaceStateWrites(tempDir);
expect((await readWorkspaceState(tempDir)).setupCompletedAt).toBeUndefined();
expect(readWorkspaceStateSnapshot(tempDir).attestation).toBeDefined();
});
it("does not overwrite a foreign root workspace-state.json file", async () => {
@@ -161,27 +175,47 @@ describe("ensureAgentWorkspace", () => {
await expectBootstrapSeeded(tempDir);
});
it("ignores unreadable legacy nested state while writing current setup state", async () => {
it("requires Doctor before using legacy JSON setup state", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await fs.mkdir(path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS), {
recursive: true,
await writeLegacyWorkspaceState(tempDir, {
version: 1,
onboardingCompletedAt: "2026-03-15T02:30:00.000Z",
});
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expect(
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
).rejects.toThrow(/run openclaw doctor --fix/u);
await expect(
fs.access(path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS)),
).resolves.toBeUndefined();
expect(readWorkspaceStateSnapshot(tempDir).setupExists).toBe(false);
});
await expectBootstrapSeeded(tempDir);
const legacyStateStat = await fs.stat(
path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS),
);
expect(legacyStateStat.isDirectory()).toBe(true);
it("requires Doctor when partial SQLite state coexists with legacy setup state", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
const seededAt = "2026-07-15T10:00:00.000Z";
mergeWorkspaceSetupState(tempDir, { bootstrapSeededAt: seededAt });
await writeLegacyWorkspaceState(tempDir, {
version: 1,
setupCompletedAt: "2026-07-15T10:01:00.000Z",
});
await expect(
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
).rejects.toThrow(/run openclaw doctor --fix/u);
await expect(
fs.access(path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS)),
).resolves.toBeUndefined();
expect(readWorkspaceStateSnapshot(tempDir).setup).toEqual({
version: 1,
bootstrapSeededAt: seededAt,
});
});
it("refuses to re-seed a recently attested workspace after the directory disappears", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expect(
fs.access(resolveCurrentWorkspaceAttestationPath(tempDir)),
).resolves.toBeUndefined();
expect(readWorkspaceStateSnapshot(tempDir).attestation).toBeDefined();
await fs.rm(tempDir, { recursive: true, force: true });
@@ -202,7 +236,7 @@ describe("ensureAgentWorkspace", () => {
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
);
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
await expectPathMissing(path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS));
expect(readWorkspaceStateSnapshot(tempDir).setupExists).toBe(true);
});
it("refuses to re-seed a recently attested workspace after only generated remnants survive", async () => {
@@ -218,7 +252,28 @@ describe("ensureAgentWorkspace", () => {
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
);
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
await expectPathMissing(path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS));
expect(readWorkspaceStateSnapshot(tempDir).setupExists).toBe(true);
});
it("refuses to re-seed a future-attested workspace after only generated remnants survive", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
const snapshot = readWorkspaceStateSnapshot(tempDir);
const generatedAgents = await fs.readFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), "utf-8");
replaceWorkspaceAttestation({
workspaceDir: tempDir,
attestedAtMs: Date.now() + 60_000,
generatedHashes: snapshot.attestation!.generatedHashes,
});
await fs.rm(tempDir, { recursive: true, force: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), generatedAgents);
await expectWorkspaceVanished(
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
);
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
});
it("refuses to re-seed a recently attested workspace after only generated git metadata survives", async () => {
@@ -235,21 +290,21 @@ describe("ensureAgentWorkspace", () => {
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
});
it("refuses to accept old generated bootstrap files recorded by the attestation marker", async () => {
it("refuses to accept old generated bootstrap files recorded by SQLite attestation", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
const oldGeneratedAgents = "old generated agents\n";
await fs.writeFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), oldGeneratedAgents);
const attestationPath = resolveCurrentWorkspaceAttestationPath(tempDir);
await fs.mkdir(path.dirname(attestationPath), { recursive: true });
await fs.writeFile(
attestationPath,
[
"openclaw-workspace-attestation:v1",
new Date().toISOString(),
`generated:${DEFAULT_AGENTS_FILENAME}:${createHash("sha256").update(oldGeneratedAgents).digest("hex")}`,
"",
].join("\n"),
);
mergeWorkspaceSetupState(tempDir, {
bootstrapSeededAt: "2026-07-15T10:00:00.000Z",
setupCompletedAt: "2026-07-15T10:01:00.000Z",
});
replaceWorkspaceAttestation({
workspaceDir: tempDir,
attestedAtMs: Date.now(),
generatedHashes: new Map([
[DEFAULT_AGENTS_FILENAME, createHash("sha256").update(oldGeneratedAgents).digest("hex")],
]),
});
await expectWorkspaceVanished(
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
@@ -257,16 +312,34 @@ describe("ensureAgentWorkspace", () => {
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
});
it("refuses a recently attested workspace when generated state and only one generated file survive", async () => {
it("refuses a recently attested workspace when only one generated file survives", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
const generatedAgents = await fs.readFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), "utf-8");
const state = await fs.readFile(path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS), "utf-8");
await fs.rm(tempDir, { recursive: true, force: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), generatedAgents);
await fs.writeFile(path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS), state);
await expectWorkspaceVanished(
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
);
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
});
it("uses template comparison when an attestation has no generated hashes", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
const generatedAgents = await fs.readFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), "utf-8");
await fs.rm(tempDir, { recursive: true, force: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), generatedAgents);
replaceWorkspaceAttestation({
workspaceDir: tempDir,
attestedAtMs: Date.now(),
generatedHashes: new Map(),
});
await expectWorkspaceVanished(
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
@@ -280,7 +353,6 @@ describe("ensureAgentWorkspace", () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await fs.writeFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), "custom instructions\n");
await fs.rm(path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS), { force: true });
await fs.rm(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME), { force: true });
await expect(
@@ -370,19 +442,104 @@ describe("ensureAgentWorkspace", () => {
).resolves.toMatchObject({ dir: tempDir });
});
it("allows a brand new workspace when the only attestation marker is stale", async () => {
it("allows a brand new workspace when its SQLite attestation is stale", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
const expiredAtMs = Date.now() - 25 * 60 * 60 * 1000;
mergeWorkspaceSetupState(
tempDir,
{
bootstrapSeededAt: "2026-07-15T10:00:00.000Z",
setupCompletedAt: "2026-07-15T10:01:00.000Z",
},
expiredAtMs,
);
replaceWorkspaceAttestation({
workspaceDir: tempDir,
attestedAtMs: expiredAtMs,
generatedHashes: new Map(),
});
await fs.rm(tempDir, { recursive: true, force: true });
const staleDate = new Date(Date.now() - 25 * 60 * 60 * 1000);
await fs.utimes(resolveCurrentWorkspaceAttestationPath(tempDir), staleDate, staleDate);
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expectBootstrapSeeded(tempDir);
expect((await readWorkspaceState(tempDir)).setupCompletedAt).toBeUndefined();
});
it("does not overwrite a sibling file that is not an OpenClaw attestation marker", async () => {
it("clears expired setup state when a wiped workspace retains only .git", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
const expiredAtMs = Date.now() - 25 * 60 * 60 * 1000;
mergeWorkspaceSetupState(
tempDir,
{
bootstrapSeededAt: "2026-07-15T10:00:00.000Z",
setupCompletedAt: "2026-07-15T10:01:00.000Z",
},
expiredAtMs,
);
replaceWorkspaceAttestation({
workspaceDir: tempDir,
attestedAtMs: expiredAtMs,
generatedHashes: new Map(),
});
await fs.rm(tempDir, { recursive: true, force: true });
await fs.mkdir(path.join(tempDir, ".git"), { recursive: true });
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expectBootstrapSeeded(tempDir);
expect((await readWorkspaceState(tempDir)).setupCompletedAt).toBeUndefined();
});
it("clears expired setup-only state before reseeding an empty workspace", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
mergeWorkspaceSetupState(
tempDir,
{
bootstrapSeededAt: "2026-07-15T10:00:00.000Z",
setupCompletedAt: "2026-07-15T10:01:00.000Z",
},
Date.now() - 25 * 60 * 60 * 1000,
);
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expectBootstrapSeeded(tempDir);
expect((await readWorkspaceState(tempDir)).setupCompletedAt).toBeUndefined();
});
it("requires Doctor before using a legacy owned attestation marker", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
const attestationPath = `${tempDir}.attested`;
const marker = `${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n${new Date().toISOString()}\n`;
await fs.writeFile(attestationPath, marker);
await expect(
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
).rejects.toThrow(/run openclaw doctor --fix/u);
expect(await fs.readFile(attestationPath, "utf-8")).toBe(marker);
expect(readWorkspaceStateSnapshot(tempDir).setupExists).toBe(false);
});
it("requires Doctor when SQLite setup state coexists with a legacy attestation", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
mergeWorkspaceSetupState(tempDir, {
bootstrapSeededAt: "2026-07-15T10:00:00.000Z",
});
const attestationPath = `${tempDir}.attested`;
const marker = `${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n${new Date().toISOString()}\n`;
await fs.writeFile(attestationPath, marker);
await expect(
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
).rejects.toThrow(/run openclaw doctor --fix/u);
expect(await fs.readFile(attestationPath, "utf-8")).toBe(marker);
expect(readWorkspaceStateSnapshot(tempDir).setupExists).toBe(true);
});
it("ignores and preserves a foreign sibling attestation file", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
const attestationPath = `${tempDir}.attested`;
const siblingContent = "external attestation data\n";
@@ -394,75 +551,6 @@ describe("ensureAgentWorkspace", () => {
expect(await fs.readFile(attestationPath, "utf-8")).toBe(siblingContent);
});
it("does not read or overwrite a large sibling file at the marker path", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
const attestationPath = `${tempDir}.attested`;
const siblingContent = "x".repeat(1024);
await fs.writeFile(attestationPath, siblingContent);
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expectBootstrapSeeded(tempDir);
expect(await fs.readFile(attestationPath, "utf-8")).toBe(siblingContent);
});
it.skipIf(process.platform === "win32")(
"refuses to re-seed when a recent owned marker becomes unreadable",
async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
const attestationPath = resolveCurrentWorkspaceAttestationPath(tempDir);
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await fs.chmod(attestationPath, 0o000);
await fs.rm(tempDir, { recursive: true, force: true });
try {
await expectWorkspaceVanished(
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
);
} finally {
await fs.chmod(attestationPath, 0o600);
}
},
);
it.skipIf(process.platform === "win32")(
"refuses to re-seed when the state marker directory is unreadable",
async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
const attestationDir = path.dirname(resolveCurrentWorkspaceAttestationPath(tempDir));
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await fs.chmod(attestationDir, 0o000);
await fs.rm(tempDir, { recursive: true, force: true });
try {
await expectWorkspaceVanished(
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
);
} finally {
await fs.chmod(attestationDir, 0o700);
}
},
);
it.skipIf(process.platform === "win32")(
"ignores symlinked attestation markers without overwriting the target",
async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
const attestationPath = resolveCurrentWorkspaceAttestationPath(tempDir);
const symlinkTargetPath = `${attestationPath}-target`;
const targetContent = "outside-marker\n";
await fs.mkdir(path.dirname(attestationPath), { recursive: true });
await fs.writeFile(symlinkTargetPath, targetContent);
await fs.symlink(symlinkTargetPath, attestationPath);
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expectBootstrapSeeded(tempDir);
expect(await fs.readFile(symlinkTargetPath, "utf-8")).toBe(targetContent);
expect((await fs.lstat(attestationPath)).isSymbolicLink()).toBe(true);
},
);
it("recovers partial initialization by creating BOOTSTRAP.md when marker is missing", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_AGENTS_FILENAME, content: "existing" });
@@ -572,27 +660,6 @@ describe("ensureAgentWorkspace", () => {
expect(state.setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
});
it("migrates legacy onboardingCompletedAt markers to setupCompletedAt", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await writeLegacyWorkspaceState(tempDir, {
version: 1,
onboardingCompletedAt: "2026-03-15T02:30:00.000Z",
});
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
const state = await readWorkspaceState(tempDir);
expect(state.setupCompletedAt).toBe("2026-03-15T02:30:00.000Z");
await expect(
fs.access(path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS)),
).resolves.toBeUndefined();
const persisted = await fs.readFile(
path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS),
"utf-8",
);
expect(persisted).toContain('"setupCompletedAt": "2026-03-15T02:30:00.000Z"');
});
it("reports bootstrap pending while BOOTSTRAP.md exists and setup is incomplete", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
@@ -822,6 +889,42 @@ describe("ensureAgentWorkspace", () => {
await expect(fs.access(path.join(tempDir, DEFAULT_AGENTS_FILENAME))).resolves.toBeUndefined();
await expect(fs.access(path.join(tempDir, DEFAULT_TOOLS_FILENAME))).resolves.toBeUndefined();
});
it("observes setup completed concurrently before writing optional bootstrap files", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
const agentsPath = path.join(tempDir, DEFAULT_AGENTS_FILENAME);
await fs.writeFile(agentsPath, "custom agents instructions\n", "utf8");
mergeWorkspaceSetupState(tempDir, {
bootstrapSeededAt: "2026-07-15T10:00:00.000Z",
});
const realAccess = fs.access.bind(fs);
let completed = false;
const accessSpy = vi.spyOn(fs, "access").mockImplementation(async (filePath, mode) => {
if (!completed && filePath === agentsPath) {
completed = true;
mergeWorkspaceSetupState(tempDir, {
setupCompletedAt: "2026-07-15T10:01:00.000Z",
});
}
return await realAccess(filePath, mode);
});
try {
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
} finally {
accessSpy.mockRestore();
}
expect(completed).toBe(true);
for (const filename of [
DEFAULT_SOUL_FILENAME,
DEFAULT_IDENTITY_FILENAME,
DEFAULT_USER_FILENAME,
DEFAULT_HEARTBEAT_FILENAME,
]) {
await expectPathMissing(path.join(tempDir, filename));
}
});
});
describe("loadWorkspaceBootstrapFiles", () => {

View File

@@ -7,12 +7,9 @@ import { createHash } from "node:crypto";
import syncFs from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
import { extractFrontmatterBlock } from "../../packages/markdown-core/src/frontmatter.js";
import { resolveLegacyStateDirs, resolveStateDir } from "../config/paths.js";
import { openRootFile } from "../infra/boundary-file-read.js";
import { pathExists } from "../infra/fs-safe.js";
import { replaceFileAtomic } from "../infra/replace-file.js";
import { retryAsync } from "../infra/retry.js";
import {
CANONICAL_ROOT_MEMORY_FILENAME,
@@ -22,6 +19,21 @@ import { runCommandWithTimeout } from "../process/exec.js";
import { isCronSessionKey, isSubagentSessionKey } from "../routing/session-key.js";
import { resolveUserPath } from "../utils.js";
import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace-default.js";
import {
assertNoUnmigratedWorkspaceState,
LEGACY_WORKSPACE_STATE_CURRENT_FILENAME,
LEGACY_WORKSPACE_STATE_DIRNAME,
} from "./workspace-legacy-state.js";
import {
clearExpiredWorkspaceStateForVanishedWorkspace,
mergeWorkspaceSetupState,
readWorkspaceStateSnapshot,
replaceWorkspaceAttestation,
WORKSPACE_ATTESTATION_RECENT_MS,
type WorkspaceAttestation,
type WorkspaceStateSnapshot,
type WorkspaceSetupState,
} from "./workspace-state-store.js";
import {
resolveWorkspaceTemplateDir,
resolveWorkspaceTemplateSearchDirs,
@@ -38,15 +50,6 @@ export const DEFAULT_USER_FILENAME = "USER.md";
export const DEFAULT_HEARTBEAT_FILENAME = "HEARTBEAT.md";
export const DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md";
export const DEFAULT_MEMORY_FILENAME = CANONICAL_ROOT_MEMORY_FILENAME;
const LEGACY_WORKSPACE_STATE_DIRNAME = ".openclaw";
const LEGACY_WORKSPACE_STATE_FILENAME = "workspace-state.json";
const WORKSPACE_STATE_FILENAME = "openclaw-workspace-state.json";
const WORKSPACE_STATE_VERSION = 1;
const WORKSPACE_ATTESTATION_SUFFIX = ".attested";
const WORKSPACE_ATTESTATION_DIRNAME = "workspace-attestations";
const WORKSPACE_ATTESTATION_RECENT_MS = 24 * 60 * 60 * 1000;
const WORKSPACE_ATTESTATION_HEADER = "openclaw-workspace-attestation:v1";
const WORKSPACE_ATTESTATION_MAX_BYTES = 2048;
const WORKSPACE_ONBOARDING_PROFILE_FILENAMES = [
DEFAULT_SOUL_FILENAME,
DEFAULT_IDENTITY_FILENAME,
@@ -204,13 +207,6 @@ export type ExtraBootstrapLoadDiagnostic = {
detail: string;
};
type WorkspaceSetupState = {
version: typeof WORKSPACE_STATE_VERSION;
bootstrapSeededAt?: string;
setupCompletedAt?: string;
};
type WorkspaceAttestationMarkerStatus = "marker" | "not-marker" | "missing" | "unknown";
/** Set of recognized bootstrap filenames for runtime validation */
const VALID_BOOTSTRAP_NAMES: ReadonlySet<string> = new Set([
DEFAULT_AGENTS_FILENAME,
@@ -235,17 +231,15 @@ export const WORKSPACE_VANISHED_ERROR_CODE = "WORKSPACE_VANISHED";
export class WorkspaceVanishedError extends Error {
readonly code = WORKSPACE_VANISHED_ERROR_CODE;
readonly workspaceDir: string;
readonly attestationPath: string;
constructor(params: { workspaceDir: string; attestationPath: string }) {
constructor(params: { workspaceDir: string }) {
super(
`OpenClaw workspace appears to have disappeared after a recent initialization: ${params.workspaceDir}. ` +
`Refusing to reseed BOOTSTRAP.md over a recently attested workspace. ` +
`Restore the workspace or remove ${params.attestationPath} if this reset was intentional.`,
"Restore the workspace or run a full OpenClaw reset if this reset was intentional.",
);
this.name = "WorkspaceVanishedError";
this.workspaceDir = params.workspaceDir;
this.attestationPath = params.attestationPath;
}
}
@@ -345,7 +339,7 @@ async function hasSkipBootstrapWorkspaceContentEvidence(dir: string): Promise<bo
if (
entry.name === ".DS_Store" ||
entry.name === LEGACY_WORKSPACE_STATE_DIRNAME ||
entry.name === WORKSPACE_STATE_FILENAME
entry.name === LEGACY_WORKSPACE_STATE_CURRENT_FILENAME
) {
continue;
}
@@ -384,13 +378,11 @@ async function workspaceProfileLooksConfigured(params: {
async function workspaceRequiredBootstrapLooksCustomized(
dir: string,
opts?: { attestationPath?: string },
opts?: { generatedHashes?: ReadonlyMap<string, string> },
): Promise<boolean> {
const fileNames = [DEFAULT_AGENTS_FILENAME, DEFAULT_TOOLS_FILENAME, DEFAULT_HEARTBEAT_FILENAME];
const generatedHashes = opts?.attestationPath
? await readWorkspaceAttestationGeneratedHashes(opts.attestationPath)
: undefined;
if (generatedHashes) {
const generatedHashes = opts?.generatedHashes;
if (generatedHashes && generatedHashes.size > 0) {
for (const fileName of fileNames) {
const filePath = path.join(dir, fileName);
const generatedHash = generatedHashes.get(fileName);
@@ -416,10 +408,12 @@ async function workspaceRequiredBootstrapLooksCustomized(
async function workspaceAttestedGeneratedFilesIntact(
dir: string,
attestationPath: string,
generatedHashes: ReadonlyMap<string, string>,
): Promise<boolean> {
const generatedHashes = await readWorkspaceAttestationGeneratedHashes(attestationPath);
if (!generatedHashes) {
if (
!generatedHashes.has(DEFAULT_AGENTS_FILENAME) ||
!generatedHashes.has(DEFAULT_TOOLS_FILENAME)
) {
return false;
}
for (const [fileName, generatedHash] of generatedHashes) {
@@ -449,7 +443,6 @@ type WorkspaceBootstrapCompletionReconcileResult = {
async function reconcileWorkspaceBootstrapCompletionState(params: {
dir: string;
bootstrapPath: string;
statePath: string;
state: WorkspaceSetupState;
bootstrapExists?: boolean;
}): Promise<WorkspaceBootstrapCompletionReconcileResult> {
@@ -466,8 +459,8 @@ async function reconcileWorkspaceBootstrapCompletionState(params: {
...params.state,
setupCompletedAt: new Date().toISOString(),
};
await writeWorkspaceSetupState(params.statePath, completedState);
return { repaired: true, bootstrapExists: false, state: completedState };
const persistedState = mergeWorkspaceSetupState(params.dir, completedState);
return { repaired: true, bootstrapExists: false, state: persistedState };
}
if (
@@ -485,131 +478,13 @@ async function reconcileWorkspaceBootstrapCompletionState(params: {
bootstrapSeededAt: params.state.bootstrapSeededAt ?? now,
setupCompletedAt: now,
};
await writeWorkspaceSetupState(params.statePath, repairedState);
const persistedState = mergeWorkspaceSetupState(params.dir, repairedState);
try {
await fs.rm(params.bootstrapPath, { force: true });
return { repaired: true, bootstrapExists: false, state: repairedState };
return { repaired: true, bootstrapExists: false, state: persistedState };
} catch {
// Completion state is authoritative; stale BOOTSTRAP cleanup is best-effort.
return { repaired: true, bootstrapExists: true, state: repairedState };
}
}
function resolveWorkspaceStatePath(dir: string): string {
return path.join(dir, WORKSPACE_STATE_FILENAME);
}
function resolveLegacyWorkspaceStatePath(dir: string): string {
return path.join(dir, LEGACY_WORKSPACE_STATE_DIRNAME, LEGACY_WORKSPACE_STATE_FILENAME);
}
function resolveWorkspaceAttestationPathInStateDir(dir: string, stateDir: string): string {
const key = createHash("sha256").update(path.resolve(dir)).digest("hex");
return path.join(stateDir, WORKSPACE_ATTESTATION_DIRNAME, `${key}.attested`);
}
function resolveLegacyWorkspaceAttestationPath(dir: string): string {
return `${dir}${WORKSPACE_ATTESTATION_SUFFIX}`;
}
export function resolveWorkspaceAttestationPaths(dir: string): string[] {
const stateAttestationPaths = [resolveStateDir(), ...resolveLegacyStateDirs()].map((stateDir) =>
resolveWorkspaceAttestationPathInStateDir(dir, stateDir),
);
const legacy = resolveLegacyWorkspaceAttestationPath(dir);
return [...new Set([...stateAttestationPaths, legacy])];
}
async function findRecentWorkspaceAttestationPath(
attestationPaths: string[],
): Promise<string | null> {
for (const [index, attestationPath] of attestationPaths.entries()) {
if (await hasRecentWorkspaceAttestation(attestationPath, { trustUnknown: index === 0 })) {
return attestationPath;
}
}
return null;
}
async function hasRecentWorkspaceAttestation(
attestationPath: string,
opts?: { trustUnknown?: boolean },
): Promise<boolean> {
try {
const stat = await fs.lstat(attestationPath);
if (
!stat.isFile() ||
stat.size > WORKSPACE_ATTESTATION_MAX_BYTES ||
Date.now() - stat.mtimeMs > WORKSPACE_ATTESTATION_RECENT_MS
) {
return false;
}
const status = await readWorkspaceAttestationMarkerStatus(attestationPath);
return status === "marker" || (opts?.trustUnknown === true && status === "unknown");
} catch (err) {
const anyErr = err as { code?: string };
if (anyErr.code !== "ENOENT") {
return opts?.trustUnknown === true;
}
return false;
}
}
export async function shouldRemoveWorkspaceAttestation(
attestationPath: string,
opts?: { trustUnknown?: boolean },
): Promise<boolean> {
try {
return (
(await readWorkspaceAttestationMarkerStatus(attestationPath)) === "marker" ||
(await hasRecentWorkspaceAttestation(attestationPath, opts))
);
} catch {
return false;
}
}
async function readWorkspaceAttestationMarkerStatus(
attestationPath: string,
): Promise<WorkspaceAttestationMarkerStatus> {
try {
const stat = await fs.lstat(attestationPath);
if (!stat.isFile() || stat.size > WORKSPACE_ATTESTATION_MAX_BYTES) {
return "not-marker";
}
const raw = await fs.readFile(attestationPath, "utf-8");
if (raw.startsWith(`${WORKSPACE_ATTESTATION_HEADER}\n`)) {
return "marker";
}
return "not-marker";
} catch (err) {
const anyErr = err as { code?: string };
return anyErr.code === "ENOENT" ? "missing" : "unknown";
}
}
async function readWorkspaceAttestationGeneratedHashes(
attestationPath: string,
): Promise<Map<string, string> | undefined> {
try {
const stat = await fs.lstat(attestationPath);
if (!stat.isFile() || stat.size > WORKSPACE_ATTESTATION_MAX_BYTES) {
return undefined;
}
const raw = await fs.readFile(attestationPath, "utf-8");
if (!raw.startsWith(`${WORKSPACE_ATTESTATION_HEADER}\n`)) {
return undefined;
}
const hashes = new Map<string, string>();
for (const line of raw.split(/\r?\n/)) {
const match = /^generated:([^:]+):([a-f0-9]{64})$/.exec(line);
if (match?.[1] && match[2]) {
hashes.set(match[1], match[2]);
}
}
return hashes.size > 0 ? hashes : undefined;
} catch {
return undefined;
return { repaired: true, bootstrapExists: true, state: persistedState };
}
}
@@ -636,74 +511,37 @@ async function collectGeneratedBootstrapHashes(dir: string): Promise<Map<string,
return hashes;
}
async function buildWorkspaceAttestationContent(dir: string, now: Date): Promise<string> {
const hashes = await collectGeneratedBootstrapHashes(dir);
const lines = [WORKSPACE_ATTESTATION_HEADER, now.toISOString()];
for (const [fileName, hash] of [...hashes.entries()].toSorted(([a], [b]) => a.localeCompare(b))) {
lines.push(`generated:${fileName}:${hash}`);
function recentWorkspaceAttestation(
attestation: WorkspaceAttestation | undefined,
nowMs = Date.now(),
): WorkspaceAttestation | undefined {
if (!attestation) {
return undefined;
}
return `${lines.join("\n")}\n`;
const ageMs = nowMs - attestation.attestedAtMs;
// Clock rollback must not turn disappearance protection into permission to
// reseed. A healthy workspace refreshes the future-dated row below.
if (ageMs > WORKSPACE_ATTESTATION_RECENT_MS) {
return undefined;
}
return attestation;
}
async function writeWorkspaceAttestation(attestationPath: string, dir: string): Promise<void> {
await fs.mkdir(path.dirname(attestationPath), { recursive: true });
const now = new Date();
const content = await buildWorkspaceAttestationContent(dir, now);
async function maybeWriteWorkspaceAttestation(dir: string): Promise<void> {
try {
const status = await readWorkspaceAttestationMarkerStatus(attestationPath);
if (status === "marker") {
await fs.writeFile(attestationPath, content, "utf-8");
await fs.utimes(attestationPath, now, now);
return;
}
if (status !== "missing") {
return;
}
// Order snapshots by when their filesystem observation starts. The store
// compares against a separate lock-time clock, so a newer committed scan
// wins when this async collection finishes later.
const attestedAtMs = Date.now();
const generatedHashes = await collectGeneratedBootstrapHashes(dir);
replaceWorkspaceAttestation({
workspaceDir: dir,
attestedAtMs,
generatedHashes,
});
} catch {
return;
}
const noFollowFlag =
typeof syncFs.constants.O_NOFOLLOW === "number" ? syncFs.constants.O_NOFOLLOW : 0;
const handle = await fs.open(
attestationPath,
syncFs.constants.O_WRONLY | syncFs.constants.O_CREAT | syncFs.constants.O_EXCL | noFollowFlag,
0o600,
);
try {
await handle.writeFile(content, "utf-8");
} finally {
await handle.close();
}
}
async function maybeWriteWorkspaceAttestation(attestationPath: string, dir: string): Promise<void> {
try {
await writeWorkspaceAttestation(attestationPath, dir);
} catch {
// The marker is a lifecycle guard; setup should not fail solely because it
// could not refresh auxiliary disappearance evidence.
}
}
function parseWorkspaceSetupState(raw: string): WorkspaceSetupState | null {
try {
const parsed = JSON.parse(raw) as {
bootstrapSeededAt?: unknown;
setupCompletedAt?: unknown;
onboardingCompletedAt?: unknown;
};
if (!parsed || typeof parsed !== "object") {
return null;
}
const legacyCompletedAt = readStringValue(parsed.onboardingCompletedAt);
return {
version: WORKSPACE_STATE_VERSION,
bootstrapSeededAt: readStringValue(parsed.bootstrapSeededAt),
setupCompletedAt: readStringValue(parsed.setupCompletedAt) ?? legacyCompletedAt,
};
} catch {
return null;
// Attestation is a lifecycle guard; setup should not fail solely because
// the auxiliary disappearance evidence could not be refreshed.
}
}
@@ -711,68 +549,80 @@ function hasWorkspaceSetupStateMarker(state: WorkspaceSetupState): boolean {
return Boolean(state.bootstrapSeededAt || state.setupCompletedAt);
}
function needsWorkspaceSetupStateRewrite(raw: string, state: WorkspaceSetupState): boolean {
function hasRecentWorkspaceSetupState(
snapshot: WorkspaceStateSnapshot,
nowMs = Date.now(),
): boolean {
if (!hasWorkspaceSetupStateMarker(snapshot.setup) || snapshot.setupUpdatedAtMs === undefined) {
return false;
}
return nowMs - snapshot.setupUpdatedAtMs <= WORKSPACE_ATTESTATION_RECENT_MS;
}
async function workspaceAttestationHasSurvivalEvidence(params: {
dir: string;
bootstrapPath: string;
state: WorkspaceSetupState;
attestation: WorkspaceAttestation;
}): Promise<boolean> {
if (await pathExists(params.bootstrapPath)) {
return true;
}
if (
await workspaceRequiredBootstrapLooksCustomized(params.dir, {
generatedHashes: params.attestation.generatedHashes,
})
) {
return true;
}
if (await workspaceProfileLooksConfigured({ dir: params.dir })) {
return true;
}
return (
raw.includes('"onboardingCompletedAt"') &&
!raw.includes('"setupCompletedAt"') &&
Boolean(state.setupCompletedAt)
hasWorkspaceSetupStateMarker(params.state) &&
(await workspaceAttestedGeneratedFilesIntact(params.dir, params.attestation.generatedHashes))
);
}
async function readWorkspaceSetupStateFile(statePath: string): Promise<{
raw: string;
state: WorkspaceSetupState;
} | null> {
try {
const raw = await fs.readFile(statePath, "utf-8");
const parsed = parseWorkspaceSetupState(raw);
return parsed ? { raw, state: parsed } : null;
} catch (err) {
const anyErr = err as { code?: string };
if (anyErr.code !== "ENOENT") {
throw err;
}
return null;
async function workspaceSetupStateHasSurvivalEvidence(params: {
dir: string;
bootstrapPath: string;
initialState: WorkspaceStateSnapshot;
}): Promise<boolean> {
if (await pathExists(params.bootstrapPath)) {
return true;
}
if (await hasWorkspaceUserContentEvidence(params.dir)) {
return true;
}
const currentState = readCanonicalWorkspaceStateSnapshot(params.dir);
if (
currentState.setup.bootstrapSeededAt !== params.initialState.setup.bootstrapSeededAt ||
currentState.setup.setupCompletedAt !== params.initialState.setup.setupCompletedAt
) {
return true;
}
const generatedHashes = await collectGeneratedBootstrapHashes(params.dir);
return [
DEFAULT_AGENTS_FILENAME,
DEFAULT_SOUL_FILENAME,
DEFAULT_TOOLS_FILENAME,
DEFAULT_IDENTITY_FILENAME,
DEFAULT_USER_FILENAME,
DEFAULT_HEARTBEAT_FILENAME,
].every((fileName) => generatedHashes.has(fileName));
}
async function readWorkspaceSetupStateForDir(
dir: string,
opts?: { persistLegacyMigration?: boolean },
): Promise<WorkspaceSetupState> {
const resolvedDir = resolveUserPath(dir);
const statePath = resolveWorkspaceStatePath(resolvedDir);
const canonical = await readWorkspaceSetupStateFile(statePath);
if (canonical) {
if (
opts?.persistLegacyMigration &&
needsWorkspaceSetupStateRewrite(canonical.raw, canonical.state)
) {
await writeWorkspaceSetupState(statePath, canonical.state);
}
return canonical.state;
}
const legacyStatePath = resolveLegacyWorkspaceStatePath(resolvedDir);
let legacy: Awaited<ReturnType<typeof readWorkspaceSetupStateFile>>;
try {
legacy = await readWorkspaceSetupStateFile(legacyStatePath);
} catch {
// Legacy state lived under a dot directory that some workspaces reject.
// Treat inaccessible legacy metadata as absent so current setup can proceed.
legacy = null;
}
if (!legacy) {
return { version: WORKSPACE_STATE_VERSION };
}
if (opts?.persistLegacyMigration && hasWorkspaceSetupStateMarker(legacy.state)) {
await writeWorkspaceSetupState(statePath, legacy.state);
}
return legacy.state;
function readCanonicalWorkspaceStateSnapshot(dir: string): WorkspaceStateSnapshot {
const snapshot = readWorkspaceStateSnapshot(dir);
assertNoUnmigratedWorkspaceState({
workspaceDir: dir,
});
return snapshot;
}
export async function isWorkspaceSetupCompleted(dir: string): Promise<boolean> {
const state = await readWorkspaceSetupStateForDir(dir);
const state = readCanonicalWorkspaceStateSnapshot(dir).setup;
return typeof state.setupCompletedAt === "string" && state.setupCompletedAt.trim().length > 0;
}
@@ -780,7 +630,7 @@ export async function resolveWorkspaceBootstrapStatus(
dir: string,
): Promise<"pending" | "complete"> {
const resolvedDir = resolveUserPath(dir);
const state = await readWorkspaceSetupStateForDir(resolvedDir);
const state = readCanonicalWorkspaceStateSnapshot(resolvedDir).setup;
if (typeof state.setupCompletedAt === "string" && state.setupCompletedAt.trim().length > 0) {
return "complete";
}
@@ -796,17 +646,6 @@ export async function isWorkspaceBootstrapPending(dir: string): Promise<boolean>
return (await resolveWorkspaceBootstrapStatus(dir)) === "pending";
}
async function writeWorkspaceSetupState(
statePath: string,
state: WorkspaceSetupState,
): Promise<void> {
await replaceFileAtomic({
filePath: statePath,
content: `${JSON.stringify(state, null, 2)}\n`,
tempPrefix: WORKSPACE_STATE_FILENAME,
});
}
async function hasGitRepo(dir: string): Promise<boolean> {
try {
await fs.stat(path.join(dir, ".git"));
@@ -872,32 +711,47 @@ export async function ensureAgentWorkspace(params?: {
}> {
const rawDir = params?.dir?.trim() ? params.dir.trim() : DEFAULT_AGENT_WORKSPACE_DIR;
const dir = resolveUserPath(rawDir);
const [attestationPath, ...legacyAttestationPaths] = resolveWorkspaceAttestationPaths(dir);
if (!attestationPath) {
throw new Error("Workspace attestation path could not be resolved");
}
const attestationPaths = [attestationPath, ...legacyAttestationPaths];
const recentAttestationPath = await findRecentWorkspaceAttestationPath(attestationPaths);
let initialState = readCanonicalWorkspaceStateSnapshot(dir);
let reseedingExpiredWorkspaceState = false;
const recentAttestation = recentWorkspaceAttestation(initialState.attestation);
const recentSetupState = hasRecentWorkspaceSetupState(initialState);
const workspaceExists = await pathExists(dir);
if (!(await pathExists(dir)) && recentAttestationPath) {
throw new WorkspaceVanishedError({
workspaceDir: dir,
attestationPath: recentAttestationPath,
});
if (!workspaceExists) {
if (recentAttestation) {
throw new WorkspaceVanishedError({ workspaceDir: dir });
}
// Old setup state lived inside the workspace and disappeared with it.
// Expired SQLite evidence must preserve that reseed contract. The write
// transaction also catches a concurrent attestation refresh.
if (!clearExpiredWorkspaceStateForVanishedWorkspace(dir)) {
throw new WorkspaceVanishedError({ workspaceDir: dir });
}
}
await fs.mkdir(dir, { recursive: true });
const bootstrapPath = path.join(dir, DEFAULT_BOOTSTRAP_FILENAME);
if (!params?.ensureBootstrapFiles) {
const hasContentEvidence = await hasSkipBootstrapWorkspaceContentEvidence(dir);
if (recentAttestationPath && !hasContentEvidence) {
throw new WorkspaceVanishedError({
workspaceDir: dir,
attestationPath: recentAttestationPath,
});
if (recentAttestation && !hasContentEvidence) {
throw new WorkspaceVanishedError({ workspaceDir: dir });
}
if (
hasWorkspaceSetupStateMarker(initialState.setup) &&
!initialState.attestation &&
!(await workspaceSetupStateHasSurvivalEvidence({
dir,
bootstrapPath,
initialState,
}))
) {
if (recentSetupState || !clearExpiredWorkspaceStateForVanishedWorkspace(dir)) {
throw new WorkspaceVanishedError({ workspaceDir: dir });
}
}
if (hasContentEvidence) {
await maybeWriteWorkspaceAttestation(attestationPath, dir);
await maybeWriteWorkspaceAttestation(dir);
}
return { dir };
}
@@ -908,8 +762,6 @@ export async function ensureAgentWorkspace(params?: {
const identityPath = path.join(dir, DEFAULT_IDENTITY_FILENAME);
const userPath = path.join(dir, DEFAULT_USER_FILENAME);
const heartbeatPath = path.join(dir, DEFAULT_HEARTBEAT_FILENAME);
const bootstrapPath = path.join(dir, DEFAULT_BOOTSTRAP_FILENAME);
const statePath = resolveWorkspaceStatePath(dir);
const isBrandNewWorkspace = await (async () => {
const templatePaths = [agentsPath, soulPath, toolsPath, identityPath, userPath, heartbeatPath];
@@ -928,36 +780,49 @@ export async function ensureAgentWorkspace(params?: {
})();
if (isBrandNewWorkspace) {
if (recentAttestationPath) {
throw new WorkspaceVanishedError({
workspaceDir: dir,
attestationPath: recentAttestationPath,
});
if (recentAttestation) {
throw new WorkspaceVanishedError({ workspaceDir: dir });
}
reseedingExpiredWorkspaceState = initialState.setupExists || Boolean(initialState.attestation);
// A wiped workspace can leave its directory (or only .git) behind. Clear
// expired SQLite evidence before deciding whether setup already completed.
if (!clearExpiredWorkspaceStateForVanishedWorkspace(dir)) {
throw new WorkspaceVanishedError({ workspaceDir: dir });
}
}
if (recentAttestationPath && !isBrandNewWorkspace) {
const bootstrapExists = await pathExists(bootstrapPath);
const state = await readWorkspaceSetupStateForDir(dir, {
persistLegacyMigration: true,
});
const hasSetupState = hasWorkspaceSetupStateMarker(state);
const hasCustomizedRequiredBootstrap = await workspaceRequiredBootstrapLooksCustomized(dir, {
attestationPath: recentAttestationPath,
});
const hasConfiguredProfile = await workspaceProfileLooksConfigured({
if (initialState.attestation && !isBrandNewWorkspace) {
const hasWorkspaceEvidence = await workspaceAttestationHasSurvivalEvidence({
dir,
bootstrapPath,
state: initialState.setup,
attestation: initialState.attestation,
});
const hasWorkspaceEvidence =
bootstrapExists ||
hasCustomizedRequiredBootstrap ||
hasConfiguredProfile ||
(hasSetupState && (await workspaceAttestedGeneratedFilesIntact(dir, recentAttestationPath)));
if (!hasWorkspaceEvidence) {
throw new WorkspaceVanishedError({
workspaceDir: dir,
attestationPath: recentAttestationPath,
});
if (recentAttestation) {
throw new WorkspaceVanishedError({ workspaceDir: dir });
}
reseedingExpiredWorkspaceState = true;
// The transaction rejects a concurrent refresh. Only the expired
// snapshot we just inspected may be cleared before reseeding.
if (!clearExpiredWorkspaceStateForVanishedWorkspace(dir)) {
throw new WorkspaceVanishedError({ workspaceDir: dir });
}
}
} else if (
hasWorkspaceSetupStateMarker(initialState.setup) &&
!isBrandNewWorkspace &&
!(await workspaceSetupStateHasSurvivalEvidence({ dir, bootstrapPath, initialState }))
) {
// Setup can outlive a best-effort attestation write or arrive alone from
// Doctor. Ambiguous partial remnants must fail closed, not inherit stale
// completion state and silently suppress BOOTSTRAP reseeding.
if (recentSetupState) {
throw new WorkspaceVanishedError({ workspaceDir: dir });
}
reseedingExpiredWorkspaceState = true;
if (!clearExpiredWorkspaceStateForVanishedWorkspace(dir)) {
throw new WorkspaceVanishedError({ workspaceDir: dir });
}
}
@@ -967,12 +832,15 @@ export async function ensureAgentWorkspace(params?: {
const identityTemplate = await loadTemplate(DEFAULT_IDENTITY_FILENAME);
const userTemplate = await loadTemplate(DEFAULT_USER_FILENAME);
const heartbeatTemplate = await loadTemplate(DEFAULT_HEARTBEAT_FILENAME);
// Template and filesystem checks above are async. Another process may have
// completed setup while they ran, so optional-file policy needs fresh state.
initialState = readCanonicalWorkspaceStateSnapshot(dir);
const skipOptionalBootstrapFiles = new Set(params?.skipOptionalBootstrapFiles ?? []);
// When the workspace is already configured, skip optional bootstrap files to
// prevent subagent spawns from recreating root-level SOUL.md, USER.md,
// IDENTITY.md, or HEARTBEAT.md that were removed intentionally or only exist
// under agent-specific subdirectories.
if (await isWorkspaceSetupCompleted(dir)) {
if (initialState.setup.setupCompletedAt) {
for (const filename of OPTIONAL_BOOTSTRAP_FILENAMES) {
skipOptionalBootstrapFiles.add(filename);
}
@@ -995,9 +863,7 @@ export async function ensureAgentWorkspace(params?: {
await writeFileIfMissing(heartbeatPath, heartbeatTemplate);
}
let state = await readWorkspaceSetupStateForDir(dir, {
persistLegacyMigration: true,
});
let state = readCanonicalWorkspaceStateSnapshot(dir).setup;
let stateDirty = false;
const markState = (next: Partial<WorkspaceSetupState>) => {
state = { ...state, ...next };
@@ -1014,7 +880,6 @@ export async function ensureAgentWorkspace(params?: {
const repair = await reconcileWorkspaceBootstrapCompletionState({
dir,
bootstrapPath,
statePath,
state,
bootstrapExists,
});
@@ -1026,19 +891,20 @@ export async function ensureAgentWorkspace(params?: {
}
if (!state.bootstrapSeededAt && !state.setupCompletedAt && !bootstrapExists) {
// Legacy migration path: if USER/IDENTITY diverged from templates, or if user-content
// indicators exist, treat setup as complete and avoid recreating BOOTSTRAP for
// already-configured workspaces.
const hasRecentAttestedCustomization = recentAttestationPath
// If USER/IDENTITY diverged from templates, or if user-content indicators
// exist, treat setup as complete and avoid recreating BOOTSTRAP.
const hasRecentAttestedCustomization = recentAttestation
? await workspaceRequiredBootstrapLooksCustomized(dir, {
attestationPath: recentAttestationPath,
generatedHashes: recentAttestation.generatedHashes,
})
: false;
if (
hasRecentAttestedCustomization ||
(await workspaceProfileLooksConfigured({
dir,
includeGitEvidence: true,
// A preexisting Git repository is user evidence. Git metadata left by
// an expired, wiped OpenClaw workspace is not completion evidence.
includeGitEvidence: !reseedingExpiredWorkspaceState,
}))
) {
markState({ setupCompletedAt: nowIso() });
@@ -1057,10 +923,10 @@ export async function ensureAgentWorkspace(params?: {
}
if (stateDirty) {
await writeWorkspaceSetupState(statePath, state);
state = mergeWorkspaceSetupState(dir, state);
}
await ensureGitRepo(dir, isBrandNewWorkspace);
await maybeWriteWorkspaceAttestation(attestationPath, dir);
await maybeWriteWorkspaceAttestation(dir);
return {
dir,

View File

@@ -2,9 +2,13 @@
import { findOverlappingWorkspaceAgentIds } from "../agents/agent-delete-safety.js";
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import {
resolveWorkspaceAttestationPaths,
shouldRemoveWorkspaceAttestation,
} from "../agents/workspace.js";
prepareLegacyWorkspaceStateReset,
removeLegacyWorkspaceStateForReset,
} from "../agents/workspace-legacy-state.js";
import {
deleteWorkspaceState,
prepareWorkspaceStateDeletion,
} from "../agents/workspace-state-store.js";
import { formatCliCommand } from "../cli/command-format.js";
import { replaceConfigFile } from "../config/config.js";
import { logConfigUpdated } from "../config/logging.js";
@@ -164,22 +168,32 @@ export async function agentsDeleteCommand(
// Only trash the workspace if no other agent can depend on that path (#70890).
const workspaceSharedWith = findOverlappingWorkspaceAgentIds(cfg, agentId, workspaceDir);
const workspaceRetained = workspaceSharedWith.length > 0;
let workspaceCleanupError: Error | undefined;
if (workspaceRetained) {
quietRuntime.log(
`Skipped workspace removal (shared with other agents: ${workspaceSharedWith.join(", ")}): ${workspaceDir}`,
);
} else {
await moveToTrash(workspaceDir, quietRuntime);
for (const [index, attestationPath] of resolveWorkspaceAttestationPaths(
workspaceDir,
).entries()) {
if (await shouldRemoveWorkspaceAttestation(attestationPath, { trustUnknown: index === 0 })) {
await moveToTrash(attestationPath, quietRuntime);
const legacyPlan = prepareLegacyWorkspaceStateReset(workspaceDir);
const statePlan = prepareWorkspaceStateDeletion(workspaceDir);
const workspaceRemoved = await moveToTrash(workspaceDir, quietRuntime);
if (workspaceRemoved) {
try {
const legacyCleanup = await removeLegacyWorkspaceStateForReset(legacyPlan);
for (const warning of legacyCleanup.warnings) {
quietRuntime.log(warning);
}
deleteWorkspaceState(statePlan);
} catch (error) {
workspaceCleanupError = error instanceof Error ? error : new Error(String(error));
}
}
}
await moveToTrash(agentDir, quietRuntime);
await moveToTrash(sessionsDir, quietRuntime);
if (workspaceCleanupError) {
throw workspaceCleanupError;
}
if (opts.json) {
writeRuntimeJson(runtime, {

View File

@@ -1,9 +1,8 @@
// Agents delete tests cover config removal, workspace attestation cleanup, and binding updates.
// Agents delete tests cover config removal, workspace-state cleanup, and binding updates.
import fs from "node:fs/promises";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveWorkspaceAttestationPaths } from "../agents/workspace.js";
import { resolveStorePath } from "../config/sessions.js";
import type { SessionEntry } from "../config/sessions.js";
import { listSessionEntries, replaceSessionEntry } from "../config/sessions/session-accessor.js";
@@ -30,13 +29,10 @@ const gatewayMocks = vi.hoisted(() => ({
isGatewayTransportError: vi.fn(),
}));
function resolveCurrentWorkspaceAttestationPath(dir: string): string {
const [attestationPath] = resolveWorkspaceAttestationPaths(dir);
if (!attestationPath) {
throw new Error("expected current workspace attestation path");
}
return attestationPath;
}
const workspaceStateMocks = vi.hoisted(() => ({
deleteWorkspaceState: vi.fn(),
prepareWorkspaceStateDeletion: vi.fn((workspaceDir: string) => ({ workspaceDir })),
}));
vi.mock("../config/config.js", async () => ({
...(await vi.importActual<typeof import("../config/config.js")>("../config/config.js")),
@@ -58,6 +54,14 @@ vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: processMocks.runCommandWithTimeout,
}));
vi.mock("../agents/workspace-state-store.js", async () => ({
...(await vi.importActual<typeof import("../agents/workspace-state-store.js")>(
"../agents/workspace-state-store.js",
)),
deleteWorkspaceState: workspaceStateMocks.deleteWorkspaceState,
prepareWorkspaceStateDeletion: workspaceStateMocks.prepareWorkspaceStateDeletion,
}));
import { agentsDeleteCommand } from "./agents.commands.delete.js";
const runtime = createTestRuntime();
@@ -130,6 +134,7 @@ describe("agents delete command", () => {
configMocks.readConfigFileSnapshot.mockReset();
configMocks.replaceConfigFile.mockReset();
fsSafeMocks.movePathToTrash.mockClear();
workspaceStateMocks.deleteWorkspaceState.mockClear();
processMocks.runCommandWithTimeout.mockClear();
gatewayMocks.callGateway.mockReset();
gatewayMocks.callGateway.mockRejectedValue(
@@ -273,13 +278,14 @@ describe("agents delete command", () => {
});
});
it("trashes workspace attestations during local deletion", async () => {
await withStateDirEnv("openclaw-agents-delete-attestation-", async ({ stateDir }) => {
it("deletes workspace state after local workspace removal", async () => {
await withStateDirEnv("openclaw-agents-delete-workspace-state-", async ({ stateDir }) => {
const opsWorkspace = path.join(stateDir, "workspace-ops");
const cfg: OpenClawConfig = {
agents: {
list: [
{ id: "main", workspace: path.join(stateDir, "workspace-main") },
{ id: "ops", workspace: path.join(stateDir, "workspace-ops") },
{ id: "ops", workspace: opsWorkspace },
],
},
} satisfies OpenClawConfig;
@@ -289,23 +295,44 @@ describe("agents delete command", () => {
deletedAgentId: "ops",
sessions: {},
});
const attestationPath = resolveCurrentWorkspaceAttestationPath(
path.join(stateDir, "workspace-ops"),
);
await fs.mkdir(path.dirname(attestationPath), { recursive: true });
await fs.writeFile(
attestationPath,
`openclaw-workspace-attestation:v1\n${new Date().toISOString()}\n`,
);
const resolvedAttestationPath = path.join(
await fs.realpath(path.dirname(attestationPath)),
path.basename(attestationPath),
);
await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
expect(workspaceStateMocks.deleteWorkspaceState).toHaveBeenCalledWith({
workspaceDir: opsWorkspace,
});
const workspaceTrashOrder = fsSafeMocks.movePathToTrash.mock.invocationCallOrder[0];
const stateDeleteOrder = workspaceStateMocks.deleteWorkspaceState.mock.invocationCallOrder[0];
expect(workspaceTrashOrder).toBeLessThan(stateDeleteOrder ?? 0);
});
});
it("finishes agent-directory cleanup when workspace state deletion fails", async () => {
await withStateDirEnv("openclaw-agents-delete-state-failure-", async ({ stateDir }) => {
const opsWorkspace = path.join(stateDir, "workspace-ops");
const opsAgentDir = path.join(stateDir, "agents", "ops", "agent");
const cfg: OpenClawConfig = {
agents: {
list: [
{ id: "main", workspace: path.join(stateDir, "workspace-main") },
{ id: "ops", workspace: opsWorkspace },
],
},
} satisfies OpenClawConfig;
await arrangeAgentsDeleteTest({ stateDir, cfg, deletedAgentId: "ops", sessions: {} });
workspaceStateMocks.deleteWorkspaceState.mockImplementationOnce(() => {
throw new Error("state database unavailable");
});
await expect(
agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime),
).rejects.toThrow("state database unavailable");
const trashedPaths = fsSafeMocks.movePathToTrash.mock.calls.map(([targetPath]) => targetPath);
expect(trashedPaths).toContain(resolvedAttestationPath);
const expectedAgentDir = path.join(
await fs.realpath(path.dirname(opsAgentDir)),
path.basename(opsAgentDir),
);
expect(trashedPaths).toContain(expectedAgentDir);
});
});
@@ -385,12 +412,6 @@ describe("agents delete command", () => {
await withStateDirEnv("openclaw-agents-delete-shared-workspace-", async ({ stateDir }) => {
const sharedWorkspace = path.join(stateDir, "workspace-shared");
await fs.mkdir(sharedWorkspace, { recursive: true });
const attestationPath = resolveCurrentWorkspaceAttestationPath(sharedWorkspace);
await fs.mkdir(path.dirname(attestationPath), { recursive: true });
await fs.writeFile(
attestationPath,
`openclaw-workspace-attestation:v1\n${new Date().toISOString()}\n`,
);
const now = Date.now();
const cfg: OpenClawConfig = {
@@ -425,7 +446,7 @@ describe("agents delete command", () => {
expect(jsonOutput[0]?.workspaceSharedWith).toEqual(["main"]);
const trashedPaths = fsSafeMocks.movePathToTrash.mock.calls.map(([targetPath]) => targetPath);
expect(trashedPaths).not.toContain(sharedWorkspace);
expect(trashedPaths).not.toContain(attestationPath);
expect(workspaceStateMocks.deleteWorkspaceState).not.toHaveBeenCalled();
});
});
@@ -576,7 +597,30 @@ describe("agents delete command", () => {
expect(fsSafeMocks.movePathToTrash).toHaveBeenCalledWith(expectedOpsWorkspace, {
allowedRoots: [path.dirname(expectedOpsWorkspace)],
});
expect(workspaceStateMocks.deleteWorkspaceState).toHaveBeenCalledWith({
workspaceDir: opsWorkspace,
});
expect(processMocks.runCommandWithTimeout).not.toHaveBeenCalled();
});
});
it("retains workspace state when workspace trash fails", async () => {
await withStateDirEnv("openclaw-agents-delete-trash-failure-", async ({ stateDir }) => {
const opsWorkspace = path.join(stateDir, "workspace-ops");
const cfg: OpenClawConfig = {
agents: {
list: [
{ id: "main", workspace: path.join(stateDir, "workspace-main") },
{ id: "ops", workspace: opsWorkspace },
],
},
} satisfies OpenClawConfig;
await arrangeAgentsDeleteTest({ stateDir, cfg, sessions: {} });
fsSafeMocks.movePathToTrash.mockRejectedValueOnce(new Error("trash unavailable"));
await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
expect(workspaceStateMocks.deleteWorkspaceState).not.toHaveBeenCalled();
});
});
});

View File

@@ -6,9 +6,15 @@ import type { MockFn } from "../test-utils/vitest-mock-fn.js";
const resolveCleanupPlanFromDisk = vi.fn();
const removePath = vi.fn();
const listAgentSessionDirs = vi.fn();
export const prepareLegacyWorkspaceStateReset = vi.fn();
export const removeLegacyWorkspaceStateForReset = vi.fn();
export const removeStateAndLinkedPaths = vi.fn();
const removeWorkspaceDirs = vi.fn();
export const removeWorkspaceAttestationPaths = vi.fn();
export const removeWorkspaceDirs = vi.fn();
vi.mock("../agents/workspace-legacy-state.js", () => ({
prepareLegacyWorkspaceStateReset,
removeLegacyWorkspaceStateForReset,
}));
vi.mock("../config/config.js", () => ({
isNixMode: false,
@@ -22,7 +28,6 @@ vi.mock("./cleanup-utils.js", () => ({
removePath,
listAgentSessionDirs,
removeStateAndLinkedPaths,
removeWorkspaceAttestationPaths,
removeWorkspaceDirs,
}));
@@ -42,9 +47,10 @@ export function resetCleanupCommandMocks() {
});
removePath.mockResolvedValue({ ok: true });
listAgentSessionDirs.mockResolvedValue(["/tmp/.openclaw/agents/main/sessions"]);
removeStateAndLinkedPaths.mockResolvedValue(undefined);
prepareLegacyWorkspaceStateReset.mockImplementation((workspaceDir: string) => ({ workspaceDir }));
removeLegacyWorkspaceStateForReset.mockResolvedValue({ removedPaths: [], warnings: [] });
removeStateAndLinkedPaths.mockResolvedValue(true);
removeWorkspaceDirs.mockResolvedValue(undefined);
removeWorkspaceAttestationPaths.mockResolvedValue(undefined);
}
export function silenceCleanupCommandRuntime(runtime: RuntimeEnv) {

View File

@@ -3,15 +3,31 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it, test, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { OpenClawConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import { withEnvAsync } from "../test-utils/env.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const workspaceStateMocks = vi.hoisted(() => ({
deleteWorkspaceState: vi.fn(),
prepareWorkspaceStateDeletion: vi.fn((workspaceDir: string) => ({ workspaceDir })),
}));
vi.mock("../agents/workspace-state-store.js", async () => ({
...(await vi.importActual<typeof import("../agents/workspace-state-store.js")>(
"../agents/workspace-state-store.js",
)),
deleteWorkspaceState: workspaceStateMocks.deleteWorkspaceState,
prepareWorkspaceStateDeletion: workspaceStateMocks.prepareWorkspaceStateDeletion,
}));
import {
buildCleanupPlan,
removePath,
removeStateAndLinkedPaths,
removeWorkspaceAttestationPaths,
removeWorkspaceDirs,
} from "./cleanup-utils.js";
@@ -71,6 +87,10 @@ describe("buildCleanupPlan", () => {
});
describe("cleanup path removals", () => {
beforeEach(() => {
workspaceStateMocks.deleteWorkspaceState.mockClear();
});
function createRuntimeMock() {
return {
log: vi.fn<(message: string) => void>(),
@@ -84,7 +104,7 @@ describe("cleanup path removals", () => {
it("removes state and only linked paths outside state", async () => {
const runtime = createRuntimeMock();
const tmpRoot = path.join(path.parse(process.cwd()).root, "tmp", "openclaw-cleanup");
await removeStateAndLinkedPaths(
const stateRemoved = await removeStateAndLinkedPaths(
{
stateDir: path.join(tmpRoot, "state"),
configPath: path.join(tmpRoot, "state", "openclaw.json"),
@@ -100,6 +120,28 @@ describe("cleanup path removals", () => {
"[dry-run] remove /tmp/openclaw-cleanup/state",
"[dry-run] remove /tmp/openclaw-cleanup/oauth",
]);
expect(stateRemoved).toBe(true);
});
it("reports when the state directory survives removal", async () => {
const runtime = createRuntimeMock();
const rmSpy = vi.spyOn(fs, "rm").mockRejectedValueOnce(new Error("permission denied"));
try {
const stateRemoved = await removeStateAndLinkedPaths(
{
stateDir: "/tmp/openclaw-cleanup-state-failure",
configPath: "/tmp/openclaw-cleanup-state-failure/openclaw.json",
oauthDir: "/tmp/openclaw-cleanup-state-failure/credentials",
configInsideState: true,
oauthInsideState: true,
},
runtime,
);
expect(stateRemoved).toBe(false);
} finally {
rmSpy.mockRestore();
}
});
it("preserves nested workspace paths during state-only removal", async () => {
@@ -150,27 +192,115 @@ describe("cleanup path removals", () => {
]);
});
it("removes owned legacy workspace attestations", async () => {
it("deletes workspace state only after workspace removal succeeds", async () => {
const runtime = createRuntimeMock();
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cleanup-attest-"));
const tmpRoot = tempDirs.make("openclaw-cleanup-workspace-");
const workspaceDir = path.join(tmpRoot, "workspace");
const legacyAttestationPath = `${workspaceDir}.attested`;
try {
await fs.mkdir(workspaceDir, { recursive: true });
await removeWorkspaceDirs([workspaceDir], runtime, { removeStateRows: true });
await expect(fs.stat(workspaceDir)).rejects.toThrow();
expect(workspaceStateMocks.deleteWorkspaceState).toHaveBeenCalledWith({ workspaceDir });
} finally {
await fs.rm(tmpRoot, { recursive: true, force: true });
}
});
it("cleans workspace state when the workspace directory is already missing", async () => {
const runtime = createRuntimeMock();
const tmpRoot = tempDirs.make("openclaw-cleanup-missing-workspace-");
const workspaceDir = path.join(tmpRoot, "workspace");
const siblingMarker = `${workspaceDir}.attested`;
try {
await fs.writeFile(
siblingMarker,
"openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n",
);
await removeWorkspaceDirs([workspaceDir], runtime, { removeStateRows: true });
await expect(fs.stat(siblingMarker)).rejects.toThrow();
expect(workspaceStateMocks.deleteWorkspaceState).toHaveBeenCalledWith({ workspaceDir });
} finally {
await fs.rm(tmpRoot, { recursive: true, force: true });
}
});
it("removes a retired sibling marker after workspace removal without opening SQLite", async () => {
const runtime = createRuntimeMock();
const tmpRoot = tempDirs.make("openclaw-cleanup-legacy-");
const workspaceDir = path.join(tmpRoot, "workspace");
const siblingMarker = `${workspaceDir}.attested`;
try {
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(
legacyAttestationPath,
`openclaw-workspace-attestation:v1\n${new Date().toISOString()}\n`,
siblingMarker,
"openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n",
);
await removeWorkspaceAttestationPaths([workspaceDir], runtime);
await removeWorkspaceDirs([workspaceDir], runtime);
await expect(fs.stat(legacyAttestationPath)).rejects.toThrow();
await expect(fs.stat(workspaceDir)).rejects.toThrow();
await expect(fs.stat(siblingMarker)).rejects.toThrow();
expect(workspaceStateMocks.deleteWorkspaceState).not.toHaveBeenCalled();
} finally {
await fs.rm(tmpRoot, { recursive: true, force: true });
}
});
it("does not delete workspace state during dry-run", async () => {
const runtime = createRuntimeMock();
await removeWorkspaceDirs(["/tmp/openclaw-workspace"], runtime, {
dryRun: true,
removeStateRows: true,
});
expect(workspaceStateMocks.deleteWorkspaceState).not.toHaveBeenCalled();
});
it("previews retired sibling-marker cleanup during workspace dry-run", async () => {
const runtime = createRuntimeMock();
const tmpRoot = tempDirs.make("openclaw-cleanup-dry-run-legacy-");
const workspaceDir = path.join(tmpRoot, "workspace");
const siblingMarker = `${workspaceDir}.attested`;
try {
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(
siblingMarker,
"openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n",
);
await removeWorkspaceDirs([workspaceDir], runtime, { dryRun: true });
expect(runtime.log).toHaveBeenCalledWith(`[dry-run] remove ${siblingMarker}`);
await expect(fs.lstat(siblingMarker)).resolves.toBeDefined();
} finally {
await fs.rm(tmpRoot, { recursive: true, force: true });
}
});
it("retains workspace state when filesystem removal fails", async () => {
const runtime = createRuntimeMock();
const rmSpy = vi.spyOn(fs, "rm").mockRejectedValueOnce(new Error("permission denied"));
try {
await removeWorkspaceDirs(["/tmp/openclaw-workspace"], runtime, {
removeStateRows: true,
});
} finally {
rmSpy.mockRestore();
}
expect(workspaceStateMocks.deleteWorkspaceState).not.toHaveBeenCalled();
});
it("refuses to remove the current working directory", async () => {
const runtime = createRuntimeMock();
const result = await removePath(process.cwd(), runtime, { dryRun: true });

View File

@@ -4,9 +4,13 @@ import path from "node:path";
import { listAgentIds, resolveAgentWorkspaceDir } from "../agents/agent-scope-config.js";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace-default.js";
import {
resolveWorkspaceAttestationPaths,
shouldRemoveWorkspaceAttestation,
} from "../agents/workspace.js";
prepareLegacyWorkspaceStateReset,
removeLegacyWorkspaceStateForReset,
} from "../agents/workspace-legacy-state.js";
import {
deleteWorkspaceState,
prepareWorkspaceStateDeletion,
} from "../agents/workspace-state-store.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isPathInside } from "../infra/path-guards.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -119,23 +123,6 @@ export async function removePath(
}
}
/** Remove workspace attestation files associated with cleanup-target workspaces. */
export async function removeWorkspaceAttestationPaths(
workspaceDirs: readonly string[],
runtime: RuntimeEnv,
opts?: RemovalOptions,
): Promise<void> {
for (const workspaceDir of workspaceDirs) {
for (const [index, attestationPath] of resolveWorkspaceAttestationPaths(
workspaceDir,
).entries()) {
if (await shouldRemoveWorkspaceAttestation(attestationPath, { trustUnknown: index === 0 })) {
await removePath(attestationPath, runtime, opts);
}
}
}
}
async function existingPaths(paths: readonly string[]): Promise<string[]> {
const existing: string[] = [];
for (const target of paths) {
@@ -213,24 +200,23 @@ export async function removeStateAndLinkedPaths(
cleanup: CleanupResolvedPaths,
runtime: RuntimeEnv,
opts?: StateRemovalOptions,
): Promise<void> {
): Promise<boolean> {
const stateDir = path.resolve(cleanup.stateDir);
const preservePaths = (
opts?.dryRun
? (opts.preservePaths ?? []).map((target) => path.resolve(target))
: await existingPaths(opts?.preservePaths ?? [])
).filter((target) => isPathWithin(target, stateDir));
if (preservePaths.length > 0) {
await removePathPreserving(stateDir, preservePaths, runtime, {
dryRun: opts?.dryRun,
label: cleanup.stateDir,
});
} else {
await removePath(cleanup.stateDir, runtime, {
dryRun: opts?.dryRun,
label: cleanup.stateDir,
});
}
const stateRemoval =
preservePaths.length > 0
? await removePathPreserving(stateDir, preservePaths, runtime, {
dryRun: opts?.dryRun,
label: cleanup.stateDir,
})
: await removePath(cleanup.stateDir, runtime, {
dryRun: opts?.dryRun,
label: cleanup.stateDir,
});
if (!cleanup.configInsideState) {
await removePath(cleanup.configPath, runtime, {
dryRun: opts?.dryRun,
@@ -243,19 +229,52 @@ export async function removeStateAndLinkedPaths(
label: cleanup.oauthDir,
});
}
return stateRemoval.ok;
}
/** Remove all workspace directories selected by the cleanup plan. */
export async function removeWorkspaceDirs(
workspaceDirs: readonly string[],
runtime: RuntimeEnv,
opts?: { dryRun?: boolean },
opts?: { dryRun?: boolean; removeStateRows?: boolean },
): Promise<void> {
for (const workspace of workspaceDirs) {
await removePath(workspace, runtime, {
const legacyPlan = prepareLegacyWorkspaceStateReset(workspace);
const statePlan = opts?.removeStateRows ? prepareWorkspaceStateDeletion(workspace) : undefined;
const result = await removePath(workspace, runtime, {
dryRun: opts?.dryRun,
label: workspace,
});
if (opts?.dryRun) {
if (!result.ok) {
continue;
}
const legacyCleanup = await removeLegacyWorkspaceStateForReset(legacyPlan, { dryRun: true });
for (const removedPath of legacyCleanup.removedPaths) {
runtime.log(`[dry-run] remove ${shortenHomeInString(removedPath)}`);
}
for (const warning of legacyCleanup.warnings) {
runtime.error(warning);
}
continue;
}
if (!result.ok || result.skipped) {
continue;
}
try {
const legacyCleanup = await removeLegacyWorkspaceStateForReset(legacyPlan);
for (const warning of legacyCleanup.warnings) {
runtime.error(warning);
}
if (!opts?.removeStateRows) {
continue;
}
deleteWorkspaceState(statePlan!);
} catch (error) {
runtime.error(
`Failed to remove workspace state for ${shortenHomeInString(workspace)}: ${String(error)}`,
);
}
}
}

View File

@@ -292,6 +292,10 @@ function createLegacyStateMigrationDetectionResult(params?: {
sourcePath: "/tmp/state/push/apns-registrations.json",
hasLegacy: false,
},
workspace: {
sources: [],
hasLegacy: false,
},
webPush: {
subscriptionsPath: "/tmp/state/push/web-push-subscriptions.json",
vapidKeysPath: "/tmp/state/push/vapid-keys.json",

View File

@@ -1,4 +1,4 @@
// Onboard helper tests cover workspace setup, control UI links, and gateway reachability probes.
// Onboard helper tests cover workspace setup, state cleanup, control UI links, and gateway probes.
import * as fs from "node:fs";
import fsPromises from "node:fs/promises";
import os from "node:os";
@@ -6,6 +6,7 @@ import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js";
import { stripAnsi } from "../../packages/terminal-core/src/ansi.js";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { RuntimeEnv } from "../runtime.js";
import { withEnvAsync } from "../test-utils/env.js";
import { withMockedPlatform } from "../test-utils/vitest-spies.js";
@@ -28,6 +29,8 @@ import {
waitForGatewayReachable,
} from "./onboard-helpers.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("onboard error summaries", () => {
it("keeps the bounded first line UTF-16 well-formed", () => {
expect(testing.summarizeError(`${"x".repeat(118)}🚀tail\nignored`)).toBe(`${"x".repeat(118)}`);
@@ -86,6 +89,8 @@ const mocks = vi.hoisted(() => ({
pickPrimaryTailnetIPv4: vi.fn<() => string | undefined>(() => undefined),
resolveAdvertisedLanHost: vi.fn<() => Promise<string | null>>(async () => null),
probeGateway: vi.fn(),
deleteWorkspaceState: vi.fn(),
prepareWorkspaceStateDeletion: vi.fn((workspaceDir: string) => ({ workspaceDir })),
}));
vi.mock("../infra/fs-safe.js", () => ({
@@ -108,6 +113,14 @@ vi.mock("../gateway/probe.js", () => ({
probeGateway: mocks.probeGateway,
}));
vi.mock("../agents/workspace-state-store.js", async () => ({
...(await vi.importActual<typeof import("../agents/workspace-state-store.js")>(
"../agents/workspace-state-store.js",
)),
deleteWorkspaceState: mocks.deleteWorkspaceState,
prepareWorkspaceStateDeletion: mocks.prepareWorkspaceStateDeletion,
}));
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
@@ -140,7 +153,6 @@ describe("handleReset", () => {
const profileCredentialsDir = path.join(profileStateDir, "credentials");
const profileSessionsDir = path.join(profileStateDir, "agents", "main", "sessions");
const workspaceDir = path.join(profileStateDir, "workspace");
const workspaceAttestationPath = `${workspaceDir}.attested`;
const defaultCredentialsDir = path.join(defaultStateDir, "credentials");
fs.mkdirSync(profileCredentialsDir, { recursive: true });
@@ -148,10 +160,6 @@ describe("handleReset", () => {
fs.mkdirSync(workspaceDir, { recursive: true });
fs.mkdirSync(defaultCredentialsDir, { recursive: true });
fs.writeFileSync(profileConfigPath, "{}\n");
fs.writeFileSync(
workspaceAttestationPath,
`openclaw-workspace-attestation:v1\n${new Date().toISOString()}\n`,
);
const runtime = { log: vi.fn() } as unknown as RuntimeEnv;
const expectedTrashedPaths = [
@@ -159,7 +167,6 @@ describe("handleReset", () => {
profileCredentialsDir,
profileSessionsDir,
workspaceDir,
workspaceAttestationPath,
].map(expectedTrashSourcePath);
const expectedDefaultCredentialsDir = expectedTrashSourcePath(defaultCredentialsDir);
@@ -181,25 +188,28 @@ describe("handleReset", () => {
const trashedPaths = mocks.movePathToTrash.mock.calls.map(([targetPath]) => targetPath);
expect(trashedPaths).toEqual(expectedTrashedPaths);
expect(trashedPaths).not.toContain(expectedDefaultCredentialsDir);
expect(mocks.deleteWorkspaceState).toHaveBeenCalledWith({ workspaceDir });
});
it("does not trash an unowned sibling attestation path during full reset", async () => {
it("retains workspace state when workspace removal fails", async () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-reset-profile-"));
const profileStateDir = path.join(homeDir, ".openclaw-work");
const profileConfigPath = path.join(profileStateDir, "openclaw.json");
const profileCredentialsDir = path.join(profileStateDir, "credentials");
const profileSessionsDir = path.join(profileStateDir, "agents", "main", "sessions");
const workspaceDir = path.join(profileStateDir, "workspace");
const workspaceAttestationPath = `${workspaceDir}.attested`;
fs.mkdirSync(profileCredentialsDir, { recursive: true });
fs.mkdirSync(profileSessionsDir, { recursive: true });
fs.mkdirSync(workspaceDir, { recursive: true });
fs.writeFileSync(profileConfigPath, "{}\n");
fs.writeFileSync(workspaceAttestationPath, "external data\n");
const runtime = { log: vi.fn() } as unknown as RuntimeEnv;
const unownedAttestationTrashPath = expectedTrashSourcePath(workspaceAttestationPath);
mocks.movePathToTrash
.mockResolvedValueOnce("config.trashed")
.mockResolvedValueOnce("credentials.trashed")
.mockResolvedValueOnce("sessions.trashed")
.mockRejectedValueOnce(new Error("trash unavailable"));
try {
await withEnvAsync(
@@ -216,53 +226,8 @@ describe("handleReset", () => {
fs.rmSync(homeDir, { recursive: true, force: true });
}
const trashedPaths = mocks.movePathToTrash.mock.calls.map(([targetPath]) => targetPath);
expect(trashedPaths).not.toContain(unownedAttestationTrashPath);
expect(mocks.deleteWorkspaceState).not.toHaveBeenCalled();
});
it.skipIf(process.platform === "win32")(
"does not abort full reset for an unreadable legacy attestation path",
async () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-reset-profile-"));
const profileStateDir = path.join(homeDir, ".openclaw-work");
const profileConfigPath = path.join(profileStateDir, "openclaw.json");
const profileCredentialsDir = path.join(profileStateDir, "credentials");
const profileSessionsDir = path.join(profileStateDir, "agents", "main", "sessions");
const workspaceDir = path.join(profileStateDir, "workspace");
const workspaceAttestationPath = `${workspaceDir}.attested`;
fs.mkdirSync(profileCredentialsDir, { recursive: true });
fs.mkdirSync(profileSessionsDir, { recursive: true });
fs.mkdirSync(workspaceDir, { recursive: true });
fs.writeFileSync(profileConfigPath, "{}\n");
fs.writeFileSync(workspaceAttestationPath, "external data\n", { mode: 0o000 });
fs.chmodSync(workspaceAttestationPath, 0o000);
const runtime = { log: vi.fn() } as unknown as RuntimeEnv;
const unreadableAttestationTrashPath = expectedTrashSourcePath(workspaceAttestationPath);
try {
await withEnvAsync(
{
HOME: homeDir,
OPENCLAW_HOME: homeDir,
OPENCLAW_PROFILE: "work",
OPENCLAW_STATE_DIR: profileStateDir,
OPENCLAW_CONFIG_PATH: profileConfigPath,
},
async () => {
await expect(handleReset("full", workspaceDir, runtime)).resolves.toBeUndefined();
},
);
} finally {
fs.chmodSync(workspaceAttestationPath, 0o600);
fs.rmSync(homeDir, { recursive: true, force: true });
}
const trashedPaths = mocks.movePathToTrash.mock.calls.map(([targetPath]) => targetPath);
expect(trashedPaths).not.toContain(unreadableAttestationTrashPath);
},
);
});
describe("moveToTrash", () => {
@@ -310,6 +275,24 @@ describe("moveToTrash", () => {
});
});
it("moves a dangling symlink instead of treating it as already removed", async () => {
const testRoot = tempDirs.make("openclaw-trash-dangling-link-");
const targetPath = path.join(testRoot, "workspace-link");
fs.symlinkSync(path.join(testRoot, "missing-target"), targetPath, "dir");
const runtime = { log: vi.fn() } as unknown as RuntimeEnv;
const sourcePath = expectedTrashSourcePath(targetPath);
try {
await expect(moveToTrash(targetPath, runtime)).resolves.toBe(true);
} finally {
fs.rmSync(testRoot, { recursive: true, force: true });
}
expect(mocks.movePathToTrash).toHaveBeenCalledWith(sourcePath, {
allowedRoots: [path.dirname(sourcePath)],
});
});
it("canonicalizes a symlinked parent before calling fs-safe trash", async () => {
const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-trash-parent-link-"));
const lexicalParent = path.join(testRoot, "state-link");

View File

@@ -14,11 +14,14 @@ import {
import { stylePromptTitle } from "../../packages/terminal-core/src/prompt-style.js";
import { resolveAgentEffectiveModelPrimary, resolveDefaultAgentId } from "../agents/agent-scope.js";
import {
DEFAULT_AGENT_WORKSPACE_DIR,
ensureAgentWorkspace,
resolveWorkspaceAttestationPaths,
shouldRemoveWorkspaceAttestation,
} from "../agents/workspace.js";
prepareLegacyWorkspaceStateReset,
removeLegacyWorkspaceStateForReset,
} from "../agents/workspace-legacy-state.js";
import {
deleteWorkspaceState,
prepareWorkspaceStateDeletion,
} from "../agents/workspace-state-store.js";
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js";
import { printClawBanner } from "../cli/claw-banner.js";
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
import { resolveConfigPath } from "../config/paths.js";
@@ -248,14 +251,14 @@ export async function ensureWorkspaceAndSessions(
}
/** Moves a path to Trash when it exists, logging a manual-delete fallback on failure. */
export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promise<void> {
export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promise<boolean> {
if (!pathname) {
return;
return false;
}
try {
await fs.access(pathname);
} catch {
return;
await fs.lstat(pathname);
} catch (error) {
return (error as NodeJS.ErrnoException).code === "ENOENT";
}
try {
const targetPath = path.resolve(pathname);
@@ -264,8 +267,10 @@ export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promis
allowedRoots: await resolveMoveToTrashAllowedRoots(sourcePath),
});
runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`);
return true;
} catch {
runtime.log(`Failed to move to Trash (manual delete): ${shortenHomePath(pathname)}`);
return false;
}
}
@@ -297,13 +302,15 @@ export async function handleReset(scope: ResetScope, workspaceDir: string, runti
await moveToTrash(path.join(resolveConfigDir(), "credentials"), runtime);
await moveToTrash(resolveSessionTranscriptsDirForAgent(), runtime);
if (scope === "full") {
await moveToTrash(workspaceDir, runtime);
for (const [index, attestationPath] of resolveWorkspaceAttestationPaths(
workspaceDir,
).entries()) {
if (await shouldRemoveWorkspaceAttestation(attestationPath, { trustUnknown: index === 0 })) {
await moveToTrash(attestationPath, runtime);
const legacyPlan = prepareLegacyWorkspaceStateReset(workspaceDir);
const statePlan = prepareWorkspaceStateDeletion(workspaceDir);
const workspaceRemoved = await moveToTrash(workspaceDir, runtime);
if (workspaceRemoved) {
const legacyCleanup = await removeLegacyWorkspaceStateForReset(legacyPlan);
for (const warning of legacyCleanup.warnings) {
runtime.log(warning);
}
deleteWorkspaceState(statePlan);
}
}
}

View File

@@ -1,9 +1,10 @@
// Reset command tests cover cleanup runtime behavior, workspace attestations, and reset prompts.
// Reset command tests cover cleanup runtime behavior, workspace state, and reset prompts.
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
cleanupCommandLogMessages,
createCleanupCommandRuntime,
removeWorkspaceAttestationPaths,
removeStateAndLinkedPaths,
removeWorkspaceDirs,
resetCleanupCommandMocks,
silenceCleanupCommandRuntime,
} from "./cleanup-command.test-support.js";
@@ -51,7 +52,7 @@ describe("resetCommand", () => {
).toBe(false);
});
it("removes workspace attestations during full reset", async () => {
it("does not reopen workspace state after full state removal", async () => {
await resetCommand(runtime, {
scope: "full",
yes: true,
@@ -59,10 +60,24 @@ describe("resetCommand", () => {
dryRun: true,
});
expect(removeWorkspaceAttestationPaths).toHaveBeenCalledWith(
["/tmp/.openclaw/workspace"],
runtime,
{ dryRun: true },
);
expect(removeWorkspaceDirs).toHaveBeenCalledWith(["/tmp/.openclaw/workspace"], runtime, {
dryRun: true,
removeStateRows: false,
});
});
it("removes workspace rows when full state removal fails", async () => {
removeStateAndLinkedPaths.mockResolvedValueOnce(false);
await resetCommand(runtime, {
scope: "full",
yes: true,
nonInteractive: true,
});
expect(removeWorkspaceDirs).toHaveBeenCalledWith(["/tmp/.openclaw/workspace"], runtime, {
dryRun: false,
removeStateRows: true,
});
});
});

View File

@@ -19,7 +19,6 @@ import {
listAgentSessionDirs,
removePath,
removeStateAndLinkedPaths,
removeWorkspaceAttestationPaths,
removeWorkspaceDirs,
} from "./cleanup-utils.js";
@@ -155,15 +154,15 @@ export async function resetCommand(runtime: RuntimeEnv, opts: ResetOptions) {
}
if (scope === "full") {
await removeStateAndLinkedPaths(
const stateRemoved = await removeStateAndLinkedPaths(
{ stateDir, configPath, oauthDir, configInsideState, oauthInsideState },
runtime,
{ dryRun },
);
await removeWorkspaceDirs(workspaceDirs, runtime, { dryRun });
// Workspace attestations live beside workspace dirs and can outlive the
// workspace itself, so full reset cleans both surfaces.
await removeWorkspaceAttestationPaths(workspaceDirs, runtime, { dryRun });
await removeWorkspaceDirs(workspaceDirs, runtime, {
dryRun,
removeStateRows: !stateRemoved,
});
runtime.log(`Next: ${formatCliCommand("openclaw onboard --install-daemon")}`);
}
}

View File

@@ -3,8 +3,10 @@ import { beforeEach, describe, expect, it } from "vitest";
import {
cleanupCommandLogMessages,
createCleanupCommandRuntime,
prepareLegacyWorkspaceStateReset,
removeLegacyWorkspaceStateForReset,
removeStateAndLinkedPaths,
removeWorkspaceAttestationPaths,
removeWorkspaceDirs,
resetCleanupCommandMocks,
silenceCleanupCommandRuntime,
} from "./cleanup-command.test-support.js";
@@ -67,6 +69,29 @@ describe("uninstallCommand", () => {
);
});
it("previews retired workspace files during state-only uninstall", async () => {
removeLegacyWorkspaceStateForReset.mockResolvedValueOnce({
removedPaths: ["/tmp/.openclaw/workspace/openclaw-workspace-state.json"],
warnings: [],
});
await uninstallCommand(runtime, {
state: true,
yes: true,
nonInteractive: true,
dryRun: true,
});
expect(prepareLegacyWorkspaceStateReset).toHaveBeenCalledWith("/tmp/.openclaw/workspace");
expect(removeLegacyWorkspaceStateForReset).toHaveBeenCalledWith(
{ workspaceDir: "/tmp/.openclaw/workspace" },
{ dryRun: true },
);
expect(cleanupCommandLogMessages(runtime)).toContain(
"[dry-run] remove /tmp/.openclaw/workspace/openclaw-workspace-state.json",
);
});
it("does not preserve workspace dirs when workspace removal is selected", async () => {
await uninstallCommand(runtime, {
state: true,
@@ -86,7 +111,7 @@ describe("uninstallCommand", () => {
);
});
it("removes workspace attestations when workspace removal is selected", async () => {
it("removes workspace state rows during workspace-only uninstall", async () => {
await uninstallCommand(runtime, {
workspace: true,
yes: true,
@@ -94,10 +119,40 @@ describe("uninstallCommand", () => {
dryRun: true,
});
expect(removeWorkspaceAttestationPaths).toHaveBeenCalledWith(
["/tmp/.openclaw/workspace"],
runtime,
{ dryRun: true },
);
expect(removeWorkspaceDirs).toHaveBeenCalledWith(["/tmp/.openclaw/workspace"], runtime, {
dryRun: true,
removeStateRows: true,
});
});
it("does not reopen workspace state after state and workspace uninstall", async () => {
await uninstallCommand(runtime, {
state: true,
workspace: true,
yes: true,
nonInteractive: true,
dryRun: true,
});
expect(removeWorkspaceDirs).toHaveBeenCalledWith(["/tmp/.openclaw/workspace"], runtime, {
dryRun: true,
removeStateRows: false,
});
});
it("removes workspace rows when combined state removal fails", async () => {
removeStateAndLinkedPaths.mockResolvedValueOnce(false);
await uninstallCommand(runtime, {
state: true,
workspace: true,
yes: true,
nonInteractive: true,
});
expect(removeWorkspaceDirs).toHaveBeenCalledWith(["/tmp/.openclaw/workspace"], runtime, {
dryRun: false,
removeStateRows: true,
});
});
});

View File

@@ -8,19 +8,18 @@ import {
stylePromptMessage,
stylePromptTitle,
} from "../../packages/terminal-core/src/prompt-style.js";
import {
prepareLegacyWorkspaceStateReset,
removeLegacyWorkspaceStateForReset,
} from "../agents/workspace-legacy-state.js";
import { formatCliCommand } from "../cli/command-format.js";
import { isNixMode } from "../config/config.js";
import { resolveGatewayService } from "../daemon/service.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveHomeDir } from "../utils.js";
import { resolveHomeDir, shortenHomeInString } from "../utils.js";
import { resolveCleanupPlanFromDisk } from "./cleanup-plan.js";
import {
removePath,
removeStateAndLinkedPaths,
removeWorkspaceAttestationPaths,
removeWorkspaceDirs,
} from "./cleanup-utils.js";
import { removePath, removeStateAndLinkedPaths, removeWorkspaceDirs } from "./cleanup-utils.js";
type UninstallScope = "service" | "state" | "workspace" | "app";
@@ -178,6 +177,7 @@ export async function uninstallCommand(runtime: RuntimeEnv, opts: UninstallOptio
}
const dryRun = Boolean(opts.dryRun);
let stateRemoved = false;
const { stateDir, configPath, oauthDir, configInsideState, oauthInsideState, workspaceDirs } =
resolveCleanupPlanFromDisk();
@@ -194,8 +194,22 @@ export async function uninstallCommand(runtime: RuntimeEnv, opts: UninstallOptio
}
if (scopes.has("state")) {
if (!scopes.has("workspace")) {
for (const workspaceDir of workspaceDirs) {
const legacyPlan = prepareLegacyWorkspaceStateReset(workspaceDir);
const legacyCleanup = await removeLegacyWorkspaceStateForReset(legacyPlan, { dryRun });
for (const removedPath of legacyCleanup.removedPaths) {
if (dryRun) {
runtime.log(`[dry-run] remove ${shortenHomeInString(removedPath)}`);
}
}
for (const warning of legacyCleanup.warnings) {
runtime.error(warning);
}
}
}
// Preserve workspaces when state-only uninstall is requested; workspace scope removes them explicitly.
await removeStateAndLinkedPaths(
stateRemoved = await removeStateAndLinkedPaths(
{ stateDir, configPath, oauthDir, configInsideState, oauthInsideState },
runtime,
{ dryRun, preservePaths: scopes.has("workspace") ? [] : workspaceDirs },
@@ -203,8 +217,10 @@ export async function uninstallCommand(runtime: RuntimeEnv, opts: UninstallOptio
}
if (scopes.has("workspace")) {
await removeWorkspaceDirs(workspaceDirs, runtime, { dryRun });
await removeWorkspaceAttestationPaths(workspaceDirs, runtime, { dryRun });
await removeWorkspaceDirs(workspaceDirs, runtime, {
dryRun,
removeStateRows: !scopes.has("state") || !stateRemoved,
});
}
if (scopes.has("app")) {

View File

@@ -1,5 +1,5 @@
// Agent mutation tests cover create/update/delete handlers, safe workspace file
// access, config preconditions, trash cleanup, and attestation handling.
// access, config preconditions, trash cleanup, and workspace-state handling.
import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it, vi, beforeEach } from "vitest";
@@ -24,10 +24,8 @@ const mocks = vi.hoisted(() => ({
}),
),
isWorkspaceSetupCompleted: vi.fn(async () => false),
resolveWorkspaceAttestationPaths: vi.fn((_workspaceDir: string) => [
"/state/workspace-attestations/test-agent.attested",
]),
shouldRemoveWorkspaceAttestation: vi.fn(async () => true),
deleteWorkspaceState: vi.fn(),
prepareWorkspaceStateDeletion: vi.fn((workspaceDir: string) => ({ workspaceDir })),
resolveAgentDir: vi.fn((_cfg?: unknown, _agentId?: string) => "/agents/test-agent"),
resolveAgentWorkspaceDir: vi.fn((_cfg?: unknown, _agentId?: string) => "/workspace/test-agent"),
resolveSessionTranscriptsDirForAgent: vi.fn((_agentId?: string) => "/transcripts/test-agent"),
@@ -127,11 +125,17 @@ vi.mock("../../agents/workspace.js", async () => {
...actual,
ensureAgentWorkspace: mocks.ensureAgentWorkspace,
isWorkspaceSetupCompleted: mocks.isWorkspaceSetupCompleted,
resolveWorkspaceAttestationPaths: mocks.resolveWorkspaceAttestationPaths,
shouldRemoveWorkspaceAttestation: mocks.shouldRemoveWorkspaceAttestation,
};
});
vi.mock("../../agents/workspace-state-store.js", async () => ({
...(await vi.importActual<typeof import("../../agents/workspace-state-store.js")>(
"../../agents/workspace-state-store.js",
)),
deleteWorkspaceState: mocks.deleteWorkspaceState,
prepareWorkspaceStateDeletion: mocks.prepareWorkspaceStateDeletion,
}));
vi.mock("../../config/sessions/paths.js", () => ({
resolveSessionTranscriptsDirForAgent: mocks.resolveSessionTranscriptsDirForAgent,
}));
@@ -222,10 +226,6 @@ beforeEach(() => {
mocks.resolveAgentWorkspaceDir.mockImplementation((cfg: unknown, agentId?: string) =>
resolveMockWorkspaceDir(cfg, agentId),
);
mocks.resolveWorkspaceAttestationPaths.mockImplementation((_workspaceDir: string) => [
"/state/workspace-attestations/test-agent.attested",
]);
mocks.shouldRemoveWorkspaceAttestation.mockResolvedValue(true);
mocks.rootOpen.mockResolvedValue({
handle: { close: vi.fn(async () => {}) },
realPath: "/workspace/test-agent/AGENTS.md",
@@ -1155,6 +1155,7 @@ describe("agents.update", () => {
describe("agents.delete", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.fsLstat.mockResolvedValue(null as unknown as import("node:fs").Stats);
mocks.loadConfigReturn = {};
mocks.findAgentEntryIndex.mockReturnValue(0);
mocks.pruneAgentConfig.mockReturnValue({ config: {}, removedBindings: 2 });
@@ -1172,28 +1173,39 @@ describe("agents.delete", () => {
undefined,
);
expect(mocks.writeConfigFile).toHaveBeenCalled();
// moveToTrashBestEffort calls fs.access then movePathToTrash for each dir
// moveToTrashBestEffort calls fs.lstat then movePathToTrash for each dir
expect(mocks.movePathToTrash).toHaveBeenCalled();
});
it("trashes workspace attestations when deleting the last workspace owner", async () => {
it("deletes workspace state after removing the last owner's workspace", async () => {
const { respond, promise } = makeCall("agents.delete", {
agentId: "test-agent",
});
await promise;
expectRespondOk(respond, { ok: true });
expect(mocks.resolveWorkspaceAttestationPaths).toHaveBeenCalledWith("/workspace/test-agent");
expect(mocks.shouldRemoveWorkspaceAttestation).toHaveBeenCalledWith(
"/state/workspace-attestations/test-agent.attested",
{ trustUnknown: true },
);
expect(mocks.movePathToTrash).toHaveBeenCalledWith(
"/state/workspace-attestations/test-agent.attested",
);
expect(mocks.movePathToTrash).toHaveBeenCalledWith("/workspace/test-agent");
expect(mocks.deleteWorkspaceState).toHaveBeenCalledWith({
workspaceDir: "/workspace/test-agent",
});
});
it("keeps workspace attestations when another agent still owns the workspace", async () => {
it("trashes a dangling workspace symlink before deleting its state", async () => {
mocks.fsAccess.mockRejectedValueOnce(
Object.assign(new Error("missing target"), { code: "ENOENT" }),
);
const { respond, promise } = makeCall("agents.delete", {
agentId: "test-agent",
});
await promise;
expectRespondOk(respond, { ok: true });
expect(mocks.movePathToTrash).toHaveBeenCalledWith("/workspace/test-agent");
expect(mocks.deleteWorkspaceState).toHaveBeenCalled();
});
it("keeps workspace state when another agent still owns the workspace", async () => {
mocks.pruneAgentConfig.mockReturnValue({
config: { agents: { list: [{ id: "other", workspace: "/workspace/test-agent" }] } },
removedBindings: 2,
@@ -1205,12 +1217,38 @@ describe("agents.delete", () => {
await promise;
expectRespondOk(respond, { ok: true });
expect(mocks.resolveWorkspaceAttestationPaths).not.toHaveBeenCalled();
expect(mocks.deleteWorkspaceState).not.toHaveBeenCalled();
expect(mocks.movePathToTrash).not.toHaveBeenCalledWith("/workspace/test-agent");
});
it("retains workspace state when best-effort workspace trash fails", async () => {
mocks.movePathToTrash.mockRejectedValueOnce(new Error("trash unavailable"));
const { respond, promise } = makeCall("agents.delete", {
agentId: "test-agent",
});
await promise;
expectRespondOk(respond, { ok: true });
expect(mocks.deleteWorkspaceState).not.toHaveBeenCalled();
});
it("retains workspace state when workspace presence cannot be checked", async () => {
mocks.fsLstat.mockRejectedValueOnce(
Object.assign(new Error("permission denied"), { code: "EACCES" }),
);
const { respond, promise } = makeCall("agents.delete", {
agentId: "test-agent",
});
await promise;
expectRespondOk(respond, { ok: true });
expect(mocks.deleteWorkspaceState).not.toHaveBeenCalled();
});
it("skips file deletion when deleteFiles is false", async () => {
mocks.fsAccess.mockClear();
mocks.fsLstat.mockClear();
const { respond, promise } = makeCall("agents.delete", {
agentId: "test-agent",
@@ -1220,7 +1258,8 @@ describe("agents.delete", () => {
expectRespondOk(respond, { ok: true });
// moveToTrashBestEffort should not be called at all
expect(mocks.fsAccess).not.toHaveBeenCalled();
expect(mocks.fsLstat).not.toHaveBeenCalled();
expect(mocks.deleteWorkspaceState).not.toHaveBeenCalled();
});
it("rejects deleting the main agent", async () => {

View File

@@ -23,6 +23,14 @@ import {
} from "../../agents/agent-scope.js";
import { mergeIdentityMarkdownContent } from "../../agents/identity-file.js";
import { resolveAgentIdentity } from "../../agents/identity.js";
import {
prepareLegacyWorkspaceStateReset,
removeLegacyWorkspaceStateForReset,
} from "../../agents/workspace-legacy-state.js";
import {
deleteWorkspaceState,
prepareWorkspaceStateDeletion,
} from "../../agents/workspace-state-store.js";
import {
DEFAULT_AGENTS_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
@@ -34,8 +42,6 @@ import {
DEFAULT_USER_FILENAME,
ensureAgentWorkspace,
isWorkspaceSetupCompleted,
resolveWorkspaceAttestationPaths,
shouldRemoveWorkspaceAttestation,
} from "../../agents/workspace.js";
import { applyAgentConfig } from "../../commands/agents.config.js";
import {
@@ -313,19 +319,21 @@ function respondAgentConfigPreconditionError(
);
}
async function moveToTrashBestEffort(pathname: string): Promise<void> {
async function moveToTrashBestEffort(pathname: string): Promise<boolean> {
if (!pathname) {
return;
return false;
}
try {
await fs.access(pathname);
} catch {
return;
await fs.lstat(pathname);
} catch (error) {
return (error as NodeJS.ErrnoException).code === "ENOENT";
}
try {
await movePathToTrash(pathname);
return true;
} catch {
// Best-effort: path may already be gone or trash unavailable.
return false;
}
}
@@ -743,14 +751,15 @@ export const agentsHandlers: GatewayRequestHandlers = {
const deleteWorkspace = workspaceSharedWith.length === 0;
const pathsToTrash = [deleteResult.agentDir, deleteResult.sessionsDir];
if (deleteWorkspace) {
pathsToTrash.unshift(deleteResult.workspaceDir);
for (const [index, attestationPath] of resolveWorkspaceAttestationPaths(
deleteResult.workspaceDir,
).entries()) {
if (
await shouldRemoveWorkspaceAttestation(attestationPath, { trustUnknown: index === 0 })
) {
pathsToTrash.push(attestationPath);
const legacyPlan = prepareLegacyWorkspaceStateReset(deleteResult.workspaceDir);
const statePlan = prepareWorkspaceStateDeletion(deleteResult.workspaceDir);
const workspaceRemoved = await moveToTrashBestEffort(deleteResult.workspaceDir);
if (workspaceRemoved) {
try {
await removeLegacyWorkspaceStateForReset(legacyPlan);
deleteWorkspaceState(statePlan);
} catch {
// Best-effort cleanup. A later explicit reset can remove stale rows.
}
}
}

View File

@@ -129,6 +129,10 @@ import {
resolveLegacyUpdateCheckPath,
} from "./state-migrations.update-check.js";
import { detectLegacyWebPush, migrateLegacyWebPush } from "./state-migrations.web-push.js";
import {
detectLegacyWorkspaceState,
migrateLegacyWorkspaceState,
} from "./state-migrations.workspace-setup.js";
function describeStateSchemaMigration(migration: OpenClawStateDatabaseSchemaMigration): string {
switch (migration.kind) {
@@ -396,6 +400,13 @@ export async function detectLegacyStateMigrations(params: {
stateDir,
doctorOnlyStateMigrations: params.doctorOnlyStateMigrations,
});
const workspace = detectLegacyWorkspaceState({
cfg: params.cfg,
stateDir,
env,
homedir,
doctorOnlyStateMigrations: params.doctorOnlyStateMigrations,
});
const webPush = detectLegacyWebPush({
stateDir,
doctorOnlyStateMigrations: params.doctorOnlyStateMigrations,
@@ -565,6 +576,9 @@ export async function detectLegacyStateMigrations(params: {
if (apns.hasLegacy) {
preview.push("- APNs registrations: legacy JSON → shared SQLite state");
}
if (workspace.hasLegacy) {
preview.push("- Workspace setup and attestations: legacy files → shared SQLite state");
}
if (webPush.hasLegacy) {
preview.push("- Web Push subscriptions and VAPID identity: legacy JSON → shared SQLite state");
}
@@ -663,6 +677,7 @@ export async function detectLegacyStateMigrations(params: {
commitments,
managedOutgoingImages,
apns,
workspace,
webPush,
nodeHost,
subagentRegistry,
@@ -861,6 +876,11 @@ export async function runLegacyStateMigrations(params: {
env,
stateDir: detected.stateDir,
});
const workspace = await migrateLegacyWorkspaceState({
detected: detected.workspace,
env,
stateDir: detected.stateDir,
});
const webPush = await migrateLegacyWebPush({
detected: detected.webPush,
env,
@@ -913,6 +933,7 @@ export async function runLegacyStateMigrations(params: {
commitments,
managedOutgoingImages,
apns,
workspace,
webPush,
nodeHost,
subagentRegistry,
@@ -935,6 +956,7 @@ export async function runLegacyStateMigrations(params: {
...commitments.changes,
...managedOutgoingImages.changes,
...apns.changes,
...workspace.changes,
...webPush.changes,
...nodeHost.changes,
...subagentRegistry.changes,
@@ -964,6 +986,7 @@ export async function runLegacyStateMigrations(params: {
...commitments.warnings,
...managedOutgoingImages.warnings,
...apns.warnings,
...workspace.warnings,
...webPush.warnings,
...nodeHost.warnings,
...subagentRegistry.warnings,
@@ -1220,6 +1243,7 @@ export async function autoMigrateLegacyState(params: {
!detected.configHealth.hasLegacy &&
!detected.pluginBindingApprovals.hasLegacy &&
!detected.currentConversationBindings.hasLegacy &&
!detected.workspace.hasLegacy &&
!detected.channelPairing.hasLegacy
) {
const changes = [

View File

@@ -2,6 +2,7 @@ import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.
import type { SessionScope } from "../config/sessions/types.js";
import type { PluginDoctorStateMigration } from "../plugins/doctor-contract-registry.js";
import type { LegacyChannelPairingStateDetection } from "./state-migrations.channel-pairing.js";
import type { LegacyWorkspaceStateDetection } from "./state-migrations.workspace-setup.types.js";
export type LegacyRescuePendingDetection = {
sourcePaths: string[];
@@ -108,6 +109,7 @@ export type LegacyStateDetection = {
sourcePath: string;
hasLegacy: boolean;
};
workspace: LegacyWorkspaceStateDetection;
webPush: {
subscriptionsPath: string;
vapidKeysPath: string;

View File

@@ -0,0 +1,63 @@
// Receipt lookup and source-removal bookkeeping for legacy workspace migration.
import { createHash } from "node:crypto";
import path from "node:path";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
} from "../state/openclaw-state-db.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "./kysely-sync.js";
import type { LegacyWorkspaceStateSource } from "./state-migrations.workspace-setup.types.js";
type WorkspaceReceiptDatabase = Pick<OpenClawStateKyselyDatabase, "migration_sources">;
export type MigrationReceipt = {
sourceKey: string;
sha256: string | null;
removedSource: boolean;
};
export function resolveWorkspaceMigrationSourceKey(source: LegacyWorkspaceStateSource): string {
return `workspace-${source.kind}:${createHash("sha256")
.update(source.workspaceKey)
.update("\0")
.update(path.resolve(source.sourcePath))
.digest("hex")}`;
}
export function readReceipt(
source: LegacyWorkspaceStateSource,
env: NodeJS.ProcessEnv,
): MigrationReceipt | null {
const key = resolveWorkspaceMigrationSourceKey(source);
const { db } = openOpenClawStateDatabase({ env });
const row = executeSqliteQueryTakeFirstSync(
db,
getNodeSqliteKysely<WorkspaceReceiptDatabase>(db)
.selectFrom("migration_sources")
.select(["source_sha256", "removed_source"])
.where("source_key", "=", key),
);
return row
? { sourceKey: key, sha256: row.source_sha256, removedSource: row.removed_source === 1 }
: null;
}
export function markSourceRemoved(sourceKey: string, env: NodeJS.ProcessEnv): void {
runOpenClawStateWriteTransaction(
({ db }) => {
executeSqliteQuerySync(
db,
getNodeSqliteKysely<WorkspaceReceiptDatabase>(db)
.updateTable("migration_sources")
.set({ removed_source: 1 })
.where("source_key", "=", sourceKey),
);
},
{ env },
);
}

View File

@@ -0,0 +1,692 @@
// SQLite import and receipt semantics for retired workspace state.
import { createHash } from "node:crypto";
import { LEGACY_WORKSPACE_ATTESTATION_HEADER } from "../agents/workspace-legacy-state.js";
import {
WORKSPACE_ATTESTED_BOOTSTRAP_FILENAMES,
WORKSPACE_LEGACY_STATE_MIGRATION_KIND,
WORKSPACE_SETUP_STATE_VERSION,
registerWorkspaceStateAliasesInTransaction,
} from "../agents/workspace-state-store.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
} from "../state/openclaw-state-db.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "./kysely-sync.js";
import { runSqliteDeferredTransactionSync } from "./sqlite-transaction.js";
import { resolveWorkspaceMigrationSourceKey } from "./state-migrations.workspace-setup-receipts.js";
import type { LegacyWorkspaceStateSource } from "./state-migrations.workspace-setup.types.js";
const MIGRATION_KIND = WORKSPACE_LEGACY_STATE_MIGRATION_KIND;
type WorkspaceMigrationDatabase = Pick<
OpenClawStateKyselyDatabase,
| "workspace_setup_state"
| "workspace_path_aliases"
| "workspace_attestations"
| "workspace_generated_bootstrap_hashes"
| "migration_runs"
| "migration_sources"
>;
export type SourceSnapshot = {
sourcePath: string;
dev: number;
ino: number;
mtimeMs: number;
sha256: string;
size: number;
raw: string;
};
type ParsedSetup = {
bootstrapSeededAt?: string;
setupCompletedAt?: string;
};
type ParsedAttestation = {
attestedAtMs: number;
generatedHashes: Map<string, string>;
};
export type ParsedSource =
| { kind: "setup"; value: ParsedSetup; recordCount: number }
| { kind: "attestation"; value: ParsedAttestation; recordCount: number };
function parseIsoTimestamp(value: unknown, field: string): string | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== "string" || value.length === 0) {
throw new Error(`legacy workspace setup ${field} is invalid`);
}
const parsed = new Date(value);
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== value) {
throw new Error(`legacy workspace setup ${field} is invalid`);
}
return value;
}
function parseSetup(raw: string): ParsedSource {
let value: unknown;
try {
value = JSON.parse(raw);
} catch {
throw new Error("legacy workspace setup contains invalid JSON");
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("legacy workspace setup is not an object");
}
const record = value as Record<string, unknown>;
const allowed = new Set([
"version",
"bootstrapSeededAt",
"setupCompletedAt",
"onboardingCompletedAt",
]);
if (Object.keys(record).some((key) => !allowed.has(key))) {
throw new Error("legacy workspace setup has an unexpected field");
}
if (record.version !== undefined && record.version !== WORKSPACE_SETUP_STATE_VERSION) {
throw new Error("legacy workspace setup has an unsupported version");
}
const bootstrapSeededAt = parseIsoTimestamp(record.bootstrapSeededAt, "bootstrap timestamp");
const setupCompletedAt = parseIsoTimestamp(record.setupCompletedAt, "completion timestamp");
const onboardingCompletedAt = parseIsoTimestamp(
record.onboardingCompletedAt,
"legacy completion timestamp",
);
if (setupCompletedAt && onboardingCompletedAt && setupCompletedAt !== onboardingCompletedAt) {
throw new Error("legacy workspace setup has conflicting completion timestamps");
}
const parsed = {
...(bootstrapSeededAt ? { bootstrapSeededAt } : {}),
...((setupCompletedAt ?? onboardingCompletedAt)
? { setupCompletedAt: setupCompletedAt ?? onboardingCompletedAt }
: {}),
};
return {
kind: "setup",
value: parsed,
recordCount:
Number(Boolean(parsed.bootstrapSeededAt)) + Number(Boolean(parsed.setupCompletedAt)),
};
}
function parseAttestation(snapshot: SourceSnapshot): ParsedSource {
const lines = snapshot.raw.split(/\r?\n/);
if (lines.at(-1) === "") {
lines.pop();
}
if (lines[0] !== LEGACY_WORKSPACE_ATTESTATION_HEADER || lines.length < 2) {
throw new Error("legacy workspace attestation has an invalid header");
}
parseIsoTimestamp(lines[1], "attestation timestamp");
const generatedHashes = new Map<string, string>();
for (const line of lines.slice(2)) {
const match = /^generated:([^:]+):([a-f0-9]{64})$/.exec(line);
if (!match?.[1] || !match[2] || !WORKSPACE_ATTESTED_BOOTSTRAP_FILENAMES.has(match[1])) {
throw new Error("legacy workspace attestation has an invalid generated hash");
}
if (generatedHashes.has(match[1])) {
throw new Error("legacy workspace attestation has a duplicate generated hash");
}
generatedHashes.set(match[1], match[2]);
}
const attestedAtMs = Math.trunc(snapshot.mtimeMs);
if (!Number.isSafeInteger(attestedAtMs) || attestedAtMs < 0) {
throw new Error("legacy workspace attestation has an invalid modification time");
}
return {
kind: "attestation",
value: { attestedAtMs, generatedHashes },
recordCount: 1 + generatedHashes.size,
};
}
export function parseSource(
source: LegacyWorkspaceStateSource,
snapshot: SourceSnapshot,
): ParsedSource {
return source.kind === "setup" ? parseSetup(snapshot.raw) : parseAttestation(snapshot);
}
function mapsEqual(left: ReadonlyMap<string, string>, right: ReadonlyMap<string, string>): boolean {
if (left.size !== right.size) {
return false;
}
for (const [key, value] of left) {
if (right.get(key) !== value) {
return false;
}
}
return true;
}
function canonicalFingerprint(value: unknown): string {
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
}
function setupFingerprint(params: {
workspacePath: string;
bootstrapSeededAt: string | null;
setupCompletedAt: string | null;
}): string {
return canonicalFingerprint({
kind: "setup",
workspacePath: params.workspacePath,
version: WORKSPACE_SETUP_STATE_VERSION,
bootstrapSeededAt: params.bootstrapSeededAt,
setupCompletedAt: params.setupCompletedAt,
});
}
function attestationFingerprint(params: {
attestedAtMs: number;
generatedHashes: ReadonlyMap<string, string>;
}): string {
return canonicalFingerprint({
kind: "attestation",
attestedAtMs: params.attestedAtMs,
generatedHashes: [...params.generatedHashes.entries()].toSorted(([left], [right]) =>
left.localeCompare(right),
),
});
}
function findMigrationAuthority(params: {
db: ReturnType<typeof openOpenClawStateDatabase>["db"];
kysely: ReturnType<typeof getNodeSqliteKysely<WorkspaceMigrationDatabase>>;
source: LegacyWorkspaceStateSource;
fingerprint: string;
}): { priority: number } | null {
const rows = executeSqliteQuerySync(
params.db,
params.kysely
.selectFrom("migration_sources")
.select("report_json")
.where("migration_kind", "=", MIGRATION_KIND)
.where(
"target_table",
"=",
params.source.kind === "setup" ? "workspace_setup_state" : "workspace_attestations",
),
).rows;
let bestPriority: number | null = null;
for (const row of rows) {
if (!row.report_json) {
continue;
}
try {
const report = JSON.parse(row.report_json) as Record<string, unknown>;
if (
report.workspaceKey !== params.source.workspaceKey ||
report.sourceKind !== params.source.kind ||
report.canonicalFingerprint !== params.fingerprint ||
report.authoritative !== true ||
typeof report.sourcePriority !== "number" ||
!Number.isSafeInteger(report.sourcePriority) ||
report.sourcePriority < 0
) {
continue;
}
bestPriority =
bestPriority === null
? report.sourcePriority
: Math.min(bestPriority, report.sourcePriority);
} catch {
// Ignore unrelated or older migration reports without authority metadata.
}
}
return bestPriority === null ? null : { priority: bestPriority };
}
export function canonicalCoversParsedSource(params: {
source: LegacyWorkspaceStateSource;
parsed: ParsedSource;
env: NodeJS.ProcessEnv;
}): boolean {
const { db } = openOpenClawStateDatabase({ env: params.env });
return runSqliteDeferredTransactionSync(db, () => {
const kysely = getNodeSqliteKysely<WorkspaceMigrationDatabase>(db);
if (params.source.kind === "setup" && params.parsed.kind === "setup") {
if (!params.source.workspaceDir) {
return false;
}
const row = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("workspace_setup_state")
.selectAll()
.where("workspace_key", "=", params.source.workspaceKey),
);
if (
!row ||
row.workspace_path !== params.source.workspaceDir ||
row.version !== WORKSPACE_SETUP_STATE_VERSION
) {
return false;
}
const fingerprint = setupFingerprint({
workspacePath: row.workspace_path,
bootstrapSeededAt: row.bootstrap_seeded_at,
setupCompletedAt: row.setup_completed_at,
});
const sourceBootstrapSeededAt = params.parsed.value.bootstrapSeededAt ?? null;
const sourceSetupCompletedAt = params.parsed.value.setupCompletedAt ?? null;
const coversSource =
(sourceBootstrapSeededAt === null || row.bootstrap_seeded_at === sourceBootstrapSeededAt) &&
(sourceSetupCompletedAt === null || row.setup_completed_at === sourceSetupCompletedAt);
const authority = findMigrationAuthority({ db, kysely, source: params.source, fingerprint });
return coversSource || Boolean(authority && authority.priority <= params.source.priority);
}
if (params.source.kind !== "attestation" || params.parsed.kind !== "attestation") {
return false;
}
const row = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("workspace_attestations")
.select("attested_at_ms")
.where("workspace_key", "=", params.source.workspaceKey),
);
if (!row) {
return false;
}
if (row.attested_at_ms > params.parsed.value.attestedAtMs) {
return true;
}
if (row.attested_at_ms < params.parsed.value.attestedAtMs) {
return false;
}
const hashes = new Map(
executeSqliteQuerySync(
db,
kysely
.selectFrom("workspace_generated_bootstrap_hashes")
.select(["filename", "sha256"])
.where("workspace_key", "=", params.source.workspaceKey),
).rows.map((hashRow) => [hashRow.filename, hashRow.sha256]),
);
if (mapsEqual(hashes, params.parsed.value.generatedHashes)) {
return true;
}
const fingerprint = attestationFingerprint({
attestedAtMs: row.attested_at_ms,
generatedHashes: hashes,
});
const authority = findMigrationAuthority({ db, kysely, source: params.source, fingerprint });
return Boolean(authority && authority.priority <= params.source.priority);
});
}
export function importAndRecordReceipt(params: {
source: LegacyWorkspaceStateSource;
snapshot: SourceSnapshot;
parsed: ParsedSource;
env: NodeJS.ProcessEnv;
}): { sourceKey: string; imported: boolean } {
const key = resolveWorkspaceMigrationSourceKey(params.source);
const runId = `${key}:${params.snapshot.sha256.slice(0, 16)}`;
const now = Date.now();
return runOpenClawStateWriteTransaction(
(database) => {
const { db } = database;
const kysely = getNodeSqliteKysely<WorkspaceMigrationDatabase>(db);
const existingReceipt = executeSqliteQueryTakeFirstSync(
db,
kysely.selectFrom("migration_sources").select("source_key").where("source_key", "=", key),
);
if (existingReceipt) {
throw new Error("workspace migration receipt appeared concurrently; retry Doctor");
}
let imported = false;
let resolution: "inserted" | "verified" | "merged" | "replaced" | "superseded";
let verifiedFingerprint: string;
if (params.parsed.kind === "setup") {
if (!params.source.workspaceDir) {
throw new Error("legacy workspace setup has no workspace path");
}
const incomingFingerprint = setupFingerprint({
workspacePath: params.source.workspaceDir,
bootstrapSeededAt: params.parsed.value.bootstrapSeededAt ?? null,
setupCompletedAt: params.parsed.value.setupCompletedAt ?? null,
});
const existing = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("workspace_setup_state")
.selectAll()
.where("workspace_key", "=", params.source.workspaceKey),
);
if (existing) {
if (
existing.workspace_path !== params.source.workspaceDir ||
existing.version !== WORKSPACE_SETUP_STATE_VERSION
) {
throw new Error("legacy workspace setup conflicts with canonical SQLite state");
}
const existingFingerprint = setupFingerprint({
workspacePath: existing.workspace_path,
bootstrapSeededAt: existing.bootstrap_seeded_at,
setupCompletedAt: existing.setup_completed_at,
});
const sourceBootstrapSeededAt = params.parsed.value.bootstrapSeededAt ?? null;
const sourceSetupCompletedAt = params.parsed.value.setupCompletedAt ?? null;
const coversSource =
(sourceBootstrapSeededAt === null ||
existing.bootstrap_seeded_at === sourceBootstrapSeededAt) &&
(sourceSetupCompletedAt === null ||
existing.setup_completed_at === sourceSetupCompletedAt);
const authority = findMigrationAuthority({
db,
kysely,
source: params.source,
fingerprint: existingFingerprint,
});
if (authority && params.source.priority < authority.priority) {
executeSqliteQuerySync(
db,
kysely
.updateTable("workspace_setup_state")
.set({
bootstrap_seeded_at: sourceBootstrapSeededAt,
setup_completed_at: sourceSetupCompletedAt,
updated_at: now,
})
.where("workspace_key", "=", params.source.workspaceKey),
);
imported = true;
resolution = "replaced";
verifiedFingerprint = incomingFingerprint;
} else if (coversSource) {
resolution = "verified";
verifiedFingerprint = existingFingerprint;
} else if (!authority) {
const mergedBootstrapSeededAt = existing.bootstrap_seeded_at ?? sourceBootstrapSeededAt;
const mergedSetupCompletedAt = existing.setup_completed_at ?? sourceSetupCompletedAt;
const hasConflictingMilestone =
(sourceBootstrapSeededAt !== null &&
existing.bootstrap_seeded_at !== null &&
sourceBootstrapSeededAt !== existing.bootstrap_seeded_at) ||
(sourceSetupCompletedAt !== null &&
existing.setup_completed_at !== null &&
sourceSetupCompletedAt !== existing.setup_completed_at);
if (hasConflictingMilestone) {
throw new Error("legacy workspace setup conflicts with canonical SQLite state");
}
executeSqliteQuerySync(
db,
kysely
.updateTable("workspace_setup_state")
.set({
bootstrap_seeded_at: mergedBootstrapSeededAt,
setup_completed_at: mergedSetupCompletedAt,
updated_at: now,
})
.where("workspace_key", "=", params.source.workspaceKey),
);
imported = true;
resolution = "merged";
verifiedFingerprint = setupFingerprint({
workspacePath: existing.workspace_path,
bootstrapSeededAt: mergedBootstrapSeededAt,
setupCompletedAt: mergedSetupCompletedAt,
});
} else {
resolution = "superseded";
verifiedFingerprint = existingFingerprint;
}
} else {
executeSqliteQuerySync(
db,
kysely.insertInto("workspace_setup_state").values({
workspace_key: params.source.workspaceKey,
workspace_path: params.source.workspaceDir,
version: WORKSPACE_SETUP_STATE_VERSION,
bootstrap_seeded_at: params.parsed.value.bootstrapSeededAt ?? null,
setup_completed_at: params.parsed.value.setupCompletedAt ?? null,
updated_at: now,
}),
);
imported = true;
resolution = "inserted";
verifiedFingerprint = incomingFingerprint;
}
const verified = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("workspace_setup_state")
.selectAll()
.where("workspace_key", "=", params.source.workspaceKey),
);
const actualFingerprint = verified
? setupFingerprint({
workspacePath: verified.workspace_path,
bootstrapSeededAt: verified.bootstrap_seeded_at,
setupCompletedAt: verified.setup_completed_at,
})
: null;
if (!verified || actualFingerprint !== verifiedFingerprint) {
throw new Error("SQLite verification failed for workspace setup state");
}
} else {
const parsedAttestation = params.parsed.value;
const incomingFingerprint = attestationFingerprint({
attestedAtMs: parsedAttestation.attestedAtMs,
generatedHashes: parsedAttestation.generatedHashes,
});
const existing = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("workspace_attestations")
.selectAll()
.where("workspace_key", "=", params.source.workspaceKey),
);
if (existing) {
const rows = executeSqliteQuerySync(
db,
kysely
.selectFrom("workspace_generated_bootstrap_hashes")
.select(["filename", "sha256"])
.where("workspace_key", "=", params.source.workspaceKey),
).rows;
const existingHashes = new Map(rows.map((row) => [row.filename, row.sha256]));
const existingFingerprint = attestationFingerprint({
attestedAtMs: existing.attested_at_ms,
generatedHashes: existingHashes,
});
const replaceExistingAttestation = () => {
executeSqliteQuerySync(
db,
kysely
.updateTable("workspace_attestations")
.set({
attested_at_ms: parsedAttestation.attestedAtMs,
updated_at_ms: now,
})
.where("workspace_key", "=", params.source.workspaceKey),
);
executeSqliteQuerySync(
db,
kysely
.deleteFrom("workspace_generated_bootstrap_hashes")
.where("workspace_key", "=", params.source.workspaceKey),
);
const replacementHashes = [...parsedAttestation.generatedHashes.entries()].toSorted(
([left], [right]) => left.localeCompare(right),
);
if (replacementHashes.length > 0) {
executeSqliteQuerySync(
db,
kysely.insertInto("workspace_generated_bootstrap_hashes").values(
replacementHashes.map(([filename, sha256]) => ({
workspace_key: params.source.workspaceKey,
filename,
sha256,
})),
),
);
}
};
const equivalent =
existing.attested_at_ms === parsedAttestation.attestedAtMs &&
mapsEqual(existingHashes, parsedAttestation.generatedHashes);
if (equivalent) {
resolution = "verified";
verifiedFingerprint = existingFingerprint;
} else if (existing.attested_at_ms > parsedAttestation.attestedAtMs) {
resolution = "superseded";
verifiedFingerprint = existingFingerprint;
} else if (existing.attested_at_ms === parsedAttestation.attestedAtMs) {
const authority = findMigrationAuthority({
db,
kysely,
source: params.source,
fingerprint: existingFingerprint,
});
if (!authority) {
throw new Error("legacy workspace attestation conflicts with canonical SQLite state");
}
if (params.source.priority < authority.priority) {
// Equal-time markers use source priority only when migration receipts
// prove which whole snapshot won; hashes are never merged.
replaceExistingAttestation();
imported = true;
resolution = "replaced";
verifiedFingerprint = incomingFingerprint;
} else {
resolution = "superseded";
verifiedFingerprint = existingFingerprint;
}
} else {
replaceExistingAttestation();
imported = true;
resolution = "replaced";
verifiedFingerprint = incomingFingerprint;
}
} else {
executeSqliteQuerySync(
db,
kysely.insertInto("workspace_attestations").values({
workspace_key: params.source.workspaceKey,
attested_at_ms: parsedAttestation.attestedAtMs,
updated_at_ms: now,
}),
);
const hashes = [...parsedAttestation.generatedHashes.entries()].toSorted(([a], [b]) =>
a.localeCompare(b),
);
if (hashes.length > 0) {
executeSqliteQuerySync(
db,
kysely.insertInto("workspace_generated_bootstrap_hashes").values(
hashes.map(([filename, sha256]) => ({
workspace_key: params.source.workspaceKey,
filename,
sha256,
})),
),
);
}
imported = true;
resolution = "inserted";
verifiedFingerprint = incomingFingerprint;
}
const verified = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("workspace_attestations")
.select("attested_at_ms")
.where("workspace_key", "=", params.source.workspaceKey),
);
const verifiedHashes = new Map(
executeSqliteQuerySync(
db,
kysely
.selectFrom("workspace_generated_bootstrap_hashes")
.select(["filename", "sha256"])
.where("workspace_key", "=", params.source.workspaceKey),
).rows.map((row) => [row.filename, row.sha256]),
);
const actualFingerprint = verified
? attestationFingerprint({
attestedAtMs: verified.attested_at_ms,
generatedHashes: verifiedHashes,
})
: null;
if (!verified || actualFingerprint !== verifiedFingerprint) {
throw new Error("SQLite verification failed for workspace attestation state");
}
}
if (params.source.workspaceDir) {
registerWorkspaceStateAliasesInTransaction({
database,
workspaceDirs: [
params.source.workspaceDir,
params.source.workspaceAliasPath ?? params.source.workspaceDir,
],
identity: {
workspaceKey: params.source.workspaceKey,
workspacePath: params.source.workspaceDir,
},
updatedAtMs: now,
});
}
const targetTable =
params.parsed.kind === "setup" ? "workspace_setup_state" : "workspace_attestations";
const reportJson = JSON.stringify({
source: MIGRATION_KIND,
sourceKind: params.parsed.kind,
target: targetTable,
workspaceKey: params.source.workspaceKey,
sourceSha256: params.snapshot.sha256,
sourceRecordCount: params.parsed.recordCount,
sourcePriority: params.source.priority,
canonicalFingerprint: verifiedFingerprint,
// Only a whole-source insert or precedence replacement can establish
// authority. Verification and complementary merges may cover cleanup,
// but must not let a legacy source overwrite unrelated canonical data.
authoritative: resolution === "inserted" || resolution === "replaced",
resolution,
imported,
});
executeSqliteQuerySync(
db,
kysely.insertInto("migration_runs").values({
id: runId,
started_at: now,
finished_at: now,
status: "completed",
report_json: reportJson,
}),
);
executeSqliteQuerySync(
db,
kysely.insertInto("migration_sources").values({
source_key: key,
migration_kind: MIGRATION_KIND,
source_path: params.source.sourcePath,
target_table: targetTable,
source_sha256: params.snapshot.sha256,
source_size_bytes: params.snapshot.size,
source_record_count: params.parsed.recordCount,
last_run_id: runId,
status: "completed",
imported_at: now,
removed_source: 0,
report_json: reportJson,
}),
);
return { sourceKey: key, imported };
},
{ env: params.env },
);
}

View File

@@ -0,0 +1,964 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import {
deleteWorkspaceState,
prepareWorkspaceStateDeletion,
readWorkspaceStateSnapshot,
resolveWorkspaceStateIdentity,
} from "../agents/workspace-state-store.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../state/openclaw-state-db.js";
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
import {
detectLegacyWorkspaceState,
migrateLegacyWorkspaceState,
} from "./state-migrations.workspace-setup.js";
const HASH = "a".repeat(64);
describe("legacy workspace Doctor migration", () => {
let envSnapshot: ReturnType<typeof captureEnv> | undefined;
const tempDirs = useAutoCleanupTempDirTracker((cleanup) => {
afterEach(() => {
closeOpenClawStateDatabaseForTest();
envSnapshot?.restore();
envSnapshot = undefined;
cleanup();
});
});
function setup() {
const homeDir = tempDirs.make("openclaw-workspace-migration-home-");
const stateDir = path.join(homeDir, ".openclaw");
const workspaceDir = path.join(homeDir, "workspace");
fs.mkdirSync(workspaceDir, { recursive: true });
envSnapshot ??= captureEnv(["HOME", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
setTestEnvValue("HOME", homeDir);
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
const cfg = {
agents: { defaults: { workspace: workspaceDir } },
} satisfies OpenClawConfig;
return {
cfg,
env: { ...process.env, HOME: homeDir, OPENCLAW_STATE_DIR: stateDir },
homeDir,
stateDir,
workspaceDir,
};
}
function detect(context: ReturnType<typeof setup>) {
return detectLegacyWorkspaceState({
cfg: context.cfg,
stateDir: context.stateDir,
env: context.env,
homedir: () => context.homeDir,
doctorOnlyStateMigrations: true,
});
}
async function migrate(context: ReturnType<typeof setup>) {
return await migrateLegacyWorkspaceState({
detected: detect(context),
env: context.env,
stateDir: context.stateDir,
});
}
it("detects configured and orphan sources only for explicit Doctor repair", async () => {
const context = setup();
const setupPath = path.join(context.workspaceDir, "openclaw-workspace-state.json");
const canonicalSetupPath = path.join(
resolveWorkspaceStateIdentity(context.workspaceDir).workspacePath,
"openclaw-workspace-state.json",
);
await fsp.writeFile(setupPath, JSON.stringify({ version: 1 }), "utf8");
const orphanKey = "b".repeat(64);
const orphanPath = path.join(
context.stateDir,
"workspace-attestations",
`${orphanKey}.attested`,
);
await fsp.mkdir(path.dirname(orphanPath), { recursive: true });
await fsp.writeFile(
orphanPath,
"openclaw-workspace-attestation:v1\n2026-07-16T00:00:00.000Z\n",
"utf8",
);
expect(
detectLegacyWorkspaceState({
cfg: context.cfg,
stateDir: context.stateDir,
env: context.env,
homedir: () => context.homeDir,
}),
).toEqual({ sources: [], hasLegacy: false });
expect(detect(context)).toMatchObject({
hasLegacy: true,
sources: expect.arrayContaining([
expect.objectContaining({ kind: "setup", sourcePath: canonicalSetupPath }),
expect.objectContaining({ kind: "attestation", workspaceKey: orphanKey }),
]),
});
});
it("imports setup and attestation state, records receipts, and removes files", async () => {
const context = setup();
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
const seededAt = "2026-07-15T10:00:00.000Z";
const completedAt = "2026-07-15T10:01:00.000Z";
const setupPath = path.join(context.workspaceDir, "openclaw-workspace-state.json");
await fsp.writeFile(
setupPath,
JSON.stringify({ version: 1, bootstrapSeededAt: seededAt, setupCompletedAt: completedAt }),
"utf8",
);
const attestationPath = path.join(
context.stateDir,
"workspace-attestations",
`${identity.workspaceKey}.attested`,
);
await fsp.mkdir(path.dirname(attestationPath), { recursive: true });
await fsp.writeFile(
attestationPath,
`openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\ngenerated:AGENTS.md:${HASH}\n`,
"utf8",
);
const mtime = new Date("2026-07-15T11:02:03.456Z");
await fsp.utimes(attestationPath, mtime, mtime);
const result = await migrate(context);
expect(result.warnings).toEqual([]);
expect(result.changes).toHaveLength(2);
expect(fs.existsSync(setupPath)).toBe(false);
expect(fs.existsSync(attestationPath)).toBe(false);
const db = openOpenClawStateDatabase({ env: context.env }).db;
expect(
db
.prepare(
"SELECT workspace_path, bootstrap_seeded_at, setup_completed_at FROM workspace_setup_state WHERE workspace_key = ?",
)
.get(identity.workspaceKey),
).toEqual({
workspace_path: identity.workspacePath,
bootstrap_seeded_at: seededAt,
setup_completed_at: completedAt,
});
expect(
db
.prepare("SELECT attested_at_ms FROM workspace_attestations WHERE workspace_key = ?")
.get(identity.workspaceKey),
).toEqual({ attested_at_ms: mtime.getTime() });
expect(
db
.prepare(
"SELECT filename, sha256 FROM workspace_generated_bootstrap_hashes WHERE workspace_key = ?",
)
.get(identity.workspaceKey),
).toEqual({ filename: "AGENTS.md", sha256: HASH });
expect(
db
.prepare(
"SELECT COUNT(*) AS count, SUM(removed_source) AS removed FROM migration_sources WHERE migration_kind = ?",
)
.get("legacy-workspace-setup-files"),
).toEqual({ count: 2, removed: 2 });
});
it("imports the legacy onboarding completion alias", async () => {
const context = setup();
const completedAt = "2026-07-15T10:01:00.000Z";
const setupPath = path.join(context.workspaceDir, ".openclaw", "workspace-state.json");
await fsp.mkdir(path.dirname(setupPath), { recursive: true });
await fsp.writeFile(setupPath, JSON.stringify({ onboardingCompletedAt: completedAt }), "utf8");
const result = await migrate(context);
expect(result.warnings).toEqual([]);
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
expect(
openOpenClawStateDatabase({ env: context.env })
.db.prepare("SELECT setup_completed_at FROM workspace_setup_state WHERE workspace_key = ?")
.get(identity.workspaceKey),
).toEqual({ setup_completed_at: completedAt });
});
it("persists the configured symlink alias during Doctor import", async () => {
const context = setup();
const workspaceAlias = path.join(context.homeDir, "workspace-link");
fs.symlinkSync(
context.workspaceDir,
workspaceAlias,
process.platform === "win32" ? "junction" : "dir",
);
const aliasContext = {
...context,
cfg: { agents: { defaults: { workspace: workspaceAlias } } } satisfies OpenClawConfig,
workspaceDir: workspaceAlias,
};
const completedAt = "2026-07-15T10:01:00.000Z";
await fsp.writeFile(
path.join(workspaceAlias, "openclaw-workspace-state.json"),
JSON.stringify({ setupCompletedAt: completedAt }),
"utf8",
);
const canonicalSiblingPath = `${resolveWorkspaceStateIdentity(context.workspaceDir).workspacePath}.attested`;
await fsp.writeFile(
canonicalSiblingPath,
`openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n`,
"utf8",
);
expect((await migrate(aliasContext)).warnings).toEqual([]);
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
expect(fs.existsSync(canonicalSiblingPath)).toBe(false);
fs.unlinkSync(workspaceAlias);
expect(readWorkspaceStateSnapshot(workspaceAlias)).toMatchObject({
identity,
setup: { setupCompletedAt: completedAt },
});
});
it("preserves configured metadata when orphan discovery finds the same marker", async () => {
const context = setup();
const workspaceAlias = path.join(context.homeDir, "workspace-link");
fs.symlinkSync(
context.workspaceDir,
workspaceAlias,
process.platform === "win32" ? "junction" : "dir",
);
const aliasContext = {
...context,
cfg: { agents: { defaults: { workspace: workspaceAlias } } } satisfies OpenClawConfig,
workspaceDir: workspaceAlias,
};
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
const attestationPath = path.join(
context.stateDir,
"workspace-attestations",
`${identity.workspaceKey}.attested`,
);
await fsp.mkdir(path.dirname(attestationPath), { recursive: true });
await fsp.writeFile(
attestationPath,
"openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n",
"utf8",
);
const attestedAt = new Date("2026-07-15T11:00:00.000Z");
await fsp.utimes(attestationPath, attestedAt, attestedAt);
const detected = detect(aliasContext);
expect(detected.sources.find((source) => source.sourcePath === attestationPath)).toMatchObject({
workspaceDir: identity.workspacePath,
workspaceAliasPath: path.resolve(workspaceAlias),
priority: 1,
});
expect((await migrate(aliasContext)).warnings).toEqual([]);
fs.unlinkSync(workspaceAlias);
expect(readWorkspaceStateSnapshot(workspaceAlias)).toMatchObject({
identity,
attestation: { attestedAtMs: attestedAt.getTime() },
});
});
it("isolates receipts when a configured alias is repointed", async () => {
const context = setup();
const targetB = path.join(context.homeDir, "workspace-b");
const workspaceAlias = path.join(context.homeDir, "workspace-link");
fs.mkdirSync(targetB, { recursive: true });
fs.symlinkSync(
context.workspaceDir,
workspaceAlias,
process.platform === "win32" ? "junction" : "dir",
);
const aliasContext = {
...context,
cfg: { agents: { defaults: { workspace: workspaceAlias } } } satisfies OpenClawConfig,
workspaceDir: workspaceAlias,
};
const sourcePath = `${workspaceAlias}.attested`;
const identityA = resolveWorkspaceStateIdentity(context.workspaceDir);
await fsp.writeFile(
sourcePath,
"openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n",
"utf8",
);
const attestedAtA = new Date("2026-07-15T11:00:00.000Z");
await fsp.utimes(sourcePath, attestedAtA, attestedAtA);
expect((await migrate(aliasContext)).warnings).toEqual([]);
fs.unlinkSync(workspaceAlias);
fs.symlinkSync(targetB, workspaceAlias, process.platform === "win32" ? "junction" : "dir");
deleteWorkspaceState(prepareWorkspaceStateDeletion(workspaceAlias));
const identityB = resolveWorkspaceStateIdentity(targetB);
await fsp.writeFile(
sourcePath,
"openclaw-workspace-attestation:v1\n2026-07-15T12:00:00.000Z\n",
"utf8",
);
const attestedAtB = new Date("2026-07-15T12:00:00.000Z");
await fsp.utimes(sourcePath, attestedAtB, attestedAtB);
expect((await migrate(aliasContext)).warnings).toEqual([]);
const db = openOpenClawStateDatabase({ env: context.env }).db;
expect(
db
.prepare(
"SELECT workspace_key, attested_at_ms FROM workspace_attestations ORDER BY workspace_key",
)
.all(),
).toEqual(
[
{
workspace_key: identityA.workspaceKey,
attested_at_ms: attestedAtA.getTime(),
},
{
workspace_key: identityB.workspaceKey,
attested_at_ms: attestedAtB.getTime(),
},
].toSorted((left, right) => left.workspace_key.localeCompare(right.workspace_key)),
);
expect(
db
.prepare("SELECT COUNT(*) AS count FROM migration_sources WHERE source_path = ?")
.get(sourcePath),
).toEqual({ count: 2 });
});
it("rejects a configured workspace identity change before claiming a source", async () => {
const context = setup();
const targetB = path.join(context.homeDir, "workspace-b");
const workspaceAlias = path.join(context.homeDir, "workspace-link");
fs.mkdirSync(targetB, { recursive: true });
fs.symlinkSync(
context.workspaceDir,
workspaceAlias,
process.platform === "win32" ? "junction" : "dir",
);
const aliasContext = {
...context,
cfg: { agents: { defaults: { workspace: workspaceAlias } } } satisfies OpenClawConfig,
workspaceDir: workspaceAlias,
};
const identityA = resolveWorkspaceStateIdentity(context.workspaceDir);
const attestationPath = path.join(
context.stateDir,
"workspace-attestations",
`${identityA.workspaceKey}.attested`,
);
await fsp.mkdir(path.dirname(attestationPath), { recursive: true });
await fsp.writeFile(
attestationPath,
"openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n",
"utf8",
);
const detected = detect(aliasContext);
const result = await migrateLegacyWorkspaceState({
detected,
env: context.env,
stateDir: context.stateDir,
beforeClaim: () => {
fs.unlinkSync(workspaceAlias);
fs.symlinkSync(targetB, workspaceAlias, process.platform === "win32" ? "junction" : "dir");
},
});
expect(result.warnings[0]).toContain("configured workspace identity changed");
expect(fs.existsSync(attestationPath)).toBe(true);
expect(fs.existsSync(`${attestationPath}.doctor-importing`)).toBe(false);
const db = openOpenClawStateDatabase({ env: context.env }).db;
expect(db.prepare("SELECT COUNT(*) AS count FROM workspace_attestations").get()).toEqual({
count: 0,
});
expect(db.prepare("SELECT COUNT(*) AS count FROM migration_sources").get()).toEqual({
count: 0,
});
expect(db.prepare("SELECT COUNT(*) AS count FROM workspace_path_aliases").get()).toEqual({
count: 0,
});
});
it("removes a stale nested setup marker after the root marker wins", async () => {
const context = setup();
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
const rootPath = path.join(context.workspaceDir, "openclaw-workspace-state.json");
const nestedPath = path.join(context.workspaceDir, ".openclaw", "workspace-state.json");
const rootSeededAt = "2026-07-15T10:00:00.000Z";
const completedAt = "2026-07-15T10:01:00.000Z";
await fsp.mkdir(path.dirname(nestedPath), { recursive: true });
await fsp.writeFile(
rootPath,
JSON.stringify({
version: 1,
bootstrapSeededAt: rootSeededAt,
setupCompletedAt: completedAt,
}),
"utf8",
);
await fsp.writeFile(
nestedPath,
JSON.stringify({ version: 1, bootstrapSeededAt: "2026-07-14T09:00:00.000Z" }),
"utf8",
);
const result = await migrate(context);
expect(result.warnings).toEqual([]);
expect(fs.existsSync(rootPath)).toBe(false);
expect(fs.existsSync(nestedPath)).toBe(false);
expect(
openOpenClawStateDatabase({ env: context.env })
.db.prepare(
"SELECT bootstrap_seeded_at, setup_completed_at FROM workspace_setup_state WHERE workspace_key = ?",
)
.get(identity.workspaceKey),
).toEqual({ bootstrap_seeded_at: rootSeededAt, setup_completed_at: completedAt });
});
it("imports an orphan state-directory attestation by its hashed workspace key", async () => {
const context = setup();
const orphanKey = "c".repeat(64);
const attestationPath = path.join(
context.stateDir,
"workspace-attestations",
`${orphanKey}.attested`,
);
await fsp.mkdir(path.dirname(attestationPath), { recursive: true });
await fsp.writeFile(
attestationPath,
`openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\ngenerated:TOOLS.md:${HASH}\n`,
"utf8",
);
const result = await migrate(context);
expect(result.warnings).toEqual([]);
expect(
openOpenClawStateDatabase({ env: context.env })
.db.prepare(
"SELECT filename, sha256 FROM workspace_generated_bootstrap_hashes WHERE workspace_key = ?",
)
.get(orphanKey),
).toEqual({ filename: "TOOLS.md", sha256: HASH });
expect(fs.existsSync(attestationPath)).toBe(false);
});
it("imports an owned sibling attestation", async () => {
const context = setup();
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
const attestationPath = `${context.workspaceDir}.attested`;
await fsp.writeFile(
attestationPath,
`openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\ngenerated:USER.md:${HASH}\n`,
"utf8",
);
const result = await migrate(context);
expect(result.warnings).toEqual([]);
expect(
openOpenClawStateDatabase({ env: context.env })
.db.prepare(
"SELECT filename FROM workspace_generated_bootstrap_hashes WHERE workspace_key = ?",
)
.get(identity.workspaceKey),
).toEqual({ filename: "USER.md" });
expect(fs.existsSync(attestationPath)).toBe(false);
});
it("consolidates attestation paths using newest modification time", async () => {
const context = setup();
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
const currentPath = path.join(
context.stateDir,
"workspace-attestations",
`${identity.workspaceKey}.attested`,
);
const siblingPath = `${context.workspaceDir}.attested`;
await fsp.mkdir(path.dirname(currentPath), { recursive: true });
await fsp.writeFile(
currentPath,
`openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\ngenerated:AGENTS.md:${HASH}\n`,
"utf8",
);
await fsp.writeFile(
siblingPath,
`openclaw-workspace-attestation:v1\n2026-07-15T12:00:00.000Z\ngenerated:USER.md:${"b".repeat(64)}\n`,
"utf8",
);
const currentMtime = new Date("2026-07-15T11:00:00.000Z");
const siblingMtime = new Date("2026-07-15T12:00:00.000Z");
await fsp.utimes(currentPath, currentMtime, currentMtime);
await fsp.utimes(siblingPath, siblingMtime, siblingMtime);
const result = await migrate(context);
expect(result.warnings).toEqual([]);
expect(fs.existsSync(currentPath)).toBe(false);
expect(fs.existsSync(siblingPath)).toBe(false);
expect(
openOpenClawStateDatabase({ env: context.env })
.db.prepare(
"SELECT filename, sha256 FROM workspace_generated_bootstrap_hashes WHERE workspace_key = ?",
)
.all(identity.workspaceKey),
).toEqual([{ filename: "USER.md", sha256: "b".repeat(64) }]);
});
it("uses source priority for equal-time attestation snapshots", async () => {
const context = setup();
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
const currentPath = path.join(
context.stateDir,
"workspace-attestations",
`${identity.workspaceKey}.attested`,
);
const siblingPath = `${context.workspaceDir}.attested`;
await fsp.mkdir(path.dirname(currentPath), { recursive: true });
await fsp.writeFile(
currentPath,
`openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\ngenerated:AGENTS.md:${HASH}\n`,
"utf8",
);
await fsp.writeFile(
siblingPath,
`openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\ngenerated:USER.md:${"b".repeat(64)}\n`,
"utf8",
);
const sameMtime = new Date("2026-07-15T11:00:00.000Z");
await fsp.utimes(currentPath, sameMtime, sameMtime);
await fsp.utimes(siblingPath, sameMtime, sameMtime);
const result = await migrate(context);
expect(result.warnings).toEqual([]);
expect(
openOpenClawStateDatabase({ env: context.env })
.db.prepare(
"SELECT filename, sha256 FROM workspace_generated_bootstrap_hashes WHERE workspace_key = ?",
)
.all(identity.workspaceKey),
).toEqual([{ filename: "AGENTS.md", sha256: HASH }]);
});
it("lets a later higher-priority marker replace an equal-time attestation", async () => {
const context = setup();
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
const currentPath = path.join(
context.stateDir,
"workspace-attestations",
`${identity.workspaceKey}.attested`,
);
const siblingPath = `${context.workspaceDir}.attested`;
const sameMtime = new Date("2026-07-15T11:00:00.000Z");
await fsp.writeFile(
siblingPath,
`openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\ngenerated:USER.md:${"b".repeat(64)}\n`,
"utf8",
);
await fsp.utimes(siblingPath, sameMtime, sameMtime);
expect((await migrate(context)).warnings).toEqual([]);
await fsp.mkdir(path.dirname(currentPath), { recursive: true });
await fsp.writeFile(
currentPath,
`openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\ngenerated:AGENTS.md:${HASH}\n`,
"utf8",
);
await fsp.utimes(currentPath, sameMtime, sameMtime);
const result = await migrate(context);
expect(result.warnings).toEqual([]);
expect(fs.existsSync(currentPath)).toBe(false);
expect(
openOpenClawStateDatabase({ env: context.env })
.db.prepare(
"SELECT filename, sha256 FROM workspace_generated_bootstrap_hashes WHERE workspace_key = ?",
)
.all(identity.workspaceKey),
).toEqual([{ filename: "AGENTS.md", sha256: HASH }]);
});
it("resumes an interrupted unreceipted claim", async () => {
const context = setup();
const setupPath = path.join(context.workspaceDir, "openclaw-workspace-state.json");
const claimPath = `${setupPath}.doctor-importing`;
await fsp.writeFile(
setupPath,
JSON.stringify({ version: 1, setupCompletedAt: "2026-07-15T10:01:00.000Z" }),
"utf8",
);
await fsp.rename(setupPath, claimPath);
const result = await migrate(context);
expect(result.warnings).toEqual([]);
expect(fs.existsSync(claimPath)).toBe(false);
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
expect(
openOpenClawStateDatabase({ env: context.env })
.db.prepare("SELECT setup_completed_at FROM workspace_setup_state WHERE workspace_key = ?")
.get(identity.workspaceKey),
).toEqual({ setup_completed_at: "2026-07-15T10:01:00.000Z" });
});
it.each(["hardlink", "oversized"] as const)(
"detects but retains an owned %s sibling attestation",
async (kind) => {
const context = setup();
const attestationPath = `${context.workspaceDir}.attested`;
const content = `openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n${kind === "oversized" ? "x".repeat(3_000) : ""}`;
if (kind === "hardlink") {
const targetPath = path.join(context.homeDir, "attestation-target");
await fsp.writeFile(targetPath, content, "utf8");
await fsp.link(targetPath, attestationPath);
} else {
await fsp.writeFile(attestationPath, content, "utf8");
}
expect(detect(context).hasLegacy).toBe(true);
const result = await migrate(context);
expect(result.warnings[0]).toMatch(/legacy workspace/i);
expect(fs.existsSync(attestationPath)).toBe(true);
expect(fs.existsSync(`${attestationPath}.doctor-importing`)).toBe(false);
},
);
it.each([
"symlink",
"hardlink",
"invalid-json",
"invalid-attestation",
"oversized-attestation",
] as const)("rejects %s without changing canonical state", async (kind) => {
const context = setup();
const setupPath = path.join(context.workspaceDir, "openclaw-workspace-state.json");
let sourcePath = setupPath;
if (kind === "invalid-attestation" || kind === "oversized-attestation") {
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
const attestationPath = path.join(
context.stateDir,
"workspace-attestations",
`${identity.workspaceKey}.attested`,
);
await fsp.mkdir(path.dirname(attestationPath), { recursive: true });
sourcePath = attestationPath;
await fsp.writeFile(
attestationPath,
kind === "invalid-attestation"
? "openclaw-workspace-attestation:v1\nnot-a-date\n"
: `openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n${"x".repeat(3_000)}`,
"utf8",
);
} else {
const targetPath = path.join(context.workspaceDir, "target.json");
await fsp.writeFile(targetPath, JSON.stringify({ version: 1 }), "utf8");
if (kind === "symlink") {
await fsp.symlink(targetPath, setupPath);
} else if (kind === "hardlink") {
await fsp.link(targetPath, setupPath);
} else {
await fsp.writeFile(setupPath, "{invalid", "utf8");
}
}
const result = await migrate(context);
expect(result.warnings[0]).toMatch(/legacy workspace/i);
expect(fs.existsSync(sourcePath)).toBe(true);
expect(fs.existsSync(`${sourcePath}.doctor-importing`)).toBe(false);
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
expect(
openOpenClawStateDatabase({ env: context.env })
.db.prepare("SELECT workspace_key FROM workspace_setup_state WHERE workspace_key = ?")
.get(identity.workspaceKey),
).toBeUndefined();
});
it("rejects a setup source beneath a symlinked workspace subdirectory", async () => {
const context = setup();
const externalDir = path.join(context.homeDir, "external-workspace-state");
const externalSource = path.join(externalDir, "workspace-state.json");
await fsp.mkdir(externalDir, { recursive: true });
await fsp.writeFile(externalSource, JSON.stringify({ version: 1 }), "utf8");
await fsp.symlink(externalDir, path.join(context.workspaceDir, ".openclaw"));
expect(detect(context).hasLegacy).toBe(true);
const result = await migrate(context);
expect(result.warnings[0]).toMatch(/legacy workspace/i);
await expect(fsp.readFile(externalSource, "utf8")).resolves.toBe(
JSON.stringify({ version: 1 }),
);
expect(fs.existsSync(`${externalSource}.doctor-importing`)).toBe(false);
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
expect(
openOpenClawStateDatabase({ env: context.env })
.db.prepare("SELECT workspace_key FROM workspace_setup_state WHERE workspace_key = ?")
.get(identity.workspaceKey),
).toBeUndefined();
});
it("rejects attestations beneath a symlinked state subdirectory", async () => {
const context = setup();
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
const externalDir = path.join(context.homeDir, "external-attestations");
const externalSource = path.join(externalDir, `${identity.workspaceKey}.attested`);
await fsp.mkdir(externalDir, { recursive: true });
await fsp.writeFile(
externalSource,
"openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n",
"utf8",
);
await fsp.mkdir(context.stateDir, { recursive: true });
await fsp.symlink(externalDir, path.join(context.stateDir, "workspace-attestations"));
expect(detect(context).hasLegacy).toBe(true);
const result = await migrate(context);
expect(result.warnings[0]).toMatch(/legacy workspace/i);
await expect(fsp.readFile(externalSource, "utf8")).resolves.toContain(
"openclaw-workspace-attestation:v1",
);
expect(fs.existsSync(`${externalSource}.doctor-importing`)).toBe(false);
expect(
openOpenClawStateDatabase({ env: context.env })
.db.prepare("SELECT workspace_key FROM workspace_attestations WHERE workspace_key = ?")
.get(identity.workspaceKey),
).toBeUndefined();
});
it("retains a setup source that changes before Doctor claims it", async () => {
const context = setup();
const setupPath = path.join(context.workspaceDir, "openclaw-workspace-state.json");
await fsp.writeFile(setupPath, JSON.stringify({ version: 1 }), "utf8");
const result = await migrateLegacyWorkspaceState({
detected: detect(context),
env: context.env,
stateDir: context.stateDir,
beforeClaim: () => {
fs.writeFileSync(
setupPath,
JSON.stringify({ version: 1, setupCompletedAt: "2026-07-16T00:00:00.000Z" }),
"utf8",
);
},
});
expect(result.warnings[0]).toContain("changed before Doctor could claim it");
expect(fs.existsSync(setupPath)).toBe(true);
expect(fs.existsSync(`${setupPath}.doctor-importing`)).toBe(false);
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
expect(
openOpenClawStateDatabase({ env: context.env })
.db.prepare("SELECT workspace_key FROM workspace_setup_state WHERE workspace_key = ?")
.get(identity.workspaceKey),
).toBeUndefined();
});
it("keeps a conflicting source and preserves canonical SQLite state", async () => {
const context = setup();
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
const db = openOpenClawStateDatabase({ env: context.env }).db;
db.prepare(
`INSERT INTO workspace_setup_state (
workspace_key, workspace_path, version, bootstrap_seeded_at, setup_completed_at, updated_at
) VALUES (?, ?, 1, ?, NULL, 1)`,
).run(identity.workspaceKey, identity.workspacePath, "2026-07-15T00:00:00.000Z");
const setupPath = path.join(context.workspaceDir, "openclaw-workspace-state.json");
await fsp.writeFile(
setupPath,
JSON.stringify({ version: 1, bootstrapSeededAt: "2026-07-16T00:00:00.000Z" }),
"utf8",
);
const result = await migrate(context);
expect(result.warnings[0]).toContain("conflicts with canonical SQLite state");
expect(fs.existsSync(setupPath)).toBe(true);
expect(fs.existsSync(`${setupPath}.doctor-importing`)).toBe(false);
expect(
db
.prepare("SELECT bootstrap_seeded_at FROM workspace_setup_state WHERE workspace_key = ?")
.get(identity.workspaceKey),
).toEqual({ bootstrap_seeded_at: "2026-07-15T00:00:00.000Z" });
});
it("merges complementary legacy milestones into unowned SQLite setup state", async () => {
const context = setup();
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
const seededAt = "2026-07-15T00:00:00.000Z";
const completedAt = "2026-07-15T00:01:00.000Z";
const db = openOpenClawStateDatabase({ env: context.env }).db;
db.prepare(
`INSERT INTO workspace_setup_state (
workspace_key, workspace_path, version, bootstrap_seeded_at, setup_completed_at, updated_at
) VALUES (?, ?, 1, ?, NULL, 1)`,
).run(identity.workspaceKey, identity.workspacePath, seededAt);
const setupPath = path.join(context.workspaceDir, "openclaw-workspace-state.json");
await fsp.writeFile(
setupPath,
JSON.stringify({ version: 1, setupCompletedAt: completedAt }),
"utf8",
);
const result = await migrate(context);
expect(result.warnings).toEqual([]);
expect(fs.existsSync(setupPath)).toBe(false);
expect(
db
.prepare(
"SELECT bootstrap_seeded_at, setup_completed_at FROM workspace_setup_state WHERE workspace_key = ?",
)
.get(identity.workspaceKey),
).toEqual({ bootstrap_seeded_at: seededAt, setup_completed_at: completedAt });
const receipt = db
.prepare("SELECT report_json FROM migration_sources WHERE source_path = ?")
.get(path.join(identity.workspacePath, "openclaw-workspace-state.json")) as {
report_json: string;
};
expect(JSON.parse(receipt.report_json)).toMatchObject({
authoritative: false,
imported: true,
resolution: "merged",
});
});
it("uses receipts for idempotent cleanup-only retries", async () => {
const context = setup();
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
const setupPath = path.join(context.workspaceDir, "openclaw-workspace-state.json");
const seededAt = "2026-07-15T00:00:00.000Z";
await fsp.writeFile(
setupPath,
JSON.stringify({ version: 1, bootstrapSeededAt: seededAt }),
"utf8",
);
const first = await migrateLegacyWorkspaceState({
detected: detect(context),
env: context.env,
stateDir: context.stateDir,
removeSource: () => {
throw new Error("simulated unlink failure");
},
});
expect(first.warnings[0]).toContain("legacy cleanup failed");
expect(fs.existsSync(`${setupPath}.doctor-importing`)).toBe(true);
const db = openOpenClawStateDatabase({ env: context.env }).db;
const retry = await migrate(context);
expect(retry.warnings).toEqual([]);
expect(fs.existsSync(`${setupPath}.doctor-importing`)).toBe(false);
expect(
db
.prepare("SELECT bootstrap_seeded_at FROM workspace_setup_state WHERE workspace_key = ?")
.get(identity.workspaceKey),
).toEqual({ bootstrap_seeded_at: seededAt });
expect(
db
.prepare(
"SELECT source_sha256, removed_source FROM migration_sources WHERE source_path = ?",
)
.get(path.join(identity.workspacePath, "openclaw-workspace-state.json")),
).toEqual({
source_sha256: createHash("sha256")
.update(JSON.stringify({ version: 1, bootstrapSeededAt: seededAt }))
.digest("hex"),
removed_source: 1,
});
});
it("cleans receipt-covered superseded setup markers after an interrupted delete", async () => {
const context = setup();
const rootPath = path.join(context.workspaceDir, "openclaw-workspace-state.json");
const nestedPath = path.join(context.workspaceDir, ".openclaw", "workspace-state.json");
await fsp.mkdir(path.dirname(nestedPath), { recursive: true });
await fsp.writeFile(
rootPath,
JSON.stringify({
version: 1,
bootstrapSeededAt: "2026-07-15T10:00:00.000Z",
setupCompletedAt: "2026-07-15T10:01:00.000Z",
}),
"utf8",
);
await fsp.writeFile(
nestedPath,
JSON.stringify({ version: 1, bootstrapSeededAt: "2026-07-14T09:00:00.000Z" }),
"utf8",
);
const first = await migrateLegacyWorkspaceState({
detected: detect(context),
env: context.env,
stateDir: context.stateDir,
removeSource: () => {
throw new Error("simulated unlink failure");
},
});
expect(first.warnings).toHaveLength(2);
const retry = await migrate(context);
expect(retry.warnings).toEqual([]);
expect(fs.existsSync(`${rootPath}.doctor-importing`)).toBe(false);
expect(fs.existsSync(`${nestedPath}.doctor-importing`)).toBe(false);
});
it("retains a receipt-covered attestation when only its modification time changed", async () => {
const context = setup();
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
const attestationPath = path.join(
context.stateDir,
"workspace-attestations",
`${identity.workspaceKey}.attested`,
);
await fsp.mkdir(path.dirname(attestationPath), { recursive: true });
await fsp.writeFile(
attestationPath,
"openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n",
"utf8",
);
const originalMtime = new Date("2026-07-15T11:01:00.000Z");
await fsp.utimes(attestationPath, originalMtime, originalMtime);
const first = await migrateLegacyWorkspaceState({
detected: detect(context),
env: context.env,
stateDir: context.stateDir,
removeSource: () => {
throw new Error("simulated unlink failure");
},
});
expect(first.warnings[0]).toContain("legacy cleanup failed");
const claimPath = `${attestationPath}.doctor-importing`;
const changedMtime = new Date("2026-07-15T11:02:00.000Z");
await fsp.utimes(claimPath, changedMtime, changedMtime);
const retry = await migrate(context);
expect(retry.warnings[0]).toContain("retired source now conflicts");
expect(fs.existsSync(claimPath)).toBe(true);
expect(
openOpenClawStateDatabase({ env: context.env })
.db.prepare("SELECT attested_at_ms FROM workspace_attestations WHERE workspace_key = ?")
.get(identity.workspaceKey),
).toEqual({ attested_at_ms: originalMtime.getTime() });
});
});

View File

@@ -0,0 +1,710 @@
// Doctor-only import for retired workspace setup and attestation files.
import { createHash } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { TextDecoder } from "node:util";
import { root, type Root } from "@openclaw/fs-safe";
import { listAgentWorkspaceDirs } from "../agents/workspace-dirs.js";
import {
LEGACY_WORKSPACE_ATTESTATION_DIRNAME,
LEGACY_WORKSPACE_ATTESTATION_HEADER,
LEGACY_WORKSPACE_ATTESTATION_MAX_BYTES,
LEGACY_WORKSPACE_STATE_CURRENT_FILENAME,
WORKSPACE_DOCTOR_CLAIM_SUFFIX,
resolveLegacyWorkspaceSourcePaths,
} from "../agents/workspace-legacy-state.js";
import { resolveWorkspaceStateIdentity } from "../agents/workspace-state-store.js";
import { resolveLegacyStateDirs } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "./errors.js";
import { acquireGatewayLock, GatewayLockError } from "./gateway-lock.js";
import type { MigrationMessages } from "./state-migrations.types.js";
import {
markSourceRemoved,
readReceipt,
type MigrationReceipt,
} from "./state-migrations.workspace-setup-receipts.js";
import {
canonicalCoversParsedSource,
importAndRecordReceipt,
parseSource,
type ParsedSource,
type SourceSnapshot,
} from "./state-migrations.workspace-setup-store.js";
import type {
LegacyWorkspaceStateDetection,
LegacyWorkspaceStateSource,
} from "./state-migrations.workspace-setup.types.js";
const SETUP_MAX_BYTES = 64 * 1024;
const CLAIM_SUFFIX = WORKSPACE_DOCTOR_CLAIM_SUFFIX;
const MIGRATION_LOCK_TIMEOUT_MS = 250;
const MIGRATION_LOCK_POLL_INTERVAL_MS = 25;
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
function pathMayExist(filePath: string): boolean {
try {
fs.lstatSync(filePath);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code !== "ENOENT";
}
}
function sourceOrClaimMayExist(sourcePath: string): boolean {
return pathMayExist(sourcePath) || pathMayExist(`${sourcePath}${CLAIM_SUFFIX}`);
}
async function readBoundedRegularFile(params: {
sourceRoot: Root;
relativePath: string;
sourcePath: string;
maxBytes: number;
}): Promise<SourceSnapshot> {
const opened = await params.sourceRoot.open(params.relativePath, {
hardlinks: "reject",
symlinks: "reject",
});
try {
const before = opened.stat;
if (
!before.isFile() ||
before.nlink !== 1 ||
!Number.isSafeInteger(before.size) ||
before.size < 0 ||
before.size > params.maxBytes
) {
throw new Error("legacy workspace source is not a safe regular file");
}
const buffer = Buffer.alloc(before.size);
let offset = 0;
while (offset < buffer.length) {
const { bytesRead } = await opened.handle.read(
buffer,
offset,
buffer.length - offset,
offset,
);
if (bytesRead === 0) {
throw new Error("legacy workspace source ended unexpectedly");
}
offset += bytesRead;
}
const after = await opened.handle.stat();
if (
!after.isFile() ||
after.nlink !== 1 ||
after.dev !== before.dev ||
after.ino !== before.ino ||
after.size !== before.size ||
after.mtimeMs !== before.mtimeMs ||
after.ctimeMs !== before.ctimeMs ||
offset !== after.size
) {
throw new Error("legacy workspace source changed while reading");
}
let raw: string;
try {
raw = utf8Decoder.decode(buffer);
} catch {
throw new Error("legacy workspace source is not valid UTF-8");
}
return {
sourcePath: params.sourcePath,
dev: after.dev,
ino: after.ino,
mtimeMs: after.mtimeMs,
sha256: createHash("sha256").update(buffer).digest("hex"),
size: after.size,
raw,
};
} finally {
await opened[Symbol.asyncDispose]();
}
}
function createLegacySource(
params: Omit<LegacyWorkspaceStateSource, "relativePath" | "rootDir"> & { rootDir: string },
): LegacyWorkspaceStateSource {
const rootDir = path.resolve(params.rootDir);
const sourcePath = path.resolve(params.sourcePath);
const relativePath = path.relative(rootDir, sourcePath);
if (
!relativePath ||
relativePath === ".." ||
relativePath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativePath)
) {
throw new Error("legacy workspace source is outside its migration root");
}
return { ...params, rootDir, relativePath, sourcePath };
}
function snapshotsMatch(left: SourceSnapshot, right: SourceSnapshot): boolean {
return (
left.dev === right.dev &&
left.ino === right.ino &&
left.mtimeMs === right.mtimeMs &&
left.sha256 === right.sha256 &&
left.size === right.size
);
}
function siblingAttestationNeedsDoctor(filePath: string): boolean {
try {
const before = fs.lstatSync(filePath);
if (!before.isFile()) {
return false;
}
const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
let fd: number;
try {
fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow);
} catch {
// An unreadable regular file could be an owned marker. Doctor must surface it.
return true;
}
try {
const opened = fs.fstatSync(fd);
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) {
return true;
}
const expected = Buffer.from(`${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n`, "utf8");
const bytes = Buffer.alloc(expected.length);
const read = fs.readSync(fd, bytes, 0, bytes.length, 0);
return read === expected.length && bytes.equals(expected);
} catch {
return true;
} finally {
fs.closeSync(fd);
}
} catch {
return false;
}
}
function listOrphanAttestationSources(params: {
stateDir: string;
homedir: () => string;
}): LegacyWorkspaceStateSource[] {
const sources: LegacyWorkspaceStateSource[] = [];
const stateDirs = [...new Set([params.stateDir, ...resolveLegacyStateDirs(params.homedir)])];
for (const [priority, stateDir] of stateDirs.entries()) {
const attestationDir = path.join(stateDir, LEGACY_WORKSPACE_ATTESTATION_DIRNAME);
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(attestationDir, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
continue;
}
// Preserve a path-shaped detection so Doctor reports the unsafe directory.
sources.push({
...createLegacySource({
kind: "attestation",
rootDir: stateDir,
sourcePath: attestationDir,
workspaceKey: "unreadable-attestation-directory",
priority,
}),
});
continue;
}
for (const entry of entries) {
const match = /^([a-f0-9]{64})\.attested(?:\.doctor-importing)?$/.exec(entry.name);
if (!match?.[1]) {
continue;
}
const sourceName = entry.name.endsWith(CLAIM_SUFFIX)
? entry.name.slice(0, -CLAIM_SUFFIX.length)
: entry.name;
sources.push(
createLegacySource({
kind: "attestation",
rootDir: stateDir,
sourcePath: path.join(attestationDir, sourceName),
workspaceKey: match[1],
priority,
}),
);
}
}
return sources;
}
/** Detect retired workspace files only when an explicit Doctor flow opts in. */
export function detectLegacyWorkspaceState(params: {
cfg: OpenClawConfig;
stateDir: string;
env?: NodeJS.ProcessEnv;
homedir?: () => string;
doctorOnlyStateMigrations?: boolean;
}): LegacyWorkspaceStateDetection {
if (params.doctorOnlyStateMigrations !== true) {
return { sources: [], hasLegacy: false };
}
const env = { ...(params.env ?? process.env), OPENCLAW_STATE_DIR: params.stateDir };
const homedir = params.homedir ?? os.homedir;
const byPath = new Map<string, LegacyWorkspaceStateSource>();
const add = (source: LegacyWorkspaceStateSource) => {
const key = `${source.kind}:${path.resolve(source.sourcePath)}`;
const existing = byPath.get(key);
const sourceIsConfigured = source.workspaceDir !== undefined;
const existingIsConfigured = existing?.workspaceDir !== undefined;
if (
!existing ||
(sourceIsConfigured && !existingIsConfigured) ||
(sourceIsConfigured === existingIsConfigured && source.priority < existing.priority)
) {
byPath.set(key, source);
}
};
for (const workspaceDir of listAgentWorkspaceDirs(params.cfg)) {
const identity = resolveWorkspaceStateIdentity(workspaceDir);
const paths = resolveLegacyWorkspaceSourcePaths(workspaceDir, { env, homedir });
for (const [priority, sourcePath] of paths.setupStatePaths.entries()) {
if (sourceOrClaimMayExist(sourcePath)) {
add(
createLegacySource({
kind: "setup",
rootDir: sourcePath.endsWith(LEGACY_WORKSPACE_STATE_CURRENT_FILENAME)
? path.dirname(sourcePath)
: path.dirname(path.dirname(sourcePath)),
sourcePath,
workspaceKey: identity.workspaceKey,
workspaceDir: identity.workspacePath,
workspaceAliasPath: paths.workspacePath,
priority,
}),
);
}
}
for (const [priority, sourcePath] of paths.stateDirAttestationPaths.entries()) {
if (sourceOrClaimMayExist(sourcePath)) {
add(
createLegacySource({
kind: "attestation",
rootDir: path.dirname(path.dirname(sourcePath)),
sourcePath,
workspaceKey: identity.workspaceKey,
workspaceDir: identity.workspacePath,
workspaceAliasPath: paths.workspacePath,
priority,
}),
);
}
}
for (const [index, sourcePath] of paths.siblingAttestationPaths.entries()) {
if (
!pathMayExist(`${sourcePath}${CLAIM_SUFFIX}`) &&
!siblingAttestationNeedsDoctor(sourcePath)
) {
continue;
}
add(
createLegacySource({
kind: "attestation",
rootDir: path.dirname(sourcePath),
sourcePath,
workspaceKey: identity.workspaceKey,
workspaceDir: identity.workspacePath,
workspaceAliasPath: paths.workspacePath,
priority: paths.stateDirAttestationPaths.length + index,
}),
);
}
}
for (const source of listOrphanAttestationSources({ stateDir: params.stateDir, homedir })) {
add(source);
}
const sources = [...byPath.values()].toSorted(
(left, right) =>
left.priority - right.priority ||
left.workspaceKey.localeCompare(right.workspaceKey) ||
left.sourcePath.localeCompare(right.sourcePath),
);
return { sources, hasLegacy: sources.length > 0 };
}
function assertConfiguredWorkspaceIdentity(source: LegacyWorkspaceStateSource): void {
if (!source.workspaceAliasPath) {
return;
}
if (!source.workspaceDir) {
throw new Error("configured legacy workspace source has no canonical path");
}
const current = resolveWorkspaceStateIdentity(source.workspaceAliasPath);
if (
current.workspaceKey !== source.workspaceKey ||
current.workspacePath !== source.workspaceDir
) {
throw new Error("configured workspace identity changed during Doctor migration");
}
}
async function restoreClaim(params: {
sourceRoot: Root;
source: LegacyWorkspaceStateSource;
}): Promise<string | null> {
const claimRelativePath = `${params.source.relativePath}${CLAIM_SUFFIX}`;
try {
if (!(await params.sourceRoot.exists(claimRelativePath))) {
return null;
}
if (await params.sourceRoot.exists(params.source.relativePath)) {
return `source path already exists: ${params.source.sourcePath}`;
}
await params.sourceRoot.move(claimRelativePath, params.source.relativePath);
return null;
} catch (error) {
return formatErrorMessage(error);
}
}
async function cleanupReceiptSource(params: {
sourceRoot: Root;
source: LegacyWorkspaceStateSource;
receipt: MigrationReceipt;
env: NodeJS.ProcessEnv;
}): Promise<MigrationMessages> {
try {
assertConfiguredWorkspaceIdentity(params.source);
const candidates = [
{
relativePath: params.source.relativePath,
sourcePath: params.source.sourcePath,
},
{
relativePath: `${params.source.relativePath}${CLAIM_SUFFIX}`,
sourcePath: `${params.source.sourcePath}${CLAIM_SUFFIX}`,
},
];
const existing = [];
for (const candidate of candidates) {
if (await params.sourceRoot.exists(candidate.relativePath)) {
existing.push(candidate);
}
}
if (existing.length === 0) {
if (!params.receipt.removedSource) {
markSourceRemoved(params.receipt.sourceKey, params.env);
}
return { changes: [], warnings: [] };
}
if (existing.length > 1) {
return {
changes: [],
warnings: ["Workspace state is in SQLite, but source and interrupted claim both exist."],
};
}
let active = existing[0]!;
let snapshot = await readBoundedRegularFile({
sourceRoot: params.sourceRoot,
relativePath: active.relativePath,
sourcePath: active.sourcePath,
maxBytes:
params.source.kind === "setup" ? SETUP_MAX_BYTES : LEGACY_WORKSPACE_ATTESTATION_MAX_BYTES,
});
let claimedByThisRun = false;
if (active.relativePath === params.source.relativePath) {
const claim = candidates[1]!;
await params.sourceRoot.move(active.relativePath, claim.relativePath);
const claimed = await readBoundedRegularFile({
sourceRoot: params.sourceRoot,
relativePath: claim.relativePath,
sourcePath: claim.sourcePath,
maxBytes:
params.source.kind === "setup" ? SETUP_MAX_BYTES : LEGACY_WORKSPACE_ATTESTATION_MAX_BYTES,
});
if (!snapshotsMatch(snapshot, claimed)) {
await restoreClaim({ sourceRoot: params.sourceRoot, source: params.source });
throw new Error("legacy workspace source changed before Doctor could claim it");
}
active = claim;
snapshot = claimed;
claimedByThisRun = true;
}
const parsed = parseSource(params.source, snapshot);
if (
!params.receipt.sha256 ||
snapshot.sha256 !== params.receipt.sha256 ||
!canonicalCoversParsedSource({ source: params.source, parsed, env: params.env })
) {
if (claimedByThisRun) {
await restoreClaim({ sourceRoot: params.sourceRoot, source: params.source });
}
return {
changes: [],
warnings: ["Workspace state is in SQLite, but the retired source now conflicts."],
};
}
const unchanged = await readBoundedRegularFile({
sourceRoot: params.sourceRoot,
relativePath: active.relativePath,
sourcePath: active.sourcePath,
maxBytes:
params.source.kind === "setup" ? SETUP_MAX_BYTES : LEGACY_WORKSPACE_ATTESTATION_MAX_BYTES,
});
if (!snapshotsMatch(snapshot, unchanged)) {
if (claimedByThisRun) {
await restoreClaim({ sourceRoot: params.sourceRoot, source: params.source });
}
throw new Error("legacy workspace claim changed before cleanup");
}
assertConfiguredWorkspaceIdentity(params.source);
await params.sourceRoot.remove(active.relativePath);
markSourceRemoved(params.receipt.sourceKey, params.env);
return {
changes: [],
warnings: [],
notices: ["Discarded retired workspace state already covered by its SQLite receipt."],
};
} catch (error) {
return {
changes: [],
warnings: [
`Workspace state is in SQLite, but legacy cleanup failed: ${formatErrorMessage(error)}`,
],
};
}
}
async function migrateOneSource(params: {
source: LegacyWorkspaceStateSource;
env: NodeJS.ProcessEnv;
beforeClaim?: (source: LegacyWorkspaceStateSource) => void;
removeSource?: (sourcePath: string) => Promise<void> | void;
}): Promise<MigrationMessages> {
let sourceRoot: Root;
try {
assertConfiguredWorkspaceIdentity(params.source);
sourceRoot = await root(params.source.rootDir, {
hardlinks: "reject",
symlinks: "reject",
});
} catch (error) {
return {
changes: [],
warnings: [`Failed reading legacy workspace state: ${formatErrorMessage(error)}`],
};
}
const receipt = readReceipt(params.source, params.env);
if (receipt) {
return cleanupReceiptSource({
sourceRoot,
source: params.source,
receipt,
env: params.env,
});
}
const sourcePath = params.source.sourcePath;
const claimPath = `${sourcePath}${CLAIM_SUFFIX}`;
const claimRelativePath = `${params.source.relativePath}${CLAIM_SUFFIX}`;
let hasSource: boolean;
let hasClaim: boolean;
try {
hasSource = await sourceRoot.exists(params.source.relativePath);
hasClaim = await sourceRoot.exists(claimRelativePath);
} catch (error) {
return {
changes: [],
warnings: [`Failed reading legacy workspace state: ${formatErrorMessage(error)}`],
};
}
if (hasSource && hasClaim) {
return {
changes: [],
warnings: [
"Failed migrating legacy workspace state: source and interrupted claim both exist.",
],
};
}
const activePath = hasSource ? sourcePath : hasClaim ? claimPath : null;
const activeRelativePath = hasSource
? params.source.relativePath
: hasClaim
? claimRelativePath
: null;
if (!activePath || !activeRelativePath) {
return { changes: [], warnings: [] };
}
let snapshot: SourceSnapshot;
let parsed: ParsedSource;
let claimedByThisRun = false;
try {
snapshot = await readBoundedRegularFile({
sourceRoot,
relativePath: activeRelativePath,
sourcePath: activePath,
maxBytes:
params.source.kind === "setup" ? SETUP_MAX_BYTES : LEGACY_WORKSPACE_ATTESTATION_MAX_BYTES,
});
parsed = parseSource(params.source, snapshot);
} catch (error) {
return {
changes: [],
warnings: [`Failed reading legacy workspace state: ${formatErrorMessage(error)}`],
};
}
if (activePath === sourcePath) {
try {
params.beforeClaim?.(params.source);
assertConfiguredWorkspaceIdentity(params.source);
await sourceRoot.move(params.source.relativePath, claimRelativePath);
const claimed = await readBoundedRegularFile({
sourceRoot,
relativePath: claimRelativePath,
sourcePath: claimPath,
maxBytes:
params.source.kind === "setup" ? SETUP_MAX_BYTES : LEGACY_WORKSPACE_ATTESTATION_MAX_BYTES,
});
if (!snapshotsMatch(snapshot, claimed)) {
throw new Error("legacy workspace source changed before Doctor could claim it");
}
snapshot = claimed;
claimedByThisRun = true;
} catch (error) {
const restoreError = await restoreClaim({ sourceRoot, source: params.source });
return {
changes: [],
warnings: [
`Failed migrating legacy workspace state: ${formatErrorMessage(error)}${restoreError ? `; restore failure: ${restoreError}` : ""}`,
],
};
}
}
let result: ReturnType<typeof importAndRecordReceipt>;
try {
assertConfiguredWorkspaceIdentity(params.source);
result = importAndRecordReceipt({
source: params.source,
snapshot,
parsed,
env: params.env,
});
} catch (error) {
const restoreError = claimedByThisRun
? await restoreClaim({ sourceRoot, source: params.source })
: null;
return {
changes: [],
warnings: [
`Failed migrating legacy workspace state: ${formatErrorMessage(error)}${restoreError ? `; restore failure: ${restoreError}` : ""}`,
],
};
}
try {
if (await sourceRoot.exists(params.source.relativePath)) {
throw new Error("legacy workspace source reappeared during import");
}
const unchanged = await readBoundedRegularFile({
sourceRoot,
relativePath: claimRelativePath,
sourcePath: claimPath,
maxBytes:
params.source.kind === "setup" ? SETUP_MAX_BYTES : LEGACY_WORKSPACE_ATTESTATION_MAX_BYTES,
});
if (!snapshotsMatch(snapshot, unchanged)) {
throw new Error("legacy workspace claim changed after import");
}
if (params.removeSource) {
await params.removeSource(claimPath);
} else {
await sourceRoot.remove(claimRelativePath);
}
markSourceRemoved(result.sourceKey, params.env);
} catch (error) {
return {
changes: [],
warnings: [
`Workspace state is in SQLite, but legacy cleanup failed: ${formatErrorMessage(error)}`,
],
};
}
const label = parsed.kind === "setup" ? "workspace setup state" : "workspace attestation";
return {
changes: [
result.imported ? `Migrated ${label} to SQLite.` : `Verified canonical SQLite ${label}.`,
],
warnings: [],
notices: ["Removed retired workspace state after verified SQLite import."],
};
}
/** Import retired workspace files while excluding Gateways that can recreate them. */
export async function migrateLegacyWorkspaceState(params: {
detected?: LegacyWorkspaceStateDetection;
stateDir: string;
env?: NodeJS.ProcessEnv;
beforeClaim?: (source: LegacyWorkspaceStateSource) => void;
removeSource?: (sourcePath: string) => Promise<void> | void;
}): Promise<MigrationMessages> {
if (!params.detected?.hasLegacy) {
return { changes: [], warnings: [] };
}
const env = { ...(params.env ?? process.env), OPENCLAW_STATE_DIR: params.stateDir };
let lock: Awaited<ReturnType<typeof acquireGatewayLock>>;
try {
lock = await acquireGatewayLock({
allowInTests: true,
env,
pollIntervalMs: MIGRATION_LOCK_POLL_INTERVAL_MS,
role: "sqlite-maintenance",
timeoutMs: MIGRATION_LOCK_TIMEOUT_MS,
});
} catch (error) {
const detail =
error instanceof GatewayLockError
? "the Gateway or another SQLite maintenance command owns this state directory"
: formatErrorMessage(error);
return {
changes: [],
warnings: [
`Failed migrating legacy workspace state: ${detail}. Stop the Gateway and run \`openclaw doctor --fix\` again.`,
],
};
}
if (!lock) {
return {
changes: [],
warnings: ["Failed migrating legacy workspace state: exclusive state ownership unavailable."],
};
}
const changes: string[] = [];
const warnings: string[] = [];
const notices: string[] = [];
let releaseError: unknown;
try {
for (const source of params.detected.sources) {
const result = await migrateOneSource({
source,
env,
...(params.beforeClaim ? { beforeClaim: params.beforeClaim } : {}),
...(params.removeSource ? { removeSource: params.removeSource } : {}),
});
changes.push(...result.changes);
warnings.push(...result.warnings);
notices.push(...(result.notices ?? []));
}
} finally {
try {
await lock.release();
} catch (error) {
releaseError = error;
}
}
if (releaseError) {
warnings.push(`Workspace migration lock release failed: ${formatErrorMessage(releaseError)}`);
}
return notices.length > 0 ? { changes, warnings, notices } : { changes, warnings };
}

View File

@@ -0,0 +1,15 @@
export type LegacyWorkspaceStateSource = {
kind: "setup" | "attestation";
rootDir: string;
relativePath: string;
sourcePath: string;
workspaceKey: string;
workspaceDir?: string;
workspaceAliasPath?: string;
priority: number;
};
export type LegacyWorkspaceStateDetection = {
sources: LegacyWorkspaceStateSource[];
hasLegacy: boolean;
};

View File

@@ -1224,6 +1224,26 @@ export interface WorkerWorkspaceReconciliations {
session_id: string;
}
export interface WorkspaceAttestations {
attested_at_ms: number;
updated_at_ms: number;
workspace_key: string;
}
export interface WorkspaceGeneratedBootstrapHashes {
filename: string;
sha256: string;
workspace_key: string;
}
export interface WorkspacePathAliases {
alias_key: string;
alias_path: string;
updated_at_ms: number;
workspace_key: string;
workspace_path: string;
}
export interface WorkspaceSetupState {
bootstrap_seeded_at: string | null;
setup_completed_at: string | null;
@@ -1343,6 +1363,9 @@ export interface DB {
worker_transcript_commits: WorkerTranscriptCommits;
worker_workspace_pending_results: WorkerWorkspacePendingResults;
worker_workspace_reconciliations: WorkerWorkspaceReconciliations;
workspace_attestations: WorkspaceAttestations;
workspace_generated_bootstrap_hashes: WorkspaceGeneratedBootstrapHashes;
workspace_path_aliases: WorkspacePathAliases;
workspace_setup_state: WorkspaceSetupState;
worktree_provisioned_file_chunks: WorktreeProvisionedFileChunks;
worktrees: Worktrees;

View File

@@ -495,6 +495,34 @@ CREATE TABLE IF NOT EXISTS workspace_setup_state (
CREATE INDEX IF NOT EXISTS idx_workspace_setup_state_path
ON workspace_setup_state(workspace_path);
CREATE TABLE IF NOT EXISTS workspace_path_aliases (
alias_key TEXT NOT NULL PRIMARY KEY,
alias_path TEXT NOT NULL,
workspace_key TEXT NOT NULL,
workspace_path TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_workspace_path_aliases_workspace
ON workspace_path_aliases(workspace_key);
CREATE TABLE IF NOT EXISTS workspace_attestations (
workspace_key TEXT NOT NULL PRIMARY KEY,
attested_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_workspace_attestations_attested
ON workspace_attestations(attested_at_ms DESC, workspace_key);
CREATE TABLE IF NOT EXISTS workspace_generated_bootstrap_hashes (
workspace_key TEXT NOT NULL,
filename TEXT NOT NULL,
sha256 TEXT NOT NULL,
PRIMARY KEY (workspace_key, filename),
FOREIGN KEY (workspace_key) REFERENCES workspace_attestations(workspace_key) ON DELETE CASCADE
) STRICT;
CREATE TABLE IF NOT EXISTS native_hook_relay_bridges (
relay_id TEXT NOT NULL PRIMARY KEY,
pid INTEGER NOT NULL,

View File

@@ -490,6 +490,34 @@ CREATE TABLE IF NOT EXISTS workspace_setup_state (
CREATE INDEX IF NOT EXISTS idx_workspace_setup_state_path
ON workspace_setup_state(workspace_path);
CREATE TABLE IF NOT EXISTS workspace_path_aliases (
alias_key TEXT NOT NULL PRIMARY KEY,
alias_path TEXT NOT NULL,
workspace_key TEXT NOT NULL,
workspace_path TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_workspace_path_aliases_workspace
ON workspace_path_aliases(workspace_key);
CREATE TABLE IF NOT EXISTS workspace_attestations (
workspace_key TEXT NOT NULL PRIMARY KEY,
attested_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_workspace_attestations_attested
ON workspace_attestations(attested_at_ms DESC, workspace_key);
CREATE TABLE IF NOT EXISTS workspace_generated_bootstrap_hashes (
workspace_key TEXT NOT NULL,
filename TEXT NOT NULL,
sha256 TEXT NOT NULL,
PRIMARY KEY (workspace_key, filename),
FOREIGN KEY (workspace_key) REFERENCES workspace_attestations(workspace_key) ON DELETE CASCADE
) STRICT;
CREATE TABLE IF NOT EXISTS native_hook_relay_bridges (
relay_id TEXT NOT NULL PRIMARY KEY,
pid INTEGER NOT NULL,

View File

@@ -257,6 +257,27 @@ describe("check-database-first-legacy-stores", () => {
expect(violations).toEqual([{ kind: "legacy store filesystem write", line: 4 }]);
});
it("flags runtime writes to retired workspace setup and attestation sidecars", () => {
const violations = collectDatabaseFirstLegacyStoreViolations(
`
import { promises as fs } from "node:fs";
import path from "node:path";
await fs.writeFile(path.join(workspaceDir, "openclaw-workspace-state.json"), "{}\\n");
await fs.writeFile(path.join(workspaceDir, ".openclaw", "workspace-state.json"), "{}\\n");
await fs.writeFile(path.join(stateDir, "workspace-attestations", \`\${workspaceKey}.attested\`), "ok\\n");
await fs.writeFile(\`\${workspaceDir}.attested\`, "ok\\n");
`,
"src/agents/workspace-sidecar-store.ts",
);
expect(violations).toEqual([
{ kind: "legacy store filesystem write", line: 4 },
{ kind: "legacy store filesystem write", line: 5 },
{ kind: "legacy store filesystem write", line: 6 },
{ kind: "legacy store filesystem write", line: 7 },
]);
});
it("flags runtime writes to the retired subagent JSON registry", () => {
const violations = collectDatabaseFirstLegacyStoreViolations(
`
@@ -8569,6 +8590,19 @@ describe("check-database-first-legacy-stores", () => {
expect(violations).toEqual([]);
});
it("allows the workspace Doctor migration owner to claim legacy sidecars", () => {
const violations = collectDatabaseFirstLegacyStoreViolations(
`
import { promises as fs } from "node:fs";
await fs.rename("openclaw-workspace-state.json", "openclaw-workspace-state.json.doctor-importing");
await fs.rename("workspace.attested", "workspace.attested.doctor-importing");
`,
"src/infra/state-migrations.workspace-setup.ts",
);
expect(violations).toEqual([]);
});
it("allows plugin doctor migration owners to archive legacy files", () => {
const violations = collectDatabaseFirstLegacyStoreViolations(
`