mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-07 06:23:33 +00:00
869 lines
37 KiB
TypeScript
869 lines
37 KiB
TypeScript
// Workspace tests cover bootstrap seeding, attestation safety, bootstrap file
|
|
// filtering, and setup-completion state for agent workspaces.
|
|
import { createHash } from "node:crypto";
|
|
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 { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js";
|
|
import {
|
|
createOpenClawTestState,
|
|
type OpenClawTestState,
|
|
} from "../test-utils/openclaw-test-state.js";
|
|
import {
|
|
DEFAULT_AGENTS_FILENAME,
|
|
DEFAULT_BOOTSTRAP_FILENAME,
|
|
DEFAULT_HEARTBEAT_FILENAME,
|
|
DEFAULT_IDENTITY_FILENAME,
|
|
DEFAULT_MEMORY_FILENAME,
|
|
DEFAULT_SOUL_FILENAME,
|
|
DEFAULT_TOOLS_FILENAME,
|
|
DEFAULT_USER_FILENAME,
|
|
ensureAgentWorkspace,
|
|
filterBootstrapFilesForSession,
|
|
isWorkspaceBootstrapPending,
|
|
loadWorkspaceBootstrapFiles,
|
|
resolveWorkspaceBootstrapStatus,
|
|
resolveDefaultAgentWorkspaceDir,
|
|
resolveWorkspaceAttestationPaths,
|
|
WORKSPACE_VANISHED_ERROR_CODE,
|
|
type WorkspaceBootstrapFile,
|
|
} from "./workspace.js";
|
|
|
|
let testState: OpenClawTestState | undefined;
|
|
|
|
beforeEach(async () => {
|
|
testState = await createOpenClawTestState({
|
|
layout: "state-only",
|
|
prefix: "openclaw-workspace-state-",
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await testState?.cleanup();
|
|
testState = undefined;
|
|
});
|
|
|
|
describe("resolveDefaultAgentWorkspaceDir", () => {
|
|
it("uses OPENCLAW_HOME for default workspace resolution", () => {
|
|
const dir = resolveDefaultAgentWorkspaceDir({
|
|
OPENCLAW_HOME: "/srv/openclaw-home",
|
|
HOME: "/home/other",
|
|
} as NodeJS.ProcessEnv);
|
|
|
|
expect(dir).toBe(path.join(path.resolve("/srv/openclaw-home"), ".openclaw", "workspace"));
|
|
});
|
|
|
|
it("prefers OPENCLAW_WORKSPACE_DIR for default workspace resolution", () => {
|
|
const dir = resolveDefaultAgentWorkspaceDir({
|
|
OPENCLAW_WORKSPACE_DIR: "/srv/openclaw-workspace",
|
|
OPENCLAW_HOME: "/srv/openclaw-home",
|
|
HOME: "/home/other",
|
|
} as NodeJS.ProcessEnv);
|
|
|
|
expect(dir).toBe(path.resolve("/srv/openclaw-workspace"));
|
|
});
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
};
|
|
}
|
|
|
|
async function writeLegacyWorkspaceState(dir: string, state: unknown): Promise<void> {
|
|
await fs.mkdir(path.join(dir, LEGACY_WORKSPACE_STATE_PATH_SEGMENTS[0]), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(dir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS),
|
|
`${JSON.stringify(state)}\n`,
|
|
);
|
|
}
|
|
|
|
async function expectBootstrapSeeded(dir: string) {
|
|
await expect(fs.access(path.join(dir, DEFAULT_BOOTSTRAP_FILENAME))).resolves.toBeUndefined();
|
|
const state = await readWorkspaceState(dir);
|
|
expect(state.bootstrapSeededAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
|
}
|
|
|
|
async function expectPathMissing(filePath: string): Promise<void> {
|
|
await expect(fs.access(filePath)).rejects.toHaveProperty("code", "ENOENT");
|
|
}
|
|
|
|
async function expectWorkspaceVanished(
|
|
action: Promise<unknown>,
|
|
expected?: { attestationPath?: string },
|
|
): Promise<void> {
|
|
// 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 expectCompletedWithoutBootstrap(dir: string) {
|
|
await expect(fs.access(path.join(dir, DEFAULT_IDENTITY_FILENAME))).resolves.toBeUndefined();
|
|
await expectPathMissing(path.join(dir, DEFAULT_BOOTSTRAP_FILENAME));
|
|
const state = await readWorkspaceState(dir);
|
|
expect(state.setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
|
}
|
|
|
|
function expectSubagentAllowedBootstrapNames(files: WorkspaceBootstrapFile[]) {
|
|
const names = files.map((file) => file.name);
|
|
expect(names).toStrictEqual(["AGENTS.md", "TOOLS.md"]);
|
|
}
|
|
|
|
function expectCronAllowedBootstrapNames(files: WorkspaceBootstrapFile[]) {
|
|
const names = files.map((file) => file.name);
|
|
expect(names).toStrictEqual(["AGENTS.md", "SOUL.md", "TOOLS.md", "IDENTITY.md", "USER.md"]);
|
|
}
|
|
|
|
describe("ensureAgentWorkspace", () => {
|
|
it("creates BOOTSTRAP.md and records a seeded marker for brand new workspaces", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
|
|
await expectBootstrapSeeded(tempDir);
|
|
await expectPathMissing(path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS));
|
|
expect((await readWorkspaceState(tempDir)).setupCompletedAt).toBeUndefined();
|
|
});
|
|
|
|
it("does not overwrite a foreign root workspace-state.json file", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
const foreignStatePath = path.join(tempDir, "workspace-state.json");
|
|
const foreignState = "not openclaw state\n";
|
|
await fs.writeFile(foreignStatePath, foreignState);
|
|
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
|
|
expect(await fs.readFile(foreignStatePath, "utf-8")).toBe(foreignState);
|
|
await expectBootstrapSeeded(tempDir);
|
|
});
|
|
|
|
it("ignores unreadable legacy nested state while writing current setup state", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
await fs.mkdir(path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS), {
|
|
recursive: true,
|
|
});
|
|
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
|
|
await expectBootstrapSeeded(tempDir);
|
|
const legacyStateStat = await fs.stat(
|
|
path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS),
|
|
);
|
|
expect(legacyStateStat.isDirectory()).toBe(true);
|
|
});
|
|
|
|
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();
|
|
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
|
|
await expectWorkspaceVanished(
|
|
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
|
);
|
|
await expectPathMissing(tempDir);
|
|
});
|
|
|
|
it("refuses to re-seed a recently attested workspace after its contents are wiped", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
await fs.mkdir(tempDir, { recursive: true });
|
|
|
|
await expectWorkspaceVanished(
|
|
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
|
);
|
|
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
|
await expectPathMissing(path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS));
|
|
});
|
|
|
|
it("refuses to re-seed a recently attested workspace 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");
|
|
|
|
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));
|
|
await expectPathMissing(path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS));
|
|
});
|
|
|
|
it("refuses to re-seed a recently attested workspace after only generated git metadata survives", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
await fs.mkdir(path.join(tempDir, ".git"), { recursive: true });
|
|
await fs.mkdir(path.join(tempDir, ".openclaw"), { recursive: true });
|
|
|
|
await expectWorkspaceVanished(
|
|
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
|
);
|
|
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
|
});
|
|
|
|
it("refuses to accept old generated bootstrap files recorded by the attestation marker", 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"),
|
|
);
|
|
|
|
await expectWorkspaceVanished(
|
|
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
|
);
|
|
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
|
});
|
|
|
|
it("refuses a recently attested workspace when generated state and only one generated file 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");
|
|
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("accepts a recently attested workspace when customized AGENTS.md survives", async () => {
|
|
// Custom instructions prove the directory is user-managed, so reseeding is
|
|
// skipped and the workspace is accepted.
|
|
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(
|
|
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
|
).resolves.toMatchObject({ dir: tempDir });
|
|
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
|
});
|
|
|
|
it("accepts a recently attested workspace when only custom skills survive", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
await fs.mkdir(path.join(tempDir, "skills", "local-skill"), { recursive: true });
|
|
await fs.writeFile(path.join(tempDir, "skills", "local-skill", "SKILL.md"), "---\n");
|
|
|
|
await expect(
|
|
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
|
).resolves.toMatchObject({ dir: tempDir });
|
|
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
|
expect((await readWorkspaceState(tempDir)).setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
|
});
|
|
|
|
it("refuses a recently attested workspace when only non-skill skills leftovers survive", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
await fs.mkdir(path.join(tempDir, "skills"), { recursive: true });
|
|
await fs.writeFile(path.join(tempDir, "skills", ".DS_Store"), "");
|
|
|
|
await expectWorkspaceVanished(
|
|
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
|
);
|
|
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
|
});
|
|
|
|
it("refuses to recreate a skip-bootstrap workspace after the directory disappears", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
await fs.writeFile(path.join(tempDir, "seed.txt"), "preseeded\n");
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: false });
|
|
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
|
|
await expectWorkspaceVanished(
|
|
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: false }),
|
|
);
|
|
await expectPathMissing(tempDir);
|
|
});
|
|
|
|
it("refuses to accept an empty skip-bootstrap workspace after contents are wiped", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
await fs.writeFile(path.join(tempDir, "seed.txt"), "preseeded\n");
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: false });
|
|
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
await fs.mkdir(tempDir, { recursive: true });
|
|
|
|
await expectWorkspaceVanished(
|
|
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: false }),
|
|
);
|
|
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
|
});
|
|
|
|
it("refuses to accept a wiped skip-bootstrap workspace with only metadata leftovers", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
await fs.writeFile(path.join(tempDir, "seed.txt"), "preseeded\n");
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: false });
|
|
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
await fs.mkdir(path.join(tempDir, ".openclaw"), { recursive: true });
|
|
await fs.mkdir(path.join(tempDir, "skills"), { recursive: true });
|
|
await fs.writeFile(path.join(tempDir, ".DS_Store"), "");
|
|
|
|
await expectWorkspaceVanished(
|
|
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: false }),
|
|
);
|
|
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
|
});
|
|
|
|
it("allows repeated skip-bootstrap setup for an intentionally empty workspace", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: false });
|
|
await expect(
|
|
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: false }),
|
|
).resolves.toMatchObject({ dir: tempDir });
|
|
});
|
|
|
|
it("allows a brand new workspace when the only attestation marker is stale", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
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);
|
|
});
|
|
|
|
it("does not overwrite a sibling file that is not an OpenClaw attestation marker", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
const attestationPath = `${tempDir}.attested`;
|
|
const siblingContent = "external attestation data\n";
|
|
await fs.writeFile(attestationPath, siblingContent);
|
|
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
|
|
await expectBootstrapSeeded(tempDir);
|
|
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" });
|
|
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
|
|
await expectBootstrapSeeded(tempDir);
|
|
});
|
|
|
|
it("does not recreate BOOTSTRAP.md after completion, even when a core file is recreated", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_IDENTITY_FILENAME, content: "custom" });
|
|
await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_USER_FILENAME, content: "custom" });
|
|
await fs.unlink(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
|
await fs.unlink(path.join(tempDir, DEFAULT_TOOLS_FILENAME));
|
|
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
|
|
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
|
await expect(fs.access(path.join(tempDir, DEFAULT_TOOLS_FILENAME))).resolves.toBeUndefined();
|
|
const state = await readWorkspaceState(tempDir);
|
|
expect(state.setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
|
});
|
|
|
|
it("does not re-seed BOOTSTRAP.md for legacy completed workspaces without state marker", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_IDENTITY_FILENAME, content: "custom" });
|
|
await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_USER_FILENAME, content: "custom" });
|
|
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
|
|
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
|
const state = await readWorkspaceState(tempDir);
|
|
expect(state.bootstrapSeededAt).toBeUndefined();
|
|
expect(state.setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
|
});
|
|
|
|
it("treats memory-backed workspaces as existing even when template files are missing", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
await fs.mkdir(path.join(tempDir, "memory"), { recursive: true });
|
|
await fs.writeFile(path.join(tempDir, "memory", "2026-02-25.md"), "# Daily log\nSome notes");
|
|
await fs.writeFile(path.join(tempDir, "MEMORY.md"), "# Long-term memory\nImportant stuff");
|
|
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
|
|
await expect(fs.access(path.join(tempDir, DEFAULT_IDENTITY_FILENAME))).resolves.toBeUndefined();
|
|
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
|
const state = await readWorkspaceState(tempDir);
|
|
expect(state.setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
|
const memoryContent = await fs.readFile(path.join(tempDir, "MEMORY.md"), "utf-8");
|
|
expect(memoryContent).toBe("# Long-term memory\nImportant stuff");
|
|
});
|
|
|
|
it("treats git-backed workspaces as existing even when template files are missing", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
await fs.mkdir(path.join(tempDir, ".git"), { recursive: true });
|
|
await fs.writeFile(path.join(tempDir, ".git", "HEAD"), "ref: refs/heads/main\n");
|
|
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
|
|
await expectCompletedWithoutBootstrap(tempDir);
|
|
});
|
|
|
|
it("skips configured optional bootstrap files without skipping required files", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
|
|
await ensureAgentWorkspace({
|
|
dir: tempDir,
|
|
ensureBootstrapFiles: true,
|
|
skipOptionalBootstrapFiles: [
|
|
DEFAULT_SOUL_FILENAME,
|
|
DEFAULT_IDENTITY_FILENAME,
|
|
DEFAULT_USER_FILENAME,
|
|
DEFAULT_HEARTBEAT_FILENAME,
|
|
],
|
|
});
|
|
|
|
await expect(fs.access(path.join(tempDir, DEFAULT_AGENTS_FILENAME))).resolves.toBeUndefined();
|
|
await expect(fs.access(path.join(tempDir, DEFAULT_TOOLS_FILENAME))).resolves.toBeUndefined();
|
|
await expect(
|
|
fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
|
|
).resolves.toBeUndefined();
|
|
for (const fileName of [
|
|
DEFAULT_SOUL_FILENAME,
|
|
DEFAULT_IDENTITY_FILENAME,
|
|
DEFAULT_USER_FILENAME,
|
|
DEFAULT_HEARTBEAT_FILENAME,
|
|
]) {
|
|
await expectPathMissing(path.join(tempDir, fileName));
|
|
}
|
|
});
|
|
|
|
it("preserves legacy setup detection when skipped profile files already exist", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_IDENTITY_FILENAME, content: "custom" });
|
|
await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_USER_FILENAME, content: "custom" });
|
|
|
|
await ensureAgentWorkspace({
|
|
dir: tempDir,
|
|
ensureBootstrapFiles: true,
|
|
skipOptionalBootstrapFiles: [DEFAULT_IDENTITY_FILENAME, DEFAULT_USER_FILENAME],
|
|
});
|
|
|
|
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
|
const state = await readWorkspaceState(tempDir);
|
|
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-");
|
|
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
|
|
await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("pending");
|
|
await expect(isWorkspaceBootstrapPending(tempDir)).resolves.toBe(true);
|
|
});
|
|
|
|
it("keeps bootstrap status read-only when stale completion evidence exists", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
await writeWorkspaceFile({
|
|
dir: tempDir,
|
|
name: DEFAULT_IDENTITY_FILENAME,
|
|
content: "# IDENTITY.md\n\n- **Name:** Example\n",
|
|
});
|
|
|
|
await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("pending");
|
|
await expect(
|
|
fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
|
|
).resolves.toBeUndefined();
|
|
expect((await readWorkspaceState(tempDir)).setupCompletedAt).toBeUndefined();
|
|
});
|
|
|
|
it("repairs stale BOOTSTRAP.md when profile files show onboarding completed", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
await writeWorkspaceFile({
|
|
dir: tempDir,
|
|
name: DEFAULT_IDENTITY_FILENAME,
|
|
content: "# IDENTITY.md\n\n- **Name:** Example\n",
|
|
});
|
|
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
|
const state = await readWorkspaceState(tempDir);
|
|
expect(state.bootstrapSeededAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
|
expect(state.setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
|
await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("complete");
|
|
await expect(isWorkspaceBootstrapPending(tempDir)).resolves.toBe(false);
|
|
});
|
|
|
|
it("records stale bootstrap completion when BOOTSTRAP.md cleanup fails", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
await writeWorkspaceFile({
|
|
dir: tempDir,
|
|
name: DEFAULT_IDENTITY_FILENAME,
|
|
content: "# IDENTITY.md\n\n- **Name:** Example\n",
|
|
});
|
|
const bootstrapPath = path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME);
|
|
const rmSpy = vi
|
|
.spyOn(fs, "rm")
|
|
.mockRejectedValueOnce(Object.assign(new Error("not a directory"), { code: "ENOTDIR" }));
|
|
|
|
try {
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
expect(rmSpy).toHaveBeenCalledWith(bootstrapPath, { force: true });
|
|
await expect(fs.access(bootstrapPath)).resolves.toBeUndefined();
|
|
const state = await readWorkspaceState(tempDir);
|
|
expect(state.setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
|
await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("complete");
|
|
await expect(isWorkspaceBootstrapPending(tempDir)).resolves.toBe(false);
|
|
} finally {
|
|
rmSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("uses SOUL.md customization as stale bootstrap completion evidence", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
await writeWorkspaceFile({
|
|
dir: tempDir,
|
|
name: DEFAULT_SOUL_FILENAME,
|
|
content: "# SOUL.md\n\nUse a concise, practical voice.\n",
|
|
});
|
|
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
|
});
|
|
|
|
it("does not treat git alone as stale bootstrap completion evidence", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
await fs.mkdir(path.join(tempDir, ".git"), { recursive: true });
|
|
await fs.writeFile(path.join(tempDir, ".git", "HEAD"), "ref: refs/heads/main\n");
|
|
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("pending");
|
|
await expect(
|
|
fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
|
|
).resolves.toBeUndefined();
|
|
expect((await readWorkspaceState(tempDir)).setupCompletedAt).toBeUndefined();
|
|
});
|
|
|
|
it("reports bootstrap complete once BOOTSTRAP.md is deleted and completion is recorded", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
await fs.unlink(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
|
|
await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("complete");
|
|
await expect(isWorkspaceBootstrapPending(tempDir)).resolves.toBe(false);
|
|
});
|
|
|
|
it("writes the clean HEARTBEAT runtime template into new workspaces", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
|
|
const heartbeat = await fs.readFile(path.join(tempDir, DEFAULT_HEARTBEAT_FILENAME), "utf-8");
|
|
expect(heartbeat).not.toContain("```");
|
|
expect(heartbeat).toContain(
|
|
"# Keep this file empty (or with only comments) to skip heartbeat API calls.",
|
|
);
|
|
expect(heartbeat).toContain(
|
|
"# Add tasks below when you want the agent to check something periodically.",
|
|
);
|
|
});
|
|
|
|
it("does not recreate optional bootstrap files when workspace setup is already completed", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
|
|
// First call: set up the workspace and complete setup by customizing profile files.
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
await writeWorkspaceFile({
|
|
dir: tempDir,
|
|
name: DEFAULT_IDENTITY_FILENAME,
|
|
content: "custom identity",
|
|
});
|
|
await writeWorkspaceFile({
|
|
dir: tempDir,
|
|
name: DEFAULT_USER_FILENAME,
|
|
content: "custom user",
|
|
});
|
|
// Delete BOOTSTRAP.md to trigger completion on next ensure call.
|
|
await fs.unlink(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
|
|
// Verify setup is completed.
|
|
const state = await readWorkspaceState(tempDir);
|
|
expect(state.setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
|
|
|
// Delete optional bootstrap files and customize AGENTS.md to simulate
|
|
// a repository workspace where optional files only exist under agent
|
|
// subdirectories but the root still has customized required files.
|
|
await fs.unlink(path.join(tempDir, DEFAULT_SOUL_FILENAME));
|
|
await fs.unlink(path.join(tempDir, DEFAULT_IDENTITY_FILENAME));
|
|
await fs.unlink(path.join(tempDir, DEFAULT_USER_FILENAME));
|
|
await fs.unlink(path.join(tempDir, DEFAULT_HEARTBEAT_FILENAME));
|
|
await writeWorkspaceFile({
|
|
dir: tempDir,
|
|
name: DEFAULT_AGENTS_FILENAME,
|
|
content: "custom agents instructions\n",
|
|
});
|
|
|
|
// Third call: should NOT recreate optional files for an already-configured workspace.
|
|
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
|
|
|
// Verify optional files are NOT recreated at the root level.
|
|
await expectPathMissing(path.join(tempDir, DEFAULT_SOUL_FILENAME));
|
|
await expectPathMissing(path.join(tempDir, DEFAULT_IDENTITY_FILENAME));
|
|
await expectPathMissing(path.join(tempDir, DEFAULT_USER_FILENAME));
|
|
await expectPathMissing(path.join(tempDir, DEFAULT_HEARTBEAT_FILENAME));
|
|
|
|
// Verify required files (AGENTS.md, TOOLS.md) still exist.
|
|
await expect(fs.access(path.join(tempDir, DEFAULT_AGENTS_FILENAME))).resolves.toBeUndefined();
|
|
await expect(fs.access(path.join(tempDir, DEFAULT_TOOLS_FILENAME))).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("loadWorkspaceBootstrapFiles", () => {
|
|
const getMemoryEntries = (files: Awaited<ReturnType<typeof loadWorkspaceBootstrapFiles>>) =>
|
|
files.filter((file) => file.name === DEFAULT_MEMORY_FILENAME);
|
|
|
|
const expectSingleMemoryEntry = (
|
|
files: Awaited<ReturnType<typeof loadWorkspaceBootstrapFiles>>,
|
|
content: string,
|
|
) => {
|
|
const memoryEntries = getMemoryEntries(files);
|
|
expect(memoryEntries).toHaveLength(1);
|
|
expect(memoryEntries[0]?.missing).toBe(false);
|
|
expect(memoryEntries[0]?.content).toBe(content);
|
|
};
|
|
|
|
it("includes MEMORY.md when present", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
await writeWorkspaceFile({ dir: tempDir, name: "MEMORY.md", content: "memory" });
|
|
|
|
const files = await loadWorkspaceBootstrapFiles(tempDir);
|
|
expectSingleMemoryEntry(files, "memory");
|
|
});
|
|
|
|
it("ignores lowercase memory.md when MEMORY.md is absent", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
await writeWorkspaceFile({ dir: tempDir, name: "memory.md", content: "alt" });
|
|
|
|
const files = await loadWorkspaceBootstrapFiles(tempDir);
|
|
expect(getMemoryEntries(files)).toHaveLength(0);
|
|
});
|
|
|
|
it("omits memory entries when no memory files exist", async () => {
|
|
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
|
|
|
const files = await loadWorkspaceBootstrapFiles(tempDir);
|
|
expect(getMemoryEntries(files)).toHaveLength(0);
|
|
});
|
|
|
|
it("treats hardlinked bootstrap aliases as missing", async () => {
|
|
if (process.platform === "win32") {
|
|
return;
|
|
}
|
|
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-hardlink-"));
|
|
try {
|
|
const workspaceDir = path.join(rootDir, "workspace");
|
|
const outsideDir = path.join(rootDir, "outside");
|
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
await fs.mkdir(outsideDir, { recursive: true });
|
|
const outsideFile = path.join(outsideDir, DEFAULT_AGENTS_FILENAME);
|
|
const linkPath = path.join(workspaceDir, DEFAULT_AGENTS_FILENAME);
|
|
await fs.writeFile(outsideFile, "outside", "utf-8");
|
|
try {
|
|
await fs.link(outsideFile, linkPath);
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const files = await loadWorkspaceBootstrapFiles(workspaceDir);
|
|
const agents = files.find((file) => file.name === DEFAULT_AGENTS_FILENAME);
|
|
expect(agents?.missing).toBe(true);
|
|
expect(agents?.content).toBeUndefined();
|
|
} finally {
|
|
await fs.rm(rootDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("filterBootstrapFilesForSession", () => {
|
|
const mockFiles: WorkspaceBootstrapFile[] = [
|
|
{ name: "AGENTS.md", path: "/w/AGENTS.md", content: "", missing: false },
|
|
{ name: "SOUL.md", path: "/w/SOUL.md", content: "", missing: false },
|
|
{ name: "TOOLS.md", path: "/w/TOOLS.md", content: "", missing: false },
|
|
{ name: "IDENTITY.md", path: "/w/IDENTITY.md", content: "", missing: false },
|
|
{ name: "USER.md", path: "/w/USER.md", content: "", missing: false },
|
|
{ name: "HEARTBEAT.md", path: "/w/HEARTBEAT.md", content: "", missing: false },
|
|
{ name: "BOOTSTRAP.md", path: "/w/BOOTSTRAP.md", content: "", missing: false },
|
|
{ name: "MEMORY.md", path: "/w/MEMORY.md", content: "", missing: false },
|
|
];
|
|
|
|
it("returns all files for main session (no sessionKey)", () => {
|
|
const result = filterBootstrapFilesForSession(mockFiles);
|
|
expect(result).toStrictEqual(mockFiles);
|
|
});
|
|
|
|
it("returns all files for normal (non-subagent, non-cron) session key", () => {
|
|
const result = filterBootstrapFilesForSession(mockFiles, "agent:default:chat:main");
|
|
expect(result).toStrictEqual(mockFiles);
|
|
});
|
|
|
|
it("filters to allowlist for subagent sessions", () => {
|
|
const result = filterBootstrapFilesForSession(mockFiles, "agent:default:subagent:task-1");
|
|
expectSubagentAllowedBootstrapNames(result);
|
|
});
|
|
|
|
it("filters to allowlist for cron sessions", () => {
|
|
const result = filterBootstrapFilesForSession(mockFiles, "agent:default:cron:daily-check");
|
|
expectCronAllowedBootstrapNames(result);
|
|
});
|
|
});
|