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
222 lines
6.9 KiB
TypeScript
222 lines
6.9 KiB
TypeScript
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
vi.mock("node:child_process", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("node:child_process")>();
|
|
return {
|
|
...actual,
|
|
spawn: vi.fn(),
|
|
};
|
|
});
|
|
const spawnMock = vi.mocked(spawn);
|
|
|
|
/** Minimal mock ChildProcess for testing. */
|
|
function mockChildProcess() {
|
|
const events: Record<string, ((...args: unknown[]) => void)[]> = {};
|
|
const child = {
|
|
exitCode: null as number | null,
|
|
killed: false,
|
|
pid: 12345,
|
|
stdout: {
|
|
on: vi.fn(),
|
|
// Act as a minimal readable for createInterface
|
|
[Symbol.asyncIterator]: vi.fn(),
|
|
},
|
|
stderr: { on: vi.fn() },
|
|
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
|
|
events[event] = events[event] || [];
|
|
events[event].push(cb);
|
|
return child;
|
|
}),
|
|
once: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
|
|
events[event] = events[event] || [];
|
|
events[event].push(cb);
|
|
return child;
|
|
}),
|
|
kill: vi.fn(),
|
|
_emit(event: string, ...args: unknown[]) {
|
|
for (const cb of events[event] || []) {
|
|
cb(...args);
|
|
}
|
|
},
|
|
};
|
|
spawnMock.mockReturnValue(child as unknown as ChildProcess);
|
|
return child;
|
|
}
|
|
|
|
describe("agent-runner", () => {
|
|
const originalEnv = { ...process.env };
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.restoreAllMocks();
|
|
process.env = { ...originalEnv };
|
|
// Re-wire mocks after resetModules
|
|
vi.mock("node:child_process", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("node:child_process")>();
|
|
return {
|
|
...actual,
|
|
spawn: vi.fn(),
|
|
};
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = originalEnv;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// ── spawnAgentProcess ──────────────────────────────────────────────
|
|
|
|
describe("spawnAgentProcess", () => {
|
|
it("always uses global openclaw", async () => {
|
|
const { spawn: mockSpawn } = await import("node:child_process");
|
|
const child = mockChildProcess();
|
|
vi.mocked(mockSpawn).mockReturnValue(child as unknown as ChildProcess);
|
|
|
|
const { spawnAgentProcess } = await import("./agent-runner.js");
|
|
spawnAgentProcess("hello");
|
|
|
|
expect(vi.mocked(mockSpawn)).toHaveBeenCalledWith(
|
|
"openclaw",
|
|
expect.arrayContaining(["agent", "--agent", "main", "--message", "hello", "--stream-json"]),
|
|
expect.objectContaining({
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("includes session-key and lane args when agentSessionId is set", async () => {
|
|
const { spawn: mockSpawn } = await import("node:child_process");
|
|
|
|
const child = mockChildProcess();
|
|
vi.mocked(mockSpawn).mockReturnValue(
|
|
child as unknown as ChildProcess,
|
|
);
|
|
|
|
const { spawnAgentProcess } = await import("./agent-runner.js");
|
|
spawnAgentProcess("msg", "session-123");
|
|
|
|
expect(vi.mocked(mockSpawn)).toHaveBeenCalledWith(
|
|
"openclaw",
|
|
expect.arrayContaining([
|
|
"--session-key",
|
|
"agent:main:web:session-123",
|
|
"--lane",
|
|
"web",
|
|
"--channel",
|
|
"webchat",
|
|
]),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── parseAgentErrorMessage ──────────────────────────────────────────
|
|
|
|
describe("parseAgentErrorMessage", () => {
|
|
it("extracts message from error field", async () => {
|
|
const { parseAgentErrorMessage } = await import(
|
|
"./agent-runner.js"
|
|
);
|
|
expect(
|
|
parseAgentErrorMessage({ error: "something went wrong" }),
|
|
).toBe("something went wrong");
|
|
});
|
|
|
|
it("extracts message from JSON error body", async () => {
|
|
const { parseAgentErrorMessage } = await import(
|
|
"./agent-runner.js"
|
|
);
|
|
const result = parseAgentErrorMessage({
|
|
errorMessage:
|
|
'402 {"error":{"message":"Insufficient funds"}}',
|
|
});
|
|
expect(result).toBe("Insufficient funds");
|
|
});
|
|
|
|
it("returns undefined for empty data", async () => {
|
|
const { parseAgentErrorMessage } = await import(
|
|
"./agent-runner.js"
|
|
);
|
|
expect(parseAgentErrorMessage(undefined)).toBeUndefined();
|
|
expect(parseAgentErrorMessage({})).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ── parseErrorFromStderr ───────────────────────────────────────────
|
|
|
|
describe("parseErrorFromStderr", () => {
|
|
it("extracts JSON error message from stderr", async () => {
|
|
const { parseErrorFromStderr } = await import(
|
|
"./agent-runner.js"
|
|
);
|
|
const stderr = `Some log line\n{"error":{"message":"Rate limit exceeded"}}\n`;
|
|
expect(parseErrorFromStderr(stderr)).toBe("Rate limit exceeded");
|
|
});
|
|
|
|
it("extracts error line containing 'error' keyword", async () => {
|
|
const { parseErrorFromStderr } = await import(
|
|
"./agent-runner.js"
|
|
);
|
|
const stderr = "Module not found error: cannot resolve 'next'";
|
|
expect(parseErrorFromStderr(stderr)).toBeTruthy();
|
|
});
|
|
|
|
it("returns undefined for empty stderr", async () => {
|
|
const { parseErrorFromStderr } = await import(
|
|
"./agent-runner.js"
|
|
);
|
|
expect(parseErrorFromStderr("")).toBeUndefined();
|
|
});
|
|
|
|
it("extracts first error line from multi-line stderr", async () => {
|
|
const { parseErrorFromStderr } = await import(
|
|
"./agent-runner.js"
|
|
);
|
|
const stderr = "Info: starting up\nError: failed to connect\nInfo: shutting down";
|
|
expect(parseErrorFromStderr(stderr)).toBeTruthy();
|
|
});
|
|
|
|
it("returns undefined for non-error stderr content", async () => {
|
|
const { parseErrorFromStderr } = await import(
|
|
"./agent-runner.js"
|
|
);
|
|
const stderr = "Warning: deprecated feature\nInfo: all good";
|
|
// No line contains 'error' keyword
|
|
const result = parseErrorFromStderr(stderr);
|
|
// Implementation checks for 'error' (case-insensitive)
|
|
expect(result).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ── parseErrorBody ──────────────────────────────────────────────
|
|
|
|
describe("parseErrorBody", () => {
|
|
it("extracts error message from JSON error body", async () => {
|
|
const { parseErrorBody } = await import("./agent-runner.js");
|
|
const body = '{"error":{"message":"Something failed"}}';
|
|
const result = parseErrorBody(body);
|
|
expect(result).toBe("Something failed");
|
|
});
|
|
|
|
it("returns raw string for non-JSON body", async () => {
|
|
const { parseErrorBody } = await import("./agent-runner.js");
|
|
expect(parseErrorBody("plain text error")).toBe("plain text error");
|
|
});
|
|
|
|
it("returns raw string for empty body", async () => {
|
|
const { parseErrorBody } = await import("./agent-runner.js");
|
|
expect(parseErrorBody("")).toBe("");
|
|
});
|
|
|
|
it("extracts message from nested error object", async () => {
|
|
const { parseErrorBody } = await import("./agent-runner.js");
|
|
const body = '{"error":{"message":"Rate limit","type":"rate_limit_error"}}';
|
|
const result = parseErrorBody(body);
|
|
expect(result).toBe("Rate limit");
|
|
});
|
|
});
|
|
|
|
});
|