BREAKING CHANGE: Convert repository to IronClaw-only package with strict external dependency on globally installed `openclaw` runtime. ### Changes - Remove entire OpenClaw core source from repository (src/agents/*, src/acp/*, src/commands/*, and related modules) - Implement CLI delegation: non-bootstrap commands now delegate to global `openclaw` binary via external contract - Remove local OpenClaw path resolution from web app; always spawn global `openclaw` binary instead of local scripts - Rename package.json scripts: `pnpm openclaw` → `pnpm ironclaw`, `openclaw:rpc` → `ironclaw:rpc` - Update bootstrap flow to verify and install global OpenClaw when missing - Migrate web workspace/profile logic to align with OpenClaw state paths - Add migration contract tests for stream-json, session subscribe, and profile resolution behaviors - Update build/release pipeline for IronClaw-only artifacts - Update documentation for new peer + global installation model ### Architecture IronClaw is now strictly a frontend/UI/bootstrap layer: - `npx ironclaw` bootstraps OpenClaw (if missing), runs guided onboarding - IronClaw UI serves on localhost:3100 - OpenClaw Gateway runs on standard port 18789 - Communication via stable CLI contracts and Gateway WebSocket protocol only ### Migration Users must have `openclaw` installed globally: npm install -g openclaw Existing IronClaw profiles and sessions remain compatible through gateway protocol stability. Refs: bootstrap_dev_testing, ironclaw_frontend_split, strict-external-openclaw
218 lines
6.5 KiB
TypeScript
218 lines
6.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
vi.mock("node:fs", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("node:fs")>();
|
|
const existsSync = vi.fn(() => false);
|
|
const readFileSync = vi.fn(() => "");
|
|
const readdirSync = vi.fn(() => []);
|
|
const writeFileSync = vi.fn();
|
|
const mkdirSync = vi.fn();
|
|
const renameSync = vi.fn();
|
|
return {
|
|
...actual,
|
|
existsSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
writeFileSync,
|
|
mkdirSync,
|
|
renameSync,
|
|
default: {
|
|
...actual,
|
|
existsSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
writeFileSync,
|
|
mkdirSync,
|
|
renameSync,
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("node:child_process", () => ({
|
|
execSync: vi.fn(() => ""),
|
|
exec: vi.fn(
|
|
(
|
|
_cmd: string,
|
|
_opts: unknown,
|
|
cb: (err: Error | null, result: { stdout: string }) => void,
|
|
) => {
|
|
cb(null, { stdout: "" });
|
|
},
|
|
),
|
|
}));
|
|
|
|
vi.mock("node:os", () => ({
|
|
homedir: vi.fn(() => "/home/testuser"),
|
|
}));
|
|
|
|
import { join } from "node:path";
|
|
|
|
describe("profile-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}`);
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.restoreAllMocks();
|
|
process.env = { ...originalEnv };
|
|
delete process.env.OPENCLAW_PROFILE;
|
|
delete process.env.OPENCLAW_HOME;
|
|
delete process.env.OPENCLAW_WORKSPACE;
|
|
delete process.env.OPENCLAW_STATE_DIR;
|
|
|
|
vi.mock("node:fs", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("node:fs")>();
|
|
const existsSync = vi.fn(() => false);
|
|
const readFileSync = vi.fn(() => "");
|
|
const readdirSync = vi.fn(() => []);
|
|
const writeFileSync = vi.fn();
|
|
const mkdirSync = vi.fn();
|
|
const renameSync = vi.fn();
|
|
return {
|
|
...actual,
|
|
existsSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
writeFileSync,
|
|
mkdirSync,
|
|
renameSync,
|
|
default: {
|
|
...actual,
|
|
existsSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
writeFileSync,
|
|
mkdirSync,
|
|
renameSync,
|
|
},
|
|
};
|
|
});
|
|
vi.mock("node:child_process", () => ({
|
|
execSync: vi.fn(() => ""),
|
|
exec: vi.fn(
|
|
(
|
|
_cmd: string,
|
|
_opts: unknown,
|
|
cb: (err: Error | null, result: { stdout: string }) => void,
|
|
) => {
|
|
cb(null, { stdout: "" });
|
|
},
|
|
),
|
|
}));
|
|
vi.mock("node:os", () => ({
|
|
homedir: vi.fn(() => "/home/testuser"),
|
|
}));
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = originalEnv;
|
|
});
|
|
|
|
async function importWorkspace() {
|
|
const { readFileSync: rfs, writeFileSync: wfs, existsSync: es } =
|
|
await import("node:fs");
|
|
const mod = await import("./workspace.js");
|
|
return {
|
|
...mod,
|
|
mockReadFile: vi.mocked(rfs),
|
|
mockWriteFile: vi.mocked(wfs),
|
|
mockExists: vi.mocked(es),
|
|
};
|
|
}
|
|
|
|
it("default profile uses web-chat directory", async () => {
|
|
const { resolveWebChatDir, mockReadFile } = await importWorkspace();
|
|
mockReadFile.mockImplementation(() => {
|
|
throw new Error("ENOENT");
|
|
});
|
|
expect(resolveWebChatDir()).toBe(join(DEFAULT_STATE_DIR, "web-chat"));
|
|
});
|
|
|
|
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 profiles produce different chat directories", async () => {
|
|
const { resolveWebChatDir, setUIActiveProfile, clearUIActiveProfileCache, mockReadFile } =
|
|
await importWorkspace();
|
|
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
|
|
|
|
setUIActiveProfile("alpha");
|
|
const dirAlpha = resolveWebChatDir();
|
|
|
|
clearUIActiveProfileCache();
|
|
setUIActiveProfile("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"));
|
|
});
|
|
|
|
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 } =
|
|
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");
|
|
|
|
mockExists.mockImplementation((p) => {
|
|
const s = String(p);
|
|
return s === defaultWs || s === workWs;
|
|
});
|
|
|
|
clearUIActiveProfileCache();
|
|
setUIActiveProfile(null);
|
|
expect(resolveWorkspaceRoot()).toBe(defaultWs);
|
|
|
|
setUIActiveProfile("work");
|
|
expect(resolveWorkspaceRoot()).toBe(workWs);
|
|
});
|
|
});
|