openclaw/apps/web/lib/workspace-profiles.test.ts
kumarabhirup 11478c752e
refactor(workspace): remove chat-slot agent pool to prevent workspace pollution
Chat-slot agents were being persisted as durable entries in openclaw.json,
causing spurious workspace directories (e.g. chat-slot-main-1) to appear.
Only explicit workspace creation via init now creates durable agent entries.
Workspace discovery and session routing ignore chat-slot internals.
2026-03-17 12:35:18 -07:00

712 lines
26 KiB
TypeScript

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")>();
const existsSync = vi.fn(() => false);
const readFileSync = vi.fn(() => "");
const readdirSync = vi.fn(() => []);
const writeFileSync = vi.fn();
const mkdirSync = vi.fn();
const renameSync = vi.fn();
return {
...actual,
existsSync,
readFileSync,
readdirSync,
writeFileSync,
mkdirSync,
renameSync,
default: {
...actual,
existsSync,
readFileSync,
readdirSync,
writeFileSync,
mkdirSync,
renameSync,
},
};
});
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"),
}));
import { join } from "node:path";
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;
}
describe("workspace (flat workspace model)", () => {
const originalEnv = { ...process.env };
const STATE_DIR = "/home/testuser/.openclaw-dench";
const UI_STATE_PATH = join(STATE_DIR, ".dench-ui-state.json");
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", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
const existsSync = vi.fn(() => false);
const readFileSync = vi.fn(() => "");
const readdirSync = vi.fn(() => []);
const writeFileSync = vi.fn();
const mkdirSync = vi.fn();
const renameSync = vi.fn();
return {
...actual,
existsSync,
readFileSync,
readdirSync,
writeFileSync,
mkdirSync,
renameSync,
default: {
...actual,
existsSync,
readFileSync,
readdirSync,
writeFileSync,
mkdirSync,
renameSync,
},
};
});
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"),
}));
});
afterEach(() => {
process.env = originalEnv;
});
async function importWorkspace() {
const {
existsSync: es,
readFileSync: rfs,
readdirSync: rds,
writeFileSync: wfs,
renameSync: rs,
} = await import("node:fs");
const mod = await import("./workspace.js");
return {
...mod,
mockExists: vi.mocked(es),
mockReadFile: vi.mocked(rfs),
mockReaddir: vi.mocked(rds),
mockWriteFile: vi.mocked(wfs),
mockRename: vi.mocked(rs),
};
}
// ─── getEffectiveProfile ──────────────────────────────────────────
describe("getEffectiveProfile", () => {
it("always returns 'dench' regardless of env/state (single profile enforcement)", async () => {
process.env.OPENCLAW_PROFILE = "work";
const { getEffectiveProfile, setUIActiveProfile, mockReadFile } =
await importWorkspace();
mockReadFile.mockReturnValue(
JSON.stringify({ activeWorkspace: "something" }) as never,
);
setUIActiveProfile("custom");
expect(getEffectiveProfile()).toBe("dench");
});
});
describe("isValidWorkspaceName", () => {
it("rejects reserved workspace names that collide with managed agent routing", async () => {
const { isValidWorkspaceName } = await importWorkspace();
expect(isValidWorkspaceName("main")).toBe(false);
expect(isValidWorkspaceName("default")).toBe(false);
expect(isValidWorkspaceName("chat-slot-main-1")).toBe(false);
expect(isValidWorkspaceName("work")).toBe(true);
});
});
// ─── getActiveWorkspaceName ───────────────────────────────────────
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);
setUIActiveWorkspace("dev");
expect(mockWriteFile).toHaveBeenCalledWith(
UI_STATE_PATH,
expect.stringContaining('"activeWorkspace": "dev"'),
);
});
it("null clears the override", async () => {
const { setUIActiveWorkspace, mockReadFile, mockWriteFile, mockExists } =
await importWorkspace();
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
mockExists.mockReturnValue(true);
setUIActiveWorkspace(null);
expect(mockWriteFile).toHaveBeenCalledWith(
UI_STATE_PATH,
expect.stringContaining('"activeWorkspace": null'),
);
});
});
// ─── clearUIActiveWorkspaceCache ──────────────────────────────────
describe("clearUIActiveWorkspaceCache", () => {
it("re-reads from file after clearing in-memory override", async () => {
const {
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({ activeWorkspace: "from-file" }) as never,
);
setUIActiveWorkspace("in-memory");
expect(getActiveWorkspaceName()).toBe("in-memory");
clearUIActiveWorkspaceCache();
expect(getActiveWorkspaceName()).toBe("from-file");
});
});
// ─── discoverWorkspaces ───────────────────────────────────────────
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");
});
mockReaddir.mockReturnValue([] as unknown as Dirent[]);
const workspaces = discoverWorkspaces();
expect(workspaces).toHaveLength(0);
});
it("discovers workspace-<name> directories under state dir", async () => {
const { discoverWorkspaces, mockReaddir, mockExists, mockReadFile } =
await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([
makeDirent("workspace-alpha", true),
makeDirent("workspace-beta", true),
makeDirent("some-other-dir", true),
makeDirent("config.json", false),
] 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();
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("discovers root workspace dir as default", async () => {
const { discoverWorkspaces, mockReaddir, mockExists, mockReadFile } =
await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([
makeDirent("workspace", true),
] as unknown as Dirent[]);
mockExists.mockImplementation((p) => String(p) === join(STATE_DIR, "workspace"));
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("ignores internal workspace directories for main/chat-slot agent ids", async () => {
const { discoverWorkspaces, mockReaddir, mockExists, mockReadFile } =
await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([
makeDirent("workspace", true),
makeDirent("workspace-main", true),
makeDirent("workspace-chat-slot-main-1", true),
] as unknown as Dirent[]);
mockExists.mockImplementation((p) => {
const s = String(p);
return (
s === join(STATE_DIR, "workspace") ||
s === join(STATE_DIR, "workspace-main") ||
s === join(STATE_DIR, "workspace-chat-slot-main-1")
);
});
const workspaces = discoverWorkspaces();
expect(workspaces.map((workspace) => workspace.name)).toEqual(["default"]);
});
it("keeps root default and workspace-dench as distinct workspaces", async () => {
const { discoverWorkspaces, mockReaddir, mockExists, mockReadFile } =
await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([
makeDirent("workspace", true),
makeDirent("workspace-dench", true),
] as unknown as Dirent[]);
mockExists.mockImplementation((p) => {
const s = String(p);
return s === join(STATE_DIR, "workspace") || s === join(STATE_DIR, "workspace-dench");
});
const workspaces = discoverWorkspaces();
expect(workspaces).toHaveLength(2);
const names = workspaces.map((workspace) => workspace.name);
expect(names).toContain("default");
expect(names).toContain("dench");
const rootDefault = workspaces.find((workspace) => workspace.name === "default");
const profileDench = workspaces.find((workspace) => workspace.name === "dench");
expect(rootDefault?.workspaceDir).toBe(join(STATE_DIR, "workspace"));
expect(profileDench?.workspaceDir).toBe(join(STATE_DIR, "workspace-dench"));
});
it("lists default, dench, 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-dench", 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-dench") ||
s === join(STATE_DIR, "workspace-kumareth")
);
});
const workspaces = discoverWorkspaces();
expect(workspaces.map((workspace) => workspace.name)).toEqual([
"default",
"dench",
"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 <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);
setUIActiveWorkspace("dev");
const wsDir = join(STATE_DIR, "workspace-dev");
mockExists.mockImplementation((p) => String(p) === wsDir);
expect(resolveWebChatDir()).toBe(join(wsDir, ".openclaw", "web-chat"));
});
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);
setUIActiveWorkspace("work");
const workDir = join(STATE_DIR, "workspace-work");
mockExists.mockImplementation((p) => String(p) === workDir);
const chatWork = resolveWebChatDir();
clearUIActiveWorkspaceCache();
setUIActiveWorkspace("personal");
const personalDir = join(STATE_DIR, "workspace-personal");
mockExists.mockImplementation((p) => String(p) === personalDir);
const chatPersonal = resolveWebChatDir();
expect(chatWork).not.toBe(chatPersonal);
expect(chatWork).toBe(join(workDir, ".openclaw", "web-chat"));
expect(chatPersonal).toBe(join(personalDir, ".openclaw", "web-chat"));
});
it("falls back to root workspace path when no workspace is active", async () => {
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"),
);
});
});
// ─── resolveWorkspaceRoot ─────────────────────────────────────────
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);
setUIActiveWorkspace("dev");
const wsDir = join(STATE_DIR, "workspace-dev");
mockExists.mockImplementation((p) => String(p) === wsDir);
expect(resolveWorkspaceRoot()).toBe(wsDir);
});
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");
});
expect(resolveWorkspaceRoot()).toBeNull();
});
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({}) as never);
setUIActiveWorkspace("other");
mockExists.mockImplementation((p) => {
const s = String(p);
return s === envWsDir || s === join(STATE_DIR, "workspace-other");
});
expect(resolveWorkspaceRoot()).toBe(envWsDir);
});
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();
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
setUIActiveWorkspace("other");
mockExists.mockImplementation((p) => {
const s = String(p);
return s === envWsDir || s === join(STATE_DIR, "workspace-other");
});
expect(resolveWorkspaceRoot()).toBe(envWsDir);
});
});
describe("ensureManagedWorkspaceRouting", () => {
it("repairs the default workspace agent and prunes chat slots without flipping the active default agent", async () => {
const { ensureManagedWorkspaceRouting, mockExists, mockReadFile, mockWriteFile } =
await importWorkspace();
const configPath = join(STATE_DIR, "openclaw.json");
const defaultWorkspaceDir = join(STATE_DIR, "workspace");
const staleWorkspaceDir = join(STATE_DIR, "workspace-main");
const otherWorkspaceDir = join(STATE_DIR, "workspace-kumareth");
mockExists.mockImplementation((p) => String(p) === configPath);
mockReadFile.mockImplementation((p) => {
if (String(p) === configPath) {
return JSON.stringify({
agents: {
defaults: {
workspace: staleWorkspaceDir,
},
list: [
{ id: "main", workspace: staleWorkspaceDir },
{ id: "chat-slot-main-1", workspace: staleWorkspaceDir },
{ id: "chat-slot-main-2", workspace: staleWorkspaceDir },
{ id: "kumareth", workspace: otherWorkspaceDir, default: true },
],
},
}) as never;
}
return "" as never;
});
ensureManagedWorkspaceRouting("default", defaultWorkspaceDir, {
markDefault: false,
poolSize: 2,
});
expect(mockWriteFile).toHaveBeenCalled();
const written = JSON.parse(String(mockWriteFile.mock.calls.at(-1)?.[1])) as {
agents: {
defaults: { workspace?: string };
list: Array<{ id: string; workspace?: string; default?: boolean }>;
};
};
expect(written.agents.defaults.workspace).toBe(defaultWorkspaceDir);
expect(written.agents.list.find((agent) => agent.id === "main")?.workspace).toBe(defaultWorkspaceDir);
expect(written.agents.list.find((agent) => agent.id === "chat-slot-main-1")).toBeUndefined();
expect(written.agents.list.find((agent) => agent.id === "chat-slot-main-2")).toBeUndefined();
expect(written.agents.list.find((agent) => agent.id === "kumareth")?.default).toBe(true);
expect(written.agents.list.find((agent) => agent.id === "main")?.default).toBeUndefined();
});
});
// ─── registerWorkspacePath / getRegisteredWorkspacePath ────────────
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(".dench-ui-state.json"),
);
expect(stateWrites).toHaveLength(0);
});
it("getRegisteredWorkspacePath always returns null", async () => {
const { getRegisteredWorkspacePath } = await importWorkspace();
expect(getRegisteredWorkspacePath("anything")).toBeNull();
expect(getRegisteredWorkspacePath(null)).toBeNull();
expect(getRegisteredWorkspacePath("test")).toBeNull();
});
});
});