From f6eee0b398f40d1d88a39e1f6f676b58f17ae84b Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Tue, 3 Mar 2026 13:47:38 -0800 Subject: [PATCH] refactor(cli): update workspace-seed with dynamic identity and dench skill Build identity template with workspace path; add seedDenchSkill for skills/dench. --- apps/web/app/api/chat/chat.test.ts | 26 + apps/web/app/api/profiles/route.test.ts | 235 +++--- .../app/api/workspace/delete/route.test.ts | 167 ++-- apps/web/app/api/workspace/init/route.test.ts | 298 +++---- .../workspace/profile-switcher.test.tsx | 92 ++- apps/web/lib/workspace-chat-isolation.test.ts | 255 ++++-- apps/web/lib/workspace-profiles.test.ts | 755 +++++++++--------- apps/web/lib/workspace.test.ts | 277 ++++--- ...otstrap-external.bootstrap-command.test.ts | 57 +- src/cli/profile.test.ts | 151 ++-- src/cli/workspace-seed.ts | 29 +- 11 files changed, 1265 insertions(+), 1077 deletions(-) diff --git a/apps/web/app/api/chat/chat.test.ts b/apps/web/app/api/chat/chat.test.ts index f035fc7dcee..b6490ff9b9b 100644 --- a/apps/web/app/api/chat/chat.test.ts +++ b/apps/web/app/api/chat/chat.test.ts @@ -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); diff --git a/apps/web/app/api/profiles/route.test.ts b/apps/web/app/api/profiles/route.test.ts index 9b7c01e8959..010031c35ae 100644 --- a/apps/web/app/api/profiles/route.test.ts +++ b/apps/web/app/api/profiles/route.test.ts @@ -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); - }); }); diff --git a/apps/web/app/api/workspace/delete/route.test.ts b/apps/web/app/api/workspace/delete/route.test.ts index f881735dc8c..68253e8aaa5 100644 --- a/apps/web/app/api/workspace/delete/route.test.ts +++ b/apps/web/app/api/workspace/delete/route.test.ts @@ -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; - end: ReturnType; - }; - kill: ReturnType; -}; - -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; - }); - 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"); }); }); diff --git a/apps/web/app/api/workspace/init/route.test.ts b/apps/web/app/api/workspace/init/route.test.ts index fff4b1bdcb7..023200786b6 100644 --- a/apps/web/app/api/workspace/init/route.test.ts +++ b/apps/web/app/api/workspace/init/route.test.ts @@ -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; - }; - 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; - }); - } 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- (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"); }); }); diff --git a/apps/web/app/components/workspace/profile-switcher.test.tsx b/apps/web/app/components/workspace/profile-switcher.test.tsx index aee5bb36e58..dc00fa3f617 100644 --- a/apps/web/app/components/workspace/profile-switcher.test.tsx +++ b/apps/web/app/components/workspace/profile-switcher.test.tsx @@ -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(); 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(); + + 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(); }); }); diff --git a/apps/web/lib/workspace-chat-isolation.test.ts b/apps/web/lib/workspace-chat-isolation.test.ts index 01cbd4c13aa..84b027c86c4 100644 --- a/apps/web/lib/workspace-chat-isolation.test.ts +++ b/apps/web/lib/workspace-chat-isolation.test.ts @@ -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(); @@ -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 /.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")); }); }); diff --git a/apps/web/lib/workspace-profiles.test.ts b/apps/web/lib/workspace-profiles.test.ts index 43dec939c44..08549bd9dc0 100644 --- a/apps/web/lib/workspace-profiles.test.ts +++ b/apps/web/lib/workspace-profiles.test.ts @@ -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- 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- state directories", async () => { - const { discoverProfiles, mockExists, mockReaddir } = + it("discovers workspace- 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 /.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- 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- and updates resolution", async () => { - const { resolveWorkspaceRoot, setUIActiveProfile, mockExists, mockReadFile, mockRename } = + it("OPENCLAW_WORKSPACE env takes priority if it points to a valid workspace- 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(); }); }); }); diff --git a/apps/web/lib/workspace.test.ts b/apps/web/lib/workspace.test.ts index 35674f4e69d..a7ac9089131 100644 --- a/apps/web/lib/workspace.test.ts +++ b/apps/web/lib/workspace.test.ts @@ -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>) { + 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"); }); diff --git a/src/cli/bootstrap-external.bootstrap-command.test.ts b/src/cli/bootstrap-external.bootstrap-command.test.ts index b63cae3ca3b..bf9cb8639da 100644 --- a/src/cli/bootstrap-external.bootstrap-command.test.ts +++ b/src/cli/bootstrap-external.bootstrap-command.test.ts @@ -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 () => { diff --git a/src/cli/profile.test.ts b/src/cli/profile.test.ts index 2fb098d1c5e..7087c6b55e8 100644 --- a/src/cli/profile.test.ts +++ b/src/cli/profile.test.ts @@ -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 = {}; + 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 = {}; + 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 = {}; + 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 = {}; + 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 = { + 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 = { + 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 = {}; const bootstrapLocalEnv: Record = {}; 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 = {}; - 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 = { - 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"); - }); -}); diff --git a/src/cli/workspace-seed.ts b/src/cli/workspace-seed.ts index 71b4a6cab1c..00d926e12bc 100644 --- a/src/cli/workspace-seed.ts +++ b/src/cli/workspace-seed.ts @@ -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)) {