From 75e750f635341c5baeea39dd62b0a4fb5f6ade82 Mon Sep 17 00:00:00 2001 From: Nick Marden Date: Thu, 12 Mar 2026 00:00:31 -0400 Subject: [PATCH 1/5] feat: abstract workspace and config I/O behind OpenClawBackend Introduce an OpenClawBackend interface that decouples Pinchy's workspace and config operations from the shared filesystem. Two implementations: - FilesystemBackend (default): reads/writes directly to the shared filesystem, identical to previous behavior. - ApiBackend: uses OpenClaw Gateway RPC (agents.files.*, config.*) so Pinchy and OpenClaw no longer need a shared volume. Selected via OPENCLAW_BACKEND env var ("filesystem" or "api"). All workspace and config functions are now async and delegate to the backend singleton. All callers updated accordingly. --- packages/web/server.ts | 2 + .../web/src/__tests__/api/agent-files.test.ts | 30 +- .../src/__tests__/lib/openclaw-config.test.ts | 249 ++++-------- .../web/src/__tests__/lib/workspace.test.ts | 369 +++++++----------- .../[agentId]/files/[filename]/route.ts | 4 +- .../web/src/app/api/agents/[agentId]/route.ts | 2 +- packages/web/src/app/api/agents/route.ts | 10 +- .../web/src/app/api/users/[userId]/route.ts | 2 +- packages/web/src/lib/agents.ts | 2 +- packages/web/src/lib/context-sync.ts | 4 +- packages/web/src/lib/migrate-onboarding.ts | 2 +- packages/web/src/lib/openclaw-backend.ts | 285 ++++++++++++++ packages/web/src/lib/openclaw-config.ts | 37 +- packages/web/src/lib/personal-agent.ts | 8 +- packages/web/src/lib/workspace.ts | 84 ++-- 15 files changed, 567 insertions(+), 523 deletions(-) create mode 100644 packages/web/src/lib/openclaw-backend.ts diff --git a/packages/web/server.ts b/packages/web/server.ts index 1c46a6536..479bb7e2c 100644 --- a/packages/web/server.ts +++ b/packages/web/server.ts @@ -8,6 +8,7 @@ import { ClientRouter } from "./src/server/client-router"; import { SessionCache } from "./src/server/session-cache"; import { validateWsSession } from "./src/server/ws-auth"; import { restartState } from "./src/server/restart-state"; +import { setBackendClient } from "./src/lib/openclaw-backend"; import { logCapture } from "./src/lib/log-capture"; logCapture.install(); @@ -178,6 +179,7 @@ app.prepare().then(async () => { console.log("Connected to OpenClaw Gateway"); hasConnected = true; errorLogged = false; + setBackendClient(openclawClient!); if (restartState.isRestarting) { restartState.notifyReady(); } diff --git a/packages/web/src/__tests__/api/agent-files.test.ts b/packages/web/src/__tests__/api/agent-files.test.ts index b9bc8b983..4e582a7ac 100644 --- a/packages/web/src/__tests__/api/agent-files.test.ts +++ b/packages/web/src/__tests__/api/agent-files.test.ts @@ -18,8 +18,8 @@ vi.mock("@/lib/auth", () => { }); vi.mock("@/lib/workspace", () => ({ - readWorkspaceFile: vi.fn().mockReturnValue("# Soul content"), - writeWorkspaceFile: vi.fn(), + readWorkspaceFile: vi.fn().mockResolvedValue("# Soul content"), + writeWorkspaceFile: vi.fn().mockResolvedValue(undefined), })); const { mockAssertAgentWriteAccess } = vi.hoisted(() => ({ @@ -70,7 +70,7 @@ describe("GET /api/agents/[agentId]/files/[filename]", () => { user: { id: "1", email: "admin@test.com" }, } as any); vi.mocked(getAgentWithAccess).mockResolvedValue(defaultAgent); - vi.mocked(readWorkspaceFile).mockReturnValue("# Soul content"); + vi.mocked(readWorkspaceFile).mockResolvedValue("# Soul content"); }); it("should return file content for an allowed file", async () => { @@ -121,9 +121,7 @@ describe("GET /api/agents/[agentId]/files/[filename]", () => { }); it("should return 400 when filename is not allowed", async () => { - vi.mocked(readWorkspaceFile).mockImplementationOnce(() => { - throw new Error("File not allowed: SECRET.md"); - }); + vi.mocked(readWorkspaceFile).mockRejectedValueOnce(new Error("File not allowed: SECRET.md")); const request = makeGetRequest("agent-1", "SECRET.md"); const response = await GET(request, makeParams("agent-1", "SECRET.md")); @@ -134,9 +132,7 @@ describe("GET /api/agents/[agentId]/files/[filename]", () => { }); it("should return 400 for USER.md (no longer in ALLOWED_FILES)", async () => { - vi.mocked(readWorkspaceFile).mockImplementationOnce(() => { - throw new Error("File not allowed: USER.md"); - }); + vi.mocked(readWorkspaceFile).mockRejectedValueOnce(new Error("File not allowed: USER.md")); const request = makeGetRequest("agent-1", "USER.md"); const response = await GET(request, makeParams("agent-1", "USER.md")); @@ -147,7 +143,7 @@ describe("GET /api/agents/[agentId]/files/[filename]", () => { }); it("should read AGENTS.md file", async () => { - vi.mocked(readWorkspaceFile).mockReturnValueOnce("# Agent instructions"); + vi.mocked(readWorkspaceFile).mockResolvedValueOnce("# Agent instructions"); const request = makeGetRequest("agent-1", "AGENTS.md"); const response = await GET(request, makeParams("agent-1", "AGENTS.md")); @@ -159,9 +155,7 @@ describe("GET /api/agents/[agentId]/files/[filename]", () => { }); it("should return 400 for IDENTITY.md (not in ALLOWED_FILES)", async () => { - vi.mocked(readWorkspaceFile).mockImplementationOnce(() => { - throw new Error("File not allowed: IDENTITY.md"); - }); + vi.mocked(readWorkspaceFile).mockRejectedValueOnce(new Error("File not allowed: IDENTITY.md")); const request = makeGetRequest("agent-1", "IDENTITY.md"); const response = await GET(request, makeParams("agent-1", "IDENTITY.md")); @@ -172,7 +166,7 @@ describe("GET /api/agents/[agentId]/files/[filename]", () => { }); it("should return empty string when file does not exist yet", async () => { - vi.mocked(readWorkspaceFile).mockReturnValueOnce(""); + vi.mocked(readWorkspaceFile).mockResolvedValueOnce(""); const request = makeGetRequest("agent-1", "SOUL.md"); const response = await GET(request, makeParams("agent-1", "SOUL.md")); @@ -246,9 +240,7 @@ describe("PUT /api/agents/[agentId]/files/[filename]", () => { }); it("should return 400 when filename is not allowed", async () => { - vi.mocked(writeWorkspaceFile).mockImplementationOnce(() => { - throw new Error("File not allowed: HACK.md"); - }); + vi.mocked(writeWorkspaceFile).mockRejectedValueOnce(new Error("File not allowed: HACK.md")); const request = makePutRequest("agent-1", "HACK.md", { content: "malicious content", @@ -261,9 +253,7 @@ describe("PUT /api/agents/[agentId]/files/[filename]", () => { }); it("should return 400 for USER.md PUT (no longer in ALLOWED_FILES)", async () => { - vi.mocked(writeWorkspaceFile).mockImplementationOnce(() => { - throw new Error("File not allowed: USER.md"); - }); + vi.mocked(writeWorkspaceFile).mockRejectedValueOnce(new Error("File not allowed: USER.md")); const request = makePutRequest("agent-1", "USER.md", { content: "# Team info", diff --git a/packages/web/src/__tests__/lib/openclaw-config.test.ts b/packages/web/src/__tests__/lib/openclaw-config.test.ts index d204f1d99..4433bdf83 100644 --- a/packages/web/src/__tests__/lib/openclaw-config.test.ts +++ b/packages/web/src/__tests__/lib/openclaw-config.test.ts @@ -1,26 +1,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -vi.mock("fs", async (importOriginal) => { - const actual = await importOriginal(); - const writeFileSyncMock = vi.fn(); - const readFileSyncMock = vi.fn(); - const existsSyncMock = vi.fn().mockReturnValue(true); - const mkdirSyncMock = vi.fn(); - return { - ...actual, - default: { - ...actual, - writeFileSync: writeFileSyncMock, - readFileSync: readFileSyncMock, - existsSync: existsSyncMock, - mkdirSync: mkdirSyncMock, - }, - writeFileSync: writeFileSyncMock, - readFileSync: readFileSyncMock, - existsSync: existsSyncMock, - mkdirSync: mkdirSyncMock, - }; -}); +const mockBackend = { + readConfig: vi.fn().mockResolvedValue({}), + writeConfig: vi.fn().mockResolvedValue(undefined), + notifyConfigChanged: vi.fn().mockResolvedValue(undefined), + ensureAgentWorkspace: vi.fn().mockResolvedValue(undefined), + writeAgentFile: vi.fn().mockResolvedValue(undefined), + readAgentFile: vi.fn().mockResolvedValue(""), + deleteAgentWorkspace: vi.fn().mockResolvedValue(undefined), +}; + +vi.mock("@/lib/openclaw-backend", () => ({ + getBackend: () => mockBackend, +})); vi.mock("@/db", () => ({ db: { @@ -42,119 +34,87 @@ vi.mock("@/lib/migrate-onboarding", () => ({ migrateExistingSmithers: vi.fn().mockResolvedValue(undefined), })); -import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs"; import { writeOpenClawConfig, regenerateOpenClawConfig } from "@/lib/openclaw-config"; import { db } from "@/db"; import { getSetting } from "@/lib/settings"; -const mockedWriteFileSync = vi.mocked(writeFileSync); -const mockedReadFileSync = vi.mocked(readFileSync); -const mockedExistsSync = vi.mocked(existsSync); -const mockedMkdirSync = vi.mocked(mkdirSync); +const mockedDb = vi.mocked(db); +const mockedGetSetting = vi.mocked(getSetting); describe("writeOpenClawConfig", () => { beforeEach(() => { vi.clearAllMocks(); - mockedExistsSync.mockReturnValue(true); - mockedReadFileSync.mockImplementation(() => { - throw new Error("ENOENT: no such file or directory"); - }); + mockBackend.readConfig.mockResolvedValue({}); }); - it("should write config with Anthropic provider", () => { - writeOpenClawConfig({ + it("should write config with Anthropic provider", async () => { + await writeOpenClawConfig({ provider: "anthropic", apiKey: "sk-ant-secret", model: "anthropic/claude-haiku-4-5-20251001", }); - expect(mockedWriteFileSync).toHaveBeenCalledWith( - expect.stringContaining("openclaw.json"), - expect.stringContaining('"ANTHROPIC_API_KEY": "sk-ant-secret"'), - { encoding: "utf-8", mode: 0o644 } - ); + expect(mockBackend.writeConfig).toHaveBeenCalledOnce(); + const config = mockBackend.writeConfig.mock.calls[0][0]; + expect(config.env.ANTHROPIC_API_KEY).toBe("sk-ant-secret"); }); - it("should write config with correct model", () => { - writeOpenClawConfig({ + it("should write config with correct model", async () => { + await writeOpenClawConfig({ provider: "openai", apiKey: "sk-key", model: "openai/gpt-4o-mini", }); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); - + const config = mockBackend.writeConfig.mock.calls[0][0]; expect(config.agents.defaults.model.primary).toBe("openai/gpt-4o-mini"); expect(config.env.OPENAI_API_KEY).toBe("sk-key"); }); - it("should include gateway mode local and bind lan", () => { - writeOpenClawConfig({ + it("should include gateway mode local and bind lan", async () => { + await writeOpenClawConfig({ provider: "anthropic", apiKey: "sk-ant-key", model: "anthropic/claude-haiku-4-5-20251001", }); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); - + const config = mockBackend.writeConfig.mock.calls[0][0]; expect(config.gateway.mode).toBe("local"); expect(config.gateway.bind).toBe("lan"); }); - it("should create directory if it does not exist", () => { - mockedExistsSync.mockReturnValue(false); - - writeOpenClawConfig({ - provider: "anthropic", - apiKey: "sk-ant-key", - model: "anthropic/claude-haiku-4-5-20251001", - }); - - expect(mockedMkdirSync).toHaveBeenCalledWith(expect.any(String), { - recursive: true, - }); - }); - - it("should write config with Google provider", () => { - writeOpenClawConfig({ + it("should write config with Google provider", async () => { + await writeOpenClawConfig({ provider: "google", apiKey: "AIza-key", model: "google/gemini-2.0-flash", }); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); - + const config = mockBackend.writeConfig.mock.calls[0][0]; expect(config.env.GOOGLE_API_KEY).toBe("AIza-key"); expect(config.agents.defaults.model.primary).toBe("google/gemini-2.0-flash"); }); - it("should generate auth token when no existing config", () => { - writeOpenClawConfig({ + it("should generate auth token when no existing config", async () => { + await writeOpenClawConfig({ provider: "anthropic", apiKey: "sk-ant-key", model: "anthropic/claude-haiku-4-5-20251001", }); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); - + const config = mockBackend.writeConfig.mock.calls[0][0]; expect(config.gateway.auth).toBeDefined(); expect(config.gateway.auth.mode).toBe("token"); expect(config.gateway.auth.token).toBeTruthy(); expect(config.gateway.auth.token).toHaveLength(48); // 24 bytes hex }); - it("should merge with existing config preserving gateway.auth", () => { - const existingConfig = { + it("should merge with existing config preserving gateway.auth", async () => { + mockBackend.readConfig.mockResolvedValue({ gateway: { mode: "local", bind: "lan", - auth: { - token: "existing-secret-token", - }, + auth: { token: "existing-secret-token" }, }, meta: { version: "1.2.3", @@ -165,17 +125,15 @@ describe("writeOpenClawConfig", () => { model: { primary: "anthropic/claude-sonnet-4-20250514" }, }, }, - }; - mockedReadFileSync.mockReturnValue(JSON.stringify(existingConfig)); + }); - writeOpenClawConfig({ + await writeOpenClawConfig({ provider: "openai", apiKey: "sk-new-key", model: "openai/gpt-4o", }); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); + const config = mockBackend.writeConfig.mock.calls[0][0]; // Pinchy's fields are applied expect(config.gateway.mode).toBe("local"); @@ -189,33 +147,26 @@ describe("writeOpenClawConfig", () => { expect(config.meta.generatedAt).toBe("2025-01-01T00:00:00Z"); }); - it("should write config with restrictive file permissions", () => { - writeOpenClawConfig({ + it("should notify config changed after writing", async () => { + await writeOpenClawConfig({ provider: "anthropic", - apiKey: "sk-ant-secret", + apiKey: "sk-ant-key", model: "anthropic/claude-haiku-4-5-20251001", }); - expect(mockedWriteFileSync).toHaveBeenCalledWith(expect.any(String), expect.any(String), { - encoding: "utf-8", - mode: 0o644, - }); + expect(mockBackend.notifyConfigChanged).toHaveBeenCalledOnce(); }); - it("should create config from scratch when no existing file", () => { - mockedReadFileSync.mockImplementation(() => { - throw new Error("ENOENT: no such file or directory"); - }); + it("should create config from scratch when no existing config", async () => { + mockBackend.readConfig.mockResolvedValue({}); - writeOpenClawConfig({ + await writeOpenClawConfig({ provider: "anthropic", apiKey: "sk-ant-fresh", model: "anthropic/claude-haiku-4-5-20251001", }); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); - + const config = mockBackend.writeConfig.mock.calls[0][0]; expect(config.gateway.mode).toBe("local"); expect(config.gateway.bind).toBe("lan"); expect(config.env.ANTHROPIC_API_KEY).toBe("sk-ant-fresh"); @@ -223,31 +174,16 @@ describe("writeOpenClawConfig", () => { }); }); -const mockedDb = vi.mocked(db); -const mockedGetSetting = vi.mocked(getSetting); - describe("regenerateOpenClawConfig", () => { beforeEach(() => { vi.clearAllMocks(); - mockedExistsSync.mockReturnValue(true); - mockedReadFileSync.mockImplementation(() => { - throw new Error("ENOENT: no such file or directory"); - }); + mockBackend.readConfig.mockResolvedValue({}); mockedDb.select.mockReturnValue({ from: vi.fn().mockResolvedValue([]), } as never); mockedGetSetting.mockResolvedValue(null); }); - it("should write config with restrictive file permissions", async () => { - await regenerateOpenClawConfig(); - - expect(mockedWriteFileSync).toHaveBeenCalledWith(expect.any(String), expect.any(String), { - encoding: "utf-8", - mode: 0o644, - }); - }); - it("should write agents.list with all agents from DB", async () => { const agentsData = [ { @@ -271,8 +207,7 @@ describe("regenerateOpenClawConfig", () => { await regenerateOpenClawConfig(); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); + const config = mockBackend.writeConfig.mock.calls[0][0]; expect(config.agents.list).toHaveLength(2); expect(config.agents.list[0]).toEqual({ @@ -292,25 +227,21 @@ describe("regenerateOpenClawConfig", () => { }); it("should preserve existing gateway.auth fields", async () => { - const existingConfig = { + mockBackend.readConfig.mockResolvedValue({ gateway: { mode: "local", bind: "lan", - auth: { - token: "existing-secret-token", - }, + auth: { token: "existing-secret-token" }, }, meta: { version: "1.2.3", generatedAt: "2025-01-01T00:00:00Z", }, - }; - mockedReadFileSync.mockReturnValue(JSON.stringify(existingConfig)); + }); await regenerateOpenClawConfig(); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); + const config = mockBackend.writeConfig.mock.calls[0][0]; expect(config.gateway.auth.token).toBe("existing-secret-token"); // Only gateway block is preserved — other top-level fields (meta, etc.) are rebuilt from DB @@ -329,8 +260,7 @@ describe("regenerateOpenClawConfig", () => { await regenerateOpenClawConfig(); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); + const config = mockBackend.writeConfig.mock.calls[0][0]; expect(config.env.ANTHROPIC_API_KEY).toBe("sk-ant-decrypted"); expect(config.env.OPENAI_API_KEY).toBe("sk-openai-decrypted"); @@ -346,8 +276,7 @@ describe("regenerateOpenClawConfig", () => { await regenerateOpenClawConfig(); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); + const config = mockBackend.writeConfig.mock.calls[0][0]; expect(config.agents.defaults.model.primary).toBe("openai/gpt-4o-mini"); }); @@ -359,8 +288,7 @@ describe("regenerateOpenClawConfig", () => { await regenerateOpenClawConfig(); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); + const config = mockBackend.writeConfig.mock.calls[0][0]; expect(config.agents.list).toEqual([]); }); @@ -370,8 +298,7 @@ describe("regenerateOpenClawConfig", () => { await regenerateOpenClawConfig(); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); + const config = mockBackend.writeConfig.mock.calls[0][0]; expect(config.env).toEqual({}); expect(config.agents.defaults).toEqual({}); @@ -394,8 +321,7 @@ describe("regenerateOpenClawConfig", () => { await regenerateOpenClawConfig(); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); + const config = mockBackend.writeConfig.mock.calls[0][0]; const kbAgent = config.agents.list.find((a: { id: string }) => a.id === "kb-agent-id"); expect(kbAgent.tools).toBeDefined(); @@ -422,8 +348,7 @@ describe("regenerateOpenClawConfig", () => { await regenerateOpenClawConfig(); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); + const config = mockBackend.writeConfig.mock.calls[0][0]; const customAgent = config.agents.list.find((a: { id: string }) => a.id === "custom-agent-id"); expect(customAgent.tools).toBeDefined(); @@ -449,8 +374,7 @@ describe("regenerateOpenClawConfig", () => { await regenerateOpenClawConfig(); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); + const config = mockBackend.writeConfig.mock.calls[0][0]; const agent = config.agents.list.find((a: { id: string }) => a.id === "power-agent-id"); expect(agent.tools.deny).not.toContain("group:runtime"); @@ -475,8 +399,7 @@ describe("regenerateOpenClawConfig", () => { await regenerateOpenClawConfig(); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); + const config = mockBackend.writeConfig.mock.calls[0][0]; expect(config.plugins.entries["pinchy-files"]).toBeDefined(); expect(config.plugins.entries["pinchy-files"].enabled).toBe(true); @@ -486,7 +409,7 @@ describe("regenerateOpenClawConfig", () => { }); it("should not keep stale env vars from previous config", async () => { - const existingConfig = { + mockBackend.readConfig.mockResolvedValue({ gateway: { mode: "local", bind: "lan", @@ -496,8 +419,7 @@ describe("regenerateOpenClawConfig", () => { ANTHROPIC_API_KEY: "old-key", OPENAI_API_KEY: "stale-key-should-be-removed", }, - }; - mockedReadFileSync.mockReturnValue(JSON.stringify(existingConfig)); + }); // Only Anthropic is configured now mockedGetSetting.mockImplementation(async (key: string) => { @@ -508,8 +430,7 @@ describe("regenerateOpenClawConfig", () => { await regenerateOpenClawConfig(); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); + const config = mockBackend.writeConfig.mock.calls[0][0]; expect(config.env.ANTHROPIC_API_KEY).toBe("sk-ant-new"); expect(config.env.OPENAI_API_KEY).toBeUndefined(); @@ -517,10 +438,9 @@ describe("regenerateOpenClawConfig", () => { }); it("should include pinchy-context plugin config for agents with context tools", async () => { - const existingConfig = { + mockBackend.readConfig.mockResolvedValue({ gateway: { mode: "local", bind: "lan", auth: { token: "gw-token-123" } }, - }; - mockedReadFileSync.mockReturnValue(JSON.stringify(existingConfig)); + }); mockedDb.select.mockReturnValue({ from: vi.fn().mockResolvedValue([ @@ -539,8 +459,7 @@ describe("regenerateOpenClawConfig", () => { await regenerateOpenClawConfig(); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); + const config = mockBackend.writeConfig.mock.calls[0][0]; expect(config.plugins.entries["pinchy-context"]).toBeDefined(); expect(config.plugins.entries["pinchy-context"].enabled).toBe(true); @@ -553,15 +472,13 @@ describe("regenerateOpenClawConfig", () => { }); it("should include pinchy-audit plugin config", async () => { - const existingConfig = { + mockBackend.readConfig.mockResolvedValue({ gateway: { mode: "local", bind: "lan", auth: { token: "gw-token-123" } }, - }; - mockedReadFileSync.mockReturnValue(JSON.stringify(existingConfig)); + }); await regenerateOpenClawConfig(); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); + const config = mockBackend.writeConfig.mock.calls[0][0]; expect(config.plugins.entries["pinchy-audit"]).toBeDefined(); expect(config.plugins.entries["pinchy-audit"].enabled).toBe(true); @@ -572,10 +489,9 @@ describe("regenerateOpenClawConfig", () => { }); it("should include both pinchy-files and pinchy-context when agents use both", async () => { - const existingConfig = { + mockBackend.readConfig.mockResolvedValue({ gateway: { mode: "local", bind: "lan", auth: { token: "gw-token" } }, - }; - mockedReadFileSync.mockReturnValue(JSON.stringify(existingConfig)); + }); mockedDb.select.mockReturnValue({ from: vi.fn().mockResolvedValue([ @@ -604,18 +520,16 @@ describe("regenerateOpenClawConfig", () => { await regenerateOpenClawConfig(); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); + const config = mockBackend.writeConfig.mock.calls[0][0]; expect(config.plugins.entries["pinchy-files"]).toBeDefined(); expect(config.plugins.entries["pinchy-context"]).toBeDefined(); }); it("should include both save tools for admin Smithers", async () => { - const existingConfig = { + mockBackend.readConfig.mockResolvedValue({ gateway: { mode: "local", bind: "lan", auth: { token: "gw-token" } }, - }; - mockedReadFileSync.mockReturnValue(JSON.stringify(existingConfig)); + }); mockedDb.select.mockReturnValue({ from: vi.fn().mockResolvedValue([ @@ -634,8 +548,7 @@ describe("regenerateOpenClawConfig", () => { await regenerateOpenClawConfig(); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); + const config = mockBackend.writeConfig.mock.calls[0][0]; expect(config.plugins.entries["pinchy-context"].config.agents["admin-smithers"]).toEqual({ tools: ["save_user_context", "save_org_context"], @@ -659,8 +572,7 @@ describe("regenerateOpenClawConfig", () => { await regenerateOpenClawConfig(); - const written = mockedWriteFileSync.mock.calls[0][1] as string; - const config = JSON.parse(written); + const config = mockBackend.writeConfig.mock.calls[0][0]; // Unused plugins are omitted from entries AND allow list to prevent // auto-discovery (restart loop) and "disabled but config present" spam @@ -677,10 +589,7 @@ describe("regenerateOpenClawConfig", () => { describe("restart-state integration", () => { beforeEach(() => { vi.clearAllMocks(); - mockedExistsSync.mockReturnValue(true); - mockedReadFileSync.mockImplementation(() => { - throw new Error("ENOENT: no such file or directory"); - }); + mockBackend.readConfig.mockResolvedValue({}); mockedDb.select.mockReturnValue({ from: vi.fn().mockResolvedValue([]), } as never); @@ -690,7 +599,7 @@ describe("restart-state integration", () => { it("writeOpenClawConfig calls restartState.notifyRestart", async () => { const { restartState } = await import("@/server/restart-state"); - writeOpenClawConfig({ + await writeOpenClawConfig({ provider: "anthropic", apiKey: "sk-ant-key", model: "anthropic/claude-haiku-4-5-20251001", diff --git a/packages/web/src/__tests__/lib/workspace.test.ts b/packages/web/src/__tests__/lib/workspace.test.ts index efb6f119e..1f46e4345 100644 --- a/packages/web/src/__tests__/lib/workspace.test.ts +++ b/packages/web/src/__tests__/lib/workspace.test.ts @@ -1,32 +1,22 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -vi.mock("fs", async (importOriginal) => { - const actual = await importOriginal(); - const writeFileSyncMock = vi.fn(); - const readFileSyncMock = vi.fn(); - const existsSyncMock = vi.fn().mockReturnValue(false); - const mkdirSyncMock = vi.fn(); - return { - ...actual, - default: { - ...actual, - writeFileSync: writeFileSyncMock, - readFileSync: readFileSyncMock, - existsSync: existsSyncMock, - mkdirSync: mkdirSyncMock, - }, - writeFileSync: writeFileSyncMock, - readFileSync: readFileSyncMock, - existsSync: existsSyncMock, - mkdirSync: mkdirSyncMock, - }; -}); +const mockBackend = { + readConfig: vi.fn().mockResolvedValue({}), + writeConfig: vi.fn().mockResolvedValue(undefined), + notifyConfigChanged: vi.fn().mockResolvedValue(undefined), + ensureAgentWorkspace: vi.fn().mockResolvedValue(undefined), + writeAgentFile: vi.fn().mockResolvedValue(undefined), + readAgentFile: vi.fn().mockResolvedValue(""), + deleteAgentWorkspace: vi.fn().mockResolvedValue(undefined), +}; + +vi.mock("@/lib/openclaw-backend", () => ({ + getBackend: () => mockBackend, +})); -import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs"; import type { WorkspaceFile } from "@/lib/workspace"; import { ALLOWED_FILES, - getWorkspacePath, getOpenClawWorkspacePath, ensureWorkspace, readWorkspaceFile, @@ -36,11 +26,6 @@ import { writeIdentityFile, } from "@/lib/workspace"; -const mockedWriteFileSync = vi.mocked(writeFileSync); -const mockedReadFileSync = vi.mocked(readFileSync); -const mockedExistsSync = vi.mocked(existsSync); -const mockedMkdirSync = vi.mocked(mkdirSync); - describe("ALLOWED_FILES", () => { it("should contain SOUL.md and AGENTS.md (not USER.md)", () => { expect(ALLOWED_FILES).toEqual(["SOUL.md", "AGENTS.md"]); @@ -60,64 +45,49 @@ describe("WorkspaceFile type", () => { describe("agentId validation", () => { it("should reject agentId containing forward slash", () => { - expect(() => getWorkspacePath("../../etc/cron.d")).toThrow("Invalid agentId: ../../etc/cron.d"); + expect(() => getOpenClawWorkspacePath("../../etc/cron.d")).toThrow( + "Invalid agentId: ../../etc/cron.d" + ); }); it("should reject agentId containing backslash", () => { - expect(() => getWorkspacePath("..\\etc\\passwd")).toThrow("Invalid agentId: ..\\etc\\passwd"); + expect(() => getOpenClawWorkspacePath("..\\etc\\passwd")).toThrow( + "Invalid agentId: ..\\etc\\passwd" + ); }); it("should reject agentId containing ..", () => { - expect(() => getWorkspacePath("..")).toThrow("Invalid agentId: .."); + expect(() => getOpenClawWorkspacePath("..")).toThrow("Invalid agentId: .."); }); it("should reject empty agentId", () => { - expect(() => getWorkspacePath("")).toThrow("Invalid agentId: "); + expect(() => getOpenClawWorkspacePath("")).toThrow("Invalid agentId: "); }); - it("should reject path traversal in ensureWorkspace", () => { - expect(() => ensureWorkspace("../evil")).toThrow("Invalid agentId: ../evil"); + it("should reject path traversal in ensureWorkspace", async () => { + await expect(ensureWorkspace("../evil")).rejects.toThrow("Invalid agentId: ../evil"); }); - it("should reject path traversal in readWorkspaceFile", () => { - expect(() => readWorkspaceFile("../../etc", "SOUL.md")).toThrow("Invalid agentId: ../../etc"); + it("should reject path traversal in readWorkspaceFile", async () => { + await expect(readWorkspaceFile("../../etc", "SOUL.md")).rejects.toThrow( + "Invalid agentId: ../../etc" + ); }); - it("should reject path traversal in writeWorkspaceFile", () => { - expect(() => writeWorkspaceFile("../hack", "SOUL.md", "content")).toThrow( + it("should reject path traversal in writeWorkspaceFile", async () => { + await expect(writeWorkspaceFile("../hack", "SOUL.md", "content")).rejects.toThrow( "Invalid agentId: ../hack" ); }); it("should accept valid agentId", () => { - const path = getWorkspacePath("agent-123"); - expect(path).toBe("/openclaw-config/workspaces/agent-123"); + const path = getOpenClawWorkspacePath("agent-123"); + expect(path).toBe("/root/.openclaw/workspaces/agent-123"); }); it("should accept agentId with UUID format", () => { - const path = getWorkspacePath("550e8400-e29b-41d4-a716-446655440000"); - expect(path).toBe("/openclaw-config/workspaces/550e8400-e29b-41d4-a716-446655440000"); - }); -}); - -describe("getWorkspacePath", () => { - it("should return path under default workspace base directory", () => { - const path = getWorkspacePath("agent-123"); - expect(path).toBe("/openclaw-config/workspaces/agent-123"); - }); - - it("should use WORKSPACE_BASE_PATH env var when set", () => { - const originalEnv = process.env.WORKSPACE_BASE_PATH; - process.env.WORKSPACE_BASE_PATH = "/custom/path"; - - const path = getWorkspacePath("agent-456"); - expect(path).toBe("/custom/path/agent-456"); - - if (originalEnv === undefined) { - delete process.env.WORKSPACE_BASE_PATH; - } else { - process.env.WORKSPACE_BASE_PATH = originalEnv; - } + const path = getOpenClawWorkspacePath("550e8400-e29b-41d4-a716-446655440000"); + expect(path).toBe("/root/.openclaw/workspaces/550e8400-e29b-41d4-a716-446655440000"); }); }); @@ -149,70 +119,68 @@ describe("getOpenClawWorkspacePath", () => { describe("ensureWorkspace", () => { beforeEach(() => { vi.clearAllMocks(); - mockedExistsSync.mockReturnValue(false); + mockBackend.readAgentFile.mockResolvedValue(""); }); - it("should create workspace directory if it does not exist", () => { - ensureWorkspace("agent-123"); + it("should call ensureAgentWorkspace on the backend", async () => { + await ensureWorkspace("agent-123"); - expect(mockedMkdirSync).toHaveBeenCalledWith("/openclaw-config/workspaces/agent-123", { - recursive: true, - }); + expect(mockBackend.ensureAgentWorkspace).toHaveBeenCalledWith("agent-123"); }); - it("should create SOUL.md with placeholder content when missing", () => { - ensureWorkspace("agent-123"); + it("should create SOUL.md with placeholder content when missing", async () => { + await ensureWorkspace("agent-123"); - const soulCall = mockedWriteFileSync.mock.calls.find( - (call) => typeof call[0] === "string" && call[0].endsWith("SOUL.md") + const soulCall = mockBackend.writeAgentFile.mock.calls.find( + (call: unknown[]) => call[1] === "SOUL.md" ); expect(soulCall).toBeDefined(); - expect(soulCall![0]).toBe("/openclaw-config/workspaces/agent-123/SOUL.md"); - expect(soulCall![1]).toContain("Describe your agent's personality here"); + expect(soulCall![0]).toBe("agent-123"); + expect(soulCall![2]).toContain("Describe your agent's personality here"); }); - it("should not create USER.md placeholder", () => { - ensureWorkspace("agent-123"); + it("should not create USER.md placeholder", async () => { + await ensureWorkspace("agent-123"); - const userCall = mockedWriteFileSync.mock.calls.find( - (call) => typeof call[0] === "string" && call[0].endsWith("USER.md") + const userCall = mockBackend.writeAgentFile.mock.calls.find( + (call: unknown[]) => call[1] === "USER.md" ); expect(userCall).toBeUndefined(); }); - it("should not overwrite existing SOUL.md", () => { - mockedExistsSync.mockImplementation((p) => { - return typeof p === "string" && p.endsWith("SOUL.md"); + it("should not overwrite existing SOUL.md", async () => { + mockBackend.readAgentFile.mockImplementation(async (_id: string, filename: string) => { + return filename === "SOUL.md" ? "existing content" : ""; }); - ensureWorkspace("agent-123"); + await ensureWorkspace("agent-123"); - const soulCall = mockedWriteFileSync.mock.calls.find( - (call) => typeof call[0] === "string" && call[0].endsWith("SOUL.md") + const soulCall = mockBackend.writeAgentFile.mock.calls.find( + (call: unknown[]) => call[1] === "SOUL.md" ); expect(soulCall).toBeUndefined(); }); - it("should create AGENTS.md with placeholder content when missing", () => { - ensureWorkspace("agent-123"); + it("should create AGENTS.md with placeholder content when missing", async () => { + await ensureWorkspace("agent-123"); - const agentsCall = mockedWriteFileSync.mock.calls.find( - (call) => typeof call[0] === "string" && call[0].endsWith("AGENTS.md") + const agentsCall = mockBackend.writeAgentFile.mock.calls.find( + (call: unknown[]) => call[1] === "AGENTS.md" ); expect(agentsCall).toBeDefined(); - expect(agentsCall![0]).toBe("/openclaw-config/workspaces/agent-123/AGENTS.md"); - expect(agentsCall![1]).toContain("Define your agent's instructions here"); + expect(agentsCall![0]).toBe("agent-123"); + expect(agentsCall![2]).toContain("Define your agent's instructions here"); }); - it("should not overwrite existing AGENTS.md", () => { - mockedExistsSync.mockImplementation((p) => { - return typeof p === "string" && p.endsWith("AGENTS.md"); + it("should not overwrite existing AGENTS.md", async () => { + mockBackend.readAgentFile.mockImplementation(async (_id: string, filename: string) => { + return filename === "AGENTS.md" ? "existing content" : ""; }); - ensureWorkspace("agent-123"); + await ensureWorkspace("agent-123"); - const agentsCall = mockedWriteFileSync.mock.calls.find( - (call) => typeof call[0] === "string" && call[0].endsWith("AGENTS.md") + const agentsCall = mockBackend.writeAgentFile.mock.calls.find( + (call: unknown[]) => call[1] === "AGENTS.md" ); expect(agentsCall).toBeUndefined(); }); @@ -223,140 +191,117 @@ describe("readWorkspaceFile", () => { vi.clearAllMocks(); }); - it("should read SOUL.md content", () => { - mockedReadFileSync.mockReturnValue("You are a helpful assistant."); + it("should read SOUL.md content via backend", async () => { + mockBackend.readAgentFile.mockResolvedValue("You are a helpful assistant."); - const content = readWorkspaceFile("agent-123", "SOUL.md"); + const content = await readWorkspaceFile("agent-123", "SOUL.md"); - expect(mockedReadFileSync).toHaveBeenCalledWith( - "/openclaw-config/workspaces/agent-123/SOUL.md", - "utf-8" - ); + expect(mockBackend.readAgentFile).toHaveBeenCalledWith("agent-123", "SOUL.md"); expect(content).toBe("You are a helpful assistant."); }); - it("should throw on USER.md (no longer in ALLOWED_FILES)", () => { - expect(() => readWorkspaceFile("agent-123", "USER.md")).toThrow("File not allowed: USER.md"); + it("should throw on USER.md (no longer in ALLOWED_FILES)", async () => { + await expect(readWorkspaceFile("agent-123", "USER.md")).rejects.toThrow( + "File not allowed: USER.md" + ); }); - it("should read AGENTS.md content", () => { - mockedReadFileSync.mockReturnValue("Answer questions about HR policies."); + it("should read AGENTS.md content", async () => { + mockBackend.readAgentFile.mockResolvedValue("Answer questions about HR policies."); - const content = readWorkspaceFile("agent-123", "AGENTS.md"); + const content = await readWorkspaceFile("agent-123", "AGENTS.md"); - expect(mockedReadFileSync).toHaveBeenCalledWith( - "/openclaw-config/workspaces/agent-123/AGENTS.md", - "utf-8" - ); + expect(mockBackend.readAgentFile).toHaveBeenCalledWith("agent-123", "AGENTS.md"); expect(content).toBe("Answer questions about HR policies."); }); - it("should return empty string if file does not exist", () => { - mockedReadFileSync.mockImplementation(() => { - throw new Error("ENOENT: no such file or directory"); - }); + it("should return empty string if file does not exist", async () => { + mockBackend.readAgentFile.mockResolvedValue(""); - const content = readWorkspaceFile("agent-123", "SOUL.md"); + const content = await readWorkspaceFile("agent-123", "SOUL.md"); expect(content).toBe(""); }); - it("should throw on disallowed filename", () => { - expect(() => readWorkspaceFile("agent-123", "SECRET.md")).toThrow( + it("should throw on disallowed filename", async () => { + await expect(readWorkspaceFile("agent-123", "SECRET.md")).rejects.toThrow( "File not allowed: SECRET.md" ); }); - it("should throw on path traversal attempt with ../", () => { - expect(() => readWorkspaceFile("agent-123", "../etc/passwd")).toThrow( + it("should throw on path traversal attempt with ../", async () => { + await expect(readWorkspaceFile("agent-123", "../etc/passwd")).rejects.toThrow( "File not allowed: ../etc/passwd" ); }); - it("should throw on path traversal attempt with subdirectory", () => { - expect(() => readWorkspaceFile("agent-123", "subdir/SOUL.md")).toThrow( + it("should throw on path traversal attempt with subdirectory", async () => { + await expect(readWorkspaceFile("agent-123", "subdir/SOUL.md")).rejects.toThrow( "File not allowed: subdir/SOUL.md" ); }); - it("should throw on empty filename", () => { - expect(() => readWorkspaceFile("agent-123", "")).toThrow("File not allowed: "); + it("should throw on empty filename", async () => { + await expect(readWorkspaceFile("agent-123", "")).rejects.toThrow("File not allowed: "); }); }); describe("writeWorkspaceFile", () => { beforeEach(() => { vi.clearAllMocks(); - mockedExistsSync.mockReturnValue(false); }); - it("should write content to SOUL.md", () => { - writeWorkspaceFile("agent-123", "SOUL.md", "You are a project manager."); + it("should write content to SOUL.md via backend", async () => { + await writeWorkspaceFile("agent-123", "SOUL.md", "You are a project manager."); - expect(mockedWriteFileSync).toHaveBeenCalledWith( - "/openclaw-config/workspaces/agent-123/SOUL.md", - "You are a project manager.", - "utf-8" + expect(mockBackend.writeAgentFile).toHaveBeenCalledWith( + "agent-123", + "SOUL.md", + "You are a project manager." ); }); - it("should throw on USER.md (no longer in ALLOWED_FILES)", () => { - expect(() => writeWorkspaceFile("agent-123", "USER.md", "content")).toThrow( + it("should throw on USER.md (no longer in ALLOWED_FILES)", async () => { + await expect(writeWorkspaceFile("agent-123", "USER.md", "content")).rejects.toThrow( "File not allowed: USER.md" ); }); - it("should write content to AGENTS.md", () => { - writeWorkspaceFile("agent-123", "AGENTS.md", "Answer questions about HR policies."); + it("should write content to AGENTS.md", async () => { + await writeWorkspaceFile("agent-123", "AGENTS.md", "Answer questions about HR policies."); - expect(mockedWriteFileSync).toHaveBeenCalledWith( - "/openclaw-config/workspaces/agent-123/AGENTS.md", - "Answer questions about HR policies.", - "utf-8" + expect(mockBackend.writeAgentFile).toHaveBeenCalledWith( + "agent-123", + "AGENTS.md", + "Answer questions about HR policies." ); }); - it("should create directory if it does not exist", () => { - writeWorkspaceFile("agent-456", "SOUL.md", "Content"); - - expect(mockedMkdirSync).toHaveBeenCalledWith("/openclaw-config/workspaces/agent-456", { - recursive: true, - }); - }); - - it("should not create directory if it already exists", () => { - mockedExistsSync.mockReturnValue(true); - - writeWorkspaceFile("agent-456", "SOUL.md", "Content"); - - expect(mockedMkdirSync).not.toHaveBeenCalled(); - }); - - it("should throw on disallowed filename", () => { - expect(() => writeWorkspaceFile("agent-123", "HACK.md", "malicious")).toThrow( + it("should throw on disallowed filename", async () => { + await expect(writeWorkspaceFile("agent-123", "HACK.md", "malicious")).rejects.toThrow( "File not allowed: HACK.md" ); }); - it("should throw on path traversal attempt", () => { - expect(() => writeWorkspaceFile("agent-123", "../../etc/passwd", "pwned")).toThrow( + it("should throw on path traversal attempt", async () => { + await expect(writeWorkspaceFile("agent-123", "../../etc/passwd", "pwned")).rejects.toThrow( "File not allowed: ../../etc/passwd" ); }); - it("should throw on filename with directory separator", () => { - expect(() => writeWorkspaceFile("agent-123", "foo/SOUL.md", "content")).toThrow( + it("should throw on filename with directory separator", async () => { + await expect(writeWorkspaceFile("agent-123", "foo/SOUL.md", "content")).rejects.toThrow( "File not allowed: foo/SOUL.md" ); }); - it("should not write file when filename is disallowed", () => { + it("should not write file when filename is disallowed", async () => { try { - writeWorkspaceFile("agent-123", "EVIL.md", "content"); + await writeWorkspaceFile("agent-123", "EVIL.md", "content"); } catch { // expected } - expect(mockedWriteFileSync).not.toHaveBeenCalled(); + expect(mockBackend.writeAgentFile).not.toHaveBeenCalled(); }); }); @@ -378,62 +323,45 @@ describe("generateIdentityContent", () => { describe("writeIdentityFile", () => { beforeEach(() => { vi.clearAllMocks(); - mockedExistsSync.mockReturnValue(false); }); - it("should write IDENTITY.md to workspace directory", () => { - writeIdentityFile("agent-123", { + it("should write IDENTITY.md to workspace via backend", async () => { + await writeIdentityFile("agent-123", { name: "Smithers", tagline: "Your reliable personal assistant", }); - expect(mockedWriteFileSync).toHaveBeenCalledWith( - "/openclaw-config/workspaces/agent-123/IDENTITY.md", - "# Smithers\n> Your reliable personal assistant", - "utf-8" + expect(mockBackend.writeAgentFile).toHaveBeenCalledWith( + "agent-123", + "IDENTITY.md", + "# Smithers\n> Your reliable personal assistant" ); }); - it("should write only name heading when tagline is null", () => { - writeIdentityFile("agent-123", { name: "Custom Agent", tagline: null }); + it("should write only name heading when tagline is null", async () => { + await writeIdentityFile("agent-123", { name: "Custom Agent", tagline: null }); - expect(mockedWriteFileSync).toHaveBeenCalledWith( - "/openclaw-config/workspaces/agent-123/IDENTITY.md", - "# Custom Agent", - "utf-8" + expect(mockBackend.writeAgentFile).toHaveBeenCalledWith( + "agent-123", + "IDENTITY.md", + "# Custom Agent" ); }); - it("should create workspace directory if needed", () => { - writeIdentityFile("agent-456", { name: "Test", tagline: null }); - - expect(mockedMkdirSync).toHaveBeenCalledWith("/openclaw-config/workspaces/agent-456", { - recursive: true, - }); - }); - - it("should not create directory if it already exists", () => { - mockedExistsSync.mockReturnValue(true); - - writeIdentityFile("agent-456", { name: "Test", tagline: null }); - - expect(mockedMkdirSync).not.toHaveBeenCalled(); - }); - - it("should reject invalid agentId", () => { - expect(() => writeIdentityFile("../evil", { name: "Evil", tagline: null })).toThrow( + it("should reject invalid agentId", async () => { + await expect(writeIdentityFile("../evil", { name: "Evil", tagline: null })).rejects.toThrow( "Invalid agentId: ../evil" ); }); - it("should not be accessible via readWorkspaceFile", () => { - expect(() => readWorkspaceFile("agent-123", "IDENTITY.md")).toThrow( + it("should not be accessible via readWorkspaceFile", async () => { + await expect(readWorkspaceFile("agent-123", "IDENTITY.md")).rejects.toThrow( "File not allowed: IDENTITY.md" ); }); - it("should not be accessible via writeWorkspaceFile", () => { - expect(() => writeWorkspaceFile("agent-123", "IDENTITY.md", "content")).toThrow( + it("should not be accessible via writeWorkspaceFile", async () => { + await expect(writeWorkspaceFile("agent-123", "IDENTITY.md", "content")).rejects.toThrow( "File not allowed: IDENTITY.md" ); }); @@ -442,42 +370,27 @@ describe("writeIdentityFile", () => { describe("writeWorkspaceFileInternal", () => { beforeEach(() => { vi.clearAllMocks(); - mockedExistsSync.mockReturnValue(false); }); - it("should write USER.md bypassing ALLOWED_FILES check", () => { - writeWorkspaceFileInternal("agent-123", "USER.md", "org context content"); + it("should write USER.md bypassing ALLOWED_FILES check", async () => { + await writeWorkspaceFileInternal("agent-123", "USER.md", "org context content"); - expect(mockedWriteFileSync).toHaveBeenCalledWith( - "/openclaw-config/workspaces/agent-123/USER.md", - "org context content", - "utf-8" + expect(mockBackend.writeAgentFile).toHaveBeenCalledWith( + "agent-123", + "USER.md", + "org context content" ); }); - it("should create directory if it does not exist", () => { - writeWorkspaceFileInternal("agent-456", "USER.md", "content"); - - expect(mockedMkdirSync).toHaveBeenCalledWith("/openclaw-config/workspaces/agent-456", { - recursive: true, - }); - }); - - it("should not create directory if it already exists", () => { - mockedExistsSync.mockReturnValue(true); - - writeWorkspaceFileInternal("agent-456", "USER.md", "content"); - - expect(mockedMkdirSync).not.toHaveBeenCalled(); - }); - - it("should reject invalid agentId with path traversal", () => { - expect(() => writeWorkspaceFileInternal("../evil", "USER.md", "content")).toThrow( + it("should reject invalid agentId with path traversal", async () => { + await expect(writeWorkspaceFileInternal("../evil", "USER.md", "content")).rejects.toThrow( "Invalid agentId: ../evil" ); }); - it("should reject empty agentId", () => { - expect(() => writeWorkspaceFileInternal("", "USER.md", "content")).toThrow("Invalid agentId: "); + it("should reject empty agentId", async () => { + await expect(writeWorkspaceFileInternal("", "USER.md", "content")).rejects.toThrow( + "Invalid agentId: " + ); }); }); diff --git a/packages/web/src/app/api/agents/[agentId]/files/[filename]/route.ts b/packages/web/src/app/api/agents/[agentId]/files/[filename]/route.ts index fa0596d95..500d41ed1 100644 --- a/packages/web/src/app/api/agents/[agentId]/files/[filename]/route.ts +++ b/packages/web/src/app/api/agents/[agentId]/files/[filename]/route.ts @@ -18,7 +18,7 @@ export async function GET(request: NextRequest, { params }: Params) { if (agentOrError instanceof NextResponse) return agentOrError; try { - const content = readWorkspaceFile(agentId, filename); + const content = await readWorkspaceFile(agentId, filename); return NextResponse.json({ content }); } catch (error: unknown) { const message = error instanceof Error ? error.message : "Invalid file"; @@ -51,7 +51,7 @@ export async function PUT(request: NextRequest, { params }: Params) { } try { - writeWorkspaceFile(agentId, filename, content); + await writeWorkspaceFile(agentId, filename, content); return NextResponse.json({ success: true }); } catch (error: unknown) { const message = error instanceof Error ? error.message : "Invalid file"; diff --git a/packages/web/src/app/api/agents/[agentId]/route.ts b/packages/web/src/app/api/agents/[agentId]/route.ts index 6a76d55f0..a029eab2d 100644 --- a/packages/web/src/app/api/agents/[agentId]/route.ts +++ b/packages/web/src/app/api/agents/[agentId]/route.ts @@ -143,7 +143,7 @@ export async function PATCH( } if (data.name !== undefined || data.tagline !== undefined) { - writeIdentityFile(agentId, { + await writeIdentityFile(agentId, { name: agent.name, tagline: agent.tagline, }); diff --git a/packages/web/src/app/api/agents/route.ts b/packages/web/src/app/api/agents/route.ts index 1ba5a7ee9..f3e949c11 100644 --- a/packages/web/src/app/api/agents/route.ts +++ b/packages/web/src/app/api/agents/route.ts @@ -116,21 +116,21 @@ export async function POST(request: NextRequest) { }).catch(() => {}); // Create workspace with personality preset's SOUL.md - ensureWorkspace(agent.id); - writeWorkspaceFile(agent.id, "SOUL.md", preset?.soulMd ?? ""); - writeIdentityFile(agent.id, { name: agent.name, tagline: agent.tagline }); + await ensureWorkspace(agent.id); + await writeWorkspaceFile(agent.id, "SOUL.md", preset?.soulMd ?? ""); + await writeIdentityFile(agent.id, { name: agent.name, tagline: agent.tagline }); const agentsMd = generateAgentsMd( template, template.pluginId && pluginConfig ? pluginConfig : undefined ); if (agentsMd) { - writeWorkspaceFile(agent.id, "AGENTS.md", agentsMd); + await writeWorkspaceFile(agent.id, "AGENTS.md", agentsMd); } const context = await getContextForAgent({ isPersonal: false, ownerId: session.user.id!, }); - writeWorkspaceFileInternal(agent.id, "USER.md", context); + await writeWorkspaceFileInternal(agent.id, "USER.md", context); await regenerateOpenClawConfig(); diff --git a/packages/web/src/app/api/users/[userId]/route.ts b/packages/web/src/app/api/users/[userId]/route.ts index 82c34eea8..b81d96765 100644 --- a/packages/web/src/app/api/users/[userId]/route.ts +++ b/packages/web/src/app/api/users/[userId]/route.ts @@ -105,7 +105,7 @@ export async function DELETE( // Soft-delete personal agents + cleanup workspaces for (const agent of personalAgents) { await db.update(agents).set({ deletedAt: new Date() }).where(eq(agents.id, agent.id)); - deleteWorkspace(agent.id); // synchronous (uses rmSync) + await deleteWorkspace(agent.id); } await regenerateOpenClawConfig(); diff --git a/packages/web/src/lib/agents.ts b/packages/web/src/lib/agents.ts index d3889c542..897dcd43a 100644 --- a/packages/web/src/lib/agents.ts +++ b/packages/web/src/lib/agents.ts @@ -26,7 +26,7 @@ export async function deleteAgent(id: string) { .returning(); if (updated) { - deleteWorkspace(id); + await deleteWorkspace(id); await regenerateOpenClawConfig(); } diff --git a/packages/web/src/lib/context-sync.ts b/packages/web/src/lib/context-sync.ts index cb8829db5..10a34a2b9 100644 --- a/packages/web/src/lib/context-sync.ts +++ b/packages/web/src/lib/context-sync.ts @@ -16,7 +16,7 @@ export async function syncUserContextToWorkspaces(userId: string): Promise }); for (const agent of personalAgents) { - writeWorkspaceFileInternal(agent.id, "USER.md", context); + await writeWorkspaceFileInternal(agent.id, "USER.md", context); } } @@ -28,7 +28,7 @@ export async function syncOrgContextToWorkspaces(): Promise { }); for (const agent of sharedAgents) { - writeWorkspaceFileInternal(agent.id, "USER.md", context); + await writeWorkspaceFileInternal(agent.id, "USER.md", context); } } diff --git a/packages/web/src/lib/migrate-onboarding.ts b/packages/web/src/lib/migrate-onboarding.ts index 496116aeb..feb86e05e 100644 --- a/packages/web/src/lib/migrate-onboarding.ts +++ b/packages/web/src/lib/migrate-onboarding.ts @@ -25,6 +25,6 @@ export async function migrateExistingSmithers(): Promise { await db.update(agents).set({ allowedTools }).where(eq(agents.id, agent.id)); - writeWorkspaceFileInternal(agent.id, "USER.md", getOnboardingPrompt(isAdmin)); + await writeWorkspaceFileInternal(agent.id, "USER.md", getOnboardingPrompt(isAdmin)); } } diff --git a/packages/web/src/lib/openclaw-backend.ts b/packages/web/src/lib/openclaw-backend.ts new file mode 100644 index 000000000..19d239518 --- /dev/null +++ b/packages/web/src/lib/openclaw-backend.ts @@ -0,0 +1,285 @@ +/** + * Backend abstraction for OpenClaw config and workspace operations. + * + * Two implementations: + * - FilesystemBackend: reads/writes directly to the shared filesystem (default, + * current behavior). Requires Pinchy and OpenClaw to share a volume. + * - ApiBackend: reads/writes via the OpenClaw Gateway RPC API. No shared + * filesystem needed; Pinchy and OpenClaw can run in separate pods. + * + * Selected via OPENCLAW_BACKEND env var: "filesystem" (default) or "api". + */ + +import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync } from "fs"; +import { join, dirname } from "path"; +import type { OpenClawClient } from "openclaw-node"; + +// --------------------------------------------------------------------------- +// Interface +// --------------------------------------------------------------------------- + +export interface OpenClawBackend { + /** Read the current openclaw.json config object. */ + readConfig(): Promise>; + + /** + * Write a complete openclaw.json config. The implementation is responsible + * for merging or replacing as appropriate. + */ + writeConfig(config: Record): Promise; + + /** Signal that the config changed and OpenClaw should restart. */ + notifyConfigChanged(): Promise; + + /** Ensure a workspace directory exists for the given agent. */ + ensureAgentWorkspace(agentId: string): Promise; + + /** Write a file into an agent's workspace. */ + writeAgentFile(agentId: string, filename: string, content: string): Promise; + + /** Read a file from an agent's workspace. Returns "" if missing. */ + readAgentFile(agentId: string, filename: string): Promise; + + /** Delete an agent's workspace directory. */ + deleteAgentWorkspace(agentId: string): Promise; +} + +// --------------------------------------------------------------------------- +// Filesystem Backend (existing behavior) +// --------------------------------------------------------------------------- + +export class FilesystemBackend implements OpenClawBackend { + constructor( + private configPath: string, + private workspaceBasePath: string + ) {} + + async readConfig(): Promise> { + try { + return JSON.parse(readFileSync(this.configPath, "utf-8")); + } catch { + return {}; + } + } + + async writeConfig(config: Record): Promise { + const dir = dirname(this.configPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(this.configPath, JSON.stringify(config, null, 2), { + encoding: "utf-8", + mode: 0o644, + }); + } + + async notifyConfigChanged(): Promise { + // Filesystem backend: OpenClaw watches the file for changes. + // Nothing to do here; the write itself triggers the watcher. + } + + async ensureAgentWorkspace(agentId: string): Promise { + const workspacePath = join(this.workspaceBasePath, agentId); + mkdirSync(workspacePath, { recursive: true }); + } + + async writeAgentFile(agentId: string, filename: string, content: string): Promise { + const workspacePath = join(this.workspaceBasePath, agentId); + if (!existsSync(workspacePath)) { + mkdirSync(workspacePath, { recursive: true }); + } + writeFileSync(join(workspacePath, filename), content, "utf-8"); + } + + async readAgentFile(agentId: string, filename: string): Promise { + try { + return readFileSync(join(this.workspaceBasePath, agentId, filename), "utf-8"); + } catch { + return ""; + } + } + + async deleteAgentWorkspace(agentId: string): Promise { + try { + rmSync(join(this.workspaceBasePath, agentId), { + recursive: true, + force: true, + }); + } catch { + // Workspace may not exist + } + } +} + +// --------------------------------------------------------------------------- +// API Backend (uses OpenClaw Gateway RPC) +// --------------------------------------------------------------------------- + +export class ApiBackend implements OpenClawBackend { + private client: OpenClawClient | null = null; + private configHash: string | null = null; + + setClient(client: OpenClawClient): void { + this.client = client; + } + + private requireClient(): OpenClawClient { + if (!this.client || !this.client.isConnected) { + throw new Error( + "OpenClaw API backend requires a connected client. " + + "Is OpenClaw running and OPENCLAW_WS_URL configured?" + ); + } + return this.client; + } + + async readConfig(): Promise> { + const client = this.requireClient(); + const res = await client.request("config.get", {}); + if (!res.ok) { + throw new Error(`config.get failed: ${res.error?.message}`); + } + const payload = res.payload as { + config?: Record; + hash?: string; + }; + this.configHash = payload.hash ?? null; + return payload.config ?? {}; + } + + async writeConfig(config: Record): Promise { + const client = this.requireClient(); + + // If we don't have a hash, get one first for optimistic locking + if (!this.configHash) { + await this.readConfig(); + } + + const res = await client.request("config.set", { + raw: JSON.stringify(config, null, 2), + baseHash: this.configHash, + }); + + if (!res.ok) { + // If hash mismatch, re-read and retry once + if (res.error?.message?.includes("config changed")) { + await this.readConfig(); + const retry = await client.request("config.set", { + raw: JSON.stringify(config, null, 2), + baseHash: this.configHash, + }); + if (!retry.ok) { + throw new Error(`config.set retry failed: ${retry.error?.message}`); + } + this.configHash = null; + return; + } + throw new Error(`config.set failed: ${res.error?.message}`); + } + + this.configHash = null; + } + + async notifyConfigChanged(): Promise { + // API backend: OpenClaw picks up config changes immediately when + // written via config.set/config.apply. For config.set, OpenClaw's + // config watcher handles the restart. No extra notification needed. + } + + async ensureAgentWorkspace(agentId: string): Promise { + // The API backend doesn't need to create directories. OpenClaw's + // agents.files.set creates the workspace dir on first write. + } + + async writeAgentFile(agentId: string, filename: string, content: string): Promise { + const client = this.requireClient(); + const res = await client.request("agents.files.set", { + agentId, + name: filename, + content, + }); + if (!res.ok) { + throw new Error(`agents.files.set(${agentId}/${filename}) failed: ${res.error?.message}`); + } + } + + async readAgentFile(agentId: string, filename: string): Promise { + const client = this.requireClient(); + const res = await client.request("agents.files.get", { + agentId, + name: filename, + }); + if (!res.ok) { + return ""; + } + const payload = res.payload as { + file?: { missing?: boolean; content?: string }; + }; + if (payload.file?.missing) { + return ""; + } + return payload.file?.content ?? ""; + } + + async deleteAgentWorkspace(agentId: string): Promise { + const client = this.requireClient(); + // OpenClaw's agents.delete with deleteFiles=true removes the workspace. + // But we may not want to delete the agent from OpenClaw's config here + // (Pinchy manages the agent list via config). Instead, clear the files. + try { + const listRes = await client.request("agents.files.list", { agentId }); + if (listRes.ok) { + const payload = listRes.payload as { + files?: Array<{ name: string; missing?: boolean }>; + }; + for (const file of payload.files ?? []) { + if (!file.missing) { + await client.request("agents.files.set", { + agentId, + name: file.name, + content: "", + }); + } + } + } + } catch { + // Best-effort cleanup + } + } +} + +// --------------------------------------------------------------------------- +// Singleton +// --------------------------------------------------------------------------- + +const BACKEND_KEY = Symbol.for("pinchy.openclawBackend"); +const g = globalThis as unknown as Record; + +/** + * Get the OpenClaw backend singleton. Created on first call based on + * the OPENCLAW_BACKEND env var ("filesystem" or "api"). + */ +export function getBackend(): OpenClawBackend { + if (!g[BACKEND_KEY]) { + const mode = process.env.OPENCLAW_BACKEND || "filesystem"; + if (mode === "api") { + g[BACKEND_KEY] = new ApiBackend(); + } else { + const configPath = process.env.OPENCLAW_CONFIG_PATH || "/openclaw-config/openclaw.json"; + const workspaceBasePath = process.env.WORKSPACE_BASE_PATH || "/openclaw-config/workspaces"; + g[BACKEND_KEY] = new FilesystemBackend(configPath, workspaceBasePath); + } + } + return g[BACKEND_KEY]; +} + +/** + * Set the OpenClaw client on the API backend. Called from server.ts after + * the client connects. No-op if using the filesystem backend. + */ +export function setBackendClient(client: OpenClawClient): void { + const backend = getBackend(); + if (backend instanceof ApiBackend) { + backend.setClient(client); + } +} diff --git a/packages/web/src/lib/openclaw-config.ts b/packages/web/src/lib/openclaw-config.ts index 5b4e48464..046581e6b 100644 --- a/packages/web/src/lib/openclaw-config.ts +++ b/packages/web/src/lib/openclaw-config.ts @@ -1,6 +1,4 @@ -import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs"; import { randomBytes } from "crypto"; -import { dirname } from "path"; import { PROVIDERS, type ProviderName } from "@/lib/providers"; import { db } from "@/db"; import { agents } from "@/db/schema"; @@ -9,8 +7,7 @@ import { computeDeniedGroups } from "@/lib/tool-registry"; import { getOpenClawWorkspacePath } from "@/lib/workspace"; import { restartState } from "@/server/restart-state"; import { migrateExistingSmithers } from "@/lib/migrate-onboarding"; - -const CONFIG_PATH = process.env.OPENCLAW_CONFIG_PATH || "/openclaw-config/openclaw.json"; +import { getBackend } from "@/lib/openclaw-backend"; interface OpenClawConfigParams { provider: ProviderName; @@ -18,14 +15,6 @@ interface OpenClawConfigParams { model: string; } -function readExistingConfig(): Record { - try { - return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); - } catch { - return {}; - } -} - function deepMerge( target: Record, source: Record @@ -51,8 +40,9 @@ function deepMerge( return result; } -export function writeOpenClawConfig({ provider, apiKey, model }: OpenClawConfigParams) { - const existing = readExistingConfig(); +export async function writeOpenClawConfig({ provider, apiKey, model }: OpenClawConfigParams) { + const backend = getBackend(); + const existing = await backend.readConfig(); // Generate auth token if none exists in the existing config const existingGateway = (existing.gateway as Record) || {}; @@ -77,12 +67,8 @@ export function writeOpenClawConfig({ provider, apiKey, model }: OpenClawConfigP const merged = deepMerge(existing, pinchyFields); - const dir = dirname(CONFIG_PATH); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - - writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2), { encoding: "utf-8", mode: 0o644 }); + await backend.writeConfig(merged); + await backend.notifyConfigChanged(); restartState.notifyRestart(); } @@ -91,7 +77,8 @@ export async function regenerateOpenClawConfig() { // are reflected in the config we're about to generate. await migrateExistingSmithers(); - const existing = readExistingConfig(); + const backend = getBackend(); + const existing = await backend.readConfig(); // Preserve only the gateway block from existing config (contains auth token, // mode, bind, and any OpenClaw-generated fields). Everything else is rebuilt @@ -222,11 +209,7 @@ export async function regenerateOpenClawConfig() { config.plugins = { allow: allowedPlugins, entries }; } - const dir = dirname(CONFIG_PATH); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - - writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { encoding: "utf-8", mode: 0o644 }); + await backend.writeConfig(config); + await backend.notifyConfigChanged(); restartState.notifyRestart(); } diff --git a/packages/web/src/lib/personal-agent.ts b/packages/web/src/lib/personal-agent.ts index 9c983477a..a0ab4ffdf 100644 --- a/packages/web/src/lib/personal-agent.ts +++ b/packages/web/src/lib/personal-agent.ts @@ -44,9 +44,9 @@ export async function createSmithersAgent({ }) .returning(); - ensureWorkspace(agent.id); - writeWorkspaceFile(agent.id, "SOUL.md", SMITHERS_SOUL_MD); - writeIdentityFile(agent.id, { name: agent.name, tagline: agent.tagline }); + await ensureWorkspace(agent.id); + await writeWorkspaceFile(agent.id, "SOUL.md", SMITHERS_SOUL_MD); + await writeIdentityFile(agent.id, { name: agent.name, tagline: agent.tagline }); const context = await getContextForAgent({ isPersonal: agent.isPersonal, @@ -56,7 +56,7 @@ export async function createSmithersAgent({ // Write onboarding prompt to USER.md if user has no context yet. // OpenClaw reads USER.md as part of the agent's system prompt, so putting // onboarding instructions there ensures Smithers sees them. - writeWorkspaceFileInternal(agent.id, "USER.md", context || getOnboardingPrompt(isAdmin)); + await writeWorkspaceFileInternal(agent.id, "USER.md", context || getOnboardingPrompt(isAdmin)); return agent; } diff --git a/packages/web/src/lib/workspace.ts b/packages/web/src/lib/workspace.ts index cf72c9d52..3bcc2d3cd 100644 --- a/packages/web/src/lib/workspace.ts +++ b/packages/web/src/lib/workspace.ts @@ -1,16 +1,10 @@ -import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync } from "fs"; -import { join } from "path"; +import { getBackend } from "@/lib/openclaw-backend"; export const ALLOWED_FILES = ["SOUL.md", "AGENTS.md"] as const; export type WorkspaceFile = (typeof ALLOWED_FILES)[number]; -const DEFAULT_WORKSPACE_BASE_PATH = "/openclaw-config/workspaces"; const DEFAULT_OPENCLAW_WORKSPACE_PREFIX = "/root/.openclaw/workspaces"; -function getWorkspaceBasePath(): string { - return process.env.WORKSPACE_BASE_PATH || DEFAULT_WORKSPACE_BASE_PATH; -} - const PLACEHOLDER_CONTENT: Record = { "SOUL.md": ``, "AGENTS.md": ``, @@ -28,81 +22,53 @@ function assertValidAgentId(agentId: string): void { } } -export function getWorkspacePath(agentId: string): string { - assertValidAgentId(agentId); - return join(getWorkspaceBasePath(), agentId); -} - export function getOpenClawWorkspacePath(agentId: string): string { assertValidAgentId(agentId); const prefix = process.env.OPENCLAW_WORKSPACE_PREFIX || DEFAULT_OPENCLAW_WORKSPACE_PREFIX; return `${prefix}/${agentId}`; } -export function ensureWorkspace(agentId: string): void { +export async function ensureWorkspace(agentId: string): Promise { assertValidAgentId(agentId); - const workspacePath = getWorkspacePath(agentId); - - mkdirSync(workspacePath, { recursive: true }); + const backend = getBackend(); + await backend.ensureAgentWorkspace(agentId); for (const file of ALLOWED_FILES) { - const filePath = join(workspacePath, file); - if (!existsSync(filePath)) { - writeFileSync(filePath, PLACEHOLDER_CONTENT[file], "utf-8"); + const existing = await backend.readAgentFile(agentId, file); + if (!existing) { + await backend.writeAgentFile(agentId, file, PLACEHOLDER_CONTENT[file]); } } } -export function deleteWorkspace(agentId: string): void { +export async function deleteWorkspace(agentId: string): Promise { assertValidAgentId(agentId); - const workspacePath = getWorkspacePath(agentId); - try { - rmSync(workspacePath, { recursive: true, force: true }); - } catch { - // Workspace may not exist, that's fine - } + await getBackend().deleteAgentWorkspace(agentId); } -export function readWorkspaceFile(agentId: string, filename: string): string { +export async function readWorkspaceFile(agentId: string, filename: string): Promise { assertValidAgentId(agentId); assertAllowedFile(filename); - - const filePath = join(getWorkspacePath(agentId), filename); - - try { - return readFileSync(filePath, "utf-8"); - } catch { - return ""; - } + return getBackend().readAgentFile(agentId, filename); } -export function writeWorkspaceFile(agentId: string, filename: string, content: string): void { +export async function writeWorkspaceFile( + agentId: string, + filename: string, + content: string +): Promise { assertValidAgentId(agentId); assertAllowedFile(filename); - - const workspacePath = getWorkspacePath(agentId); - - if (!existsSync(workspacePath)) { - mkdirSync(workspacePath, { recursive: true }); - } - - writeFileSync(join(workspacePath, filename), content, "utf-8"); + await getBackend().writeAgentFile(agentId, filename, content); } -export function writeWorkspaceFileInternal( +export async function writeWorkspaceFileInternal( agentId: string, filename: string, content: string -): void { +): Promise { assertValidAgentId(agentId); - - const workspacePath = getWorkspacePath(agentId); - - if (!existsSync(workspacePath)) { - mkdirSync(workspacePath, { recursive: true }); - } - - writeFileSync(join(workspacePath, filename), content, "utf-8"); + await getBackend().writeAgentFile(agentId, filename, content); } export function generateIdentityContent(agent: { name: string; tagline: string | null }): string { @@ -111,14 +77,10 @@ export function generateIdentityContent(agent: { name: string; tagline: string | return lines.join("\n"); } -export function writeIdentityFile( +export async function writeIdentityFile( agentId: string, agent: { name: string; tagline: string | null } -): void { +): Promise { assertValidAgentId(agentId); - const workspacePath = getWorkspacePath(agentId); - if (!existsSync(workspacePath)) { - mkdirSync(workspacePath, { recursive: true }); - } - writeFileSync(join(workspacePath, "IDENTITY.md"), generateIdentityContent(agent), "utf-8"); + await getBackend().writeAgentFile(agentId, "IDENTITY.md", generateIdentityContent(agent)); } From ea9999a509383adfd990200298ccf4e7ccbe2c8d Mon Sep 17 00:00:00 2001 From: Nick Marden Date: Wed, 11 Mar 2026 22:54:05 -0400 Subject: [PATCH 2/5] fix: ensure OpenClaw knows about agents before workspace writes In API backend mode, workspace file writes (agents.files.set) require OpenClaw to already have the agent in its config. This was not guaranteed because regenerateOpenClawConfig could run after agent creation but before the config was pushed. Changes: - Add onAgentCreated callback to createSmithersAgent so callers can push config between DB insert and workspace writes - Split migrateExistingSmithers into DB-only and file-only phases so the migration can update allowedTools before config push, then write workspace files after - Move regenerateOpenClawConfig before workspace writes in POST /api/agents - Persist gateway token in DB settings so regenerateOpenClawConfig can include it in plugin configs without relying on config.get (which redacts secrets in API mode) --- packages/web/server.ts | 13 ++++++- packages/web/src/__tests__/api/setup.test.ts | 4 +++ packages/web/src/__tests__/db/seed.test.ts | 2 ++ .../src/__tests__/lib/openclaw-config.test.ts | 7 ++++ .../src/__tests__/lib/personal-agent.test.ts | 5 +++ packages/web/src/app/api/agents/route.ts | 6 ++-- packages/web/src/db/seed.ts | 2 ++ packages/web/src/lib/migrate-onboarding.ts | 24 +++++++++---- packages/web/src/lib/openclaw-config.ts | 34 ++++++++++++------- packages/web/src/lib/personal-agent.ts | 13 ++++++- 10 files changed, 88 insertions(+), 22 deletions(-) diff --git a/packages/web/server.ts b/packages/web/server.ts index 479bb7e2c..90bb95b86 100644 --- a/packages/web/server.ts +++ b/packages/web/server.ts @@ -175,11 +175,22 @@ app.prepare().then(async () => { // Swallow rejection — the error event handler logs once }); - openclawClient.on("connected", () => { + openclawClient.on("connected", async () => { console.log("Connected to OpenClaw Gateway"); hasConnected = true; errorLogged = false; setBackendClient(openclawClient!); + + // Persist the gateway token so regenerateOpenClawConfig can include + // it in plugin configs without reading it from the (redacted) config. + if (gatewayToken) { + try { + const { setSetting } = await import("./src/lib/settings"); + await setSetting("gateway_token", gatewayToken, true); + } catch { + // Best effort; DB may not be ready on first connect + } + } if (restartState.isRestarting) { restartState.notifyReady(); } diff --git a/packages/web/src/__tests__/api/setup.test.ts b/packages/web/src/__tests__/api/setup.test.ts index db707543f..3a65b12fc 100644 --- a/packages/web/src/__tests__/api/setup.test.ts +++ b/packages/web/src/__tests__/api/setup.test.ts @@ -66,6 +66,10 @@ vi.mock("@/lib/workspace", () => ({ writeIdentityFile: vi.fn(), })); +vi.mock("@/lib/openclaw-config", () => ({ + regenerateOpenClawConfig: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/context-sync", () => ({ getContextForAgent: vi.fn().mockResolvedValue(""), })); diff --git a/packages/web/src/__tests__/db/seed.test.ts b/packages/web/src/__tests__/db/seed.test.ts index 919c0a78b..7b43f0b9d 100644 --- a/packages/web/src/__tests__/db/seed.test.ts +++ b/packages/web/src/__tests__/db/seed.test.ts @@ -56,6 +56,7 @@ describe("seedDefaultAgent", () => { ownerId: null, isPersonal: false, isAdmin: false, + onAgentCreated: expect.any(Function), }); }); @@ -80,6 +81,7 @@ describe("seedDefaultAgent", () => { ownerId: "user-1", isPersonal: true, isAdmin: true, + onAgentCreated: expect.any(Function), }); }); diff --git a/packages/web/src/__tests__/lib/openclaw-config.test.ts b/packages/web/src/__tests__/lib/openclaw-config.test.ts index 4433bdf83..86eda0e83 100644 --- a/packages/web/src/__tests__/lib/openclaw-config.test.ts +++ b/packages/web/src/__tests__/lib/openclaw-config.test.ts @@ -24,6 +24,7 @@ vi.mock("@/db", () => ({ vi.mock("@/lib/settings", () => ({ getSetting: vi.fn().mockResolvedValue(null), + setSetting: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/server/restart-state", () => ({ @@ -441,6 +442,9 @@ describe("regenerateOpenClawConfig", () => { mockBackend.readConfig.mockResolvedValue({ gateway: { mode: "local", bind: "lan", auth: { token: "gw-token-123" } }, }); + mockedGetSetting.mockImplementation(async (key: string) => + key === "gateway_token" ? "gw-token-123" : null + ); mockedDb.select.mockReturnValue({ from: vi.fn().mockResolvedValue([ @@ -475,6 +479,9 @@ describe("regenerateOpenClawConfig", () => { mockBackend.readConfig.mockResolvedValue({ gateway: { mode: "local", bind: "lan", auth: { token: "gw-token-123" } }, }); + mockedGetSetting.mockImplementation(async (key: string) => + key === "gateway_token" ? "gw-token-123" : null + ); await regenerateOpenClawConfig(); diff --git a/packages/web/src/__tests__/lib/personal-agent.test.ts b/packages/web/src/__tests__/lib/personal-agent.test.ts index 5c0e8ed6c..eb8bab1b8 100644 --- a/packages/web/src/__tests__/lib/personal-agent.test.ts +++ b/packages/web/src/__tests__/lib/personal-agent.test.ts @@ -25,6 +25,11 @@ vi.mock("@/lib/workspace", () => ({ writeIdentityFile: vi.fn(), })); +// ── Mock @/lib/openclaw-config ─────────────────────────────────────────────── +vi.mock("@/lib/openclaw-config", () => ({ + regenerateOpenClawConfig: vi.fn().mockResolvedValue(undefined), +})); + // ── Mock @/lib/context-sync ───────────────────────────────────────────────── const getContextForAgentMock = vi.fn().mockResolvedValue(""); vi.mock("@/lib/context-sync", () => ({ diff --git a/packages/web/src/app/api/agents/route.ts b/packages/web/src/app/api/agents/route.ts index f3e949c11..88d263a5b 100644 --- a/packages/web/src/app/api/agents/route.ts +++ b/packages/web/src/app/api/agents/route.ts @@ -115,6 +115,10 @@ export async function POST(request: NextRequest) { detail: { name: agent.name, model: agent.model, templateId }, }).catch(() => {}); + // Push config to OpenClaw so it knows about the new agent before we + // write workspace files (required when using the API backend). + await regenerateOpenClawConfig(); + // Create workspace with personality preset's SOUL.md await ensureWorkspace(agent.id); await writeWorkspaceFile(agent.id, "SOUL.md", preset?.soulMd ?? ""); @@ -132,8 +136,6 @@ export async function POST(request: NextRequest) { }); await writeWorkspaceFileInternal(agent.id, "USER.md", context); - await regenerateOpenClawConfig(); - revalidatePath("/", "layout"); return NextResponse.json(agent, { status: 201 }); diff --git a/packages/web/src/db/seed.ts b/packages/web/src/db/seed.ts index 1a9ed624f..6ce427020 100644 --- a/packages/web/src/db/seed.ts +++ b/packages/web/src/db/seed.ts @@ -1,5 +1,6 @@ import { db } from "@/db"; import { createSmithersAgent } from "@/lib/personal-agent"; +import { regenerateOpenClawConfig } from "@/lib/openclaw-config"; export async function seedDefaultAgent(ownerId?: string) { const existing = await db.query.agents.findFirst(); @@ -10,5 +11,6 @@ export async function seedDefaultAgent(ownerId?: string) { ownerId: ownerId ?? null, isPersonal: ownerId ? true : false, isAdmin: ownerId ? true : false, + onAgentCreated: regenerateOpenClawConfig, }); } diff --git a/packages/web/src/lib/migrate-onboarding.ts b/packages/web/src/lib/migrate-onboarding.ts index feb86e05e..b96c7a50c 100644 --- a/packages/web/src/lib/migrate-onboarding.ts +++ b/packages/web/src/lib/migrate-onboarding.ts @@ -4,7 +4,14 @@ import { eq } from "drizzle-orm"; import { writeWorkspaceFileInternal } from "@/lib/workspace"; import { getOnboardingPrompt } from "@/lib/onboarding-prompt"; -export async function migrateExistingSmithers(): Promise { +interface MigrateOptions { + /** Skip workspace file writes (used when OpenClaw config hasn't been pushed yet). */ + skipFileWrites?: boolean; + /** Skip DB updates (used on second pass after config push). */ + skipDbUpdates?: boolean; +} + +export async function migrateExistingSmithers(options: MigrateOptions = {}): Promise { const personalAgents = await db.query.agents.findMany({ where: eq(agents.isPersonal, true), }); @@ -19,12 +26,17 @@ export async function migrateExistingSmithers(): Promise { if (!user || user.context !== null) continue; const isAdmin = user.role === "admin"; - const allowedTools = isAdmin - ? ["pinchy_save_user_context", "pinchy_save_org_context"] - : ["pinchy_save_user_context"]; - await db.update(agents).set({ allowedTools }).where(eq(agents.id, agent.id)); + if (!options.skipDbUpdates) { + const allowedTools = isAdmin + ? ["pinchy_save_user_context", "pinchy_save_org_context"] + : ["pinchy_save_user_context"]; + + await db.update(agents).set({ allowedTools }).where(eq(agents.id, agent.id)); + } - await writeWorkspaceFileInternal(agent.id, "USER.md", getOnboardingPrompt(isAdmin)); + if (!options.skipFileWrites) { + await writeWorkspaceFileInternal(agent.id, "USER.md", getOnboardingPrompt(isAdmin)); + } } } diff --git a/packages/web/src/lib/openclaw-config.ts b/packages/web/src/lib/openclaw-config.ts index 046581e6b..8472a9989 100644 --- a/packages/web/src/lib/openclaw-config.ts +++ b/packages/web/src/lib/openclaw-config.ts @@ -2,7 +2,7 @@ import { randomBytes } from "crypto"; import { PROVIDERS, type ProviderName } from "@/lib/providers"; import { db } from "@/db"; import { agents } from "@/db/schema"; -import { getSetting } from "@/lib/settings"; +import { getSetting, setSetting } from "@/lib/settings"; import { computeDeniedGroups } from "@/lib/tool-registry"; import { getOpenClawWorkspacePath } from "@/lib/workspace"; import { restartState } from "@/server/restart-state"; @@ -44,10 +44,13 @@ export async function writeOpenClawConfig({ provider, apiKey, model }: OpenClawC const backend = getBackend(); const existing = await backend.readConfig(); - // Generate auth token if none exists in the existing config + // Generate auth token if none exists. Prefer DB-stored token (reliable + // across backends), fall back to reading from config (filesystem mode). const existingGateway = (existing.gateway as Record) || {}; const existingAuth = (existingGateway.auth as Record) || {}; - const token = (existingAuth.token as string) || randomBytes(24).toString("hex"); + const storedToken = await getSetting("gateway_token"); + const token = storedToken || (existingAuth.token as string) || randomBytes(24).toString("hex"); + await setSetting("gateway_token", token, true); const pinchyFields = { gateway: { @@ -74,15 +77,17 @@ export async function writeOpenClawConfig({ provider, apiKey, model }: OpenClawC export async function regenerateOpenClawConfig() { // Migrate existing Smithers agents first, so their updated allowedTools - // are reflected in the config we're about to generate. - await migrateExistingSmithers(); + // are reflected in the config we're about to generate. The migration + // only updates DB rows (allowedTools); workspace file writes happen + // after the config is pushed so OpenClaw knows about all agents. + await migrateExistingSmithers({ skipFileWrites: true }); const backend = getBackend(); const existing = await backend.readConfig(); - // Preserve only the gateway block from existing config (contains auth token, - // mode, bind, and any OpenClaw-generated fields). Everything else is rebuilt - // from DB state so deleted providers/agents get cleaned up. + // Preserve the gateway block from existing config. In API mode, the + // auth token comes back as a redacted sentinel; OpenClaw will un-redact + // it when we send the config back via config.set. const gateway = (existing.gateway as Record) || { mode: "local", bind: "lan" }; // Ensure mode and bind are always set gateway.mode = "local"; @@ -170,10 +175,10 @@ export async function regenerateOpenClawConfig() { }; } - const gatewayAuth = (gateway as Record).auth as - | Record - | undefined; - const gatewayToken = (gatewayAuth?.token as string) || ""; + // Read the gateway token from DB settings (where writeOpenClawConfig + // persists it). This avoids relying on config.get which redacts secrets + // in API mode. + const gatewayToken = (await getSetting("gateway_token")) || ""; // Only include pinchy-context when agents use it. Including disabled plugins // with config causes OpenClaw to spam "disabled in config but config is present". @@ -211,5 +216,10 @@ export async function regenerateOpenClawConfig() { await backend.writeConfig(config); await backend.notifyConfigChanged(); + + // Now that the config is pushed (and OpenClaw knows about all agents), + // write any workspace files that the migration deferred. + await migrateExistingSmithers({ skipDbUpdates: true }); + restartState.notifyRestart(); } diff --git a/packages/web/src/lib/personal-agent.ts b/packages/web/src/lib/personal-agent.ts index a0ab4ffdf..1d81608ad 100644 --- a/packages/web/src/lib/personal-agent.ts +++ b/packages/web/src/lib/personal-agent.ts @@ -7,6 +7,7 @@ import { writeIdentityFile, } from "@/lib/workspace"; import { getContextForAgent } from "@/lib/context-sync"; +import { regenerateOpenClawConfig } from "@/lib/openclaw-config"; import { getSetting } from "@/lib/settings"; import { PROVIDERS, type ProviderName } from "@/lib/providers"; import { SMITHERS_SOUL_MD } from "@/lib/smithers-soul"; @@ -17,6 +18,7 @@ interface CreateSmithersOptions { ownerId: string | null; isPersonal: boolean; isAdmin?: boolean; + onAgentCreated?: () => Promise; } export async function createSmithersAgent({ @@ -24,6 +26,7 @@ export async function createSmithersAgent({ ownerId, isPersonal, isAdmin = false, + onAgentCreated, }: CreateSmithersOptions) { const allowedTools = isAdmin ? ["pinchy_save_user_context", "pinchy_save_org_context"] @@ -44,6 +47,8 @@ export async function createSmithersAgent({ }) .returning(); + if (onAgentCreated) await onAgentCreated(); + await ensureWorkspace(agent.id); await writeWorkspaceFile(agent.id, "SOUL.md", SMITHERS_SOUL_MD); await writeIdentityFile(agent.id, { name: agent.name, tagline: agent.tagline }); @@ -72,5 +77,11 @@ export async function seedPersonalAgent(userId: string, isAdmin = false) { ? PROVIDERS[defaultProvider].defaultModel : "anthropic/claude-sonnet-4-20250514"; - return createSmithersAgent({ model, ownerId: userId, isPersonal: true, isAdmin }); + return createSmithersAgent({ + model, + ownerId: userId, + isPersonal: true, + isAdmin, + onAgentCreated: regenerateOpenClawConfig, + }); } From 429e68e3a45b0844e176387a762a03584893c820 Mon Sep 17 00:00:00 2001 From: Nick Marden Date: Wed, 11 Mar 2026 22:54:24 -0400 Subject: [PATCH 3/5] fix: handle config.set restart semantics in API backend OpenClaw's config.set RPC writes the config file to disk, but the file watcher immediately triggers a full process restart (SIGTERM). This kills the WebSocket connection before the RPC response is sent, leaving the caller hanging on a promise that never resolves. Replace the await-response pattern with fire-and-forget: send the config.set request, then wait for the client to disconnect and reconnect. Reconnection confirms OpenClaw has restarted with the new config applied. Also adds docker-compose.api.yml override and docs for running in API backend mode. --- docker-compose.api.yml | 17 +++++ docs/src/content/docs/installation.mdx | 12 ++++ packages/web/src/lib/openclaw-backend.ts | 79 +++++++++++++++++------- 3 files changed, 85 insertions(+), 23 deletions(-) create mode 100644 docker-compose.api.yml diff --git a/docker-compose.api.yml b/docker-compose.api.yml new file mode 100644 index 000000000..a1f5e42ad --- /dev/null +++ b/docker-compose.api.yml @@ -0,0 +1,17 @@ +# Override: use OpenClaw Gateway API instead of shared filesystem. +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.api.yml up --build +# +# In this mode Pinchy manages workspaces and config via OpenClaw's RPC API +# (agents.files.*, config.*) instead of writing directly to the shared +# filesystem volumes. This is the deployment model for Kubernetes and +# multi-instance setups where Pinchy and OpenClaw run in separate pods. +# +# The shared volumes are still mounted (Compose overrides can't remove them) +# but Pinchy won't read or write them when OPENCLAW_BACKEND=api. + +services: + pinchy: + environment: + - OPENCLAW_BACKEND=api diff --git a/docs/src/content/docs/installation.mdx b/docs/src/content/docs/installation.mdx index c933828ab..6b31f047d 100644 --- a/docs/src/content/docs/installation.mdx +++ b/docs/src/content/docs/installation.mdx @@ -91,6 +91,18 @@ services: - PORT=3000 ``` +## API backend mode + +By default, Pinchy and OpenClaw share filesystem volumes for configuration and workspace files. For deployments where they run in separate containers or pods (e.g. Kubernetes), you can switch Pinchy to manage these via OpenClaw's Gateway RPC API instead. + +```bash +docker compose -f docker-compose.yml -f docker-compose.api.yml up --build +``` + +This sets `OPENCLAW_BACKEND=api` so Pinchy communicates with OpenClaw exclusively over its WebSocket connection using the `agents.files.*` and `config.*` RPC methods, rather than writing to the shared filesystem. + +The filesystem backend remains the default and is recommended for single-machine Docker Compose deployments. + ## Development mode For development with hot reload (code changes reflect immediately in the browser), use the dev-mode Docker override: diff --git a/packages/web/src/lib/openclaw-backend.ts b/packages/web/src/lib/openclaw-backend.ts index 19d239518..c5132735c 100644 --- a/packages/web/src/lib/openclaw-backend.ts +++ b/packages/web/src/lib/openclaw-backend.ts @@ -155,35 +155,68 @@ export class ApiBackend implements OpenClawBackend { await this.readConfig(); } - const res = await client.request("config.set", { - raw: JSON.stringify(config, null, 2), - baseHash: this.configHash, - }); + // config.set is fire-and-forget: OpenClaw writes the config file, + // its file watcher detects the change, and it restarts. The restart + // kills the WebSocket before the response arrives. We send the + // request, then wait for the client to reconnect (which means + // OpenClaw is back up with the new config applied). + client + .request("config.set", { + raw: JSON.stringify(config, null, 2), + baseHash: this.configHash, + }) + .catch(() => { + // Expected: request times out because the restart killed the connection. + }); - if (!res.ok) { - // If hash mismatch, re-read and retry once - if (res.error?.message?.includes("config changed")) { - await this.readConfig(); - const retry = await client.request("config.set", { - raw: JSON.stringify(config, null, 2), - baseHash: this.configHash, - }); - if (!retry.ok) { - throw new Error(`config.set retry failed: ${retry.error?.message}`); + this.configHash = null; + await this.waitForReconnect(client); + } + + /** + * Wait for the client to disconnect and reconnect. Used after config.set + * to ensure OpenClaw has restarted with the new config before proceeding. + */ + private waitForReconnect(client: OpenClawClient, timeoutMs = 30_000): Promise { + return new Promise((resolve, reject) => { + // If already disconnected, wait for connected event directly. + // If still connected, wait for disconnect first, then connected. + const timer = setTimeout(() => { + client.off("connected", onConnected); + client.off("disconnected", onDisconnected); + // If we're still connected, config.set may not have triggered + // a restart (e.g. no actual config change). That's OK. + if (client.isConnected) { + resolve(); + } else { + reject(new Error("Timed out waiting for OpenClaw to restart after config.set")); } - this.configHash = null; - return; + }, timeoutMs); + + const onConnected = () => { + clearTimeout(timer); + client.off("connected", onConnected); + client.off("disconnected", onDisconnected); + resolve(); + }; + + const onDisconnected = () => { + // Now wait for reconnect + client.off("disconnected", onDisconnected); + client.on("connected", onConnected); + }; + + if (client.isConnected) { + client.on("disconnected", onDisconnected); + } else { + client.on("connected", onConnected); } - throw new Error(`config.set failed: ${res.error?.message}`); - } - - this.configHash = null; + }); } async notifyConfigChanged(): Promise { - // API backend: OpenClaw picks up config changes immediately when - // written via config.set/config.apply. For config.set, OpenClaw's - // config watcher handles the restart. No extra notification needed. + // API backend: writeConfig already waits for the restart cycle. + // Nothing additional needed. } async ensureAgentWorkspace(agentId: string): Promise { From 1491761732ed3ec54c83700f3bcf2a12af8cdaf5 Mon Sep 17 00:00:00 2001 From: Nick Marden Date: Wed, 11 Mar 2026 23:30:21 -0400 Subject: [PATCH 4/5] fix: restart OpenClaw when shared agent config or files change Editing a shared agent's settings, personality, or instructions now triggers regenerateOpenClawConfig so OpenClaw restarts and all users' sessions pick up the changes. Previously only agent creation and deletion pushed config; updates to existing agents were silent. Personal agent edits skip the restart to avoid disrupting other users. The owner starts a new conversation to pick up their changes. --- .../web/src/__tests__/api/agent-files.test.ts | 4 +++ .../src/__tests__/api/agents-audit.test.ts | 35 ++++++++++++++++++- .../src/__tests__/api/agents-delete.test.ts | 4 +-- .../[agentId]/files/[filename]/route.ts | 8 +++++ .../web/src/app/api/agents/[agentId]/route.ts | 9 +++++ packages/web/src/lib/openclaw-config.ts | 9 +++-- 6 files changed, 63 insertions(+), 6 deletions(-) diff --git a/packages/web/src/__tests__/api/agent-files.test.ts b/packages/web/src/__tests__/api/agent-files.test.ts index 4e582a7ac..4cafb3123 100644 --- a/packages/web/src/__tests__/api/agent-files.test.ts +++ b/packages/web/src/__tests__/api/agent-files.test.ts @@ -22,6 +22,10 @@ vi.mock("@/lib/workspace", () => ({ writeWorkspaceFile: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/openclaw-config", () => ({ + regenerateOpenClawConfig: vi.fn().mockResolvedValue(undefined), +})); + const { mockAssertAgentWriteAccess } = vi.hoisted(() => ({ mockAssertAgentWriteAccess: vi.fn(), })); diff --git a/packages/web/src/__tests__/api/agents-audit.test.ts b/packages/web/src/__tests__/api/agents-audit.test.ts index 1f0597ade..677587594 100644 --- a/packages/web/src/__tests__/api/agents-audit.test.ts +++ b/packages/web/src/__tests__/api/agents-audit.test.ts @@ -545,7 +545,7 @@ describe("PATCH /api/agents/[agentId] config regeneration", () => { PATCH = mod.PATCH; }); - it("should not call regenerateOpenClawConfig directly when allowedTools change (updateAgent handles it)", async () => { + it("should call regenerateOpenClawConfig when updating a shared agent", async () => { vi.mocked(auth.api.getSession).mockResolvedValueOnce({ user: { id: "user-1", role: "admin" }, expires: "", @@ -575,6 +575,39 @@ describe("PATCH /api/agents/[agentId] config regeneration", () => { }); expect(response.status).toBe(200); + expect(regenerateOpenClawConfig).toHaveBeenCalled(); + }); + + it("should not call regenerateOpenClawConfig when updating a personal agent", async () => { + vi.mocked(auth.api.getSession).mockResolvedValueOnce({ + user: { id: "user-1", role: "admin" }, + expires: "", + } as any); + + mockAgent({ + id: "agent-1", + name: "Smithers", + isPersonal: true, + ownerId: "user-1", + }); + + vi.mocked(updateAgent).mockResolvedValueOnce({ + id: "agent-1", + name: "Smithers", + model: "anthropic/claude-haiku-4-5-20251001", + } as never); + + const request = new NextRequest("http://localhost:7777/api/agents/agent-1", { + method: "PATCH", + body: JSON.stringify({ model: "anthropic/claude-haiku-4-5-20251001" }), + headers: { "Content-Type": "application/json" }, + }); + + const response = await PATCH(request, { + params: Promise.resolve({ agentId: "agent-1" }), + }); + expect(response.status).toBe(200); + expect(regenerateOpenClawConfig).not.toHaveBeenCalled(); }); }); diff --git a/packages/web/src/__tests__/api/agents-delete.test.ts b/packages/web/src/__tests__/api/agents-delete.test.ts index 244965062..12f7c22b4 100644 --- a/packages/web/src/__tests__/api/agents-delete.test.ts +++ b/packages/web/src/__tests__/api/agents-delete.test.ts @@ -343,7 +343,7 @@ describe("PATCH /api/agents/[agentId]", () => { expect(body.error).toBe("Cannot change permissions for personal agents"); }); - it("does not call regenerateOpenClawConfig directly (updateAgent handles it)", async () => { + it("calls regenerateOpenClawConfig after updating agent", async () => { vi.mocked(auth.api.getSession).mockResolvedValueOnce({ user: { id: "admin-1", role: "admin" }, expires: "", @@ -372,7 +372,7 @@ describe("PATCH /api/agents/[agentId]", () => { }); expect(response.status).toBe(200); - expect(regenerateOpenClawConfig).not.toHaveBeenCalled(); + expect(regenerateOpenClawConfig).toHaveBeenCalled(); }); it("admin can update pluginConfig for shared agent", async () => { diff --git a/packages/web/src/app/api/agents/[agentId]/files/[filename]/route.ts b/packages/web/src/app/api/agents/[agentId]/files/[filename]/route.ts index 500d41ed1..63029e7c0 100644 --- a/packages/web/src/app/api/agents/[agentId]/files/[filename]/route.ts +++ b/packages/web/src/app/api/agents/[agentId]/files/[filename]/route.ts @@ -3,6 +3,7 @@ import { headers } from "next/headers"; import { getSession } from "@/lib/auth"; import { readWorkspaceFile, writeWorkspaceFile } from "@/lib/workspace"; import { getAgentWithAccess, assertAgentWriteAccess } from "@/lib/agent-access"; +import { regenerateOpenClawConfig } from "@/lib/openclaw-config"; type Params = { params: Promise<{ agentId: string; filename: string }> }; @@ -52,6 +53,13 @@ export async function PUT(request: NextRequest, { params }: Params) { try { await writeWorkspaceFile(agentId, filename, content); + + // For shared agents, restart OpenClaw so all users' sessions pick up + // the new file content. Personal agent edits skip the restart. + if (!agentOrError.isPersonal) { + await regenerateOpenClawConfig(); + } + return NextResponse.json({ success: true }); } catch (error: unknown) { const message = error instanceof Error ? error.message : "Invalid file"; diff --git a/packages/web/src/app/api/agents/[agentId]/route.ts b/packages/web/src/app/api/agents/[agentId]/route.ts index a029eab2d..d0cf60bea 100644 --- a/packages/web/src/app/api/agents/[agentId]/route.ts +++ b/packages/web/src/app/api/agents/[agentId]/route.ts @@ -11,6 +11,7 @@ import { writeIdentityFile } from "@/lib/workspace"; import { db } from "@/db"; import { agentGroups } from "@/db/schema"; import { getAgentGroupIds } from "@/lib/groups"; +import { regenerateOpenClawConfig } from "@/lib/openclaw-config"; export async function GET( request: NextRequest, @@ -149,6 +150,13 @@ export async function PATCH( }); } + // For shared agents, push config and restart OpenClaw so changes take + // effect and all users' sessions are invalidated. Personal agent edits + // skip the restart; the owner just needs to start a new conversation. + if (!existingAgent.isPersonal) { + await regenerateOpenClawConfig(); + } + appendAuditLog({ actorType: "user", actorId: session.user.id!, @@ -183,6 +191,7 @@ export async function DELETE( } await deleteAgent(agentId); + await regenerateOpenClawConfig(); appendAuditLog({ actorType: "user", diff --git a/packages/web/src/lib/openclaw-config.ts b/packages/web/src/lib/openclaw-config.ts index 8472a9989..015d30a47 100644 --- a/packages/web/src/lib/openclaw-config.ts +++ b/packages/web/src/lib/openclaw-config.ts @@ -70,9 +70,9 @@ export async function writeOpenClawConfig({ provider, apiKey, model }: OpenClawC const merged = deepMerge(existing, pinchyFields); + restartState.notifyRestart(); await backend.writeConfig(merged); await backend.notifyConfigChanged(); - restartState.notifyRestart(); } export async function regenerateOpenClawConfig() { @@ -214,12 +214,15 @@ export async function regenerateOpenClawConfig() { config.plugins = { allow: allowedPlugins, entries }; } + // Notify before writeConfig so the restart state is set when the + // reconnect handler fires (in API mode, writeConfig blocks until + // reconnection). + restartState.notifyRestart(); + await backend.writeConfig(config); await backend.notifyConfigChanged(); // Now that the config is pushed (and OpenClaw knows about all agents), // write any workspace files that the migration deferred. await migrateExistingSmithers({ skipDbUpdates: true }); - - restartState.notifyRestart(); } From e9a9881a52083ab36595218c1a06ef04488f9fda Mon Sep 17 00:00:00 2001 From: Nick Marden Date: Wed, 11 Mar 2026 23:49:30 -0400 Subject: [PATCH 5/5] fix: make validateGatewayToken async to support DB-stored tokens In API mode the gateway token is stored in the DB, not on the filesystem. Reading it requires an async getSetting call, so validateGatewayToken must be async. Updated all three internal route callers and their test mocks accordingly. --- .../api/internal-audit-tool-use.test.ts | 6 +-- .../api/internal-settings-context.test.ts | 6 +-- .../api/internal-user-context.test.ts | 6 +-- .../src/__tests__/lib/gateway-auth.test.ts | 42 +++++++++++++++---- .../app/api/internal/audit/tool-use/route.ts | 2 +- .../api/internal/settings/context/route.ts | 2 +- .../internal/users/[userId]/context/route.ts | 2 +- packages/web/src/lib/gateway-auth.ts | 10 +++-- 8 files changed, 54 insertions(+), 22 deletions(-) diff --git a/packages/web/src/__tests__/api/internal-audit-tool-use.test.ts b/packages/web/src/__tests__/api/internal-audit-tool-use.test.ts index e3d0c3c66..015763634 100644 --- a/packages/web/src/__tests__/api/internal-audit-tool-use.test.ts +++ b/packages/web/src/__tests__/api/internal-audit-tool-use.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; vi.mock("@/lib/gateway-auth", () => ({ - validateGatewayToken: vi.fn().mockReturnValue(true), + validateGatewayToken: vi.fn().mockResolvedValue(true), })); vi.mock("@/lib/audit", () => ({ @@ -27,11 +27,11 @@ function makeRequest(body: Record) { describe("POST /api/internal/audit/tool-use", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(validateGatewayToken).mockReturnValue(true); + vi.mocked(validateGatewayToken).mockResolvedValue(true); }); it("returns 401 when gateway token is invalid", async () => { - vi.mocked(validateGatewayToken).mockReturnValue(false); + vi.mocked(validateGatewayToken).mockResolvedValue(false); const res = await POST( makeRequest({ diff --git a/packages/web/src/__tests__/api/internal-settings-context.test.ts b/packages/web/src/__tests__/api/internal-settings-context.test.ts index 6fa00032b..dbc04a4de 100644 --- a/packages/web/src/__tests__/api/internal-settings-context.test.ts +++ b/packages/web/src/__tests__/api/internal-settings-context.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; vi.mock("@/lib/gateway-auth", () => ({ - validateGatewayToken: vi.fn().mockReturnValue(true), + validateGatewayToken: vi.fn().mockResolvedValue(true), })); vi.mock("@/lib/settings", () => ({ @@ -32,11 +32,11 @@ function makePutRequest(body: Record) { describe("PUT /api/internal/settings/context", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(validateGatewayToken).mockReturnValue(true); + vi.mocked(validateGatewayToken).mockResolvedValue(true); }); it("returns 401 when gateway token is invalid", async () => { - vi.mocked(validateGatewayToken).mockReturnValue(false); + vi.mocked(validateGatewayToken).mockResolvedValue(false); const res = await PUT(makePutRequest({ content: "test" })); expect(res.status).toBe(401); diff --git a/packages/web/src/__tests__/api/internal-user-context.test.ts b/packages/web/src/__tests__/api/internal-user-context.test.ts index 564585656..5c8fbd553 100644 --- a/packages/web/src/__tests__/api/internal-user-context.test.ts +++ b/packages/web/src/__tests__/api/internal-user-context.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; vi.mock("@/lib/gateway-auth", () => ({ - validateGatewayToken: vi.fn().mockReturnValue(true), + validateGatewayToken: vi.fn().mockResolvedValue(true), })); vi.mock("@/db", () => ({ @@ -51,7 +51,7 @@ function makeParams(userId: string) { describe("PUT /api/internal/users/:userId/context", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(validateGatewayToken).mockReturnValue(true); + vi.mocked(validateGatewayToken).mockResolvedValue(true); vi.mocked(db.query.users.findFirst).mockResolvedValue({ id: "user-1", role: "member", @@ -60,7 +60,7 @@ describe("PUT /api/internal/users/:userId/context", () => { }); it("returns 401 when gateway token is invalid", async () => { - vi.mocked(validateGatewayToken).mockReturnValue(false); + vi.mocked(validateGatewayToken).mockResolvedValue(false); const res = await PUT(makePutRequest("user-1", { content: "test" }), makeParams("user-1")); expect(res.status).toBe(401); diff --git a/packages/web/src/__tests__/lib/gateway-auth.test.ts b/packages/web/src/__tests__/lib/gateway-auth.test.ts index 104d310ea..0fe044552 100644 --- a/packages/web/src/__tests__/lib/gateway-auth.test.ts +++ b/packages/web/src/__tests__/lib/gateway-auth.test.ts @@ -6,6 +6,10 @@ import { tmpdir } from "os"; const TEST_CONFIG_DIR = join(tmpdir(), "pinchy-gateway-auth-test"); const TEST_CONFIG_PATH = join(TEST_CONFIG_DIR, "openclaw.json"); +vi.mock("@/lib/settings", () => ({ + getSetting: vi.fn().mockResolvedValue(null), +})); + describe("validateGatewayToken", () => { beforeEach(() => { vi.resetModules(); @@ -24,7 +28,7 @@ describe("validateGatewayToken", () => { } }); - it("returns true when Authorization header matches gateway token", async () => { + it("returns true when Authorization header matches gateway token from file", async () => { writeFileSync( TEST_CONFIG_PATH, JSON.stringify({ gateway: { auth: { token: "secret-token-123" } } }) @@ -33,7 +37,32 @@ describe("validateGatewayToken", () => { const { validateGatewayToken } = await import("@/lib/gateway-auth"); const headers = new Headers({ Authorization: "Bearer secret-token-123" }); - expect(validateGatewayToken(headers)).toBe(true); + expect(await validateGatewayToken(headers)).toBe(true); + }); + + it("returns true when Authorization header matches gateway token from DB", async () => { + const { getSetting } = await import("@/lib/settings"); + vi.mocked(getSetting).mockResolvedValue("db-token-456"); + + const { validateGatewayToken } = await import("@/lib/gateway-auth"); + + const headers = new Headers({ Authorization: "Bearer db-token-456" }); + expect(await validateGatewayToken(headers)).toBe(true); + }); + + it("prefers DB token over file token", async () => { + writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ gateway: { auth: { token: "file-token" } } })); + + const { getSetting } = await import("@/lib/settings"); + vi.mocked(getSetting).mockResolvedValue("db-token"); + + const { validateGatewayToken } = await import("@/lib/gateway-auth"); + + const headers = new Headers({ Authorization: "Bearer db-token" }); + expect(await validateGatewayToken(headers)).toBe(true); + + const headersFile = new Headers({ Authorization: "Bearer file-token" }); + expect(await validateGatewayToken(headersFile)).toBe(false); }); it("returns false when token does not match", async () => { @@ -45,7 +74,7 @@ describe("validateGatewayToken", () => { const { validateGatewayToken } = await import("@/lib/gateway-auth"); const headers = new Headers({ Authorization: "Bearer wrong-token" }); - expect(validateGatewayToken(headers)).toBe(false); + expect(await validateGatewayToken(headers)).toBe(false); }); it("returns false when Authorization header is missing", async () => { @@ -57,11 +86,10 @@ describe("validateGatewayToken", () => { const { validateGatewayToken } = await import("@/lib/gateway-auth"); const headers = new Headers(); - expect(validateGatewayToken(headers)).toBe(false); + expect(await validateGatewayToken(headers)).toBe(false); }); - it("returns false when config file does not exist", async () => { - // Don't write the config file + it("returns false when config file does not exist and no DB token", async () => { try { unlinkSync(TEST_CONFIG_PATH); } catch { @@ -71,6 +99,6 @@ describe("validateGatewayToken", () => { const { validateGatewayToken } = await import("@/lib/gateway-auth"); const headers = new Headers({ Authorization: "Bearer some-token" }); - expect(validateGatewayToken(headers)).toBe(false); + expect(await validateGatewayToken(headers)).toBe(false); }); }); diff --git a/packages/web/src/app/api/internal/audit/tool-use/route.ts b/packages/web/src/app/api/internal/audit/tool-use/route.ts index b50e87ef6..987403215 100644 --- a/packages/web/src/app/api/internal/audit/tool-use/route.ts +++ b/packages/web/src/app/api/internal/audit/tool-use/route.ts @@ -77,7 +77,7 @@ function parsePayload(value: unknown): ToolAuditPayload | null { } export async function POST(request: NextRequest) { - if (!validateGatewayToken(request.headers)) { + if (!(await validateGatewayToken(request.headers))) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/packages/web/src/app/api/internal/settings/context/route.ts b/packages/web/src/app/api/internal/settings/context/route.ts index 05630ff18..a9d640911 100644 --- a/packages/web/src/app/api/internal/settings/context/route.ts +++ b/packages/web/src/app/api/internal/settings/context/route.ts @@ -4,7 +4,7 @@ import { setSetting } from "@/lib/settings"; import { syncOrgContextToWorkspaces } from "@/lib/context-sync"; export async function PUT(request: NextRequest) { - if (!validateGatewayToken(request.headers)) { + if (!(await validateGatewayToken(request.headers))) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/packages/web/src/app/api/internal/users/[userId]/context/route.ts b/packages/web/src/app/api/internal/users/[userId]/context/route.ts index 9d1e9d655..59eaa58c5 100644 --- a/packages/web/src/app/api/internal/users/[userId]/context/route.ts +++ b/packages/web/src/app/api/internal/users/[userId]/context/route.ts @@ -10,7 +10,7 @@ export async function PUT( request: NextRequest, { params }: { params: Promise<{ userId: string }> } ) { - if (!validateGatewayToken(request.headers)) { + if (!(await validateGatewayToken(request.headers))) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/packages/web/src/lib/gateway-auth.ts b/packages/web/src/lib/gateway-auth.ts index b7d768510..b0e1494dc 100644 --- a/packages/web/src/lib/gateway-auth.ts +++ b/packages/web/src/lib/gateway-auth.ts @@ -1,8 +1,9 @@ import { readFileSync } from "fs"; +import { getSetting } from "@/lib/settings"; const CONFIG_PATH = process.env.OPENCLAW_CONFIG_PATH || "/openclaw-config/openclaw.json"; -function readGatewayToken(): string | null { +function readGatewayTokenFromFile(): string | null { try { const config = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); return config?.gateway?.auth?.token ?? null; @@ -11,12 +12,15 @@ function readGatewayToken(): string | null { } } -export function validateGatewayToken(headers: Headers): boolean { +export async function validateGatewayToken(headers: Headers): Promise { const authHeader = headers.get("Authorization"); if (!authHeader?.startsWith("Bearer ")) return false; const token = authHeader.slice(7); - const gatewayToken = readGatewayToken(); + + // Prefer DB-stored token (works in both filesystem and API mode), + // fall back to reading from config file (filesystem mode only). + const gatewayToken = (await getSetting("gateway_token")) || readGatewayTokenFromFile(); if (!gatewayToken) return false; return token === gatewayToken;