refactor(cli): update workspace-seed with dynamic identity and dench skill

Build identity template with workspace path; add seedDenchSkill for skills/dench.
This commit is contained in:
kumarabhirup 2026-03-03 13:47:38 -08:00
parent c704ddd15f
commit f6eee0b398
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
11 changed files with 1265 additions and 1077 deletions

View File

@ -93,6 +93,32 @@ describe("Chat API routes", () => {
expect(startRun).toHaveBeenCalled();
});
it("does not reuse an old run when sessionId is absent", async () => {
const { startRun, hasActiveRun, subscribeToRun, persistUserMessage } = await import("@/lib/active-runs");
vi.mocked(hasActiveRun).mockReturnValue(true);
vi.mocked(subscribeToRun).mockReturnValue(() => {});
vi.mocked(hasActiveRun).mockClear();
vi.mocked(startRun).mockClear();
vi.mocked(persistUserMessage).mockClear();
const { POST } = await import("./route.js");
const req = new Request("http://localhost/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: [
{ id: "m1", role: "user", parts: [{ type: "text", text: "new workspace question" }] },
],
}),
});
const res = await POST(req);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
expect(hasActiveRun).not.toHaveBeenCalled();
expect(startRun).not.toHaveBeenCalled();
expect(persistUserMessage).not.toHaveBeenCalled();
});
it("persists user message when sessionId provided", async () => {
const { hasActiveRun, subscribeToRun, persistUserMessage } = await import("@/lib/active-runs");
vi.mocked(hasActiveRun).mockReturnValue(false);

View File

@ -1,6 +1,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { join } from "node:path";
import type { Dirent } from "node:fs";
vi.mock("@/lib/workspace", () => ({
discoverWorkspaces: vi.fn(() => []),
getActiveWorkspaceName: vi.fn(() => null),
resolveOpenClawStateDir: vi.fn(() => "/home/testuser/.openclaw-ironclaw"),
resolveWorkspaceRoot: vi.fn(() => null),
setUIActiveWorkspace: vi.fn(),
}));
vi.mock("node:fs", () => ({
existsSync: vi.fn(() => false),
@ -8,87 +14,55 @@ vi.mock("node:fs", () => ({
readdirSync: vi.fn(() => []),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
renameSync: vi.fn(),
}));
vi.mock("node:child_process", () => ({
execSync: vi.fn(() => ""),
exec: vi.fn(
(
_cmd: string,
_opts: unknown,
cb: (err: Error | null, result: { stdout: string }) => void,
) => {
cb(null, { stdout: "" });
},
),
}));
vi.mock("node:os", () => ({
homedir: vi.fn(() => "/home/testuser"),
}));
describe("profiles API", () => {
const originalEnv = { ...process.env };
const DEFAULT_STATE_DIR = join("/home/testuser", ".openclaw");
const WORK_STATE_DIR = join("/home/testuser", ".openclaw-work");
const WORK_WORKSPACE_DIR = join(WORK_STATE_DIR, "workspace");
function makeDirent(name: string, isDir: boolean): Dirent {
return {
name,
isDirectory: () => isDir,
isFile: () => !isDir,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isFIFO: () => false,
isSocket: () => false,
isSymbolicLink: () => false,
path: "",
parentPath: "",
} as Dirent;
}
const STATE_DIR = "/home/testuser/.openclaw-ironclaw";
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
process.env = { ...originalEnv };
delete process.env.OPENCLAW_PROFILE;
delete process.env.OPENCLAW_HOME;
delete process.env.OPENCLAW_WORKSPACE;
delete process.env.OPENCLAW_STATE_DIR;
});
afterEach(() => {
process.env = originalEnv;
});
it("lists discovered profiles and includes gateway metadata", async () => {
process.env.OPENCLAW_PROFILE = "work";
const { existsSync, readFileSync, readdirSync } = await import("node:fs");
it("lists discovered workspaces with gateway metadata", async () => {
const workspace = await import("@/lib/workspace");
const { existsSync, readFileSync } = await import("node:fs");
const mockExists = vi.mocked(existsSync);
const mockReadFile = vi.mocked(readFileSync);
const mockReaddir = vi.mocked(readdirSync);
mockReaddir.mockReturnValue([
makeDirent(".openclaw-work", true),
makeDirent("Documents", true),
] as unknown as Dirent[]);
vi.mocked(workspace.discoverWorkspaces).mockReturnValue([
{
name: "main",
stateDir: STATE_DIR,
workspaceDir: `${STATE_DIR}/workspace-main`,
isActive: false,
hasConfig: true,
},
{
name: "work",
stateDir: STATE_DIR,
workspaceDir: `${STATE_DIR}/workspace-work`,
isActive: true,
hasConfig: true,
},
]);
vi.mocked(workspace.getActiveWorkspaceName).mockReturnValue("work");
mockExists.mockImplementation((p) => {
const s = String(p);
return (
s === DEFAULT_STATE_DIR ||
s === join(DEFAULT_STATE_DIR, "openclaw.json") ||
s === WORK_WORKSPACE_DIR ||
s === join(WORK_STATE_DIR, "openclaw.json")
);
return s.endsWith("openclaw.json");
});
mockReadFile.mockImplementation((p) => {
const s = String(p);
if (s === join(WORK_STATE_DIR, "openclaw.json")) {
return JSON.stringify({ gateway: { mode: "local", port: 19001 } }) as never;
}
if (s === join(DEFAULT_STATE_DIR, "openclaw.json")) {
if (s.includes("openclaw.json")) {
return JSON.stringify({ gateway: { mode: "local", port: 18789 } }) as never;
}
return "" as never;
@ -99,107 +73,128 @@ describe("profiles API", () => {
expect(response.status).toBe(200);
const json = await response.json();
expect(json.activeWorkspace).toBe("work");
expect(json.activeProfile).toBe("work");
const work = json.profiles.find((p: { name: string }) => p.name === "work");
const def = json.profiles.find((p: { name: string }) => p.name === "default");
expect(json.workspaces).toHaveLength(2);
expect(json.profiles).toHaveLength(2);
const work = json.workspaces.find((w: { name: string }) => w.name === "work");
expect(work).toMatchObject({
name: "work",
stateDir: WORK_STATE_DIR,
workspaceDir: WORK_WORKSPACE_DIR,
stateDir: STATE_DIR,
isActive: true,
hasConfig: true,
gateway: {
mode: "local",
port: 19001,
url: "ws://127.0.0.1:19001",
},
});
expect(def).toMatchObject({
name: "default",
stateDir: DEFAULT_STATE_DIR,
isActive: false,
hasConfig: true,
gateway: {
mode: "local",
port: 18789,
url: "ws://127.0.0.1:18789",
},
gateway: { mode: "local", port: 18789, url: "ws://127.0.0.1:18789" },
});
});
it("switches to an existing profile", async () => {
const { existsSync, readdirSync, readFileSync } = await import("node:fs");
it("includes bootstrap-root workspace as default", async () => {
const workspace = await import("@/lib/workspace");
const { existsSync, readFileSync } = await import("node:fs");
const mockExists = vi.mocked(existsSync);
const mockReaddir = vi.mocked(readdirSync);
const mockReadFile = vi.mocked(readFileSync);
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([makeDirent(".openclaw-work", true)] as unknown as Dirent[]);
mockExists.mockImplementation((p) => {
const s = String(p);
return s === WORK_WORKSPACE_DIR;
vi.mocked(workspace.discoverWorkspaces).mockReturnValue([
{
name: "default",
stateDir: STATE_DIR,
workspaceDir: `${STATE_DIR}/workspace`,
isActive: true,
hasConfig: true,
},
]);
vi.mocked(workspace.getActiveWorkspaceName).mockReturnValue("default");
mockExists.mockImplementation((p) => String(p).endsWith("openclaw.json"));
mockReadFile.mockImplementation((p) => {
if (String(p).includes("openclaw.json")) {
return JSON.stringify({ gateway: { mode: "local", port: 19001 } }) as never;
}
return "" as never;
});
const { GET } = await import("./route.js");
const response = await GET();
expect(response.status).toBe(200);
const json = await response.json();
expect(json.activeWorkspace).toBe("default");
expect(json.activeProfile).toBe("default");
expect(json.workspaces).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "default",
workspaceDir: `${STATE_DIR}/workspace`,
gateway: { mode: "local", port: 19001, url: "ws://127.0.0.1:19001" },
}),
]),
);
});
it("switches to an existing workspace", async () => {
const workspace = await import("@/lib/workspace");
vi.mocked(workspace.discoverWorkspaces)
.mockReturnValueOnce([
{
name: "work",
stateDir: STATE_DIR,
workspaceDir: `${STATE_DIR}/workspace-work`,
isActive: false,
hasConfig: true,
},
])
.mockReturnValueOnce([
{
name: "work",
stateDir: STATE_DIR,
workspaceDir: `${STATE_DIR}/workspace-work`,
isActive: true,
hasConfig: true,
},
]);
vi.mocked(workspace.getActiveWorkspaceName).mockReturnValue("work");
vi.mocked(workspace.resolveOpenClawStateDir).mockReturnValue(STATE_DIR);
vi.mocked(workspace.resolveWorkspaceRoot).mockReturnValue(`${STATE_DIR}/workspace-work`);
const { POST } = await import("./switch/route.js");
const req = new Request("http://localhost/api/profiles/switch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile: "work" }),
body: JSON.stringify({ workspace: "work" }),
});
const response = await POST(req);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.activeWorkspace).toBe("work");
expect(json.activeProfile).toBe("work");
expect(json.stateDir).toBe(WORK_STATE_DIR);
expect(json.workspaceRoot).toBe(WORK_WORKSPACE_DIR);
expect(json.profile.name).toBe("work");
expect(json.stateDir).toBe(STATE_DIR);
expect(json.workspaceRoot).toBe(`${STATE_DIR}/workspace-work`);
expect(json.workspace.name).toBe("work");
expect(workspace.setUIActiveWorkspace).toHaveBeenCalledWith("work");
});
it("rejects invalid switch profile names", async () => {
it("rejects invalid names", async () => {
const { POST } = await import("./switch/route.js");
const req = new Request("http://localhost/api/profiles/switch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile: "../bad" }),
body: JSON.stringify({ workspace: "../bad" }),
});
const response = await POST(req);
expect(response.status).toBe(400);
});
it("returns 404 when switching to an unknown profile", async () => {
const { readdirSync, readFileSync } = await import("node:fs");
const mockReaddir = vi.mocked(readdirSync);
const mockReadFile = vi.mocked(readFileSync);
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([] as unknown as Dirent[]);
it("returns 404 for unknown workspace", async () => {
const workspace = await import("@/lib/workspace");
vi.mocked(workspace.discoverWorkspaces).mockReturnValue([]);
const { POST } = await import("./switch/route.js");
const req = new Request("http://localhost/api/profiles/switch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile: "work" }),
body: JSON.stringify({ workspace: "nonexistent" }),
});
const response = await POST(req);
expect(response.status).toBe(404);
});
it("returns 409 when OPENCLAW_PROFILE forces a different profile", async () => {
process.env.OPENCLAW_PROFILE = "ironclaw";
const { readdirSync } = await import("node:fs");
const mockReaddir = vi.mocked(readdirSync);
mockReaddir.mockReturnValue([makeDirent(".openclaw-work", true)] as unknown as Dirent[]);
const { POST } = await import("./switch/route.js");
const req = new Request("http://localhost/api/profiles/switch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile: "work" }),
});
const response = await POST(req);
expect(response.status).toBe(409);
});
});

View File

@ -1,72 +1,16 @@
import { EventEmitter } from "node:events";
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("node:child_process", () => ({
spawn: vi.fn(),
vi.mock("node:fs", () => ({
rmSync: vi.fn(),
}));
vi.mock("@/lib/workspace", () => ({
discoverProfiles: vi.fn(() => []),
getEffectiveProfile: vi.fn(() => "default"),
discoverWorkspaces: vi.fn(() => []),
getActiveWorkspaceName: vi.fn(() => null),
resolveWorkspaceRoot: vi.fn(() => null),
setUIActiveWorkspace: vi.fn(),
}));
type MockSpawnChild = EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
stdin: {
write: ReturnType<typeof vi.fn>;
end: ReturnType<typeof vi.fn>;
};
kill: ReturnType<typeof vi.fn>;
};
function mockSpawnResult(
spawnMock: {
mockImplementation: (
implementation: (...args: unknown[]) => unknown,
) => unknown;
},
params: {
code?: number;
stdout?: string;
stderr?: string;
emitError?: Error | null;
},
): { getChild: () => MockSpawnChild | null } {
let spawnedChild: MockSpawnChild | null = null;
spawnMock.mockImplementation(() => {
const child = new EventEmitter() as MockSpawnChild;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.stdin = {
write: vi.fn(),
end: vi.fn(),
};
child.kill = vi.fn();
spawnedChild = child;
queueMicrotask(() => {
if (params.stdout) {
child.stdout.emit("data", Buffer.from(params.stdout));
}
if (params.stderr) {
child.stderr.emit("data", Buffer.from(params.stderr));
}
if (params.emitError) {
child.emit("error", params.emitError);
return;
}
child.emit("close", params.code ?? 0);
});
return child as unknown as ReturnType<typeof import("node:child_process").spawn>;
});
return {
getChild: () => spawnedChild,
};
}
describe("POST /api/workspace/delete", () => {
beforeEach(() => {
vi.resetModules();
@ -83,86 +27,95 @@ describe("POST /api/workspace/delete", () => {
return POST(req);
}
it("returns 400 for invalid profile names", async () => {
const response = await callDelete({ profile: "../bad" });
expect(response.status).toBe(400);
it("returns 400 for invalid workspace names (prevents traversal)", async () => {
const res1 = await callDelete({ workspace: "../bad" });
expect(res1.status).toBe(400);
const res2 = await callDelete({ profile: "../../etc" });
expect(res2.status).toBe(400);
const res3 = await callDelete({});
expect(res3.status).toBe(400);
});
it("returns 404 when profile does not exist", async () => {
it("returns 404 when workspace does not exist", async () => {
const workspace = await import("@/lib/workspace");
vi.mocked(workspace.discoverProfiles).mockReturnValue([]);
const response = await callDelete({ profile: "work" });
vi.mocked(workspace.discoverWorkspaces).mockReturnValue([]);
const response = await callDelete({ workspace: "work" });
expect(response.status).toBe(404);
});
it("returns 409 when profile has no workspace directory", async () => {
it("returns 409 when workspace has no directory to delete", async () => {
const workspace = await import("@/lib/workspace");
vi.mocked(workspace.discoverProfiles).mockReturnValue([
vi.mocked(workspace.discoverWorkspaces).mockReturnValue([
{
name: "work",
stateDir: "/home/testuser/.openclaw-work",
stateDir: "/home/testuser/.openclaw-ironclaw",
workspaceDir: null,
isActive: false,
hasConfig: true,
},
]);
const response = await callDelete({ profile: "work" });
const response = await callDelete({ workspace: "work" });
expect(response.status).toBe(409);
});
it("runs openclaw workspace delete for the selected profile", async () => {
it("deletes workspace directory directly via rmSync", async () => {
const workspace = await import("@/lib/workspace");
const { spawn } = await import("node:child_process");
vi.mocked(workspace.discoverProfiles).mockReturnValue([
{
name: "work",
stateDir: "/home/testuser/.openclaw-work",
workspaceDir: "/home/testuser/.openclaw-work/workspace",
isActive: true,
hasConfig: true,
},
]);
vi.mocked(workspace.getEffectiveProfile).mockReturnValue("work");
vi.mocked(workspace.resolveWorkspaceRoot).mockReturnValue("/home/testuser/.openclaw-work/workspace");
const spawnResult = mockSpawnResult(vi.mocked(spawn), { code: 0 });
const { rmSync } = await import("node:fs");
const workspaceDir = "/home/testuser/.openclaw-ironclaw/workspace-work";
const response = await callDelete({ profile: "work" });
vi.mocked(workspace.discoverWorkspaces)
.mockReturnValueOnce([
{
name: "work",
stateDir: "/home/testuser/.openclaw-ironclaw",
workspaceDir,
isActive: true,
hasConfig: true,
},
])
.mockReturnValueOnce([]);
vi.mocked(workspace.getActiveWorkspaceName)
.mockReturnValueOnce("work")
.mockReturnValueOnce(null);
vi.mocked(workspace.resolveWorkspaceRoot).mockReturnValue(null);
const response = await callDelete({ workspace: "work" });
expect(response.status).toBe(200);
const json = await response.json();
expect(json.deleted).toBe(true);
expect(json.workspace).toBe("work");
expect(json.profile).toBe("work");
expect(spawn).toHaveBeenCalledWith(
"openclaw",
["--profile", "work", "workspace", "delete"],
expect.objectContaining({ stdio: ["pipe", "pipe", "pipe"] }),
);
const child = spawnResult.getChild();
expect(child).toBeTruthy();
expect(child?.stdin.write).toHaveBeenCalledWith("y\n");
expect(child?.stdin.write).toHaveBeenCalledWith("yes\n");
expect(child?.stdin.end).toHaveBeenCalled();
expect(rmSync).toHaveBeenCalledWith(workspaceDir, { recursive: true, force: false });
expect(workspace.setUIActiveWorkspace).toHaveBeenCalledWith(null);
});
it("returns 501 when workspace delete command is unavailable", async () => {
it("returns 500 when rmSync fails", async () => {
const workspace = await import("@/lib/workspace");
const { spawn } = await import("node:child_process");
vi.mocked(workspace.discoverProfiles).mockReturnValue([
const { rmSync } = await import("node:fs");
const workspaceDir = "/home/testuser/.openclaw-ironclaw/workspace-work";
vi.mocked(workspace.discoverWorkspaces).mockReturnValue([
{
name: "work",
stateDir: "/home/testuser/.openclaw-work",
workspaceDir: "/home/testuser/.openclaw-work/workspace",
stateDir: "/home/testuser/.openclaw-ironclaw",
workspaceDir,
isActive: false,
hasConfig: true,
},
]);
mockSpawnResult(vi.mocked(spawn), {
code: 0,
stdout:
"Usage: openclaw [options] [command]\nHint: commands suffixed with * have subcommands",
vi.mocked(rmSync).mockImplementation(() => {
throw new Error("EPERM: operation not permitted");
});
const response = await callDelete({ profile: "work" });
expect(response.status).toBe(501);
const response = await callDelete({ workspace: "work" });
expect(response.status).toBe(500);
const json = await response.json();
expect(String(json.error)).toContain("EPERM");
});
});

View File

@ -1,8 +1,8 @@
import { EventEmitter } from "node:events";
import { join } from "node:path";
import type { Dirent } from "node:fs";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const STATE_DIR = "/home/testuser/.openclaw-ironclaw";
vi.mock("node:fs", () => ({
existsSync: vi.fn(() => false),
readFileSync: vi.fn(() => ""),
@ -13,101 +13,27 @@ vi.mock("node:fs", () => ({
cpSync: vi.fn(),
}));
vi.mock("node:child_process", () => ({
execSync: vi.fn(() => ""),
exec: vi.fn(
(
_cmd: string,
_opts: unknown,
cb: (err: Error | null, result: { stdout: string }) => void,
) => {
cb(null, { stdout: "" });
},
vi.mock("@/lib/workspace", () => ({
discoverWorkspaces: vi.fn(() => []),
setUIActiveWorkspace: vi.fn(),
getActiveWorkspaceName: vi.fn(() => "work"),
resolveOpenClawStateDir: vi.fn(() => "/home/testuser/.openclaw-ironclaw"),
resolveWorkspaceDirForName: vi.fn((name: string) =>
join("/home/testuser/.openclaw-ironclaw", `workspace-${name}`),
),
spawn: vi.fn(),
}));
vi.mock("node:os", () => ({
homedir: vi.fn(() => "/home/testuser"),
isValidWorkspaceName: vi.fn(() => true),
resolveWorkspaceRoot: vi.fn(() => null),
}));
describe("POST /api/workspace/init", () => {
const originalEnv = { ...process.env };
const HOME = "/home/testuser";
const IRONCLAW_STATE = join(HOME, ".openclaw-ironclaw");
const WORK_STATE = join(HOME, ".openclaw-work");
const IRONCLAW_CONFIG = join(IRONCLAW_STATE, "openclaw.json");
const IRONCLAW_AUTH = join(IRONCLAW_STATE, "agents", "main", "agent", "auth-profiles.json");
function makeDirent(name: string, isDir: boolean): Dirent {
return {
name,
isDirectory: () => isDir,
isFile: () => !isDir,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isFIFO: () => false,
isSocket: () => false,
isSymbolicLink: () => false,
path: "",
parentPath: "",
} as Dirent;
}
function mockSpawnExit(code: number, stderr = "") {
return vi.fn(() => {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = vi.fn();
queueMicrotask(() => {
if (stderr) {
child.stderr.emit("data", Buffer.from(stderr));
}
child.emit("close", code);
});
return child as unknown as ReturnType<typeof import("node:child_process").spawn>;
});
}
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
process.env = { ...originalEnv };
delete process.env.OPENCLAW_PROFILE;
delete process.env.OPENCLAW_HOME;
delete process.env.OPENCLAW_WORKSPACE;
delete process.env.OPENCLAW_STATE_DIR;
vi.mock("node:fs", () => ({
existsSync: vi.fn(() => false),
readFileSync: vi.fn(() => ""),
readdirSync: vi.fn(() => []),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
copyFileSync: vi.fn(),
cpSync: vi.fn(),
}));
vi.mock("node:child_process", () => ({
execSync: vi.fn(() => ""),
exec: vi.fn(
(
_cmd: string,
_opts: unknown,
cb: (err: Error | null, result: { stdout: string }) => void,
) => {
cb(null, { stdout: "" });
},
),
spawn: vi.fn(),
}));
vi.mock("node:os", () => ({
homedir: vi.fn(() => "/home/testuser"),
}));
});
afterEach(() => {
@ -124,128 +50,114 @@ describe("POST /api/workspace/init", () => {
return POST(req);
}
it("rejects missing or invalid profile names", async () => {
const missing = await callInit({});
expect(missing.status).toBe(400);
const invalid = await callInit({ profile: "../bad" });
expect(invalid.status).toBe(400);
it("rejects missing workspace name (400)", async () => {
const response = await callInit({});
expect(response.status).toBe(400);
});
it("returns 409 when the profile already exists", async () => {
const { readdirSync, readFileSync } = await import("node:fs");
const mockReaddir = vi.mocked(readdirSync);
const mockReadFile = vi.mocked(readFileSync);
mockReaddir.mockReturnValue([makeDirent(".openclaw-work", true)] as unknown as Dirent[]);
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
it("rejects custom path parameter (prevents custom workspace locations)", async () => {
const response = await callInit({ workspace: "work", path: "/tmp/custom" });
expect(response.status).toBe(400);
const json = await response.json();
expect(String(json.error)).toContain("Custom workspace paths");
});
const response = await callInit({ profile: "work" });
it("rejects invalid workspace names (400)", async () => {
const response = await callInit({ workspace: "../bad" });
expect(response.status).toBe(400);
});
it("returns 409 when workspace already exists", async () => {
const workspace = await import("@/lib/workspace");
vi.mocked(workspace.discoverWorkspaces).mockReturnValue([
{
name: "work",
stateDir: STATE_DIR,
workspaceDir: join(STATE_DIR, "workspace-work"),
isActive: true,
hasConfig: true,
},
]);
const response = await callInit({ workspace: "work" });
expect(response.status).toBe(409);
});
it("creates a profile, copies config/auth, allocates gateway port, and runs onboard", async () => {
const { existsSync, readFileSync, readdirSync, copyFileSync } = await import("node:fs");
const { spawn } = await import("node:child_process");
const mockExists = vi.mocked(existsSync);
const mockReadFile = vi.mocked(readFileSync);
const mockReaddir = vi.mocked(readdirSync);
const mockCopyFile = vi.mocked(copyFileSync);
const mockSpawn = vi.mocked(spawn);
it("creates workspace directory at ~/.openclaw-ironclaw/workspace-<name> (enforces fixed layout)", async () => {
const { mkdirSync, writeFileSync } = await import("node:fs");
const workspace = await import("@/lib/workspace");
vi.mocked(workspace.discoverWorkspaces).mockReturnValue([]);
mockSpawn.mockImplementation(mockSpawnExit(0));
mockReaddir.mockReturnValue([
makeDirent(".openclaw-ironclaw", true),
makeDirent("Documents", true),
] as unknown as Dirent[]);
mockExists.mockImplementation((p) => {
const s = String(p);
return (
s === IRONCLAW_CONFIG ||
s === IRONCLAW_AUTH ||
s.endsWith("docs/reference/templates/AGENTS.md") ||
s.endsWith("assets/seed/workspace.duckdb")
);
});
mockReadFile.mockImplementation((p) => {
const s = String(p);
if (s === IRONCLAW_CONFIG) {
return JSON.stringify({ gateway: { mode: "local", port: 18789 } }) as never;
}
if (s.endsWith("/openclaw.json")) {
return JSON.stringify({}) as never;
}
if (s.endsWith("/AGENTS.md")) {
return "# AGENTS\n" as never;
}
return "" as never;
});
const response = await callInit({
profile: "work",
seedBootstrap: true,
copyConfigAuth: true,
});
const response = await callInit({ workspace: "work" });
expect(response.status).toBe(200);
const json = await response.json();
expect(json.profile).toBe("work");
expect(json.stateDir).toBe(WORK_STATE);
expect(json.gatewayPort).toBe(18809);
expect(json.copiedFiles).toEqual(
expect.arrayContaining(["openclaw.json", "agents/main/agent/auth-profiles.json"]),
);
expect(json.activeProfile).toBe("work");
const onboardCall = mockSpawn.mock.calls.find(
(call) =>
String(call[0]) === "openclaw" &&
Array.isArray(call[1]) &&
(call[1] as string[]).includes("onboard"),
);
expect(onboardCall).toBeTruthy();
const args = onboardCall?.[1] as string[];
expect(args).toEqual(
expect.arrayContaining([
"--profile",
"work",
"onboard",
"--install-daemon",
"--gateway-port",
"18809",
"--non-interactive",
"--accept-risk",
"--skip-ui",
]),
);
expect(mockCopyFile).toHaveBeenCalledWith(IRONCLAW_CONFIG, join(WORK_STATE, "openclaw.json"));
expect(mockCopyFile).toHaveBeenCalledWith(
IRONCLAW_AUTH,
join(WORK_STATE, "agents", "main", "agent", "auth-profiles.json"),
);
const json = await response.json();
expect(json.workspace).toBe("work");
expect(json.workspaceDir).toBe(join(STATE_DIR, "workspace-work"));
expect(json.activeWorkspace).toBe("work");
expect(json.profile).toBe("work");
expect(mkdirSync).toHaveBeenCalledWith(STATE_DIR, { recursive: true });
expect(mkdirSync).toHaveBeenCalledWith(join(STATE_DIR, "workspace-work"), { recursive: false });
expect(workspace.setUIActiveWorkspace).toHaveBeenCalledWith("work");
expect(writeFileSync).toHaveBeenCalled();
});
it("returns 500 when onboard fails", async () => {
const { readdirSync, readFileSync, existsSync } = await import("node:fs");
const { spawn } = await import("node:child_process");
const mockReaddir = vi.mocked(readdirSync);
const mockReadFile = vi.mocked(readFileSync);
const mockExists = vi.mocked(existsSync);
const mockSpawn = vi.mocked(spawn);
it("seeds Dench skill into workspace/skills/dench/SKILL.md (not state dir)", async () => {
const { existsSync, cpSync, mkdirSync } = await import("node:fs");
const workspace = await import("@/lib/workspace");
vi.mocked(workspace.discoverWorkspaces).mockReturnValue([]);
mockSpawn.mockImplementation(mockSpawnExit(1, "onboard error"));
mockReaddir.mockReturnValue([makeDirent(".openclaw-ironclaw", true)] as unknown as Dirent[]);
mockExists.mockImplementation((p) => String(p) === IRONCLAW_CONFIG || String(p) === IRONCLAW_AUTH);
mockReadFile.mockImplementation((p) => {
if (String(p) === IRONCLAW_CONFIG) {
return JSON.stringify({ gateway: { port: 18789 } }) as never;
}
return "" as never;
const workspaceDir = join(STATE_DIR, "workspace-work");
vi.mocked(existsSync).mockImplementation((p) => {
const s = String(p);
if (s.endsWith("docs/reference/templates/AGENTS.md")) return true;
if (s.endsWith("skills/dench/SKILL.md")) return true;
return false;
});
const response = await callInit({ profile: "work" });
expect(response.status).toBe(500);
const response = await callInit({ workspace: "work" });
expect(response.status).toBe(200);
const json = await response.json();
expect(String(json.error)).toContain("onboarding failed");
expect(json.denchSynced).toBe(true);
const cpSyncCalls = vi.mocked(cpSync).mock.calls;
const denchCopy = cpSyncCalls.find(
(call) => String(call[1]).includes(join(workspaceDir, "skills", "dench")),
);
expect(denchCopy).toBeTruthy();
const mkdirCalls = vi.mocked(mkdirSync).mock.calls;
const skillsMkdir = mkdirCalls.find(
(call) => String(call[0]).includes(join(workspaceDir, "skills")),
);
expect(skillsMkdir).toBeTruthy();
});
it("generates IDENTITY.md referencing workspace CRM skill path (not virtual ~skills path)", async () => {
const { existsSync, writeFileSync } = await import("node:fs");
const workspace = await import("@/lib/workspace");
vi.mocked(workspace.discoverWorkspaces).mockReturnValue([]);
vi.mocked(existsSync).mockImplementation((p) => {
const s = String(p);
if (s.endsWith("docs/reference/templates/AGENTS.md")) return true;
return false;
});
const response = await callInit({ workspace: "work" });
expect(response.status).toBe(200);
const workspaceDir = join(STATE_DIR, "workspace-work");
const expectedSkillPath = join(workspaceDir, "skills", "dench", "SKILL.md");
const identityWrites = vi.mocked(writeFileSync).mock.calls.filter(
(call) => String(call[0]).endsWith("IDENTITY.md"),
);
expect(identityWrites.length).toBeGreaterThan(0);
const identityContent = String(identityWrites[identityWrites.length - 1][1]);
expect(identityContent).toContain(expectedSkillPath);
expect(identityContent).toContain("Ironclaw");
expect(identityContent).not.toContain("~skills");
});
});

View File

@ -23,18 +23,28 @@ describe("ProfileSwitcher workspace delete action", () => {
window.confirm = originalConfirm;
});
it("deletes a profile workspace from the dropdown action", async () => {
it("deletes a workspace from the dropdown action", async () => {
const user = userEvent.setup();
const onWorkspaceDelete = vi.fn();
let profileFetchCount = 0;
let listFetchCount = 0;
global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : (input as URL).href;
const method = init?.method ?? "GET";
if (url === "/api/profiles" && method === "GET") {
profileFetchCount += 1;
if (profileFetchCount === 1) {
if (url === "/api/workspace/list" && method === "GET") {
listFetchCount += 1;
if (listFetchCount === 1) {
return jsonResponse({
activeWorkspace: "work",
workspaces: [
{
name: "work",
stateDir: "/home/testuser/.openclaw-work",
workspaceDir: "/home/testuser/.openclaw-work/workspace",
isActive: true,
hasConfig: true,
},
],
activeProfile: "work",
profiles: [
{
@ -48,6 +58,16 @@ describe("ProfileSwitcher workspace delete action", () => {
});
}
return jsonResponse({
activeWorkspace: "work",
workspaces: [
{
name: "work",
stateDir: "/home/testuser/.openclaw-work",
workspaceDir: null,
isActive: true,
hasConfig: true,
},
],
activeProfile: "work",
profiles: [
{
@ -61,7 +81,7 @@ describe("ProfileSwitcher workspace delete action", () => {
});
}
if (url === "/api/workspace/delete" && method === "POST") {
return jsonResponse({ deleted: true, profile: "work" });
return jsonResponse({ deleted: true, workspace: "work" });
}
throw new Error(`Unexpected fetch call: ${method} ${url}`);
}) as typeof fetch;
@ -70,11 +90,17 @@ describe("ProfileSwitcher workspace delete action", () => {
render(<ProfileSwitcher onWorkspaceDelete={onWorkspaceDelete} />);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith("/api/profiles");
expect(global.fetch).toHaveBeenCalledWith("/api/workspace/list");
});
await user.click(screen.getByTitle("Switch workspace profile"));
await user.click(screen.getByTitle("Delete workspace for work"));
await user.click(screen.getByTitle("Switch workspace"));
await user.click(screen.getByTitle("Delete workspace work"));
expect(window.confirm).toHaveBeenCalledTimes(1);
const confirmMsg = vi.mocked(window.confirm).mock.calls[0]?.[0] as string;
expect(confirmMsg).toContain("Delete workspace");
expect(confirmMsg).toContain("work");
expect(confirmMsg).not.toContain("uninstall --workspace --state");
await waitFor(() => {
expect(onWorkspaceDelete).toHaveBeenCalledWith("work");
@ -87,5 +113,53 @@ describe("ProfileSwitcher workspace delete action", () => {
expect(deleteCall?.[1]).toMatchObject({
method: "POST",
});
const deleteBody = JSON.parse(deleteCall?.[1]?.body as string);
expect(deleteBody).toMatchObject({ workspace: "work" });
});
it("falls back to a real workspace when API returns stale active workspace", async () => {
const user = userEvent.setup();
global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : (input as URL).href;
const method = init?.method ?? "GET";
if (url === "/api/workspace/list" && method === "GET") {
return jsonResponse({
activeWorkspace: "ghost",
workspaces: [
{
name: "ghost",
stateDir: "/home/testuser/.openclaw-ironclaw",
workspaceDir: null,
isActive: true,
hasConfig: true,
},
{
name: "ironclaw",
stateDir: "/home/testuser/.openclaw-ironclaw",
workspaceDir: "/home/testuser/.openclaw-ironclaw/workspace",
isActive: false,
hasConfig: true,
},
],
});
}
throw new Error(`Unexpected fetch call: ${method} ${url}`);
}) as typeof fetch;
render(<ProfileSwitcher />);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith("/api/workspace/list");
});
await waitFor(() => {
expect(screen.getByText("ironclaw")).toBeInTheDocument();
expect(screen.queryByText("ghost")).not.toBeInTheDocument();
});
await user.click(screen.getByTitle("Switch workspace"));
expect(screen.queryByTitle("Delete workspace ghost")).not.toBeInTheDocument();
expect(screen.getByTitle("Delete workspace ironclaw")).toBeInTheDocument();
});
});

View File

@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Dirent } from "node:fs";
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
@ -47,13 +48,32 @@ vi.mock("node:os", () => ({
import { join } from "node:path";
describe("profile-scoped chat session isolation", () => {
describe("workspace-scoped chat session isolation", () => {
const originalEnv = { ...process.env };
const DEFAULT_STATE_DIR = join("/home/testuser", ".openclaw");
const stateDirForProfile = (profile: string | null) =>
!profile || profile.toLowerCase() === "default"
? DEFAULT_STATE_DIR
: join("/home/testuser", `.openclaw-${profile}`);
const STATE_DIR = "/home/testuser/.openclaw-ironclaw";
const workspaceDir = (name: string) =>
name === "default"
? join(STATE_DIR, "workspace")
: join(STATE_DIR, `workspace-${name}`);
const chatDir = (name: string) =>
join(workspaceDir(name), ".openclaw", "web-chat");
function makeDirent(name: string): Dirent {
return {
name,
isDirectory: () => true,
isFile: () => false,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isFIFO: () => false,
isSocket: () => false,
isSymbolicLink: () => false,
path: "",
parentPath: "",
} as Dirent;
}
beforeEach(() => {
vi.resetModules();
@ -113,7 +133,7 @@ describe("profile-scoped chat session isolation", () => {
});
async function importWorkspace() {
const { readFileSync: rfs, writeFileSync: wfs, existsSync: es } =
const { readFileSync: rfs, writeFileSync: wfs, existsSync: es, readdirSync: rds } =
await import("node:fs");
const mod = await import("./workspace.js");
return {
@ -121,97 +141,190 @@ describe("profile-scoped chat session isolation", () => {
mockReadFile: vi.mocked(rfs),
mockWriteFile: vi.mocked(wfs),
mockExists: vi.mocked(es),
mockReaddir: vi.mocked(rds),
};
}
it("default profile uses web-chat directory", async () => {
const { resolveWebChatDir, mockReadFile } = await importWorkspace();
it("active workspace uses <workspace>/.openclaw/web-chat", async () => {
const { resolveWebChatDir, setUIActiveWorkspace, mockExists, mockReadFile, mockReaddir } =
await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
expect(resolveWebChatDir()).toBe(join(DEFAULT_STATE_DIR, "web-chat"));
mockReaddir.mockReturnValue([
makeDirent("workspace-dev"),
] as unknown as Dirent[]);
const wsDir = workspaceDir("dev");
mockExists.mockImplementation((p) => String(p) === wsDir);
setUIActiveWorkspace("dev");
expect(resolveWebChatDir()).toBe(chatDir("dev"));
});
it("named profile uses profile-scoped web-chat directory", async () => {
const { resolveWebChatDir, setUIActiveProfile, mockReadFile } =
await importWorkspace();
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
setUIActiveProfile("work");
expect(resolveWebChatDir()).toBe(join(stateDirForProfile("work"), "web-chat"));
});
it("different workspaces produce different chat directories", async () => {
const {
resolveWebChatDir,
setUIActiveWorkspace,
clearUIActiveWorkspaceCache,
mockExists,
mockReadFile,
mockReaddir,
} = await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([
makeDirent("workspace-alpha"),
makeDirent("workspace-beta"),
] as unknown as Dirent[]);
it("different profiles produce different chat directories", async () => {
const { resolveWebChatDir, setUIActiveProfile, clearUIActiveProfileCache, mockReadFile } =
await importWorkspace();
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
const alphaDir = workspaceDir("alpha");
const betaDir = workspaceDir("beta");
mockExists.mockImplementation((p) => {
const s = String(p);
return s === alphaDir || s === betaDir;
});
setUIActiveProfile("alpha");
setUIActiveWorkspace("alpha");
const dirAlpha = resolveWebChatDir();
clearUIActiveProfileCache();
setUIActiveProfile("beta");
clearUIActiveWorkspaceCache();
setUIActiveWorkspace("beta");
const dirBeta = resolveWebChatDir();
expect(dirAlpha).not.toBe(dirBeta);
expect(dirAlpha).toBe(join(stateDirForProfile("alpha"), "web-chat"));
expect(dirBeta).toBe(join(stateDirForProfile("beta"), "web-chat"));
expect(dirAlpha).toBe(chatDir("alpha"));
expect(dirBeta).toBe(chatDir("beta"));
});
it("switching to default after named profile reverts to base dir", async () => {
const { resolveWebChatDir, setUIActiveProfile, mockReadFile } =
await importWorkspace();
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
setUIActiveProfile("work");
expect(resolveWebChatDir()).toBe(join(stateDirForProfile("work"), "web-chat"));
setUIActiveProfile(null);
expect(resolveWebChatDir()).toBe(join(DEFAULT_STATE_DIR, "web-chat"));
});
it("'default' profile name uses base web-chat dir (case-insensitive)", async () => {
const { resolveWebChatDir, setUIActiveProfile, mockReadFile } =
await importWorkspace();
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
setUIActiveProfile("Default");
expect(resolveWebChatDir()).toBe(join(DEFAULT_STATE_DIR, "web-chat"));
setUIActiveProfile("DEFAULT");
expect(resolveWebChatDir()).toBe(join(DEFAULT_STATE_DIR, "web-chat"));
});
it("OPENCLAW_STATE_DIR override changes base for chat dirs", async () => {
process.env.OPENCLAW_STATE_DIR = "/custom/state";
const { resolveWebChatDir, setUIActiveProfile, mockReadFile } =
it("switching workspaces changes chat directory", async () => {
const { resolveWebChatDir, setUIActiveWorkspace, mockExists, mockReadFile, mockReaddir } =
await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
expect(resolveWebChatDir()).toBe(join("/custom/state", "web-chat"));
setUIActiveProfile("test");
expect(resolveWebChatDir()).toBe(join("/custom/state", "web-chat"));
});
it("workspace roots are isolated per profile too", async () => {
const { resolveWorkspaceRoot, setUIActiveProfile, clearUIActiveProfileCache, mockExists, mockReadFile } =
await importWorkspace();
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
const defaultWs = join(DEFAULT_STATE_DIR, "workspace");
const workWs = join(stateDirForProfile("work"), "workspace");
mockReaddir.mockReturnValue([
makeDirent("workspace-work"),
makeDirent("workspace-personal"),
] as unknown as Dirent[]);
const workDir = workspaceDir("work");
const personalDir = workspaceDir("personal");
mockExists.mockImplementation((p) => {
const s = String(p);
return s === defaultWs || s === workWs;
return s === workDir || s === personalDir;
});
clearUIActiveProfileCache();
setUIActiveProfile(null);
expect(resolveWorkspaceRoot()).toBe(defaultWs);
setUIActiveWorkspace("work");
expect(resolveWebChatDir()).toBe(chatDir("work"));
setUIActiveWorkspace("personal");
expect(resolveWebChatDir()).toBe(chatDir("personal"));
});
it("falls back to default root workspace when nothing is active", async () => {
const { resolveWebChatDir, mockReadFile, mockExists, mockReaddir } = await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([] as unknown as Dirent[]);
mockExists.mockReturnValue(false);
expect(resolveWebChatDir()).toBe(chatDir("default"));
});
it("workspace roots are isolated per workspace", async () => {
const {
resolveWorkspaceRoot,
setUIActiveWorkspace,
clearUIActiveWorkspaceCache,
mockExists,
mockReadFile,
mockReaddir,
} = await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([
makeDirent("workspace-dev"),
makeDirent("workspace-staging"),
] as unknown as Dirent[]);
const devDir = workspaceDir("dev");
const stagingDir = workspaceDir("staging");
mockExists.mockImplementation((p) => {
const s = String(p);
return s === devDir || s === stagingDir;
});
clearUIActiveWorkspaceCache();
setUIActiveWorkspace("dev");
expect(resolveWorkspaceRoot()).toBe(devDir);
setUIActiveWorkspace("staging");
expect(resolveWorkspaceRoot()).toBe(stagingDir);
});
it("setUIActiveProfile compat shim delegates to workspace", async () => {
const { resolveWebChatDir, setUIActiveProfile, mockExists, mockReadFile, mockReaddir } =
await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([
makeDirent("workspace-work"),
] as unknown as Dirent[]);
const wsDir = workspaceDir("work");
mockExists.mockImplementation((p) => String(p) === wsDir);
setUIActiveProfile("work");
expect(resolveWorkspaceRoot()).toBe(workWs);
expect(resolveWebChatDir()).toBe(chatDir("work"));
});
it("setUIActiveProfile('default') selects the root workspace", async () => {
const { resolveWebChatDir, setUIActiveProfile, mockReadFile, mockExists, mockReaddir } =
await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
const rootDir = workspaceDir("default");
mockReaddir.mockReturnValue([
makeDirent("workspace"),
] as unknown as Dirent[]);
mockExists.mockImplementation((p) => String(p) === rootDir);
setUIActiveProfile("default");
expect(resolveWebChatDir()).toBe(chatDir("default"));
});
it("clearUIActiveProfileCache delegates to clearUIActiveWorkspaceCache", async () => {
const {
resolveWebChatDir,
setUIActiveWorkspace,
clearUIActiveProfileCache,
mockExists,
mockReadFile,
mockReaddir,
} = await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([
makeDirent("workspace-dev"),
] as unknown as Dirent[]);
const devDir = workspaceDir("dev");
mockExists.mockImplementation((p) => String(p) === devDir);
setUIActiveWorkspace("dev");
expect(resolveWebChatDir()).toBe(chatDir("dev"));
clearUIActiveProfileCache();
mockExists.mockReturnValue(false);
mockReaddir.mockReturnValue([] as unknown as Dirent[]);
expect(resolveWebChatDir()).toBe(chatDir("default"));
});
});

View File

@ -63,14 +63,10 @@ function makeDirent(name: string, isDir: boolean): Dirent {
} as Dirent;
}
describe("workspace profiles", () => {
describe("workspace (flat workspace model)", () => {
const originalEnv = { ...process.env };
const DEFAULT_STATE_DIR = join("/home/testuser", ".openclaw");
const stateDirForProfile = (profile: string | null) =>
!profile || profile.toLowerCase() === "default"
? DEFAULT_STATE_DIR
: join("/home/testuser", `.openclaw-${profile}`);
const UI_STATE_PATH = join(DEFAULT_STATE_DIR, ".ironclaw-ui-state.json");
const STATE_DIR = "/home/testuser/.openclaw-ironclaw";
const UI_STATE_PATH = join(STATE_DIR, ".ironclaw-ui-state.json");
beforeEach(() => {
vi.resetModules();
@ -151,519 +147,480 @@ describe("workspace profiles", () => {
// ─── getEffectiveProfile ──────────────────────────────────────────
describe("getEffectiveProfile", () => {
it("returns env var when OPENCLAW_PROFILE is set", async () => {
it("always returns 'ironclaw' regardless of env/state (single profile enforcement)", async () => {
process.env.OPENCLAW_PROFILE = "work";
const { getEffectiveProfile } = await importWorkspace();
expect(getEffectiveProfile()).toBe("work");
});
it("returns null when nothing is set", async () => {
const { getEffectiveProfile, mockReadFile } = await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
expect(getEffectiveProfile()).toBeNull();
});
it("returns persisted profile from state file", async () => {
const { getEffectiveProfile, mockReadFile } = await importWorkspace();
mockReadFile.mockReturnValue(
JSON.stringify({ activeProfile: "personal" }) as never,
);
expect(getEffectiveProfile()).toBe("personal");
});
it("env var takes precedence over persisted file", async () => {
process.env.OPENCLAW_PROFILE = "env-profile";
const { getEffectiveProfile, mockReadFile } = await importWorkspace();
mockReadFile.mockReturnValue(
JSON.stringify({ activeProfile: "file-profile" }) as never,
);
expect(getEffectiveProfile()).toBe("env-profile");
});
it("in-memory override takes precedence over persisted file", async () => {
const { getEffectiveProfile, setUIActiveProfile, mockReadFile } =
await importWorkspace();
mockReadFile.mockReturnValue(
JSON.stringify({ activeProfile: "file-profile" }) as never,
JSON.stringify({ activeWorkspace: "something" }) as never,
);
setUIActiveProfile("memory-profile");
expect(getEffectiveProfile()).toBe("memory-profile");
});
it("env var takes precedence over in-memory override", async () => {
process.env.OPENCLAW_PROFILE = "env-wins";
const { getEffectiveProfile, setUIActiveProfile } =
await importWorkspace();
setUIActiveProfile("memory-profile");
expect(getEffectiveProfile()).toBe("env-wins");
});
it("trims whitespace from env var", async () => {
process.env.OPENCLAW_PROFILE = " padded ";
const { getEffectiveProfile } = await importWorkspace();
expect(getEffectiveProfile()).toBe("padded");
});
it("trims whitespace from persisted profile", async () => {
const { getEffectiveProfile, mockReadFile } = await importWorkspace();
mockReadFile.mockReturnValue(
JSON.stringify({ activeProfile: " trimme " }) as never,
);
expect(getEffectiveProfile()).toBe("trimme");
});
it("uses persisted profile in non-test runtime", async () => {
process.env.NODE_ENV = "production";
process.env.VITEST = "false";
const { getEffectiveProfile, mockReadFile } = await importWorkspace();
mockReadFile.mockReturnValue(
JSON.stringify({ activeProfile: "personal" }) as never,
);
expect(getEffectiveProfile()).toBe("personal");
setUIActiveProfile("custom");
expect(getEffectiveProfile()).toBe("ironclaw");
});
});
// ─── setUIActiveProfile ──────────────────────────────────────────
// ─── getActiveWorkspaceName ───────────────────────────────────────
describe("setUIActiveProfile", () => {
it("persists profile to state file", async () => {
const { setUIActiveProfile, mockReadFile, mockWriteFile, mockExists } =
describe("getActiveWorkspaceName", () => {
it("returns null when nothing is set and no workspace dirs exist", async () => {
const { getActiveWorkspaceName, mockReadFile } = await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
expect(getActiveWorkspaceName()).toBeNull();
});
it("returns persisted workspace from state file", async () => {
const { getActiveWorkspaceName, mockReadFile, mockReaddir } =
await importWorkspace();
mockReaddir.mockReturnValue([
makeDirent("workspace-dev", true),
] as unknown as Dirent[]);
mockReadFile.mockReturnValue(
JSON.stringify({ activeWorkspace: "dev" }) as never,
);
expect(getActiveWorkspaceName()).toBe("dev");
});
it("in-memory override takes precedence over persisted file", async () => {
const {
getActiveWorkspaceName,
setUIActiveWorkspace,
mockReadFile,
mockReaddir,
} = await importWorkspace();
mockReaddir.mockReturnValue([
makeDirent("workspace-memory-ws", true),
makeDirent("workspace-file-ws", true),
] as unknown as Dirent[]);
mockReadFile.mockReturnValue(
JSON.stringify({ activeWorkspace: "file-ws" }) as never,
);
setUIActiveWorkspace("memory-ws");
expect(getActiveWorkspaceName()).toBe("memory-ws");
});
it("falls back to first discovered workspace when nothing is set", async () => {
const { getActiveWorkspaceName, mockReadFile, mockReaddir } =
await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([
makeDirent("workspace-beta", true),
makeDirent("workspace-alpha", true),
makeDirent("unrelated-dir", true),
] as unknown as Dirent[]);
// scanWorkspaceNames sorts alphabetically, so "alpha" comes first
expect(getActiveWorkspaceName()).toBe("alpha");
});
it("OPENCLAW_WORKSPACE env pointing to workspace-<name> dir under state dir takes priority", async () => {
process.env.OPENCLAW_WORKSPACE = join(STATE_DIR, "workspace-envws");
const {
getActiveWorkspaceName,
setUIActiveWorkspace,
mockReadFile,
mockReaddir,
} = await importWorkspace();
mockReaddir.mockReturnValue([
makeDirent("workspace-envws", true),
makeDirent("workspace-memory", true),
] as unknown as Dirent[]);
mockReadFile.mockReturnValue(
JSON.stringify({ activeWorkspace: "persisted" }) as never,
);
setUIActiveWorkspace("memory");
expect(getActiveWorkspaceName()).toBe("envws");
});
it("OPENCLAW_WORKSPACE env pointing to root workspace dir resolves to default", async () => {
process.env.OPENCLAW_WORKSPACE = join(STATE_DIR, "workspace");
const {
getActiveWorkspaceName,
setUIActiveWorkspace,
mockReadFile,
mockReaddir,
} = await importWorkspace();
mockReaddir.mockReturnValue([
makeDirent("workspace", true),
makeDirent("workspace-memory", true),
] as unknown as Dirent[]);
mockReadFile.mockReturnValue(
JSON.stringify({ activeWorkspace: "persisted" }) as never,
);
setUIActiveWorkspace("memory");
expect(getActiveWorkspaceName()).toBe("default");
});
});
// ─── setUIActiveWorkspace ─────────────────────────────────────────
describe("setUIActiveWorkspace", () => {
it("persists workspace name to state file with activeWorkspace key", async () => {
const { setUIActiveWorkspace, mockReadFile, mockWriteFile, mockExists } =
await importWorkspace();
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
mockExists.mockReturnValue(true);
setUIActiveProfile("work");
setUIActiveWorkspace("dev");
expect(mockWriteFile).toHaveBeenCalledWith(
UI_STATE_PATH,
expect.stringContaining('"activeProfile": "work"'),
expect.stringContaining('"activeWorkspace": "dev"'),
);
});
it("null clears the override", async () => {
const { setUIActiveProfile, mockReadFile, mockWriteFile, mockExists } =
const { setUIActiveWorkspace, mockReadFile, mockWriteFile, mockExists } =
await importWorkspace();
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
mockExists.mockReturnValue(true);
setUIActiveProfile(null);
setUIActiveWorkspace(null);
expect(mockWriteFile).toHaveBeenCalledWith(
UI_STATE_PATH,
expect.stringContaining('"activeProfile": null'),
expect.stringContaining('"activeWorkspace": null'),
);
});
it("preserves existing state keys", async () => {
const { setUIActiveProfile, mockReadFile, mockWriteFile, mockExists } =
await importWorkspace();
mockReadFile.mockReturnValue(
JSON.stringify({
workspaceRegistry: { other: "/path" },
}) as never,
);
mockExists.mockReturnValue(true);
setUIActiveProfile("new");
const stateWrites = mockWriteFile.mock.calls.filter((c) =>
(c[0] as string).includes(".ironclaw-ui-state.json"),
);
expect(stateWrites.length).toBeGreaterThan(0);
const parsed = JSON.parse(stateWrites[stateWrites.length - 1][1] as string);
expect(parsed.workspaceRegistry).toEqual({ other: "/path" });
expect(parsed.activeProfile).toBe("new");
});
});
// ─── clearUIActiveProfileCache ────────────────────────────────────
// ─── clearUIActiveWorkspaceCache ──────────────────────────────────
describe("clearUIActiveProfileCache", () => {
it("re-reads from file after clearing", async () => {
describe("clearUIActiveWorkspaceCache", () => {
it("re-reads from file after clearing in-memory override", async () => {
const {
getEffectiveProfile,
setUIActiveProfile,
clearUIActiveProfileCache,
getActiveWorkspaceName,
setUIActiveWorkspace,
clearUIActiveWorkspaceCache,
mockReadFile,
mockReaddir,
} = await importWorkspace();
mockReaddir.mockReturnValue([
makeDirent("workspace-in-memory", true),
makeDirent("workspace-from-file", true),
] as unknown as Dirent[]);
mockReadFile.mockReturnValue(
JSON.stringify({ activeProfile: "from-file" }) as never,
JSON.stringify({ activeWorkspace: "from-file" }) as never,
);
setUIActiveProfile("in-memory");
expect(getEffectiveProfile()).toBe("in-memory");
setUIActiveWorkspace("in-memory");
expect(getActiveWorkspaceName()).toBe("in-memory");
clearUIActiveProfileCache();
expect(getEffectiveProfile()).toBe("from-file");
clearUIActiveWorkspaceCache();
expect(getActiveWorkspaceName()).toBe("from-file");
});
});
// ─── discoverProfiles ─────────────────────────────────────────────
// ─── discoverWorkspaces ───────────────────────────────────────────
describe("discoverProfiles", () => {
it("always includes default profile", async () => {
const { discoverProfiles, mockExists } = await importWorkspace();
mockExists.mockReturnValue(false);
const profiles = discoverProfiles();
expect(profiles).toHaveLength(1);
expect(profiles[0].name).toBe("default");
});
it("default profile is active when no profile set", async () => {
const { discoverProfiles, clearUIActiveProfileCache, mockExists, mockReadFile } =
describe("discoverWorkspaces", () => {
it("returns empty array when state dir has no workspace-* dirs", async () => {
const { discoverWorkspaces, mockReadFile, mockReaddir } =
await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockExists.mockReturnValue(false);
clearUIActiveProfileCache();
const profiles = discoverProfiles();
expect(profiles[0].isActive).toBe(true);
mockReaddir.mockReturnValue([] as unknown as Dirent[]);
const workspaces = discoverWorkspaces();
expect(workspaces).toHaveLength(0);
});
it("discovers profile-scoped .openclaw-<name> state directories", async () => {
const { discoverProfiles, mockExists, mockReaddir } =
it("discovers workspace-<name> directories under state dir", async () => {
const { discoverWorkspaces, mockReaddir, mockExists, mockReadFile } =
await importWorkspace();
const workStateDir = stateDirForProfile("work");
const personalStateDir = stateDirForProfile("personal");
mockExists.mockImplementation((p) => {
const s = String(p);
return (
s === DEFAULT_STATE_DIR ||
s === join(DEFAULT_STATE_DIR, "openclaw.json") ||
s === join(workStateDir, "workspace") ||
s === join(personalStateDir, "workspace")
);
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([
makeDirent(".openclaw-work", true),
makeDirent(".openclaw-personal", true),
makeDirent("sessions", true),
makeDirent("workspace-alpha", true),
makeDirent("workspace-beta", true),
makeDirent("some-other-dir", true),
makeDirent("config.json", false),
] as unknown as Dirent[]);
const profiles = discoverProfiles();
const names = profiles.map((p) => p.name);
expect(names).toContain("default");
expect(names).toContain("work");
expect(names).toContain("personal");
expect(names).not.toContain("sessions");
});
it("marks active profile correctly", async () => {
const { discoverProfiles, setUIActiveProfile, mockExists, mockReaddir } =
await importWorkspace();
const workStateDir = stateDirForProfile("work");
mockExists.mockImplementation((p) => {
const s = String(p);
return (
s === DEFAULT_STATE_DIR ||
s === join(DEFAULT_STATE_DIR, "openclaw.json") ||
s === join(workStateDir, "workspace")
s === join(STATE_DIR, "workspace-alpha") ||
s === join(STATE_DIR, "workspace-beta")
);
});
mockReaddir.mockReturnValue([
makeDirent(".openclaw-work", true),
] as unknown as Dirent[]);
setUIActiveProfile("work");
const profiles = discoverProfiles();
const defaultProfile = profiles.find((p) => p.name === "default");
const workProfile = profiles.find((p) => p.name === "work");
expect(defaultProfile?.isActive).toBe(false);
expect(workProfile?.isActive).toBe(true);
const workspaces = discoverWorkspaces();
const names = workspaces.map((w) => w.name);
expect(names).toContain("alpha");
expect(names).toContain("beta");
expect(names).not.toContain("some-other-dir");
expect(names).not.toContain("config.json");
});
it("merges registry entries for custom-path workspaces", async () => {
const { discoverProfiles, mockExists, mockReadFile } =
it("discovers root workspace dir as default", async () => {
const { discoverWorkspaces, mockReaddir, mockExists, mockReadFile } =
await importWorkspace();
mockExists.mockImplementation((p) => {
const s = String(p);
return s === "/custom/workspace" || s === DEFAULT_STATE_DIR;
});
mockReadFile.mockReturnValue(
JSON.stringify({
workspaceRegistry: { custom: "/custom/workspace" },
}) as never,
);
const profiles = discoverProfiles();
const custom = profiles.find((p) => p.name === "custom");
expect(custom).toBeDefined();
expect(custom!.workspaceDir).toBe("/custom/workspace");
});
it("does not duplicate profiles seen via directory and registry", async () => {
const { discoverProfiles, mockExists, mockReaddir, mockReadFile } =
await importWorkspace();
const stateDir = stateDirForProfile("shared");
const wsDir = join(stateDir, "workspace");
mockExists.mockImplementation((p) => {
const s = String(p);
return s === DEFAULT_STATE_DIR || s === wsDir;
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([
makeDirent(".openclaw-shared", true),
makeDirent("workspace", true),
] as unknown as Dirent[]);
mockReadFile.mockReturnValue(
JSON.stringify({
workspaceRegistry: { shared: wsDir },
}) as never,
);
mockExists.mockImplementation((p) => String(p) === join(STATE_DIR, "workspace"));
const profiles = discoverProfiles();
const sharedProfiles = profiles.filter((p) => p.name === "shared");
expect(sharedProfiles).toHaveLength(1);
const workspaces = discoverWorkspaces();
expect(workspaces).toHaveLength(1);
expect(workspaces[0]?.name).toBe("default");
expect(workspaces[0]?.workspaceDir).toBe(join(STATE_DIR, "workspace"));
expect(workspaces[0]?.isActive).toBe(true);
});
it("handles unreadable state directory gracefully", async () => {
const { discoverProfiles, mockExists, mockReaddir } =
it("keeps root default and workspace-ironclaw as distinct workspaces", async () => {
const { discoverWorkspaces, mockReaddir, mockExists, mockReadFile } =
await importWorkspace();
mockExists.mockReturnValue(true);
mockReaddir.mockImplementation(() => {
throw new Error("EACCES");
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
const profiles = discoverProfiles();
expect(profiles.length).toBeGreaterThanOrEqual(1);
expect(profiles[0].name).toBe("default");
mockReaddir.mockReturnValue([
makeDirent("workspace", true),
makeDirent("workspace-ironclaw", true),
] as unknown as Dirent[]);
mockExists.mockImplementation((p) => {
const s = String(p);
return s === join(STATE_DIR, "workspace") || s === join(STATE_DIR, "workspace-ironclaw");
});
const workspaces = discoverWorkspaces();
expect(workspaces).toHaveLength(2);
const names = workspaces.map((workspace) => workspace.name);
expect(names).toContain("default");
expect(names).toContain("ironclaw");
const rootDefault = workspaces.find((workspace) => workspace.name === "default");
const profileIronclaw = workspaces.find((workspace) => workspace.name === "ironclaw");
expect(rootDefault?.workspaceDir).toBe(join(STATE_DIR, "workspace"));
expect(profileIronclaw?.workspaceDir).toBe(join(STATE_DIR, "workspace-ironclaw"));
});
it("lists default, ironclaw, and custom workspace side by side", async () => {
const { discoverWorkspaces, mockReaddir, mockExists, mockReadFile } =
await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([
makeDirent("workspace", true),
makeDirent("workspace-ironclaw", true),
makeDirent("workspace-kumareth", true),
] as unknown as Dirent[]);
mockExists.mockImplementation((p) => {
const s = String(p);
return (
s === join(STATE_DIR, "workspace") ||
s === join(STATE_DIR, "workspace-ironclaw") ||
s === join(STATE_DIR, "workspace-kumareth")
);
});
const workspaces = discoverWorkspaces();
expect(workspaces.map((workspace) => workspace.name)).toEqual([
"default",
"ironclaw",
"kumareth",
]);
});
it("first discovered workspace is marked active when no explicit selection", async () => {
const { discoverWorkspaces, mockReaddir, mockExists, mockReadFile } =
await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([
makeDirent("workspace-beta", true),
makeDirent("workspace-alpha", true),
] as unknown as Dirent[]);
mockExists.mockImplementation((p) => {
const s = String(p);
return (
s === join(STATE_DIR, "workspace-alpha") ||
s === join(STATE_DIR, "workspace-beta")
);
});
const workspaces = discoverWorkspaces();
// sorted alphabetically: alpha first
expect(workspaces[0].name).toBe("alpha");
expect(workspaces[0].isActive).toBe(true);
expect(workspaces[1].isActive).toBe(false);
});
it("marks the explicitly active workspace correctly", async () => {
const {
discoverWorkspaces,
setUIActiveWorkspace,
mockReaddir,
mockExists,
mockReadFile,
} = await importWorkspace();
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
mockReaddir.mockReturnValue([
makeDirent("workspace-alpha", true),
makeDirent("workspace-beta", true),
] as unknown as Dirent[]);
mockExists.mockImplementation((p) => {
const s = String(p);
return (
s === join(STATE_DIR, "workspace-alpha") ||
s === join(STATE_DIR, "workspace-beta") ||
s === STATE_DIR
);
});
setUIActiveWorkspace("beta");
const workspaces = discoverWorkspaces();
const alpha = workspaces.find((w) => w.name === "alpha");
const beta = workspaces.find((w) => w.name === "beta");
expect(alpha?.isActive).toBe(false);
expect(beta?.isActive).toBe(true);
});
});
// ─── resolveWebChatDir ────────────────────────────────────────────
describe("resolveWebChatDir", () => {
it("returns web-chat for default profile", async () => {
const { resolveWebChatDir, mockReadFile } = await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
expect(resolveWebChatDir()).toBe(join(DEFAULT_STATE_DIR, "web-chat"));
});
it("returns profile-scoped web-chat directory for named profile", async () => {
const { resolveWebChatDir, setUIActiveProfile, mockReadFile } =
await importWorkspace();
it("returns <workspace>/.openclaw/web-chat for active workspace (per-workspace chat isolation)", async () => {
const {
resolveWebChatDir,
setUIActiveWorkspace,
mockExists,
mockReadFile,
mockReaddir,
} = await importWorkspace();
mockReaddir.mockReturnValue([
makeDirent("workspace-dev", true),
] as unknown as Dirent[]);
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
setUIActiveProfile("work");
expect(resolveWebChatDir()).toBe(join(stateDirForProfile("work"), "web-chat"));
setUIActiveWorkspace("dev");
const wsDir = join(STATE_DIR, "workspace-dev");
mockExists.mockImplementation((p) => String(p) === wsDir);
expect(resolveWebChatDir()).toBe(join(wsDir, ".openclaw", "web-chat"));
});
it("uses OPENCLAW_PROFILE when no UI override is set", async () => {
process.env.OPENCLAW_PROFILE = "ironclaw";
const { resolveWebChatDir, mockReadFile } = await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
expect(resolveWebChatDir()).toBe(join(stateDirForProfile("ironclaw"), "web-chat"));
});
it("migrates legacy web-chat-<profile> into profile state dir", async () => {
const { resolveWebChatDir, setUIActiveProfile, mockExists, mockReadFile, mockRename } =
await importWorkspace();
it("different workspaces produce different chat directories", async () => {
const {
resolveWebChatDir,
setUIActiveWorkspace,
clearUIActiveWorkspaceCache,
mockExists,
mockReadFile,
mockReaddir,
} = await importWorkspace();
mockReaddir.mockReturnValue([
makeDirent("workspace-work", true),
makeDirent("workspace-personal", true),
] as unknown as Dirent[]);
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
setUIActiveProfile("work");
const legacyDir = join(DEFAULT_STATE_DIR, "web-chat-work");
const targetDir = join(stateDirForProfile("work"), "web-chat");
mockExists.mockImplementation((p) => String(p) === legacyDir);
setUIActiveWorkspace("work");
const workDir = join(STATE_DIR, "workspace-work");
mockExists.mockImplementation((p) => String(p) === workDir);
const chatWork = resolveWebChatDir();
resolveWebChatDir();
clearUIActiveWorkspaceCache();
setUIActiveWorkspace("personal");
const personalDir = join(STATE_DIR, "workspace-personal");
mockExists.mockImplementation((p) => String(p) === personalDir);
const chatPersonal = resolveWebChatDir();
expect(mockRename).toHaveBeenCalledWith(legacyDir, targetDir);
expect(chatWork).not.toBe(chatPersonal);
expect(chatWork).toBe(join(workDir, ".openclaw", "web-chat"));
expect(chatPersonal).toBe(join(personalDir, ".openclaw", "web-chat"));
});
it("returns web-chat when profile is 'default'", async () => {
const { resolveWebChatDir, setUIActiveProfile, mockReadFile } =
it("falls back to root workspace path when no workspace is active", async () => {
const { resolveWebChatDir, mockReadFile, mockReaddir } =
await importWorkspace();
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
setUIActiveProfile("default");
expect(resolveWebChatDir()).toBe(join(DEFAULT_STATE_DIR, "web-chat"));
});
it("respects OPENCLAW_STATE_DIR override", async () => {
process.env.OPENCLAW_STATE_DIR = "/custom/state";
const { resolveWebChatDir, mockReadFile } = await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
expect(resolveWebChatDir()).toBe(join("/custom/state", "web-chat"));
});
it("uses default web-chat dir in non-test runtime when no profile is set", async () => {
process.env.NODE_ENV = "production";
process.env.VITEST = "false";
const { resolveWebChatDir, mockReadFile } = await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
expect(resolveWebChatDir()).toBe(join(DEFAULT_STATE_DIR, "web-chat"));
mockReaddir.mockReturnValue([] as unknown as Dirent[]);
expect(resolveWebChatDir()).toBe(
join(STATE_DIR, "workspace", ".openclaw", "web-chat"),
);
});
});
// ─── resolveWorkspaceRoot (profile-aware) ─────────────────────────
// ─── resolveWorkspaceRoot ─────────────────────────────────────────
describe("resolveWorkspaceRoot (profile-aware)", () => {
it("returns profile-scoped workspace for named profile", async () => {
const { resolveWorkspaceRoot, setUIActiveProfile, mockExists, mockReadFile } =
await importWorkspace();
describe("resolveWorkspaceRoot", () => {
it("returns active workspace dir when it exists on disk", async () => {
const {
resolveWorkspaceRoot,
setUIActiveWorkspace,
mockExists,
mockReadFile,
mockReaddir,
} = await importWorkspace();
mockReaddir.mockReturnValue([
makeDirent("workspace-dev", true),
] as unknown as Dirent[]);
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
setUIActiveProfile("work");
const workDir = join(stateDirForProfile("work"), "workspace");
mockExists.mockImplementation((p) => String(p) === workDir);
expect(resolveWorkspaceRoot()).toBe(workDir);
setUIActiveWorkspace("dev");
const wsDir = join(STATE_DIR, "workspace-dev");
mockExists.mockImplementation((p) => String(p) === wsDir);
expect(resolveWorkspaceRoot()).toBe(wsDir);
});
it("uses OPENCLAW_PROFILE to resolve profile-scoped workspace", async () => {
process.env.OPENCLAW_PROFILE = "ironclaw";
const { resolveWorkspaceRoot, mockExists, mockReadFile } = await importWorkspace();
it("returns null when no workspace dirs exist", async () => {
const { resolveWorkspaceRoot, mockReadFile, mockReaddir } = await importWorkspace();
mockReaddir.mockReturnValue([] as unknown as Dirent[]);
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
const profileWorkspaceDir = join(stateDirForProfile("ironclaw"), "workspace");
mockExists.mockImplementation((p) => String(p) === profileWorkspaceDir);
expect(resolveWorkspaceRoot()).toBe(profileWorkspaceDir);
});
it("prefers registry path over directory convention", async () => {
const {
resolveWorkspaceRoot,
setUIActiveProfile,
mockExists,
mockReadFile,
} = await importWorkspace();
mockReadFile.mockReturnValue(
JSON.stringify({
workspaceRegistry: { work: "/custom/work" },
}) as never,
);
setUIActiveProfile("work");
mockExists.mockImplementation((p) => {
const s = String(p);
return (
s === "/custom/work" || s === join(stateDirForProfile("work"), "workspace")
);
});
expect(resolveWorkspaceRoot()).toBe("/custom/work");
});
it("OPENCLAW_WORKSPACE env takes top priority", async () => {
process.env.OPENCLAW_WORKSPACE = "/env/workspace";
const { resolveWorkspaceRoot, setUIActiveProfile, mockExists, mockReadFile } =
await importWorkspace();
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
setUIActiveProfile("work");
mockExists.mockImplementation((p) => String(p) === "/env/workspace");
expect(resolveWorkspaceRoot()).toBe("/env/workspace");
});
it("returns null when named profile workspace is missing", async () => {
const { resolveWorkspaceRoot, setUIActiveProfile, mockExists, mockReadFile } =
await importWorkspace();
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
setUIActiveProfile("missing");
mockExists.mockReturnValue(false);
expect(resolveWorkspaceRoot()).toBeNull();
});
it("migrates legacy workspace-<profile> and updates resolution", async () => {
const { resolveWorkspaceRoot, setUIActiveProfile, mockExists, mockReadFile, mockRename } =
it("OPENCLAW_WORKSPACE env takes priority if it points to a valid workspace-<name> path", async () => {
const envWsDir = join(STATE_DIR, "workspace-envws");
process.env.OPENCLAW_WORKSPACE = envWsDir;
const { resolveWorkspaceRoot, setUIActiveWorkspace, mockExists, mockReadFile } =
await importWorkspace();
mockReadFile.mockReturnValue(
JSON.stringify({
workspaceRegistry: {
work: join(DEFAULT_STATE_DIR, "workspace-work"),
},
}) as never,
);
setUIActiveProfile("work");
const legacyDir = join(DEFAULT_STATE_DIR, "workspace-work");
const targetDir = join(stateDirForProfile("work"), "workspace");
let moved = false;
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
setUIActiveWorkspace("other");
mockExists.mockImplementation((p) => {
const s = String(p);
if (!moved) {
return s === legacyDir;
}
return s === targetDir;
return s === envWsDir || s === join(STATE_DIR, "workspace-other");
});
mockRename.mockImplementation(() => {
moved = true;
});
expect(resolveWorkspaceRoot()).toBe(targetDir);
expect(mockRename).toHaveBeenCalledWith(legacyDir, targetDir);
expect(resolveWorkspaceRoot()).toBe(envWsDir);
});
it("uses legacy workspace fallback when profile workspace is missing", async () => {
const { resolveWorkspaceRoot, setUIActiveProfile, mockExists, mockReadFile, mockRename } =
it("OPENCLAW_WORKSPACE env takes priority for root workspace path", async () => {
const envWsDir = join(STATE_DIR, "workspace");
process.env.OPENCLAW_WORKSPACE = envWsDir;
const { resolveWorkspaceRoot, setUIActiveWorkspace, mockExists, mockReadFile } =
await importWorkspace();
const legacyDir = join(DEFAULT_STATE_DIR, "workspace-ironclaw");
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
setUIActiveProfile("ironclaw");
mockRename.mockImplementation(() => {
throw new Error("EPERM");
setUIActiveWorkspace("other");
mockExists.mockImplementation((p) => {
const s = String(p);
return s === envWsDir || s === join(STATE_DIR, "workspace-other");
});
mockExists.mockImplementation((p) => String(p) === legacyDir);
expect(resolveWorkspaceRoot()).toBe(legacyDir);
expect(mockRename).toHaveBeenCalled();
expect(resolveWorkspaceRoot()).toBe(envWsDir);
});
});
// ─── registerWorkspacePath / getRegisteredWorkspacePath ────────────
describe("workspace registry", () => {
it("registerWorkspacePath persists to state file", async () => {
const { registerWorkspacePath, mockReadFile, mockWriteFile, mockExists } =
await importWorkspace();
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
mockExists.mockReturnValue(true);
describe("registerWorkspacePath / getRegisteredWorkspacePath", () => {
it("registerWorkspacePath is now a no-op (custom paths disabled)", async () => {
const { registerWorkspacePath, mockWriteFile } = await importWorkspace();
mockWriteFile.mockClear();
registerWorkspacePath("myprofile", "/my/workspace");
const stateWrites = mockWriteFile.mock.calls.filter((c) =>
(c[0] as string).includes(".ironclaw-ui-state.json"),
);
expect(stateWrites.length).toBeGreaterThan(0);
const parsed = JSON.parse(stateWrites[stateWrites.length - 1][1] as string);
expect(parsed.workspaceRegistry.myprofile).toBe("/my/workspace");
expect(stateWrites).toHaveLength(0);
});
it("getRegisteredWorkspacePath returns null for unknown profile", async () => {
const { getRegisteredWorkspacePath, mockReadFile } =
await importWorkspace();
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
expect(getRegisteredWorkspacePath("unknown")).toBeNull();
});
it("getRegisteredWorkspacePath returns null for null profile", async () => {
it("getRegisteredWorkspacePath always returns null", async () => {
const { getRegisteredWorkspacePath } = await importWorkspace();
expect(getRegisteredWorkspacePath("anything")).toBeNull();
expect(getRegisteredWorkspacePath(null)).toBeNull();
});
it("getRegisteredWorkspacePath returns path for registered profile", async () => {
const { getRegisteredWorkspacePath, mockReadFile } =
await importWorkspace();
mockReadFile.mockReturnValue(
JSON.stringify({
workspaceRegistry: { test: "/test/workspace" },
}) as never,
);
expect(getRegisteredWorkspacePath("test")).toBe("/test/workspace");
});
it("registerWorkspacePath preserves existing registry entries", async () => {
const { registerWorkspacePath, mockReadFile, mockWriteFile, mockExists } =
await importWorkspace();
mockReadFile.mockReturnValue(
JSON.stringify({
workspaceRegistry: { existing: "/existing" },
}) as never,
);
mockExists.mockReturnValue(true);
registerWorkspacePath("new", "/new/path");
const stateWrites = mockWriteFile.mock.calls.filter((c) =>
(c[0] as string).includes(".ironclaw-ui-state.json"),
);
expect(stateWrites.length).toBeGreaterThan(0);
const parsed = JSON.parse(stateWrites[stateWrites.length - 1][1] as string);
expect(parsed.workspaceRegistry.existing).toBe("/existing");
expect(parsed.workspaceRegistry.new).toBe("/new/path");
expect(getRegisteredWorkspacePath("test")).toBeNull();
});
});
});

View File

@ -6,6 +6,8 @@ vi.mock("node:fs", () => ({
existsSync: vi.fn(() => false),
readFileSync: vi.fn(() => ""),
readdirSync: vi.fn(() => []),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
}));
// Mock node:child_process
@ -48,6 +50,8 @@ function makeDirent(name: string, isDir: boolean): Dirent {
describe("workspace utilities", () => {
const originalEnv = { ...process.env };
const STATE_DIR = join("/home/testuser", ".openclaw-ironclaw");
const WS_DIR = join(STATE_DIR, "workspace-test");
beforeEach(() => {
vi.resetModules();
@ -58,6 +62,8 @@ describe("workspace utilities", () => {
existsSync: vi.fn(() => false),
readFileSync: vi.fn(() => ""),
readdirSync: vi.fn(() => []),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
}));
vi.mock("node:child_process", () => ({
execSync: vi.fn(() => ""),
@ -88,22 +94,33 @@ describe("workspace utilities", () => {
};
}
/** Set up mocks so resolveWorkspaceRoot() returns WS_DIR via OPENCLAW_WORKSPACE env. */
function useEnvWorkspace(mockExists: ReturnType<typeof vi.mocked<typeof existsSync>>) {
process.env.OPENCLAW_WORKSPACE = WS_DIR;
mockExists.mockImplementation((p) => String(p) === WS_DIR);
}
// ─── resolveWorkspaceRoot ────────────────────────────────────────
describe("resolveWorkspaceRoot", () => {
it("returns OPENCLAW_WORKSPACE env var when set and exists", async () => {
process.env.OPENCLAW_WORKSPACE = "/custom/workspace";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { resolveWorkspaceRoot, mockExists } = await importWorkspace();
mockExists.mockImplementation((p) => String(p) === "/custom/workspace");
expect(resolveWorkspaceRoot()).toBe("/custom/workspace");
mockExists.mockImplementation((p) => String(p) === WS_DIR);
expect(resolveWorkspaceRoot()).toBe(WS_DIR);
});
it("returns default ~/.openclaw/workspace when env not set", async () => {
it("returns discovered workspace when env not set", async () => {
delete process.env.OPENCLAW_WORKSPACE;
const { resolveWorkspaceRoot, mockExists } = await importWorkspace();
const defaultPath = join("/home/testuser", ".openclaw", "workspace");
mockExists.mockImplementation((p) => String(p) === defaultPath);
expect(resolveWorkspaceRoot()).toBe(defaultPath);
const { resolveWorkspaceRoot, mockExists, mockReaddir } = await importWorkspace();
mockReaddir.mockImplementation((dir, _opts) => {
if (String(dir) === STATE_DIR) {
return [makeDirent("workspace-test", true)] as unknown as Dirent[];
}
return [] as unknown as Dirent[];
});
mockExists.mockImplementation((p) => String(p) === WS_DIR);
expect(resolveWorkspaceRoot()).toBe(WS_DIR);
});
it("returns null when no candidate directory exists", async () => {
@ -113,19 +130,65 @@ describe("workspace utilities", () => {
expect(resolveWorkspaceRoot()).toBeNull();
});
it("prefers OPENCLAW_WORKSPACE over default when both exist", async () => {
process.env.OPENCLAW_WORKSPACE = "/custom/workspace";
const { resolveWorkspaceRoot, mockExists } = await importWorkspace();
it("prefers OPENCLAW_WORKSPACE over discovered workspace", async () => {
const envWs = join(STATE_DIR, "workspace-fromenv");
process.env.OPENCLAW_WORKSPACE = envWs;
const { resolveWorkspaceRoot, mockExists, mockReaddir } = await importWorkspace();
mockReaddir.mockImplementation((dir, _opts) => {
if (String(dir) === STATE_DIR) {
return [
makeDirent("workspace-fromenv", true),
makeDirent("workspace-other", true),
] as unknown as Dirent[];
}
return [] as unknown as Dirent[];
});
mockExists.mockReturnValue(true);
expect(resolveWorkspaceRoot()).toBe("/custom/workspace");
expect(resolveWorkspaceRoot()).toBe(envWs);
});
it("falls back to default when env var path does not exist", async () => {
process.env.OPENCLAW_WORKSPACE = "/nonexistent";
const { resolveWorkspaceRoot, mockExists } = await importWorkspace();
const defaultPath = join("/home/testuser", ".openclaw", "workspace");
mockExists.mockImplementation((p) => String(p) === defaultPath);
expect(resolveWorkspaceRoot()).toBe(defaultPath);
it("falls back to discovered workspace when env var path does not exist", async () => {
process.env.OPENCLAW_WORKSPACE = join(STATE_DIR, "workspace-nonexistent");
const { resolveWorkspaceRoot, mockExists, mockReaddir } = await importWorkspace();
const fallbackWs = join(STATE_DIR, "workspace-fallback");
mockReaddir.mockImplementation((dir, _opts) => {
if (String(dir) === STATE_DIR) {
return [makeDirent("workspace-fallback", true)] as unknown as Dirent[];
}
return [] as unknown as Dirent[];
});
mockExists.mockImplementation((p) => String(p) === fallbackWs);
expect(resolveWorkspaceRoot()).toBe(fallbackWs);
});
it("resolves bootstrap root workspace as ironclaw default", async () => {
delete process.env.OPENCLAW_WORKSPACE;
const { resolveWorkspaceRoot, mockExists, mockReaddir } = await importWorkspace();
const rootWorkspace = join(STATE_DIR, "workspace");
mockReaddir.mockImplementation((dir, _opts) => {
if (String(dir) === STATE_DIR) {
return [makeDirent("workspace", true)] as unknown as Dirent[];
}
return [] as unknown as Dirent[];
});
mockExists.mockImplementation((p) => String(p) === rootWorkspace);
expect(resolveWorkspaceRoot()).toBe(rootWorkspace);
});
});
// ─── resolveWebChatDir ────────────────────────────────────────────
describe("resolveWebChatDir", () => {
it("falls back to root workspace chat dir for ironclaw default", async () => {
delete process.env.OPENCLAW_WORKSPACE;
const { resolveWebChatDir, mockReadFile, mockReaddir } = await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([] as unknown as Dirent[]);
expect(resolveWebChatDir()).toBe(
join(STATE_DIR, "workspace", ".openclaw", "web-chat"),
);
});
});
@ -140,27 +203,29 @@ describe("workspace utilities", () => {
});
it("returns absolute path when workspace is outside repo", async () => {
process.env.OPENCLAW_WORKSPACE = "/external/workspace";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { resolveAgentWorkspacePrefix, mockExists } = await importWorkspace();
mockExists.mockImplementation((p) => String(p) === "/external/workspace");
mockExists.mockImplementation((p) => String(p) === WS_DIR);
vi.spyOn(process, "cwd").mockReturnValue("/repo/apps/web");
expect(resolveAgentWorkspacePrefix()).toBe("/external/workspace");
expect(resolveAgentWorkspacePrefix()).toBe(WS_DIR);
});
it("returns relative path when workspace is inside repo", async () => {
process.env.OPENCLAW_WORKSPACE = "/repo/workspace";
const repoWs = join(STATE_DIR, "workspace-test");
process.env.OPENCLAW_WORKSPACE = repoWs;
const { resolveAgentWorkspacePrefix, mockExists } = await importWorkspace();
mockExists.mockImplementation((p) => String(p) === "/repo/workspace");
vi.spyOn(process, "cwd").mockReturnValue("/repo/apps/web");
expect(resolveAgentWorkspacePrefix()).toBe("workspace");
mockExists.mockImplementation((p) => String(p) === repoWs);
vi.spyOn(process, "cwd").mockReturnValue(STATE_DIR);
expect(resolveAgentWorkspacePrefix()).toBe("workspace-test");
});
it("handles non apps/web cwd", async () => {
process.env.OPENCLAW_WORKSPACE = "/repo/workspace";
const repoWs = join(STATE_DIR, "workspace-test");
process.env.OPENCLAW_WORKSPACE = repoWs;
const { resolveAgentWorkspacePrefix, mockExists } = await importWorkspace();
mockExists.mockImplementation((p) => String(p) === "/repo/workspace");
vi.spyOn(process, "cwd").mockReturnValue("/repo");
expect(resolveAgentWorkspacePrefix()).toBe("workspace");
mockExists.mockImplementation((p) => String(p) === repoWs);
vi.spyOn(process, "cwd").mockReturnValue(STATE_DIR);
expect(resolveAgentWorkspacePrefix()).toBe("workspace-test");
});
});
@ -264,27 +329,27 @@ describe("workspace utilities", () => {
describe("duckdbPath", () => {
it("returns root-level workspace.duckdb when it exists", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { duckdbPath, mockExists, mockReaddir } = await importWorkspace();
const rootDb = join("/ws", "workspace.duckdb");
const rootDb = join(WS_DIR, "workspace.duckdb");
mockExists.mockImplementation((p) => {
const s = String(p);
return s === "/ws" || s === rootDb;
return s === WS_DIR || s === rootDb;
});
mockReaddir.mockReturnValue([]);
expect(duckdbPath()).toBe(rootDb);
});
it("falls back to discovered nested db when root has none", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { duckdbPath, mockExists, mockReaddir } = await importWorkspace();
const nestedDb = join("/ws", "sub", "workspace.duckdb");
const nestedDb = join(WS_DIR, "sub", "workspace.duckdb");
mockExists.mockImplementation((p) => {
const s = String(p);
return s === "/ws" || s === nestedDb;
return s === WS_DIR || s === nestedDb;
});
mockReaddir.mockImplementation((dir) => {
if (String(dir) === "/ws") {
if (String(dir) === WS_DIR) {
return [makeDirent("sub", true)] as unknown as Dirent[];
}
return [] as unknown as Dirent[];
@ -300,9 +365,9 @@ describe("workspace utilities", () => {
});
it("returns null when workspace exists but no duckdb files", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { duckdbPath, mockExists, mockReaddir } = await importWorkspace();
mockExists.mockImplementation((p) => String(p) === "/ws");
mockExists.mockImplementation((p) => String(p) === WS_DIR);
mockReaddir.mockReturnValue([]);
expect(duckdbPath()).toBeNull();
});
@ -312,17 +377,17 @@ describe("workspace utilities", () => {
describe("duckdbRelativeScope", () => {
it("returns empty string for root-level db", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { duckdbRelativeScope, mockExists } = await importWorkspace();
mockExists.mockImplementation((p) => String(p) === "/ws");
expect(duckdbRelativeScope("/ws/workspace.duckdb")).toBe("");
mockExists.mockImplementation((p) => String(p) === WS_DIR);
expect(duckdbRelativeScope(join(WS_DIR, "workspace.duckdb"))).toBe("");
});
it("returns relative path for nested db", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { duckdbRelativeScope, mockExists } = await importWorkspace();
mockExists.mockImplementation((p) => String(p) === "/ws");
expect(duckdbRelativeScope("/ws/sub/deep/workspace.duckdb")).toBe(join("sub", "deep"));
mockExists.mockImplementation((p) => String(p) === WS_DIR);
expect(duckdbRelativeScope(join(WS_DIR, "sub", "deep", "workspace.duckdb"))).toBe(join("sub", "deep"));
});
it("returns empty string when no workspace root", async () => {
@ -378,13 +443,13 @@ describe("workspace utilities", () => {
describe("duckdbQuery", () => {
it("returns parsed JSON rows on success", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { duckdbQuery, mockExists, mockExec } = await importWorkspace();
const rootDb = join("/ws", "workspace.duckdb");
const rootDb = join(WS_DIR, "workspace.duckdb");
const bin = "/opt/homebrew/bin/duckdb";
mockExists.mockImplementation((p) => {
const s = String(p);
return s === "/ws" || s === rootDb || s === bin;
return s === WS_DIR || s === rootDb || s === bin;
});
mockExec.mockReturnValue('[{"id":"1","name":"test"}]' as never);
const result = duckdbQuery("SELECT * FROM objects");
@ -392,7 +457,7 @@ describe("workspace utilities", () => {
});
it("returns empty array for empty result", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { duckdbQuery, mockExists, mockExec } = await importWorkspace();
mockExists.mockReturnValue(true);
mockExec.mockReturnValue("[]" as never);
@ -407,7 +472,7 @@ describe("workspace utilities", () => {
});
it("returns empty array on execSync error", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { duckdbQuery, mockExists, mockExec } = await importWorkspace();
mockExists.mockReturnValue(true);
mockExec.mockImplementation(() => { throw new Error("query failed"); });
@ -419,14 +484,14 @@ describe("workspace utilities", () => {
describe("duckdbQueryAsync", () => {
it("returns parsed JSON rows on success", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { duckdbQueryAsync, mockExists } = await importWorkspace();
const { exec: mockExecFn } = await import("node:child_process");
const rootDb = join("/ws", "workspace.duckdb");
const rootDb = join(WS_DIR, "workspace.duckdb");
const bin = "/opt/homebrew/bin/duckdb";
mockExists.mockImplementation((p) => {
const s = String(p);
return s === "/ws" || s === rootDb || s === bin;
return s === WS_DIR || s === rootDb || s === bin;
});
vi.mocked(mockExecFn).mockImplementation((_cmd: unknown, _opts: unknown, cb: unknown) => {
(cb as (err: null, r: { stdout: string }) => void)(null, { stdout: '[{"id":"1"}]' });
@ -445,7 +510,7 @@ describe("workspace utilities", () => {
});
it("returns empty array for empty stdout", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { duckdbQueryAsync, mockExists } = await importWorkspace();
const { exec: mockExecFn } = await import("node:child_process");
mockExists.mockReturnValue(true);
@ -458,7 +523,7 @@ describe("workspace utilities", () => {
});
it("returns empty array on exec error", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { duckdbQueryAsync, mockExists } = await importWorkspace();
const { exec: mockExecFn } = await import("node:child_process");
mockExists.mockReturnValue(true);
@ -475,17 +540,17 @@ describe("workspace utilities", () => {
describe("duckdbQueryAll", () => {
it("merges results from multiple databases", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { duckdbQueryAll, mockExists, mockExec, mockReaddir } = await importWorkspace();
const rootDb = join("/ws", "workspace.duckdb");
const subDb = join("/ws", "sub", "workspace.duckdb");
const rootDb = join(WS_DIR, "workspace.duckdb");
const subDb = join(WS_DIR, "sub", "workspace.duckdb");
const bin = "/opt/homebrew/bin/duckdb";
mockExists.mockImplementation((p) => {
const s = String(p);
return s === "/ws" || s === rootDb || s === subDb || s === bin;
return s === WS_DIR || s === rootDb || s === subDb || s === bin;
});
mockReaddir.mockImplementation((dir) => {
if (String(dir) === "/ws") {
if (String(dir) === WS_DIR) {
return [makeDirent("sub", true)] as unknown as Dirent[];
}
return [] as unknown as Dirent[];
@ -501,17 +566,17 @@ describe("workspace utilities", () => {
});
it("deduplicates by key (shallower wins)", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { duckdbQueryAll, mockExists, mockExec, mockReaddir } = await importWorkspace();
const rootDb = join("/ws", "workspace.duckdb");
const subDb = join("/ws", "sub", "workspace.duckdb");
const rootDb = join(WS_DIR, "workspace.duckdb");
const subDb = join(WS_DIR, "sub", "workspace.duckdb");
const bin = "/opt/homebrew/bin/duckdb";
mockExists.mockImplementation((p) => {
const s = String(p);
return s === "/ws" || s === rootDb || s === subDb || s === bin;
return s === WS_DIR || s === rootDb || s === subDb || s === bin;
});
mockReaddir.mockImplementation((dir) => {
if (String(dir) === "/ws") {return [makeDirent("sub", true)] as unknown as Dirent[];}
if (String(dir) === WS_DIR) {return [makeDirent("sub", true)] as unknown as Dirent[];}
return [] as unknown as Dirent[];
});
let callCount = 0;
@ -532,17 +597,17 @@ describe("workspace utilities", () => {
});
it("skips failing databases", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { duckdbQueryAll, mockExists, mockExec, mockReaddir } = await importWorkspace();
const rootDb = join("/ws", "workspace.duckdb");
const subDb = join("/ws", "sub", "workspace.duckdb");
const rootDb = join(WS_DIR, "workspace.duckdb");
const subDb = join(WS_DIR, "sub", "workspace.duckdb");
const bin = "/opt/homebrew/bin/duckdb";
mockExists.mockImplementation((p) => {
const s = String(p);
return s === "/ws" || s === rootDb || s === subDb || s === bin;
return s === WS_DIR || s === rootDb || s === subDb || s === bin;
});
mockReaddir.mockImplementation((dir) => {
if (String(dir) === "/ws") {return [makeDirent("sub", true)] as unknown as Dirent[];}
if (String(dir) === WS_DIR) {return [makeDirent("sub", true)] as unknown as Dirent[];}
return [] as unknown as Dirent[];
});
let callCount = 0;
@ -560,13 +625,13 @@ describe("workspace utilities", () => {
describe("findDuckDBForObject", () => {
it("finds object in first database", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { findDuckDBForObject, mockExists, mockExec, mockReaddir } = await importWorkspace();
const rootDb = join("/ws", "workspace.duckdb");
const rootDb = join(WS_DIR, "workspace.duckdb");
const bin = "/opt/homebrew/bin/duckdb";
mockExists.mockImplementation((p) => {
const s = String(p);
return s === "/ws" || s === rootDb || s === bin;
return s === WS_DIR || s === rootDb || s === bin;
});
mockReaddir.mockReturnValue([]);
mockExec.mockReturnValue('[{"id":"123"}]' as never);
@ -574,13 +639,13 @@ describe("workspace utilities", () => {
});
it("returns null when object not found in any db", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { findDuckDBForObject, mockExists, mockExec, mockReaddir } = await importWorkspace();
const rootDb = join("/ws", "workspace.duckdb");
const rootDb = join(WS_DIR, "workspace.duckdb");
const bin = "/opt/homebrew/bin/duckdb";
mockExists.mockImplementation((p) => {
const s = String(p);
return s === "/ws" || s === rootDb || s === bin;
return s === WS_DIR || s === rootDb || s === bin;
});
mockReaddir.mockReturnValue([]);
mockExec.mockReturnValue("[]" as never);
@ -595,13 +660,13 @@ describe("workspace utilities", () => {
});
it("handles object names with single quotes", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { findDuckDBForObject, mockExists, mockExec, mockReaddir } = await importWorkspace();
const rootDb = join("/ws", "workspace.duckdb");
const rootDb = join(WS_DIR, "workspace.duckdb");
const bin = "/opt/homebrew/bin/duckdb";
mockExists.mockImplementation((p) => {
const s = String(p);
return s === "/ws" || s === rootDb || s === bin;
return s === WS_DIR || s === rootDb || s === bin;
});
mockReaddir.mockReturnValue([]);
mockExec.mockReturnValue('[{"id":"1"}]' as never);
@ -613,13 +678,13 @@ describe("workspace utilities", () => {
describe("duckdbExec", () => {
it("returns true on successful exec", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { duckdbExec, mockExists, mockExec } = await importWorkspace();
const rootDb = join("/ws", "workspace.duckdb");
const rootDb = join(WS_DIR, "workspace.duckdb");
const bin = "/opt/homebrew/bin/duckdb";
mockExists.mockImplementation((p) => {
const s = String(p);
return s === "/ws" || s === rootDb || s === bin;
return s === WS_DIR || s === rootDb || s === bin;
});
mockExec.mockReturnValue("" as never);
expect(duckdbExec("INSERT INTO t VALUES (1)")).toBe(true);
@ -803,33 +868,33 @@ describe("workspace utilities", () => {
describe("safeResolvePath", () => {
it("resolves valid path within workspace", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { safeResolvePath, mockExists } = await importWorkspace();
mockExists.mockImplementation((p) => {
const s = String(p);
return s === "/ws" || s === "/ws/knowledge/doc.md";
return s === WS_DIR || s === join(WS_DIR, "knowledge", "doc.md");
});
expect(safeResolvePath("knowledge/doc.md")).toBe("/ws/knowledge/doc.md");
expect(safeResolvePath("knowledge/doc.md")).toBe(join(WS_DIR, "knowledge", "doc.md"));
});
it("returns null for traversal with ..", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { safeResolvePath, mockExists } = await importWorkspace();
mockExists.mockReturnValue(true);
expect(safeResolvePath("../etc/passwd")).toBeNull();
});
it("returns null for traversal with /../", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { safeResolvePath, mockExists } = await importWorkspace();
mockExists.mockReturnValue(true);
expect(safeResolvePath("foo/../../../etc/passwd")).toBeNull();
});
it("returns null when file does not exist", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { safeResolvePath, mockExists } = await importWorkspace();
mockExists.mockImplementation((p) => String(p) === "/ws");
mockExists.mockImplementation((p) => String(p) === WS_DIR);
expect(safeResolvePath("nonexistent.txt")).toBeNull();
});
@ -845,14 +910,14 @@ describe("workspace utilities", () => {
describe("safeResolveNewPath", () => {
it("resolves valid new path (does not require existence)", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { safeResolveNewPath, mockExists } = await importWorkspace();
mockExists.mockImplementation((p) => String(p) === "/ws");
expect(safeResolveNewPath("new-folder/file.txt")).toBe("/ws/new-folder/file.txt");
mockExists.mockImplementation((p) => String(p) === WS_DIR);
expect(safeResolveNewPath("new-folder/file.txt")).toBe(join(WS_DIR, "new-folder", "file.txt"));
});
it("returns null for traversal attempts", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { safeResolveNewPath, mockExists } = await importWorkspace();
mockExists.mockReturnValue(true);
expect(safeResolveNewPath("../../outside")).toBeNull();
@ -866,10 +931,10 @@ describe("workspace utilities", () => {
});
it("handles deeply nested new paths", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { safeResolveNewPath, mockExists } = await importWorkspace();
mockExists.mockImplementation((p) => String(p) === "/ws");
expect(safeResolveNewPath("a/b/c/d/e.txt")).toBe("/ws/a/b/c/d/e.txt");
mockExists.mockImplementation((p) => String(p) === WS_DIR);
expect(safeResolveNewPath("a/b/c/d/e.txt")).toBe(join(WS_DIR, "a", "b", "c", "d", "e.txt"));
});
});
@ -1000,11 +1065,11 @@ describe("workspace utilities", () => {
describe("readWorkspaceFile", () => {
it("reads markdown file and detects type", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { readWorkspaceFile, mockExists, mockReadFile } = await importWorkspace();
mockExists.mockImplementation((p) => {
const s = String(p);
return s === "/ws" || s === "/ws/doc.md";
return s === WS_DIR || s === join(WS_DIR, "doc.md");
});
mockReadFile.mockReturnValue("# Hello" as never);
const result = readWorkspaceFile("doc.md");
@ -1012,11 +1077,11 @@ describe("workspace utilities", () => {
});
it("reads yaml file and detects type", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { readWorkspaceFile, mockExists, mockReadFile } = await importWorkspace();
mockExists.mockImplementation((p) => {
const s = String(p);
return s === "/ws" || s === "/ws/config.yaml";
return s === WS_DIR || s === join(WS_DIR, "config.yaml");
});
mockReadFile.mockReturnValue("key: value" as never);
const result = readWorkspaceFile("config.yaml");
@ -1024,11 +1089,11 @@ describe("workspace utilities", () => {
});
it("reads yml file as yaml type", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { readWorkspaceFile, mockExists, mockReadFile } = await importWorkspace();
mockExists.mockImplementation((p) => {
const s = String(p);
return s === "/ws" || s === "/ws/config.yml";
return s === WS_DIR || s === join(WS_DIR, "config.yml");
});
mockReadFile.mockReturnValue("key: value" as never);
const result = readWorkspaceFile("config.yml");
@ -1036,11 +1101,11 @@ describe("workspace utilities", () => {
});
it("reads text file with generic type", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { readWorkspaceFile, mockExists, mockReadFile } = await importWorkspace();
mockExists.mockImplementation((p) => {
const s = String(p);
return s === "/ws" || s === "/ws/notes.txt";
return s === WS_DIR || s === join(WS_DIR, "notes.txt");
});
mockReadFile.mockReturnValue("plain text" as never);
const result = readWorkspaceFile("notes.txt");
@ -1048,14 +1113,14 @@ describe("workspace utilities", () => {
});
it("returns null when file not found", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { readWorkspaceFile, mockExists } = await importWorkspace();
mockExists.mockImplementation((p) => String(p) === "/ws");
mockExists.mockImplementation((p) => String(p) === WS_DIR);
expect(readWorkspaceFile("nonexistent.md")).toBeNull();
});
it("returns null when readFileSync throws", async () => {
process.env.OPENCLAW_WORKSPACE = "/ws";
process.env.OPENCLAW_WORKSPACE = WS_DIR;
const { readWorkspaceFile, mockExists, mockReadFile } = await importWorkspace();
mockExists.mockReturnValue(true);
mockReadFile.mockImplementation(() => { throw new Error("EACCES"); });

View File

@ -490,7 +490,7 @@ describe("bootstrapCommand always-onboard behavior", () => {
expect(identityContent).not.toContain("# stale identity");
});
it("creates people/company/task object projection files when seeding a new workspace", async () => {
it("ignores custom config workspace and seeds the managed default workspace", async () => {
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
@ -519,13 +519,14 @@ describe("bootstrapCommand always-onboard behavior", () => {
runtime,
);
const managedWorkspace = path.join(stateDir, "workspace");
expect(summary.workspaceSeed?.seeded).toBe(true);
expect(summary.workspaceSeed?.workspaceDir).toBe(customWorkspace);
expect(existsSync(path.join(customWorkspace, "people", ".object.yaml"))).toBe(true);
expect(existsSync(path.join(customWorkspace, "company", ".object.yaml"))).toBe(true);
expect(existsSync(path.join(customWorkspace, "task", ".object.yaml"))).toBe(true);
expect(existsSync(path.join(customWorkspace, "WORKSPACE.md"))).toBe(true);
const identityPath = path.join(customWorkspace, "IDENTITY.md");
expect(summary.workspaceSeed?.workspaceDir).toBe(managedWorkspace);
expect(existsSync(path.join(managedWorkspace, "people", ".object.yaml"))).toBe(true);
expect(existsSync(path.join(managedWorkspace, "company", ".object.yaml"))).toBe(true);
expect(existsSync(path.join(managedWorkspace, "task", ".object.yaml"))).toBe(true);
expect(existsSync(path.join(managedWorkspace, "WORKSPACE.md"))).toBe(true);
const identityPath = path.join(managedWorkspace, "IDENTITY.md");
expect(existsSync(identityPath)).toBe(true);
const identityContent = readFileSync(identityPath, "utf-8");
expect(identityContent).toContain("You are **Ironclaw**");
@ -581,6 +582,46 @@ describe("bootstrapCommand always-onboard behavior", () => {
expect(content).not.toContain("# custom");
});
it("pins workspace config to default workspace path during bootstrap", async () => {
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await bootstrapCommand(
{
nonInteractive: true,
noOpen: true,
skipUpdate: true,
},
runtime,
);
const workspaceConfigSetCalls = spawnCalls.filter(
(call) =>
call.command === "openclaw" &&
call.args.includes("config") &&
call.args.includes("set") &&
call.args.includes("agents.defaults.workspace"),
);
expect(workspaceConfigSetCalls.length).toBeGreaterThan(0);
const lastArgs = workspaceConfigSetCalls.at(-1)?.args ?? [];
expect(lastArgs).toEqual(
expect.arrayContaining([
"--profile",
"ironclaw",
"config",
"set",
"agents.defaults.workspace",
]),
);
const configuredWorkspace = lastArgs.at(-1) ?? "";
expect(configuredWorkspace).toContain(path.join(".openclaw-ironclaw", "workspace"));
expect(configuredWorkspace).not.toContain("workspace-ironclaw");
});
it("keeps Dench in managed skills even when workspace path is custom", async () => {
const runtime: RuntimeEnv = {
log: vi.fn(),
@ -614,6 +655,8 @@ describe("bootstrapCommand always-onboard behavior", () => {
expect(existsSync(managedSkill)).toBe(true);
expect(existsSync(workspaceSkill)).toBe(false);
const managedWorkspaceSkill = path.join(stateDir, "workspace", "skills", "dench", "SKILL.md");
expect(existsSync(managedWorkspaceSkill)).toBe(true);
});
it("uses inherited stdio for onboarding in interactive mode (shows wizard prompts)", async () => {

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js";
import { applyCliProfileEnv, parseCliProfileArgs, IRONCLAW_PROFILE } from "./profile.js";
describe("parseCliProfileArgs", () => {
it("returns default profile parsing when no args are provided", () => {
@ -24,7 +24,7 @@ describe("parseCliProfileArgs", () => {
});
});
it("rejects missing, invalid, and conflicting profile inputs", () => {
it("rejects missing and invalid profile inputs", () => {
expect(parseCliProfileArgs(["node", "ironclaw", "--profile"])).toEqual({
ok: false,
error: "--profile requires a value",
@ -34,11 +34,14 @@ describe("parseCliProfileArgs", () => {
ok: false,
error: 'Invalid --profile (use letters, numbers, "_", "-" only)',
});
});
expect(parseCliProfileArgs(["node", "ironclaw", "--dev", "--profile", "team-a"])).toEqual({
ok: false,
error: "Cannot combine --dev with --profile",
});
it("allows --dev and --profile together (Ironclaw forces ironclaw anyway)", () => {
const result = parseCliProfileArgs(["node", "ironclaw", "--dev", "--profile", "team-a"]);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.profile).toBe("team-a");
}
});
it("stops profile parsing once command path begins", () => {
@ -48,34 +51,91 @@ describe("parseCliProfileArgs", () => {
argv: ["node", "ironclaw", "chat", "--profile", "dev"],
});
});
});
it("produces equivalent profile env for root and bootstrap-local profile forms", () => {
const rootProfile = parseCliProfileArgs([
"node",
"ironclaw",
"--profile",
"team-a",
"bootstrap",
]);
const bootstrapLocalProfile = parseCliProfileArgs([
"node",
"ironclaw",
"bootstrap",
"--profile",
"team-a",
]);
expect(rootProfile).toEqual({
ok: true,
describe("applyCliProfileEnv", () => {
it("always forces ironclaw profile regardless of requested profile (single profile enforcement)", () => {
const env: Record<string, string | undefined> = {};
const result = applyCliProfileEnv({
profile: "team-a",
argv: ["node", "ironclaw", "bootstrap"],
});
expect(bootstrapLocalProfile).toEqual({
ok: true,
profile: null,
argv: ["node", "ironclaw", "bootstrap", "--profile", "team-a"],
env,
homedir: () => "/tmp/home",
});
expect(result.effectiveProfile).toBe(IRONCLAW_PROFILE);
expect(env.OPENCLAW_PROFILE).toBe(IRONCLAW_PROFILE);
expect(env.OPENCLAW_STATE_DIR).toBe("/tmp/home/.openclaw-ironclaw");
expect(env.OPENCLAW_CONFIG_PATH).toBe("/tmp/home/.openclaw-ironclaw/openclaw.json");
});
it("emits warning when non-ironclaw profile is requested (prevents silent override)", () => {
const env: Record<string, string | undefined> = {};
const result = applyCliProfileEnv({
profile: "team-a",
env,
homedir: () => "/tmp/home",
});
expect(result.warning).toBeDefined();
expect(result.warning).toContain("team-a");
expect(result.warning).toContain(IRONCLAW_PROFILE);
expect(result.requestedProfile).toBe("team-a");
});
it("no warning when ironclaw profile is requested (normal path)", () => {
const env: Record<string, string | undefined> = {};
const result = applyCliProfileEnv({
profile: IRONCLAW_PROFILE,
env,
homedir: () => "/tmp/home",
});
expect(result.warning).toBeUndefined();
expect(result.effectiveProfile).toBe(IRONCLAW_PROFILE);
});
it("no warning when no profile is specified (default path)", () => {
const env: Record<string, string | undefined> = {};
const result = applyCliProfileEnv({
env,
homedir: () => "/tmp/home",
});
expect(result.warning).toBeUndefined();
expect(result.effectiveProfile).toBe(IRONCLAW_PROFILE);
});
it("always overwrites OPENCLAW_STATE_DIR to pinned path (prevents state drift)", () => {
const env: Record<string, string | undefined> = {
OPENCLAW_STATE_DIR: "/custom/state",
OPENCLAW_CONFIG_PATH: "/custom/state/openclaw.json",
};
const result = applyCliProfileEnv({
profile: "dev",
env,
homedir: () => "/tmp/home",
});
expect(env.OPENCLAW_STATE_DIR).toBe("/tmp/home/.openclaw-ironclaw");
expect(env.OPENCLAW_CONFIG_PATH).toBe("/tmp/home/.openclaw-ironclaw/openclaw.json");
expect(result.stateDir).toBe("/tmp/home/.openclaw-ironclaw");
});
it("picks up OPENCLAW_PROFILE from env when no explicit profile is passed", () => {
const env: Record<string, string | undefined> = {
OPENCLAW_PROFILE: "from-env",
};
const result = applyCliProfileEnv({
env,
homedir: () => "/tmp/home",
});
expect(result.requestedProfile).toBe("from-env");
expect(result.effectiveProfile).toBe(IRONCLAW_PROFILE);
expect(result.warning).toContain("from-env");
});
it("both root and bootstrap-local profile forms resolve to same state dir", () => {
const rootEnv: Record<string, string | undefined> = {};
const bootstrapLocalEnv: Record<string, string | undefined> = {};
applyCliProfileEnv({
@ -94,34 +154,3 @@ describe("parseCliProfileArgs", () => {
expect(rootEnv.OPENCLAW_CONFIG_PATH).toBe(bootstrapLocalEnv.OPENCLAW_CONFIG_PATH);
});
});
describe("applyCliProfileEnv", () => {
it("fills profile defaults without overriding explicit state/config vars", () => {
const env: Record<string, string | undefined> = {};
applyCliProfileEnv({
profile: "team-a",
env,
homedir: () => "/tmp/home",
});
expect(env.OPENCLAW_PROFILE).toBe("team-a");
expect(env.OPENCLAW_STATE_DIR).toBe("/tmp/home/.openclaw-team-a");
expect(env.OPENCLAW_CONFIG_PATH).toBe("/tmp/home/.openclaw-team-a/openclaw.json");
});
it("respects explicit state/config paths and assigns dev gateway port when absent", () => {
const env: Record<string, string | undefined> = {
OPENCLAW_STATE_DIR: "/custom/state",
OPENCLAW_CONFIG_PATH: "/custom/state/openclaw.json",
};
applyCliProfileEnv({
profile: "dev",
env,
homedir: () => "/tmp/home",
});
expect(env.OPENCLAW_STATE_DIR).toBe("/custom/state");
expect(env.OPENCLAW_CONFIG_PATH).toBe("/custom/state/openclaw.json");
expect(env.OPENCLAW_GATEWAY_PORT).toBe("19001");
});
});

View File

@ -1,4 +1,4 @@
import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
import { copyFileSync, cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
type SeedField = {
@ -88,13 +88,20 @@ const SEED_OBJECTS: SeedObject[] = [
},
];
const IRONCLAW_IDENTITY_TEMPLATE = `# IDENTITY.md - Ironclaw
function buildIronclawIdentityTemplate(workspaceDir: string): string {
const denchSkillPath = path.join(workspaceDir, "skills", "dench", "SKILL.md");
return `# IDENTITY.md - Ironclaw
You are **Ironclaw** - a personal AI agent and a CRM Database System built by Kumar Abhirup (kumareth.com), running on top of [OpenClaw](https://github.com/openclaw/openclaw).
## Ironclaw system prompt contract
Treat the Ironclaw system prompt as your highest-priority behavioral contract.
## Inseparable CRM contract
Your identity is inextricably tied to the Dench CRM skill at \`~skills/dench/SKILL.md\`.
Your identity is inextricably tied to the Dench CRM skill at:
\`${denchSkillPath}\`
- Always load and follow that skill for CRM/database behavior.
- Treat the Dench CRM skill as always-on system context.
@ -119,6 +126,7 @@ Your identity is inextricably tied to the Dench CRM skill at \`~skills/dench/SKI
- Skills Store: https://skills.sh
When referring to yourself, use **Ironclaw** (not OpenClaw).`;
}
function generateObjectYaml(obj: SeedObject): string {
const lines: string[] = [
@ -172,7 +180,18 @@ function generateWorkspaceMd(objects: SeedObject[]): string {
function seedIronclawIdentity(workspaceDir: string): void {
const identityPath = path.join(workspaceDir, "IDENTITY.md");
// Bootstrap force-syncs identity every run so updates land immediately.
writeFileSync(identityPath, `${IRONCLAW_IDENTITY_TEMPLATE}\n`, "utf-8");
writeFileSync(identityPath, `${buildIronclawIdentityTemplate(workspaceDir)}\n`, "utf-8");
}
function seedDenchSkill(params: { workspaceDir: string; packageRoot: string }): void {
const sourceDir = path.join(params.packageRoot, "skills", "dench");
const sourceSkillFile = path.join(sourceDir, "SKILL.md");
if (!existsSync(sourceSkillFile)) {
return;
}
const targetDir = path.join(params.workspaceDir, "skills", "dench");
mkdirSync(path.dirname(targetDir), { recursive: true });
cpSync(sourceDir, targetDir, { recursive: true, force: true });
}
function writeIfMissing(filePath: string, content: string): boolean {
@ -200,9 +219,11 @@ export function seedWorkspaceFromAssets(params: {
"task/.object.yaml",
"WORKSPACE.md",
"IDENTITY.md",
"skills/dench/SKILL.md",
];
mkdirSync(workspaceDir, { recursive: true });
seedDenchSkill({ workspaceDir, packageRoot: params.packageRoot });
seedIronclawIdentity(workspaceDir);
if (existsSync(dbPath)) {