diff --git a/docs/cli/reset.md b/docs/cli/reset.md index 464111c2e6f3..254fd5fc88d4 100644 --- a/docs/cli/reset.md +++ b/docs/cli/reset.md @@ -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. diff --git a/docs/concepts/agent-workspace.md b/docs/concepts/agent-workspace.md index c0840081eed7..acc5dc007df3 100644 --- a/docs/concepts/agent-workspace.md +++ b/docs/concepts/agent-workspace.md @@ -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//agent/auth-profiles.json` (model auth profiles: OAuth + API keys) - `~/.openclaw/agents//agent/openclaw-agent.sqlite` (session rows, transcripts, and per-agent runtime state) - `~/.openclaw/agents//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. diff --git a/docs/concepts/agent.md b/docs/concepts/agent.md index fb85680eb284..4e01450b6f2f 100644 --- a/docs/concepts/agent.md +++ b/docs/concepts/agent.md @@ -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: diff --git a/docs/refactor/database-first.md b/docs/refactor/database-first.md index 075bdc99c251..1502498b42f9 100644 --- a/docs/refactor/database-first.md +++ b/docs/refactor/database-first.md @@ -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 `.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 `.attested` - Matrix `credentials*.json` and `recovery-key.json` - `cron/runs/*.jsonl` - `cron/jobs.json` diff --git a/scripts/check-database-first-legacy-stores.mjs b/scripts/check-database-first-legacy-stores.mjs index 00ce8aa0c5d1..da9168833e6e 100644 --- a/scripts/check-database-first-legacy-stores.mjs +++ b/scripts/check-database-first-legacy-stores.mjs @@ -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", diff --git a/src/agents/bootstrap-files.test.ts b/src/agents/bootstrap-files.test.ts index 4908d27b837c..8cb1da103808 100644 --- a/src/agents/bootstrap-files.test.ts +++ b/src/agents/bootstrap-files.test.ts @@ -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 { - 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 { @@ -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 () => { diff --git a/src/agents/workspace-legacy-state.test-support.ts b/src/agents/workspace-legacy-state.test-support.ts new file mode 100644 index 000000000000..5f0cbb00b57f --- /dev/null +++ b/src/agents/workspace-legacy-state.test-support.ts @@ -0,0 +1,19 @@ +import "./workspace-legacy-state.js"; + +type WorkspaceLegacyStateTestApi = { + resetLegacyWorkspaceStateCheckForTest(): void; +}; + +function getTestApi(): WorkspaceLegacyStateTestApi { + const api = (globalThis as Record)[ + 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(); +} diff --git a/src/agents/workspace-legacy-state.test.ts b/src/agents/workspace-legacy-state.test.ts new file mode 100644 index 000000000000..66b96296c2fd --- /dev/null +++ b/src/agents/workspace-legacy-state.test.ts @@ -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) { + 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, + ); + }); +}); diff --git a/src/agents/workspace-legacy-state.ts b/src/agents/workspace-legacy-state.ts new file mode 100644 index 000000000000..6c843a4b4284 --- /dev/null +++ b/src/agents/workspace-legacy-state.ts @@ -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(); + +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(); + 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)[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 { + 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 }; +} diff --git a/src/agents/workspace-sqlite-safety.test.ts b/src/agents/workspace-sqlite-safety.test.ts new file mode 100644 index 000000000000..b3fda90b7b36 --- /dev/null +++ b/src/agents/workspace-sqlite-safety.test.ts @@ -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", + ); + }); +}); diff --git a/src/agents/workspace-state-store.test.ts b/src/agents/workspace-state-store.test.ts new file mode 100644 index 000000000000..4e4bce73c6ee --- /dev/null +++ b/src/agents/workspace-state-store.test.ts @@ -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); + }); +}); diff --git a/src/agents/workspace-state-store.ts b/src/agents/workspace-state-store.ts new file mode 100644 index 000000000000..c106c4745e2d --- /dev/null +++ b/src/agents/workspace-state-store.ts @@ -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 = 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; +}; + +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(); + + 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; +}): WorkspaceIdentityResolution { + const aliases = resolveWorkspaceStateAliases(params.workspaceDir); + const canonicalIdentity = aliases.at(-1)!; + const kysely = getNodeSqliteKysely(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; + identity: WorkspaceStateIdentity; + aliases: readonly WorkspaceStateIdentity[]; + updatedAtMs: number; +}): void { + assertCanonicalIntegerTimestamp(params.updatedAtMs, "path alias update"); + const kysely = getNodeSqliteKysely(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; + workspaceDirs: readonly string[]; + identity: WorkspaceStateIdentity; + updatedAtMs: number; +}): void { + const aliases = new Map(); + 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; +}): WorkspaceStateSnapshot { + const identity = params.identity; + const kysely = getNodeSqliteKysely(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(); + 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>, + 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(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; + 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(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, + workspaceKey: string, +): void { + const kysely = getNodeSqliteKysely(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; + 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(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); + }); +} diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index 29837b3d45e9..12b745140408 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -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 { @@ -108,19 +112,28 @@ async function expectPathMissing(filePath: string): Promise { await expect(fs.access(filePath)).rejects.toHaveProperty("code", "ENOENT"); } -async function expectWorkspaceVanished( - action: Promise, - expected?: { attestationPath?: string }, -): Promise { +async function expectWorkspaceVanished(action: Promise): Promise { // 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 { + 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", () => { diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index 5af3c67e334b..3de9c19b0994 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -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 = 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 }, ): Promise { 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, ): Promise { - 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 { @@ -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 { - 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 { - 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 { - try { - return ( - (await readWorkspaceAttestationMarkerStatus(attestationPath)) === "marker" || - (await hasRecentWorkspaceAttestation(attestationPath, opts)) - ); - } catch { - return false; - } -} - -async function readWorkspaceAttestationMarkerStatus( - attestationPath: string, -): Promise { - 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 | 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(); - 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 { - 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 { - await fs.mkdir(path.dirname(attestationPath), { recursive: true }); - const now = new Date(); - const content = await buildWorkspaceAttestationContent(dir, now); +async function maybeWriteWorkspaceAttestation(dir: string): Promise { 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 { - 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 { + 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 { + 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 { - 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>; - 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 { - 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 return (await resolveWorkspaceBootstrapStatus(dir)) === "pending"; } -async function writeWorkspaceSetupState( - statePath: string, - state: WorkspaceSetupState, -): Promise { - await replaceFileAtomic({ - filePath: statePath, - content: `${JSON.stringify(state, null, 2)}\n`, - tempPrefix: WORKSPACE_STATE_FILENAME, - }); -} - async function hasGitRepo(dir: string): Promise { 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) => { 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, diff --git a/src/commands/agents.commands.delete.ts b/src/commands/agents.commands.delete.ts index f49b80677bd2..8b3b39779d2a 100644 --- a/src/commands/agents.commands.delete.ts +++ b/src/commands/agents.commands.delete.ts @@ -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, { diff --git a/src/commands/agents.delete.test.ts b/src/commands/agents.delete.test.ts index 9ba63c4acc88..a03f9ad750e6 100644 --- a/src/commands/agents.delete.test.ts +++ b/src/commands/agents.delete.test.ts @@ -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("../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( + "../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(); + }); + }); }); diff --git a/src/commands/cleanup-command.test-support.ts b/src/commands/cleanup-command.test-support.ts index 4eff30cf7c75..2c4ba907b201 100644 --- a/src/commands/cleanup-command.test-support.ts +++ b/src/commands/cleanup-command.test-support.ts @@ -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) { diff --git a/src/commands/cleanup-utils.test.ts b/src/commands/cleanup-utils.test.ts index e753a801ff13..21a7e3391fda 100644 --- a/src/commands/cleanup-utils.test.ts +++ b/src/commands/cleanup-utils.test.ts @@ -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( + "../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 }); diff --git a/src/commands/cleanup-utils.ts b/src/commands/cleanup-utils.ts index be1bec3c348d..86c88f50d8a5 100644 --- a/src/commands/cleanup-utils.ts +++ b/src/commands/cleanup-utils.ts @@ -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 { - 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 { const existing: string[] = []; for (const target of paths) { @@ -213,24 +200,23 @@ export async function removeStateAndLinkedPaths( cleanup: CleanupResolvedPaths, runtime: RuntimeEnv, opts?: StateRemovalOptions, -): Promise { +): Promise { 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 { 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)}`, + ); + } } } diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index 72a45ea83006..d54f6d30b1d0 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -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", diff --git a/src/commands/onboard-helpers.test.ts b/src/commands/onboard-helpers.test.ts index 3c9056ef0b28..ecd4c167111e 100644 --- a/src/commands/onboard-helpers.test.ts +++ b/src/commands/onboard-helpers.test.ts @@ -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>(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( + "../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"); diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index b4cbaa7f77fd..d938336b35e2 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -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 { +export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promise { 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); } } } diff --git a/src/commands/reset.test.ts b/src/commands/reset.test.ts index a91f4e94d548..a15cdb95aff2 100644 --- a/src/commands/reset.test.ts +++ b/src/commands/reset.test.ts @@ -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, + }); }); }); diff --git a/src/commands/reset.ts b/src/commands/reset.ts index e326524ffc30..00297d1c8288 100644 --- a/src/commands/reset.ts +++ b/src/commands/reset.ts @@ -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")}`); } } diff --git a/src/commands/uninstall.test.ts b/src/commands/uninstall.test.ts index 6dd59ae344cb..1449b7792ff5 100644 --- a/src/commands/uninstall.test.ts +++ b/src/commands/uninstall.test.ts @@ -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, + }); }); }); diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts index 99293dee85a4..8b624b482418 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -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")) { diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index cb96a58e1154..1648872ff74f 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -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( + "../../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 () => { diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index 3544ed7deb84..51878ecedc0f 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -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 { +async function moveToTrashBestEffort(pathname: string): Promise { 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. } } } diff --git a/src/infra/state-migrations.doctor.ts b/src/infra/state-migrations.doctor.ts index ebdec2d4d4df..94a946dbde2b 100644 --- a/src/infra/state-migrations.doctor.ts +++ b/src/infra/state-migrations.doctor.ts @@ -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 = [ diff --git a/src/infra/state-migrations.types.ts b/src/infra/state-migrations.types.ts index 723a564d9fd1..0da5252996b8 100644 --- a/src/infra/state-migrations.types.ts +++ b/src/infra/state-migrations.types.ts @@ -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; diff --git a/src/infra/state-migrations.workspace-setup-receipts.ts b/src/infra/state-migrations.workspace-setup-receipts.ts new file mode 100644 index 000000000000..4314d3a49906 --- /dev/null +++ b/src/infra/state-migrations.workspace-setup-receipts.ts @@ -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; + +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(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(db) + .updateTable("migration_sources") + .set({ removed_source: 1 }) + .where("source_key", "=", sourceKey), + ); + }, + { env }, + ); +} diff --git a/src/infra/state-migrations.workspace-setup-store.ts b/src/infra/state-migrations.workspace-setup-store.ts new file mode 100644 index 000000000000..ed79aad5953a --- /dev/null +++ b/src/infra/state-migrations.workspace-setup-store.ts @@ -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; +}; + +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; + 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(); + 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, right: ReadonlyMap): 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 { + return canonicalFingerprint({ + kind: "attestation", + attestedAtMs: params.attestedAtMs, + generatedHashes: [...params.generatedHashes.entries()].toSorted(([left], [right]) => + left.localeCompare(right), + ), + }); +} + +function findMigrationAuthority(params: { + db: ReturnType["db"]; + kysely: ReturnType>; + 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; + 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(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(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 }, + ); +} diff --git a/src/infra/state-migrations.workspace-setup.test.ts b/src/infra/state-migrations.workspace-setup.test.ts new file mode 100644 index 000000000000..cb22263b3286 --- /dev/null +++ b/src/infra/state-migrations.workspace-setup.test.ts @@ -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 | 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) { + return detectLegacyWorkspaceState({ + cfg: context.cfg, + stateDir: context.stateDir, + env: context.env, + homedir: () => context.homeDir, + doctorOnlyStateMigrations: true, + }); + } + + async function migrate(context: ReturnType) { + 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() }); + }); +}); diff --git a/src/infra/state-migrations.workspace-setup.ts b/src/infra/state-migrations.workspace-setup.ts new file mode 100644 index 000000000000..9e8fdd2cefa8 --- /dev/null +++ b/src/infra/state-migrations.workspace-setup.ts @@ -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 { + 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 & { 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(); + 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 { + 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 { + 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; +}): Promise { + 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; + 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; +}): Promise { + if (!params.detected?.hasLegacy) { + return { changes: [], warnings: [] }; + } + const env = { ...(params.env ?? process.env), OPENCLAW_STATE_DIR: params.stateDir }; + let lock: Awaited>; + 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 }; +} diff --git a/src/infra/state-migrations.workspace-setup.types.ts b/src/infra/state-migrations.workspace-setup.types.ts new file mode 100644 index 000000000000..5e1cf1c0cd6a --- /dev/null +++ b/src/infra/state-migrations.workspace-setup.types.ts @@ -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; +}; diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index 23b25b5dc1f2..fa92f57b3ac2 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -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; diff --git a/src/state/openclaw-state-schema.generated.ts b/src/state/openclaw-state-schema.generated.ts index 0ed146e284e0..fe45446f34da 100644 --- a/src/state/openclaw-state-schema.generated.ts +++ b/src/state/openclaw-state-schema.generated.ts @@ -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, diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index 7e61b28f3415..557e859fc5ff 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -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, diff --git a/test/scripts/check-database-first-legacy-stores.test.ts b/test/scripts/check-database-first-legacy-stores.test.ts index ee4b3c77af69..901783c1f5dc 100644 --- a/test/scripts/check-database-first-legacy-stores.test.ts +++ b/test/scripts/check-database-first-legacy-stores.test.ts @@ -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( `