Create Claw-managed workspace files (#101973)

* Create Claw-managed workspace files

* test(claws): cover managed workspace files

* docs(claws): document workspace files

* docs(claws): correct workspace manifest example

* fix(claws): preserve failed workspace ownership boundaries

* test(claws): align symlink escape diagnostic

* test(claws): canonicalize workspace alias fixture

* test(claws): clean workspace temp dirs

---------

Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
This commit is contained in:
Gio Della-Libera
2026-07-21 17:36:33 -07:00
committed by GitHub
parent 88a7306308
commit ebe2420482
11 changed files with 992 additions and 7 deletions
+24 -3
View File
@@ -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
+65 -4
View File
@@ -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<NonNullable<OpenClawConfig["agents"]>["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 },
};
}
@@ -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"
}
]
}
}
@@ -0,0 +1,3 @@
# Workspace Policy
Keep operator settings and unrelated agents unchanged.
+46
View File
@@ -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,
+24
View File
@@ -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 = [];
+396
View File
@@ -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<void> {
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<void>;
}) {
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");
});
});
+373
View File
@@ -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<PersistedClawWorkspaceFile[]> {
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;
}
+13
View File
@@ -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;
@@ -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`;
+13
View File
@@ -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;