Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docker-compose.api.yml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions docs/src/content/docs/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 14 additions & 1 deletion packages/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
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();
Expand All @@ -23,14 +24,14 @@
function readGatewayToken(): string {
// Try dedicated token file first (world-readable, written by OpenClaw startup)
try {
const token = readFileSync(GATEWAY_TOKEN_PATH, "utf-8").trim();

Check warning on line 27 in packages/web/server.ts

View workflow job for this annotation

GitHub Actions / Lint, Test & Build

Found readFileSync from package "fs" with non literal argument at index 0
if (token) return token;
} catch {
// Fall through to config file
}
// Fall back to reading from main config (works when running as same user)
try {
const config = JSON.parse(readFileSync(OPENCLAW_CONFIG_PATH, "utf-8"));

Check warning on line 34 in packages/web/server.ts

View workflow job for this annotation

GitHub Actions / Lint, Test & Build

Found readFileSync from package "fs" with non literal argument at index 0
return config.gateway?.auth?.token ?? "";
} catch {
return "";
Expand Down Expand Up @@ -174,10 +175,22 @@
// 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();
}
Expand Down
34 changes: 14 additions & 20 deletions packages/web/src/__tests__/api/agent-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ 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),
}));

vi.mock("@/lib/openclaw-config", () => ({
regenerateOpenClawConfig: vi.fn().mockResolvedValue(undefined),
}));

const { mockAssertAgentWriteAccess } = vi.hoisted(() => ({
Expand Down Expand Up @@ -70,7 +74,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 () => {
Expand Down Expand Up @@ -121,9 +125,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"));
Expand All @@ -134,9 +136,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"));
Expand All @@ -147,7 +147,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"));
Expand All @@ -159,9 +159,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"));
Expand All @@ -172,7 +170,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"));
Expand Down Expand Up @@ -246,9 +244,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",
Expand All @@ -261,9 +257,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",
Expand Down
35 changes: 34 additions & 1 deletion packages/web/src/__tests__/api/agents-audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand Down Expand Up @@ -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();
});
});
4 changes: 2 additions & 2 deletions packages/web/src/__tests__/api/agents-delete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand All @@ -27,11 +27,11 @@ function makeRequest(body: Record<string, unknown>) {
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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -32,11 +32,11 @@ function makePutRequest(body: Record<string, unknown>) {
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);
Expand Down
6 changes: 3 additions & 3 deletions packages/web/src/__tests__/api/internal-user-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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",
Expand All @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions packages/web/src/__tests__/api/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(""),
}));
Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/__tests__/db/seed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ describe("seedDefaultAgent", () => {
ownerId: null,
isPersonal: false,
isAdmin: false,
onAgentCreated: expect.any(Function),
});
});

Expand All @@ -80,6 +81,7 @@ describe("seedDefaultAgent", () => {
ownerId: "user-1",
isPersonal: true,
isAdmin: true,
onAgentCreated: expect.any(Function),
});
});

Expand Down
Loading
Loading