diff --git a/docs/cli/claws.md b/docs/cli/claws.md index ff149813ddc4..fe4ea0a9463d 100644 --- a/docs/cli/claws.md +++ b/docs/cli/claws.md @@ -47,6 +47,26 @@ limited to 1 MiB, package metadata to 256 KiB, and workspace sources enforce separate per-file and aggregate limits. Workspace sources also reject symlinked parents. +Workspace files are declared by path and read from package sidecars. Bootstrap +files such as `SOUL.md` use named entries; additional files use package-relative +sources and workspace-relative targets: + +```json +{ + "workspace": { + "bootstrapFiles": { + "SOUL.md": { "source": "workspace/SOUL.md" } + }, + "files": [ + { + "source": "workspace/reference/policy.md", + "path": "reference/policy.md" + } + ] + } +} +``` + ## Inspect and preview Validate the source without planning local changes: @@ -77,9 +97,10 @@ when the source, destination, or live configuration changed after preview. Use `--agent-id` or `--workspace` during both preview and apply when package defaults collide with local state. -At this stage, adding a Claw creates the new agent and workspace configuration -and records installation provenance. Later Claws stages add managed workspace -files and other declared resources. +Adding a Claw creates the new agent and workspace configuration, writes declared +workspace files, and records installation and per-file provenance. Existing +files are not overwritten, and retries fail closed when owned content drifted. +Later Claws stages add other declared resources. ## Command reference diff --git a/src/claws/add.ts b/src/claws/add.ts index d26724cc8df9..5e5bef539db8 100644 --- a/src/claws/add.ts +++ b/src/claws/add.ts @@ -1,4 +1,4 @@ -// Applies the narrow agent/workspace creation slice of a consented Claw add plan. +// Applies the agent, workspace, and managed-file slice of a consented Claw add plan. import { lstat, mkdir, rmdir } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { findOverlappingWorkspaceAgentIds } from "../agents/agent-delete-safety.js"; @@ -18,6 +18,11 @@ import { type PersistedClawInstall, } from "./provenance.js"; import { CLAW_OUTPUT_STABILITY, type ClawAddPlan } from "./types.js"; +import { + ClawWorkspaceWriteError, + createClawWorkspaceFiles, + type PersistedClawWorkspaceFile, +} from "./workspace.js"; export const CLAW_ADD_RESULT_SCHEMA_VERSION = "openclaw.clawAddResult.v1" as const; @@ -28,6 +33,7 @@ type ClawAddApplyOptions = OpenClawStateDatabaseOptions & { persistRecord?: typeof persistClawInstallRecord; deleteRecord?: typeof deleteClawInstallRecord; updateRecord?: typeof updateClawInstallRecordStatus; + createWorkspaceFiles?: typeof createClawWorkspaceFiles; nowMs?: number; }; type AgentConfig = NonNullable["list"]>[number]; @@ -53,12 +59,19 @@ type ClawAddResult = { agent: ClawAddPlan["agent"]; workspaceCreated: boolean; configCommitted: boolean; + workspaceFiles: PersistedClawWorkspaceFile[]; installRecord?: PersistedClawInstall; - error?: { code: string; message: string }; + error?: { + code: string; + message: string; + diagnostics?: ClawWorkspaceWriteError["diagnostics"]; + }; }; function hasUnsupportedMutationActions(plan: ClawAddPlan): boolean { - return plan.actions.some((action) => !["agent", "workspace"].includes(action.kind)); + return plan.actions.some( + (action) => !["agent", "workspace", "workspaceFile"].includes(action.kind), + ); } function statusAtLeast(status: ClawInstallStatus, phase: ClawInstallStatus): boolean { @@ -123,7 +136,7 @@ export async function applyClawAddPlan( if (hasUnsupportedMutationActions(plan)) { throw new ClawAddMutationError( "unsupported_components", - "This build can only add Claws with agent settings and an empty workspace; declared files, packages, MCP servers, or cron jobs require later lifecycle slices.", + "This build can add agent settings and workspace files; declared packages, MCP servers, or cron jobs require later lifecycle slices.", ); } if (options.consentPlanIntegrity !== plan.planIntegrity) { @@ -284,6 +297,52 @@ export async function applyClawAddPlan( throw error; } + const createFiles = options.createWorkspaceFiles ?? createClawWorkspaceFiles; + let workspaceFiles: PersistedClawWorkspaceFile[] = []; + try { + workspaceFiles = await createFiles(plan, options); + } catch (error) { + const workspaceError = + error instanceof ClawWorkspaceWriteError + ? error + : new ClawWorkspaceWriteError( + [ + { + level: "error", + code: "workspace_file_io_error", + phase: "mutation", + path: "$.workspace", + message: error instanceof Error ? error.message : String(error), + }, + ], + workspaceFiles, + ); + markInstallStatus(plan.agent.finalId, "config_committed", ["config_committed"], options); + return { + schemaVersion: CLAW_ADD_RESULT_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + dryRun: false, + mutationAllowed: true, + planIntegrity: plan.planIntegrity, + status: "partial", + claw: plan.claw, + agent: plan.agent, + workspaceCreated, + configCommitted, + workspaceFiles: workspaceError.createdFiles, + installRecord: { + ...installRecord, + status: "config_committed", + updatedAtMs: options.nowMs ?? Date.now(), + }, + error: { + code: "workspace_files_failed", + message: workspaceError.message, + diagnostics: workspaceError.diagnostics, + }, + }; + } + try { markInstallStatus(plan.agent.finalId, "complete", ["config_committed", "complete"], options); return { @@ -297,6 +356,7 @@ export async function applyClawAddPlan( agent: plan.agent, workspaceCreated, configCommitted, + workspaceFiles, installRecord: { ...installRecord, status: "complete", @@ -315,6 +375,7 @@ export async function applyClawAddPlan( agent: plan.agent, workspaceCreated, configCommitted, + workspaceFiles, error: { code: "provenance_failed", message: (error as Error).message }, }; } diff --git a/src/claws/fixtures/workspace-agent.claw.json b/src/claws/fixtures/workspace-agent.claw.json new file mode 100644 index 000000000000..109a84c02fdd --- /dev/null +++ b/src/claws/fixtures/workspace-agent.claw.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "agent": { + "id": "workspace-agent", + "name": "Workspace Agent", + "identity": { + "name": "Workspace" + } + }, + "workspace": { + "bootstrapFiles": { + "SOUL.md": { "source": "workspace/SOUL.md" }, + "HEARTBEAT.md": { "source": "workspace/HEARTBEAT.md" } + }, + "files": [ + { + "source": "workspace/reference/policy.md", + "path": "reference/policy.md" + } + ] + } +} diff --git a/src/claws/fixtures/workspace/reference/policy.md b/src/claws/fixtures/workspace/reference/policy.md new file mode 100644 index 000000000000..cb770a28bcaf --- /dev/null +++ b/src/claws/fixtures/workspace/reference/policy.md @@ -0,0 +1,3 @@ +# Workspace Policy + +Keep operator settings and unrelated agents unchanged. diff --git a/src/claws/lifecycle.e2e.test.ts b/src/claws/lifecycle.e2e.test.ts index efd4054565fd..5ef69f425acf 100644 --- a/src/claws/lifecycle.e2e.test.ts +++ b/src/claws/lifecycle.e2e.test.ts @@ -150,6 +150,52 @@ describe("claws lifecycle cli e2e", () => { ]); }); + it("creates declared bootstrap and supporting files in the new workspace", async () => { + const preview = await runOpenClaw([ + "claws", + "add", + "src/claws/fixtures/workspace-agent.claw.json", + "--dry-run", + "--json", + ]); + const plan = parseJson(preview.stdout) as { planIntegrity: string }; + const result = await runOpenClaw( + [ + "claws", + "add", + "src/claws/fixtures/workspace-agent.claw.json", + "--yes", + "--plan-integrity", + plan.planIntegrity, + "--json", + ], + { stateDir: preview.stateDir }, + ); + const payload = parseJson(result.stdout); + const workspace = join(result.stateDir, ".openclaw", "workspace-workspace-agent"); + + expect(payload).toMatchObject({ + schemaVersion: "openclaw.clawAddResult.v1", + status: "complete", + agent: { finalId: "workspace-agent", workspace }, + workspaceFiles: [ + expect.objectContaining({ path: "SOUL.md" }), + expect.objectContaining({ path: "HEARTBEAT.md" }), + expect.objectContaining({ path: "reference/policy.md" }), + ], + installRecord: { agentId: "workspace-agent", status: "complete" }, + }); + await expect(readFile(join(workspace, "SOUL.md"), "utf8")).resolves.toContain( + "Incident Response", + ); + await expect(readFile(join(workspace, "HEARTBEAT.md"), "utf8")).resolves.toContain( + "Incident Heartbeat", + ); + await expect(readFile(join(workspace, "reference", "policy.md"), "utf8")).resolves.toContain( + "operator settings", + ); + }); + it("blocks mutation when declared components need later lifecycle slices", async () => { const preview = await runOpenClaw(["claws", "add", manifestPath, "--dry-run", "--json"], { expectFailure: true, diff --git a/src/claws/schema.test.ts b/src/claws/schema.test.ts index eae0c69d4855..106a2ad49555 100644 --- a/src/claws/schema.test.ts +++ b/src/claws/schema.test.ts @@ -577,6 +577,30 @@ describe("buildClawAddPlan", () => { expect(workspaceAction).not.toHaveProperty("digest"); }); + it.runIf(process.platform !== "win32")( + "canonicalizes workspace identity through symlinked parents", + async () => { + const { source } = await createPlanSource(); + const realParent = join(source.packageRoot, "real-parent"); + const aliasParent = join(source.packageRoot, "alias-parent"); + await mkdir(realParent, { recursive: true }); + await symlink(realParent, aliasParent, "dir"); + + const plan = await buildClawAddPlan({ + manifest: requireManifest({ schemaVersion: 1, agent: { id: "canonical-agent" } }), + source, + context: { workspace: join(aliasParent, "workspace-canonical-agent") }, + }); + + const canonicalWorkspace = join(realParent, "workspace-canonical-agent"); + expect(plan.agent.workspace).toBe(canonicalWorkspace); + expect(plan.agent.config.workspace).toBe(canonicalWorkspace); + expect(plan.actions.find((action) => action.kind === "workspace")?.target).toBe( + canonicalWorkspace, + ); + }, + ); + it("blocks aggregate workspace bytes before hashing sources", async () => { const { source, workspace } = await createPlanSource(); const files = []; diff --git a/src/claws/workspace.test.ts b/src/claws/workspace.test.ts new file mode 100644 index 000000000000..f1a723d1549c --- /dev/null +++ b/src/claws/workspace.test.ts @@ -0,0 +1,396 @@ +// Tests create-only Claw workspace files and immediate per-file provenance. +import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "../state/openclaw-state-db.js"; +import { applyClawAddPlan } from "./add.js"; +import { buildClawAddPlan } from "./lifecycle.js"; +import { parseClawManifest } from "./schema.js"; +import type { ClawAddPlan, ClawSourceIdentity } from "./types.js"; +import { ClawWorkspaceWriteError, createClawWorkspaceFiles } from "./workspace.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +afterEach(() => { + closeOpenClawStateDatabaseForTest(); +}); + +async function writeSource(root: string, path: string, content: string): Promise { + const target = join(root, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, content, "utf8"); +} + +async function makePlan(params?: { + workspace?: unknown; + createWorkspace?: boolean; + mutateAfterPlan?: (plan: ClawAddPlan, root: string) => Promise; +}) { + const root = tempDirs.make("openclaw-claw-workspace-"); + const workspace = join(root, "workspace-agent"); + await writeSource(root, "content/AGENTS.md", "# Agent\n"); + await writeSource(root, "content/policy.md", "Policy\n"); + const parsed = parseClawManifest({ + schemaVersion: 1, + agent: { id: "workspace-agent" }, + workspace: params?.workspace ?? { + bootstrapFiles: { "AGENTS.md": { source: "content/AGENTS.md" } }, + files: [{ source: "content/policy.md", path: "reference/policy.md" }], + }, + }); + if (!parsed.ok) { + throw new Error(JSON.stringify(parsed.diagnostics)); + } + const source: ClawSourceIdentity = { + kind: "package", + name: "@acme/workspace-agent", + version: "1.0.0", + packageRoot: root, + manifestPath: join(root, "openclaw.claw.json"), + integrityKind: "development-snapshot", + integrity: "sha256:manifest", + byteLength: 0, + }; + const plan = await buildClawAddPlan({ + manifest: parsed.manifest, + source, + context: { workspace }, + }); + expect(plan.blockers).toEqual([]); + if (params?.createWorkspace !== false) { + await mkdir(workspace); + } + await params?.mutateAfterPlan?.(plan, root); + return { root, workspace, plan }; +} + +function stateEnv(root: string) { + return { OPENCLAW_STATE_DIR: join(root, "state") }; +} + +type WorkspaceFileRow = { + schema_version: string; + agent_id: string; + workspace: string; + target_path: string; + source_path: string; + content_digest: string; + status: "pending" | "complete" | "failed"; + created_at_ms: number | bigint; + updated_at_ms: number | bigint; +}; + +function readWorkspaceFileRows(agentId: string, root: string) { + const rows = openOpenClawStateDatabase({ env: stateEnv(root) }) + .db.prepare( + `SELECT schema_version, agent_id, workspace, target_path, source_path, + content_digest, status, created_at_ms, updated_at_ms + FROM claw_workspace_files + WHERE agent_id = ? + ORDER BY target_path`, + ) + .all(agentId) as WorkspaceFileRow[]; + return rows.map((row) => ({ + schemaVersion: "openclaw.clawWorkspaceFileRecord.v1" as const, + agentId: row.agent_id, + workspace: row.workspace, + path: row.target_path, + sourcePath: row.source_path, + contentDigest: row.content_digest, + status: row.status, + createdAtMs: Number(row.created_at_ms), + updatedAtMs: Number(row.updated_at_ms), + })); +} + +function readInstallStatus(agentId: string, root: string): string | undefined { + const row = openOpenClawStateDatabase({ env: stateEnv(root) }) + .db.prepare(`SELECT status FROM claw_installs WHERE agent_id = ?`) + .get(agentId) as { status: string } | undefined; + return row?.status; +} + +describe("createClawWorkspaceFiles", () => { + it("creates canonical bootstrap and supporting files and records their hashes", async () => { + const { root, workspace, plan } = await makePlan(); + + const records = await createClawWorkspaceFiles(plan, { env: stateEnv(root), nowMs: 10 }); + + await expect(readFile(join(workspace, "AGENTS.md"), "utf8")).resolves.toBe("# Agent\n"); + await expect(readFile(join(workspace, "reference", "policy.md"), "utf8")).resolves.toBe( + "Policy\n", + ); + expect(records).toEqual([ + expect.objectContaining({ + schemaVersion: "openclaw.clawWorkspaceFileRecord.v1", + agentId: "workspace-agent", + path: "AGENTS.md", + sourcePath: "content/AGENTS.md", + contentDigest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/), + status: "complete", + createdAtMs: 10, + updatedAtMs: 10, + }), + expect.objectContaining({ + agentId: "workspace-agent", + path: "reference/policy.md", + }), + ]); + expect(readWorkspaceFileRows("workspace-agent", root)).toEqual(records); + }); + + it("never overwrites an unexpected destination", async () => { + const { root, workspace, plan } = await makePlan(); + await writeFile(join(workspace, "AGENTS.md"), "operator content\n", "utf8"); + + await expect(createClawWorkspaceFiles(plan, { env: stateEnv(root) })).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ code: "workspace_file_collision" })], + createdFiles: [], + }); + await expect(readFile(join(workspace, "AGENTS.md"), "utf8")).resolves.toBe( + "operator content\n", + ); + }); + + it("revalidates source content immediately before writing", async () => { + const { root, workspace, plan } = await makePlan({ + mutateAfterPlan: async (_plan, packageRoot) => { + await writeFile(join(packageRoot, "content", "AGENTS.md"), "changed\n", "utf8"); + }, + }); + + await expect(createClawWorkspaceFiles(plan, { env: stateEnv(root) })).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ code: "workspace_source_changed" })], + }); + await expect(readFile(join(workspace, "AGENTS.md"), "utf8")).rejects.toThrow(); + }); + + it.runIf(process.platform !== "win32")( + "rejects a source replaced by a symlink after planning", + async () => { + const outside = tempDirs.make("openclaw-claw-outside-"); + await writeFile(join(outside, "outside.md"), "outside\n", "utf8"); + const { root, workspace, plan } = await makePlan({ + mutateAfterPlan: async (_plan, packageRoot) => { + const source = join(packageRoot, "content", "AGENTS.md"); + await rm(source); + await symlink(join(outside, "outside.md"), source); + }, + }); + + await expect(createClawWorkspaceFiles(plan, { env: stateEnv(root) })).rejects.toBeInstanceOf( + ClawWorkspaceWriteError, + ); + await expect(readFile(join(workspace, "AGENTS.md"), "utf8")).rejects.toThrow(); + }, + ); + + it.runIf(process.platform !== "win32")( + "rejects a tampered plan source through a symlinked parent", + async () => { + const { root, workspace, plan } = await makePlan(); + await symlink(join(root, "content"), join(root, "content-link"), "dir"); + const action = plan.actions.find( + (candidate) => candidate.kind === "workspaceFile" && candidate.id === "AGENTS.md", + ); + if (!action) { + throw new Error("expected workspace action"); + } + action.source = join(plan.claw.packageRoot, "content-link", "AGENTS.md"); + + await expect(createClawWorkspaceFiles(plan, { env: stateEnv(root) })).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ code: "workspace_file_path_alias" })], + }); + await expect(readFile(join(workspace, "AGENTS.md"), "utf8")).rejects.toThrow(); + }, + ); + + it("persists earlier files when a later destination collides", async () => { + const { root, workspace, plan } = await makePlan({ + workspace: { + files: [ + { source: "content/AGENTS.md", path: "first.md" }, + { source: "content/policy.md", path: "second.md" }, + ], + }, + }); + await writeFile(join(workspace, "second.md"), "collision\n", "utf8"); + + await expect( + createClawWorkspaceFiles(plan, { env: stateEnv(root), nowMs: 20 }), + ).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ code: "workspace_file_collision" })], + createdFiles: [expect.objectContaining({ path: "first.md" })], + }); + await expect(readFile(join(workspace, "first.md"), "utf8")).resolves.toBe("# Agent\n"); + expect(readWorkspaceFileRows("workspace-agent", root)).toEqual([ + expect.objectContaining({ path: "first.md", createdAtMs: 20 }), + ]); + }); + + it("resumes matching owned files without weakening create-only collision checks", async () => { + const { root, workspace, plan } = await makePlan(); + + const first = await createClawWorkspaceFiles(plan, { env: stateEnv(root), nowMs: 10 }); + const resumed = await createClawWorkspaceFiles(plan, { env: stateEnv(root), nowMs: 20 }); + + expect(resumed).toHaveLength(first.length); + for (const [index, record] of resumed.entries()) { + const initial = first[index]; + if (!initial) { + throw new Error(`missing initial workspace record at index ${index}`); + } + expect(record).toEqual({ ...initial, status: "complete", updatedAtMs: 20 }); + } + await expect(readFile(join(workspace, "AGENTS.md"), "utf8")).resolves.toBe("# Agent\n"); + }); + + it("does not adopt an independently created file after a failed write record", async () => { + const { root, workspace, plan } = await makePlan(); + const action = plan.actions.find( + (candidate) => candidate.kind === "workspaceFile" && candidate.id === "AGENTS.md", + ); + if (!action?.digest) { + throw new Error("expected AGENTS.md workspace action"); + } + openOpenClawStateDatabase({ env: stateEnv(root) }) + .db.prepare( + `INSERT INTO claw_workspace_files ( + schema_version, agent_id, workspace, target_path, source_path, + content_digest, status, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + "openclaw.clawWorkspaceFileRecord.v1", + plan.agent.finalId, + plan.agent.workspace, + "AGENTS.md", + "content/AGENTS.md", + action.digest, + "failed", + 10, + 10, + ); + await writeFile(join(workspace, "AGENTS.md"), "# Agent\n", "utf8"); + + await expect( + createClawWorkspaceFiles(plan, { env: stateEnv(root), nowMs: 20 }), + ).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ code: "workspace_file_collision" })], + createdFiles: [], + }); + expect(readWorkspaceFileRows("workspace-agent", root)).toEqual([ + expect.objectContaining({ path: "AGENTS.md", status: "failed", updatedAtMs: 10 }), + ]); + }); + + it("fails closed when a previously owned destination drifts before resume", async () => { + const { root, workspace, plan } = await makePlan(); + await createClawWorkspaceFiles(plan, { env: stateEnv(root), nowMs: 10 }); + await writeFile(join(workspace, "AGENTS.md"), "operator edit\n", "utf8"); + + await expect( + createClawWorkspaceFiles(plan, { env: stateEnv(root), nowMs: 20 }), + ).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ code: "workspace_file_drift" })], + createdFiles: [], + }); + await expect(readFile(join(workspace, "AGENTS.md"), "utf8")).resolves.toBe("operator edit\n"); + }); + + it("rejects a tampered plan destination outside the new workspace", async () => { + const { root, plan } = await makePlan(); + const action = plan.actions.find((candidate) => candidate.kind === "workspaceFile"); + if (!action) { + throw new Error("expected workspace action"); + } + action.target = join(root, "outside.md"); + + await expect(createClawWorkspaceFiles(plan, { env: stateEnv(root) })).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ code: "workspace_file_path_escape" })], + }); + }); +}); + +describe("workspace files in the consented add lifecycle", () => { + it("marks the root install complete after every declared file is created", async () => { + const { root, plan } = await makePlan({ createWorkspace: false }); + let config: OpenClawConfig = {}; + + const result = await applyClawAddPlan(plan, { + consentPlanIntegrity: plan.planIntegrity, + env: stateEnv(root), + nowMs: 30, + commitConfig: async (transform) => { + config = transform(config); + }, + }); + + expect(result).toMatchObject({ + status: "complete", + workspaceFiles: [ + expect.objectContaining({ path: "AGENTS.md" }), + expect.objectContaining({ path: "reference/policy.md" }), + ], + installRecord: { status: "complete" }, + }); + expect(config.agents?.list?.some((agent) => agent.id === "workspace-agent")).toBe(true); + expect(readInstallStatus("workspace-agent", root)).toBe("complete"); + }); + + it("keeps root add resumable and retains earlier file refs after a later source changes", async () => { + const { root, plan } = await makePlan({ + createWorkspace: false, + mutateAfterPlan: async (_plan, packageRoot) => { + await writeFile(join(packageRoot, "content", "policy.md"), "changed\n", "utf8"); + }, + }); + let config: OpenClawConfig = {}; + + const result = await applyClawAddPlan(plan, { + consentPlanIntegrity: plan.planIntegrity, + env: stateEnv(root), + nowMs: 40, + commitConfig: async (transform) => { + config = transform(config); + }, + }); + + expect(result).toMatchObject({ + status: "partial", + workspaceFiles: [expect.objectContaining({ path: "AGENTS.md" })], + installRecord: { status: "config_committed" }, + error: { + code: "workspace_files_failed", + diagnostics: [expect.objectContaining({ code: "workspace_source_changed" })], + }, + }); + expect(config.agents?.list?.some((agent) => agent.id === "workspace-agent")).toBe(true); + expect(readInstallStatus("workspace-agent", root)).toBe("config_committed"); + + await writeFile(join(root, "content", "policy.md"), "Policy\n", "utf8"); + const resumed = await applyClawAddPlan(plan, { + consentPlanIntegrity: plan.planIntegrity, + env: stateEnv(root), + nowMs: 50, + commitConfig: async (transform) => { + config = transform(config); + }, + }); + + expect(resumed).toMatchObject({ + status: "complete", + workspaceFiles: [ + expect.objectContaining({ path: "AGENTS.md", status: "complete" }), + expect.objectContaining({ path: "reference/policy.md", status: "complete" }), + ], + installRecord: { status: "complete" }, + }); + expect(readInstallStatus("workspace-agent", root)).toBe("complete"); + }); +}); diff --git a/src/claws/workspace.ts b/src/claws/workspace.ts new file mode 100644 index 000000000000..d3441dc0a82b --- /dev/null +++ b/src/claws/workspace.ts @@ -0,0 +1,373 @@ +// Creates Claw-owned bootstrap and supporting files inside the new agent workspace. +import { createHash } from "node:crypto"; +import { realpath } from "node:fs/promises"; +import { isAbsolute, relative, resolve, sep } from "node:path"; +import { root as fsSafeRoot, FsSafeError } from "../infra/fs-safe.js"; +import { + runOpenClawStateWriteTransaction, + type OpenClawStateDatabaseOptions, +} from "../state/openclaw-state-db.js"; +import type { ClawAddPlan, ClawAddPlanAction, ClawDiagnostic } from "./types.js"; + +const CLAW_WORKSPACE_FILE_RECORD_SCHEMA_VERSION = "openclaw.clawWorkspaceFileRecord.v1" as const; + +const MAX_CLAW_WORKSPACE_FILE_BYTES = 1024 * 1024; + +export type PersistedClawWorkspaceFile = { + schemaVersion: typeof CLAW_WORKSPACE_FILE_RECORD_SCHEMA_VERSION; + agentId: string; + workspace: string; + path: string; + sourcePath: string; + contentDigest: string; + status: "pending" | "complete" | "failed"; + createdAtMs: number; + updatedAtMs: number; +}; + +export class ClawWorkspaceWriteError extends Error { + constructor( + readonly diagnostics: ClawDiagnostic[], + readonly createdFiles: PersistedClawWorkspaceFile[], + ) { + super("Claw workspace file creation failed"); + this.name = "ClawWorkspaceWriteError"; + } +} + +function diagnostic(action: ClawAddPlanAction, code: string, message: string): ClawDiagnostic { + return { + level: "error", + code, + phase: "mutation", + path: `$.workspace[${JSON.stringify(action.id)}]`, + message, + }; +} + +function contentDigest(content: Uint8Array): string { + return `sha256:${createHash("sha256").update(content).digest("hex")}`; +} + +function containedRelativePath(root: string, path: string): string | undefined { + const child = relative(root, path); + if (child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child)) { + return undefined; + } + return child; +} + +function persistWorkspaceFile( + record: PersistedClawWorkspaceFile, + options: OpenClawStateDatabaseOptions, +): void { + runOpenClawStateWriteTransaction(({ db }) => { + // sqlite-allow-raw: this Claw prototype state-table write is scoped to one owned row. + db.prepare( + `INSERT INTO claw_workspace_files ( + agent_id, target_path, schema_version, workspace, source_path, + content_digest, status, created_at_ms, updated_at_ms + ) VALUES ( + @agent_id, @target_path, @schema_version, @workspace, @source_path, + @content_digest, @status, @created_at_ms, @updated_at_ms + )`, + ).run({ + agent_id: record.agentId, + target_path: record.path, + schema_version: record.schemaVersion, + workspace: record.workspace, + source_path: record.sourcePath, + content_digest: record.contentDigest, + status: record.status, + created_at_ms: record.createdAtMs, + updated_at_ms: record.updatedAtMs, + }); + }, options); +} + +type PersistedClawWorkspaceFileRow = { + schema_version: string; + agent_id: string; + workspace: string; + target_path: string; + source_path: string; + content_digest: string; + status: string; + created_at_ms: number | bigint; + updated_at_ms: number | bigint; +}; + +function readWorkspaceFile( + agentId: string, + targetPath: string, + options: OpenClawStateDatabaseOptions, +): PersistedClawWorkspaceFile | undefined { + return runOpenClawStateWriteTransaction(({ db }) => { + const statement = db /* sqlite-allow-raw: one owned Claw state-table row */ + .prepare( + `SELECT schema_version, agent_id, workspace, target_path, source_path, + content_digest, status, created_at_ms, updated_at_ms + FROM claw_workspace_files + WHERE agent_id = ? AND target_path = ?`, + ); + const row = statement.get(agentId, targetPath) as PersistedClawWorkspaceFileRow | undefined; + if (!row) { + return undefined; + } + if ( + row.schema_version !== CLAW_WORKSPACE_FILE_RECORD_SCHEMA_VERSION || + (row.status !== "pending" && row.status !== "complete" && row.status !== "failed") + ) { + throw new Error( + `Claw workspace file ${JSON.stringify(targetPath)} has unsupported provenance state.`, + ); + } + return { + schemaVersion: CLAW_WORKSPACE_FILE_RECORD_SCHEMA_VERSION, + agentId: row.agent_id, + workspace: row.workspace, + path: row.target_path, + sourcePath: row.source_path, + contentDigest: row.content_digest, + status: row.status, + createdAtMs: Number(row.created_at_ms), + updatedAtMs: Number(row.updated_at_ms), + }; + }, options); +} + +function sameWorkspaceFileOwner( + existing: PersistedClawWorkspaceFile, + expected: PersistedClawWorkspaceFile, +): boolean { + return ( + existing.schemaVersion === expected.schemaVersion && + existing.agentId === expected.agentId && + existing.workspace === expected.workspace && + existing.path === expected.path && + existing.sourcePath === expected.sourcePath && + existing.contentDigest === expected.contentDigest + ); +} + +function updateWorkspaceFileStatus( + record: PersistedClawWorkspaceFile, + expectedStatuses: PersistedClawWorkspaceFile["status"][], + options: OpenClawStateDatabaseOptions, +): void { + runOpenClawStateWriteTransaction(({ db }) => { + const expectedPlaceholders = expectedStatuses.map(() => "?").join(", "); + const statement = db /* sqlite-allow-raw: one owned Claw state-table row */ + .prepare( + `UPDATE claw_workspace_files + SET status = ?, updated_at_ms = ? + WHERE agent_id = ? AND target_path = ? + AND status IN (${expectedPlaceholders})`, + ); + const result = statement.run( + record.status, + record.updatedAtMs, + record.agentId, + record.path, + ...expectedStatuses, + ); + if (Number(result.changes) !== 1) { + throw new Error( + `Claw workspace file ${JSON.stringify(record.path)} changed ownership state concurrently.`, + ); + } + }, options); +} + +function workspaceFileActions(plan: ClawAddPlan): ClawAddPlanAction[] { + return plan.actions.filter((action) => action.kind === "workspaceFile"); +} + +export async function createClawWorkspaceFiles( + plan: ClawAddPlan, + options: OpenClawStateDatabaseOptions & { nowMs?: number } = {}, +): Promise { + const actions = workspaceFileActions(plan); + if (actions.length === 0) { + return []; + } + + const workspaceRoot = await realpath(resolve(plan.agent.workspace)); + const packageRoot = await realpath(resolve(plan.claw.packageRoot)); + const source = await fsSafeRoot(packageRoot, { + hardlinks: "reject", + maxBytes: MAX_CLAW_WORKSPACE_FILE_BYTES, + symlinks: "reject", + }); + const workspace = await fsSafeRoot(workspaceRoot, { + hardlinks: "reject", + maxBytes: MAX_CLAW_WORKSPACE_FILE_BYTES, + symlinks: "reject", + }); + const createdFiles: PersistedClawWorkspaceFile[] = []; + const nowMs = options.nowMs ?? Date.now(); + + for (const action of actions) { + try { + if (!action.source || !action.digest) { + throw new ClawWorkspaceWriteError( + [ + diagnostic( + action, + "workspace_file_plan_invalid", + "File action lacks source or digest.", + ), + ], + createdFiles, + ); + } + const sourcePath = resolve(action.source); + const targetPath = resolve(action.target); + const sourceRelative = containedRelativePath(packageRoot, sourcePath); + const targetRelative = containedRelativePath(workspaceRoot, targetPath); + if (!sourceRelative || !targetRelative) { + throw new ClawWorkspaceWriteError( + [ + diagnostic( + action, + "workspace_file_path_escape", + "Workspace file source and destination must remain inside their owned roots.", + ), + ], + createdFiles, + ); + } + const read = await source.read(sourceRelative, { + hardlinks: "reject", + maxBytes: MAX_CLAW_WORKSPACE_FILE_BYTES, + symlinks: "reject", + }); + if (resolve(read.realPath) !== sourcePath) { + throw new ClawWorkspaceWriteError( + [ + diagnostic( + action, + "workspace_file_path_alias", + `Workspace source ${JSON.stringify(action.id)} no longer resolves to the consented file.`, + ), + ], + createdFiles, + ); + } + const digest = contentDigest(read.buffer); + if (digest !== action.digest) { + throw new ClawWorkspaceWriteError( + [ + diagnostic( + action, + "workspace_source_changed", + `Workspace source for ${JSON.stringify(action.id)} changed after planning.`, + ), + ], + createdFiles, + ); + } + const expectedRecord: PersistedClawWorkspaceFile = { + schemaVersion: CLAW_WORKSPACE_FILE_RECORD_SCHEMA_VERSION, + agentId: plan.agent.finalId, + workspace: workspace.rootReal, + path: targetRelative.replaceAll(sep, "/"), + sourcePath: sourceRelative.replaceAll(sep, "/"), + contentDigest: digest, + status: "pending", + createdAtMs: nowMs, + updatedAtMs: nowMs, + }; + const existingRecord = readWorkspaceFile( + expectedRecord.agentId, + expectedRecord.path, + options, + ); + if (existingRecord && !sameWorkspaceFileOwner(existingRecord, expectedRecord)) { + throw new ClawWorkspaceWriteError( + [ + diagnostic( + action, + "workspace_file_ownership_conflict", + `Workspace destination ${JSON.stringify(targetRelative)} is already claimed by different Claw provenance.`, + ), + ], + createdFiles, + ); + } + if (await workspace.exists(targetRelative)) { + if (!existingRecord || existingRecord.status === "failed") { + throw new ClawWorkspaceWriteError( + [ + diagnostic( + action, + "workspace_file_collision", + `Workspace destination ${JSON.stringify(targetRelative)} already exists.`, + ), + ], + createdFiles, + ); + } + const existingTarget = await workspace.read(targetRelative, { + hardlinks: "reject", + maxBytes: MAX_CLAW_WORKSPACE_FILE_BYTES, + symlinks: "reject", + }); + if (contentDigest(existingTarget.buffer) !== expectedRecord.contentDigest) { + throw new ClawWorkspaceWriteError( + [ + diagnostic( + action, + "workspace_file_drift", + `Claw-owned workspace destination ${JSON.stringify(targetRelative)} no longer matches its recorded content.`, + ), + ], + createdFiles, + ); + } + const previousStatus = existingRecord.status; + existingRecord.status = "complete"; + existingRecord.updatedAtMs = nowMs; + updateWorkspaceFileStatus(existingRecord, [previousStatus], options); + createdFiles.push(existingRecord); + continue; + } + const record = existingRecord ?? expectedRecord; + if (existingRecord) { + const previousStatus = record.status; + record.status = "pending"; + record.updatedAtMs = nowMs; + updateWorkspaceFileStatus(record, [previousStatus], options); + } else { + persistWorkspaceFile(record, options); + } + try { + await workspace.write(targetRelative, read.buffer, { mkdir: true, overwrite: false }); + record.status = "complete"; + updateWorkspaceFileStatus(record, ["pending"], options); + createdFiles.push(record); + } catch (error) { + record.status = "failed"; + try { + updateWorkspaceFileStatus(record, ["pending"], options); + } catch { + // A pending row intentionally remains as evidence of uncertain owner state. + record.status = "pending"; + } + createdFiles.push(record); + throw error; + } + } catch (error) { + if (error instanceof ClawWorkspaceWriteError) { + throw error; + } + const code = + error instanceof FsSafeError ? `workspace_file_${error.code}` : "workspace_file_io_error"; + throw new ClawWorkspaceWriteError( + [diagnostic(action, code, error instanceof Error ? error.message : String(error))], + createdFiles, + ); + } + } + return createdFiles; +} diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index 05f907738c51..e8a3b44d6f9a 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -277,6 +277,18 @@ export interface ClawInstalls { workspace: string; } +export interface ClawWorkspaceFiles { + agent_id: string; + content_digest: string; + created_at_ms: number; + schema_version: string; + source_path: string; + status: string; + target_path: string; + updated_at_ms: number; + workspace: string; +} + export interface ClawhubPromotionClaims { claimed_at_ms: number; ends_at_ms: number; @@ -1378,6 +1390,7 @@ export interface DB { channel_pairing_allow_entries: ChannelPairingAllowEntries; channel_pairing_requests: ChannelPairingRequests; claw_installs: ClawInstalls; + claw_workspace_files: ClawWorkspaceFiles; clawhub_promotion_claims: ClawhubPromotionClaims; clawhub_promotions_feed_state: ClawhubPromotionsFeedState; command_log_entries: CommandLogEntries; diff --git a/src/state/openclaw-state-schema.generated.ts b/src/state/openclaw-state-schema.generated.ts index b056f8ef7bbf..87cba36ad83b 100644 --- a/src/state/openclaw-state-schema.generated.ts +++ b/src/state/openclaw-state-schema.generated.ts @@ -1953,4 +1953,17 @@ CREATE TABLE IF NOT EXISTS claw_installs ( ), added_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS claw_workspace_files ( + agent_id TEXT NOT NULL, + target_path TEXT NOT NULL, + schema_version TEXT NOT NULL, + workspace TEXT NOT NULL, + source_path TEXT NOT NULL, + content_digest TEXT NOT NULL, + status TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + PRIMARY KEY (agent_id, target_path) ) STRICT;\n`; diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index 8b2592794d78..25f42a725ffe 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -1949,3 +1949,16 @@ CREATE TABLE IF NOT EXISTS claw_installs ( added_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL ) STRICT; + +CREATE TABLE IF NOT EXISTS claw_workspace_files ( + agent_id TEXT NOT NULL, + target_path TEXT NOT NULL, + schema_version TEXT NOT NULL, + workspace TEXT NOT NULL, + source_path TEXT NOT NULL, + content_digest TEXT NOT NULL, + status TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + PRIMARY KEY (agent_id, target_path) +) STRICT;