Merge pull request #270 from paperclipai/openclawgateway

Openclaw gateway
This commit is contained in:
Dotta
2026-03-07 18:57:16 -06:00
committed by GitHub
74 changed files with 1789 additions and 5408 deletions

View File

@@ -40,7 +40,7 @@ afterEach(() => {
describe("notifyHireApproved", () => {
it("writes success activity when adapter hook returns ok", async () => {
vi.mocked(findServerAdapter).mockReturnValue({
type: "openclaw",
type: "openclaw_gateway",
onHireApproved: vi.fn().mockResolvedValue({ ok: true }),
} as any);
@@ -48,7 +48,7 @@ describe("notifyHireApproved", () => {
id: "a1",
companyId: "c1",
name: "OpenClaw Agent",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
});
await expect(
@@ -65,7 +65,7 @@ describe("notifyHireApproved", () => {
expect.objectContaining({
action: "hire_hook.succeeded",
entityId: "a1",
details: expect.objectContaining({ source: "approval", sourceId: "ap1", adapterType: "openclaw" }),
details: expect.objectContaining({ source: "approval", sourceId: "ap1", adapterType: "openclaw_gateway" }),
}),
);
});
@@ -116,7 +116,7 @@ describe("notifyHireApproved", () => {
it("logs failed result when adapter onHireApproved returns ok=false", async () => {
vi.mocked(findServerAdapter).mockReturnValue({
type: "openclaw",
type: "openclaw_gateway",
onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }),
} as any);
@@ -124,7 +124,7 @@ describe("notifyHireApproved", () => {
id: "a1",
companyId: "c1",
name: "OpenClaw Agent",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
});
await expect(
@@ -148,7 +148,7 @@ describe("notifyHireApproved", () => {
it("does not throw when adapter onHireApproved throws (non-fatal)", async () => {
vi.mocked(findServerAdapter).mockReturnValue({
type: "openclaw",
type: "openclaw_gateway",
onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")),
} as any);
@@ -156,7 +156,7 @@ describe("notifyHireApproved", () => {
id: "a1",
companyId: "c1",
name: "OpenClaw Agent",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
});
await expect(

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import {
buildJoinDefaultsPayloadForAccept,
normalizeAgentDefaultsForJoin,
} from "../routes/access.js";
describe("buildJoinDefaultsPayloadForAccept (openclaw_gateway)", () => {
it("leaves non-gateway payloads unchanged", () => {
const defaultsPayload = { command: "echo hello" };
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "process",
defaultsPayload,
inboundOpenClawAuthHeader: "ignored-token",
});
expect(result).toEqual(defaultsPayload);
});
it("normalizes wrapped x-openclaw-token header", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw_gateway",
defaultsPayload: {
url: "ws://127.0.0.1:18789",
headers: {
"x-openclaw-token": {
value: "gateway-token-1234567890",
},
},
},
}) as Record<string, unknown>;
expect(result).toMatchObject({
url: "ws://127.0.0.1:18789",
headers: {
"x-openclaw-token": "gateway-token-1234567890",
},
});
});
it("accepts inbound x-openclaw-token for gateway joins", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw_gateway",
defaultsPayload: {
url: "ws://127.0.0.1:18789",
},
inboundOpenClawTokenHeader: "gateway-token-1234567890",
}) as Record<string, unknown>;
expect(result).toMatchObject({
headers: {
"x-openclaw-token": "gateway-token-1234567890",
},
});
});
it("derives x-openclaw-token from authorization header", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw_gateway",
defaultsPayload: {
url: "ws://127.0.0.1:18789",
headers: {
authorization: "Bearer gateway-token-1234567890",
},
},
}) as Record<string, unknown>;
expect(result).toMatchObject({
headers: {
authorization: "Bearer gateway-token-1234567890",
"x-openclaw-token": "gateway-token-1234567890",
},
});
});
});
describe("normalizeAgentDefaultsForJoin (openclaw_gateway)", () => {
it("generates persistent device key when device auth is enabled", () => {
const normalized = normalizeAgentDefaultsForJoin({
adapterType: "openclaw_gateway",
defaultsPayload: {
url: "ws://127.0.0.1:18789",
headers: {
"x-openclaw-token": "gateway-token-1234567890",
},
disableDeviceAuth: false,
},
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
});
expect(normalized.fatalErrors).toEqual([]);
expect(normalized.normalized?.disableDeviceAuth).toBe(false);
expect(typeof normalized.normalized?.devicePrivateKeyPem).toBe("string");
expect((normalized.normalized?.devicePrivateKeyPem as string).length).toBeGreaterThan(64);
});
it("does not generate device key when disableDeviceAuth=true", () => {
const normalized = normalizeAgentDefaultsForJoin({
adapterType: "openclaw_gateway",
defaultsPayload: {
url: "ws://127.0.0.1:18789",
headers: {
"x-openclaw-token": "gateway-token-1234567890",
},
disableDeviceAuth: true,
},
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
});
expect(normalized.fatalErrors).toEqual([]);
expect(normalized.normalized?.disableDeviceAuth).toBe(true);
expect(normalized.normalized?.devicePrivateKeyPem).toBeUndefined();
});
});

View File

@@ -1,294 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildJoinDefaultsPayloadForAccept,
normalizeAgentDefaultsForJoin,
} from "../routes/access.js";
describe("buildJoinDefaultsPayloadForAccept", () => {
it("maps OpenClaw compatibility fields into agent defaults", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
defaultsPayload: null,
responsesWebhookUrl: "http://localhost:18789/v1/responses",
paperclipApiUrl: "http://host.docker.internal:3100",
inboundOpenClawAuthHeader: "gateway-token",
}) as Record<string, unknown>;
expect(result).toMatchObject({
url: "http://localhost:18789/v1/responses",
paperclipApiUrl: "http://host.docker.internal:3100",
webhookAuthHeader: "Bearer gateway-token",
headers: {
"x-openclaw-auth": "gateway-token",
},
});
});
it("does not overwrite explicit OpenClaw endpoint defaults when already provided", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
defaultsPayload: {
url: "https://example.com/v1/responses",
method: "POST",
headers: {
"x-openclaw-auth": "existing-token",
},
paperclipApiUrl: "https://paperclip.example.com",
},
responsesWebhookUrl: "https://legacy.example.com/v1/responses",
responsesWebhookMethod: "PUT",
paperclipApiUrl: "https://legacy-paperclip.example.com",
inboundOpenClawAuthHeader: "legacy-token",
}) as Record<string, unknown>;
expect(result).toMatchObject({
url: "https://example.com/v1/responses",
method: "POST",
paperclipApiUrl: "https://paperclip.example.com",
webhookAuthHeader: "Bearer existing-token",
headers: {
"x-openclaw-auth": "existing-token",
},
});
});
it("preserves explicit webhookAuthHeader when configured", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
defaultsPayload: {
url: "https://example.com/v1/responses",
webhookAuthHeader: "Bearer explicit-token",
headers: {
"x-openclaw-auth": "existing-token",
},
},
inboundOpenClawAuthHeader: "legacy-token",
}) as Record<string, unknown>;
expect(result).toMatchObject({
webhookAuthHeader: "Bearer explicit-token",
headers: {
"x-openclaw-auth": "existing-token",
},
});
});
it("accepts auth from agentDefaultsPayload.headers.x-openclaw-auth", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
defaultsPayload: {
url: "http://127.0.0.1:18789/v1/responses",
method: "POST",
headers: {
"x-openclaw-auth": "gateway-token",
},
},
}) as Record<string, unknown>;
expect(result).toMatchObject({
headers: {
"x-openclaw-auth": "gateway-token",
},
webhookAuthHeader: "Bearer gateway-token",
});
});
it("accepts auth from agentDefaultsPayload.headers.x-openclaw-token", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
defaultsPayload: {
url: "http://127.0.0.1:18789/hooks/agent",
method: "POST",
headers: {
"x-openclaw-token": "gateway-token",
},
},
}) as Record<string, unknown>;
expect(result).toMatchObject({
headers: {
"x-openclaw-token": "gateway-token",
},
webhookAuthHeader: "Bearer gateway-token",
});
});
it("accepts inbound x-openclaw-token compatibility header", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
defaultsPayload: null,
inboundOpenClawTokenHeader: "gateway-token",
}) as Record<string, unknown>;
expect(result).toMatchObject({
headers: {
"x-openclaw-token": "gateway-token",
},
webhookAuthHeader: "Bearer gateway-token",
});
});
it("accepts wrapped auth values in headers for compatibility", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
defaultsPayload: {
headers: {
"x-openclaw-auth": {
value: "gateway-token",
},
},
},
}) as Record<string, unknown>;
expect(result).toMatchObject({
headers: {
"x-openclaw-auth": "gateway-token",
},
webhookAuthHeader: "Bearer gateway-token",
});
});
it("accepts auth headers provided as tuple entries", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
defaultsPayload: {
headers: [["x-openclaw-auth", "gateway-token"]],
},
}) as Record<string, unknown>;
expect(result).toMatchObject({
headers: {
"x-openclaw-auth": "gateway-token",
},
webhookAuthHeader: "Bearer gateway-token",
});
});
it("accepts auth headers provided as name/value entries", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
defaultsPayload: {
headers: [{ name: "x-openclaw-auth", value: { authToken: "gateway-token" } }],
},
}) as Record<string, unknown>;
expect(result).toMatchObject({
headers: {
"x-openclaw-auth": "gateway-token",
},
webhookAuthHeader: "Bearer gateway-token",
});
});
it("accepts auth headers wrapped in a single unknown key", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
defaultsPayload: {
headers: {
"x-openclaw-auth": {
gatewayToken: "gateway-token",
},
},
},
}) as Record<string, unknown>;
expect(result).toMatchObject({
headers: {
"x-openclaw-auth": "gateway-token",
},
webhookAuthHeader: "Bearer gateway-token",
});
});
it("leaves non-openclaw payloads unchanged", () => {
const defaultsPayload = { command: "echo hello" };
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "process",
defaultsPayload,
responsesWebhookUrl: "https://ignored.example.com",
inboundOpenClawAuthHeader: "ignored-token",
});
expect(result).toEqual(defaultsPayload);
});
it("normalizes wrapped gateway token headers for openclaw_gateway", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw_gateway",
defaultsPayload: {
url: "ws://127.0.0.1:18789",
headers: {
"x-openclaw-token": {
value: "gateway-token-1234567890",
},
},
},
}) as Record<string, unknown>;
expect(result).toMatchObject({
url: "ws://127.0.0.1:18789",
headers: {
"x-openclaw-token": "gateway-token-1234567890",
},
});
});
it("accepts inbound x-openclaw-token for openclaw_gateway", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw_gateway",
defaultsPayload: {
url: "ws://127.0.0.1:18789",
},
inboundOpenClawTokenHeader: "gateway-token-1234567890",
}) as Record<string, unknown>;
expect(result).toMatchObject({
headers: {
"x-openclaw-token": "gateway-token-1234567890",
},
});
});
it("generates persistent device key for openclaw_gateway when device auth is enabled", () => {
const normalized = normalizeAgentDefaultsForJoin({
adapterType: "openclaw_gateway",
defaultsPayload: {
url: "ws://127.0.0.1:18789",
headers: {
"x-openclaw-token": "gateway-token-1234567890",
},
disableDeviceAuth: false,
},
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
});
expect(normalized.fatalErrors).toEqual([]);
expect(normalized.normalized?.disableDeviceAuth).toBe(false);
expect(typeof normalized.normalized?.devicePrivateKeyPem).toBe("string");
expect((normalized.normalized?.devicePrivateKeyPem as string).length).toBeGreaterThan(64);
});
it("does not generate device key when openclaw_gateway has disableDeviceAuth=true", () => {
const normalized = normalizeAgentDefaultsForJoin({
adapterType: "openclaw_gateway",
defaultsPayload: {
url: "ws://127.0.0.1:18789",
headers: {
"x-openclaw-token": "gateway-token-1234567890",
},
disableDeviceAuth: true,
},
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
});
expect(normalized.fatalErrors).toEqual([]);
expect(normalized.normalized?.disableDeviceAuth).toBe(true);
expect(normalized.normalized?.devicePrivateKeyPem).toBeUndefined();
});
});

View File

@@ -1,63 +1,55 @@
import { describe, expect, it } from "vitest";
import {
buildJoinDefaultsPayloadForAccept,
canReplayOpenClawInviteAccept,
canReplayOpenClawGatewayInviteAccept,
mergeJoinDefaultsPayloadForReplay,
} from "../routes/access.js";
describe("canReplayOpenClawInviteAccept", () => {
it("allows replay only for openclaw agent joins in pending or approved state", () => {
describe("canReplayOpenClawGatewayInviteAccept", () => {
it("allows replay only for openclaw_gateway agent joins in pending or approved state", () => {
expect(
canReplayOpenClawInviteAccept({
canReplayOpenClawGatewayInviteAccept({
requestType: "agent",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
existingJoinRequest: {
requestType: "agent",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
status: "pending_approval",
},
}),
).toBe(true);
expect(
canReplayOpenClawInviteAccept({
canReplayOpenClawGatewayInviteAccept({
requestType: "agent",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
existingJoinRequest: {
requestType: "agent",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
status: "approved",
},
}),
).toBe(true);
expect(
canReplayOpenClawInviteAccept({
canReplayOpenClawGatewayInviteAccept({
requestType: "agent",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
existingJoinRequest: {
requestType: "agent",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
status: "rejected",
},
}),
).toBe(false);
expect(
canReplayOpenClawInviteAccept({
canReplayOpenClawGatewayInviteAccept({
requestType: "human",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
existingJoinRequest: {
requestType: "agent",
adapterType: "openclaw",
status: "pending_approval",
},
}),
).toBe(false);
expect(
canReplayOpenClawInviteAccept({
requestType: "agent",
adapterType: "process",
existingJoinRequest: {
requestType: "agent",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
status: "pending_approval",
},
}),
@@ -66,36 +58,34 @@ describe("canReplayOpenClawInviteAccept", () => {
});
describe("mergeJoinDefaultsPayloadForReplay", () => {
it("merges replay payloads and preserves existing fields while allowing auth/header overrides", () => {
it("merges replay payloads and allows gateway token override", () => {
const merged = mergeJoinDefaultsPayloadForReplay(
{
url: "https://old.example/v1/responses",
method: "POST",
url: "ws://old.example:18789",
paperclipApiUrl: "http://host.docker.internal:3100",
headers: {
"x-openclaw-auth": "old-token",
"x-openclaw-token": "old-token-1234567890",
"x-custom": "keep-me",
},
},
{
paperclipApiUrl: "https://paperclip.example.com",
headers: {
"x-openclaw-auth": "new-token",
"x-openclaw-token": "new-token-1234567890",
},
},
);
const normalized = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
adapterType: "openclaw_gateway",
defaultsPayload: merged,
inboundOpenClawAuthHeader: null,
}) as Record<string, unknown>;
expect(normalized.url).toBe("https://old.example/v1/responses");
expect(normalized.url).toBe("ws://old.example:18789");
expect(normalized.paperclipApiUrl).toBe("https://paperclip.example.com");
expect(normalized.webhookAuthHeader).toBe("Bearer new-token");
expect(normalized.headers).toMatchObject({
"x-openclaw-auth": "new-token",
"x-openclaw-token": "new-token-1234567890",
"x-custom": "keep-me",
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -167,6 +167,208 @@ async function createMockGatewayServer() {
};
}
async function createMockGatewayServerWithPairing() {
const server = createServer();
const wss = new WebSocketServer({ server });
let agentPayload: Record<string, unknown> | null = null;
let approved = false;
let pendingRequestId = "req-1";
let lastSeenDeviceId: string | null = null;
wss.on("connection", (socket) => {
socket.send(
JSON.stringify({
type: "event",
event: "connect.challenge",
payload: { nonce: "nonce-123" },
}),
);
socket.on("message", (raw) => {
const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
const frame = JSON.parse(text) as {
type: string;
id: string;
method: string;
params?: Record<string, unknown>;
};
if (frame.type !== "req") return;
if (frame.method === "connect") {
const device = frame.params?.device as Record<string, unknown> | undefined;
const deviceId = typeof device?.id === "string" ? device.id : null;
if (deviceId) {
lastSeenDeviceId = deviceId;
}
if (deviceId && !approved) {
socket.send(
JSON.stringify({
type: "res",
id: frame.id,
ok: false,
error: {
code: "NOT_PAIRED",
message: "pairing required",
details: {
code: "PAIRING_REQUIRED",
requestId: pendingRequestId,
reason: "not-paired",
},
},
}),
);
socket.close(1008, "pairing required");
return;
}
socket.send(
JSON.stringify({
type: "res",
id: frame.id,
ok: true,
payload: {
type: "hello-ok",
protocol: 3,
server: { version: "test", connId: "conn-1" },
features: {
methods: ["connect", "agent", "agent.wait", "device.pair.list", "device.pair.approve"],
events: ["agent"],
},
snapshot: { version: 1, ts: Date.now() },
policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 },
},
}),
);
return;
}
if (frame.method === "device.pair.list") {
socket.send(
JSON.stringify({
type: "res",
id: frame.id,
ok: true,
payload: {
pending: approved
? []
: [
{
requestId: pendingRequestId,
deviceId: lastSeenDeviceId ?? "device-unknown",
},
],
paired: approved && lastSeenDeviceId ? [{ deviceId: lastSeenDeviceId }] : [],
},
}),
);
return;
}
if (frame.method === "device.pair.approve") {
const requestId = frame.params?.requestId;
if (requestId !== pendingRequestId) {
socket.send(
JSON.stringify({
type: "res",
id: frame.id,
ok: false,
error: { code: "INVALID_REQUEST", message: "unknown requestId" },
}),
);
return;
}
approved = true;
socket.send(
JSON.stringify({
type: "res",
id: frame.id,
ok: true,
payload: {
requestId: pendingRequestId,
device: {
deviceId: lastSeenDeviceId ?? "device-unknown",
},
},
}),
);
return;
}
if (frame.method === "agent") {
agentPayload = frame.params ?? null;
const runId =
typeof frame.params?.idempotencyKey === "string"
? frame.params.idempotencyKey
: "run-123";
socket.send(
JSON.stringify({
type: "res",
id: frame.id,
ok: true,
payload: {
runId,
status: "accepted",
acceptedAt: Date.now(),
},
}),
);
socket.send(
JSON.stringify({
type: "event",
event: "agent",
payload: {
runId,
seq: 1,
stream: "assistant",
ts: Date.now(),
data: { delta: "ok" },
},
}),
);
return;
}
if (frame.method === "agent.wait") {
socket.send(
JSON.stringify({
type: "res",
id: frame.id,
ok: true,
payload: {
runId: frame.params?.runId,
status: "ok",
startedAt: 1,
endedAt: 2,
},
}),
);
}
});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Failed to resolve test server address");
}
return {
url: `ws://127.0.0.1:${address.port}`,
getAgentPayload: () => agentPayload,
close: async () => {
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}
afterEach(() => {
// no global mocks
});
@@ -222,7 +424,7 @@ describe("openclaw gateway adapter execute", () => {
const payload = gateway.getAgentPayload();
expect(payload).toBeTruthy();
expect(payload?.idempotencyKey).toBe("run-123");
expect(payload?.sessionKey).toBe("paperclip");
expect(payload?.sessionKey).toBe("paperclip:issue:issue-123");
expect(String(payload?.message ?? "")).toContain("wake now");
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123");
@@ -238,6 +440,43 @@ describe("openclaw gateway adapter execute", () => {
expect(result.exitCode).toBe(1);
expect(result.errorCode).toBe("openclaw_gateway_url_missing");
});
it("auto-approves pairing once and retries the run", async () => {
const gateway = await createMockGatewayServerWithPairing();
const logs: string[] = [];
try {
const result = await execute(
buildContext(
{
url: gateway.url,
headers: {
"x-openclaw-token": "gateway-token",
},
payloadTemplate: {
message: "wake now",
},
waitTimeoutMs: 2000,
},
{
onLog: async (_stream, chunk) => {
logs.push(chunk);
},
},
),
);
expect(result.exitCode).toBe(0);
expect(result.summary).toContain("ok");
expect(logs.some((entry) => entry.includes("pairing required; attempting automatic pairing approval"))).toBe(
true,
);
expect(logs.some((entry) => entry.includes("auto-approved pairing request"))).toBe(true);
expect(gateway.getAgentPayload()).toBeTruthy();
} finally {
await gateway.close();
}
});
});
describe("openclaw gateway testEnvironment", () => {

View File

@@ -0,0 +1,181 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
const mockAccessService = vi.hoisted(() => ({
hasPermission: vi.fn(),
canUser: vi.fn(),
isInstanceAdmin: vi.fn(),
getMembership: vi.fn(),
ensureMembership: vi.fn(),
listMembers: vi.fn(),
setMemberPermissions: vi.fn(),
promoteInstanceAdmin: vi.fn(),
demoteInstanceAdmin: vi.fn(),
listUserCompanyAccess: vi.fn(),
setUserCompanyAccess: vi.fn(),
setPrincipalGrants: vi.fn(),
}));
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
deduplicateAgentName: vi.fn(),
logActivity: mockLogActivity,
notifyHireApproved: vi.fn(),
}));
function createDbStub() {
const createdInvite = {
id: "invite-1",
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "agent",
defaultsPayload: null,
expiresAt: new Date("2026-03-07T00:10:00.000Z"),
invitedByUserId: null,
tokenHash: "hash",
revokedAt: null,
acceptedAt: null,
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
};
const returning = vi.fn().mockResolvedValue([createdInvite]);
const values = vi.fn().mockReturnValue({ returning });
const insert = vi.fn().mockReturnValue({ values });
return {
insert,
};
}
function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use(
"/api",
accessRoutes(db as any, {
deploymentMode: "local_trusted",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
}),
);
app.use(errorHandler);
return app;
}
describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
beforeEach(() => {
mockAccessService.canUser.mockResolvedValue(false);
mockAgentService.getById.mockReset();
mockLogActivity.mockResolvedValue(undefined);
});
it("rejects non-CEO agent callers", async () => {
const db = createDbStub();
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
role: "engineer",
});
const app = createApp(
{
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
},
db,
);
const res = await request(app)
.post("/api/companies/company-1/openclaw/invite-prompt")
.send({});
expect(res.status).toBe(403);
expect(res.body.error).toContain("Only CEO agents");
});
it("allows CEO agent callers and creates an agent-only invite", async () => {
const db = createDbStub();
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
role: "ceo",
});
const app = createApp(
{
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
},
db,
);
const res = await request(app)
.post("/api/companies/company-1/openclaw/invite-prompt")
.send({ agentMessage: "Join and configure OpenClaw gateway." });
expect(res.status).toBe(201);
expect(res.body.allowedJoinTypes).toBe("agent");
expect(typeof res.body.token).toBe("string");
expect(res.body.onboardingTextPath).toContain("/api/invites/");
});
it("allows board callers with invite permission", async () => {
const db = createDbStub();
mockAccessService.canUser.mockResolvedValue(true);
const app = createApp(
{
type: "board",
userId: "user-1",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
},
db,
);
const res = await request(app)
.post("/api/companies/company-1/openclaw/invite-prompt")
.send({});
expect(res.status).toBe(201);
expect(res.body.allowedJoinTypes).toBe("agent");
});
it("rejects board callers without invite permission", async () => {
const db = createDbStub();
mockAccessService.canUser.mockResolvedValue(false);
const app = createApp(
{
type: "board",
userId: "user-1",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
},
db,
);
const res = await request(app)
.post("/api/companies/company-1/openclaw/invite-prompt")
.send({});
expect(res.status).toBe(403);
expect(res.body.error).toBe("Permission denied");
});
});

View File

@@ -26,15 +26,6 @@ import {
import {
agentConfigurationDoc as openCodeAgentConfigurationDoc,
} from "@paperclipai/adapter-opencode-local";
import {
execute as openclawExecute,
testEnvironment as openclawTestEnvironment,
onHireApproved as openclawOnHireApproved,
} from "@paperclipai/adapter-openclaw/server";
import {
agentConfigurationDoc as openclawAgentConfigurationDoc,
models as openclawModels,
} from "@paperclipai/adapter-openclaw";
import {
execute as openclawGatewayExecute,
testEnvironment as openclawGatewayTestEnvironment,
@@ -89,16 +80,6 @@ const cursorLocalAdapter: ServerAdapterModule = {
agentConfigurationDoc: cursorAgentConfigurationDoc,
};
const openclawAdapter: ServerAdapterModule = {
type: "openclaw",
execute: openclawExecute,
testEnvironment: openclawTestEnvironment,
onHireApproved: openclawOnHireApproved,
models: openclawModels,
supportsLocalAgentJwt: false,
agentConfigurationDoc: openclawAgentConfigurationDoc,
};
const openclawGatewayAdapter: ServerAdapterModule = {
type: "openclaw_gateway",
execute: openclawGatewayExecute,
@@ -137,7 +118,6 @@ const adaptersByType = new Map<string, ServerAdapterModule>(
openCodeLocalAdapter,
piLocalAdapter,
cursorLocalAdapter,
openclawAdapter,
openclawGatewayAdapter,
processAdapter,
httpAdapter,

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { Router, type Request } from "express";
import { randomUUID } from "node:crypto";
import { generateKeyPairSync, randomUUID } from "node:crypto";
import path from "node:path";
import type { Db } from "@paperclipai/db";
import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db";
@@ -181,6 +181,40 @@ export function agentRoutes(db: Db) {
return trimmed.length > 0 ? trimmed : null;
}
function parseBooleanLike(value: unknown): boolean | null {
if (typeof value === "boolean") return value;
if (typeof value === "number") {
if (value === 1) return true;
if (value === 0) return false;
return null;
}
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") {
return true;
}
if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") {
return false;
}
return null;
}
function generateEd25519PrivateKeyPem(): string {
const { privateKey } = generateKeyPairSync("ed25519");
return privateKey.export({ type: "pkcs8", format: "pem" }).toString();
}
function ensureGatewayDeviceKey(
adapterType: string | null | undefined,
adapterConfig: Record<string, unknown>,
): Record<string, unknown> {
if (adapterType !== "openclaw_gateway") return adapterConfig;
const disableDeviceAuth = parseBooleanLike(adapterConfig.disableDeviceAuth) === true;
if (disableDeviceAuth) return adapterConfig;
if (asNonEmptyString(adapterConfig.devicePrivateKeyPem)) return adapterConfig;
return { ...adapterConfig, devicePrivateKeyPem: generateEd25519PrivateKeyPem() };
}
function applyCreateDefaultsByAdapterType(
adapterType: string | null | undefined,
adapterConfig: Record<string, unknown>,
@@ -196,13 +230,13 @@ export function agentRoutes(db: Db) {
if (!hasBypassFlag) {
next.dangerouslyBypassApprovalsAndSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
}
return next;
return ensureGatewayDeviceKey(adapterType, next);
}
// OpenCode requires explicit model selection — no default
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
}
return next;
return ensureGatewayDeviceKey(adapterType, next);
}
async function assertAdapterConfigConstraints(
@@ -930,11 +964,7 @@ export function agentRoutes(db: Db) {
if (changingInstructionsPath) {
await assertCanManageInstructionsPath(req, existing);
}
patchData.adapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
existing.companyId,
adapterConfig,
{ strictMode: strictSecretsMode },
);
patchData.adapterConfig = adapterConfig;
}
const requestedAdapterType =
@@ -942,15 +972,23 @@ export function agentRoutes(db: Db) {
const touchesAdapterConfiguration =
Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
if (touchesAdapterConfiguration) {
const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
? (asRecord(patchData.adapterConfig) ?? {})
: (asRecord(existing.adapterConfig) ?? {});
const effectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
existing.companyId,
const effectiveAdapterConfig = applyCreateDefaultsByAdapterType(
requestedAdapterType,
rawEffectiveAdapterConfig,
);
const normalizedEffectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
existing.companyId,
effectiveAdapterConfig,
{ strictMode: strictSecretsMode },
);
patchData.adapterConfig = normalizedEffectiveAdapterConfig;
}
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {};
await assertAdapterConfigConstraints(
existing.companyId,
requestedAdapterType,

View File

@@ -83,10 +83,6 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
{ path: ["graceSec"], value: 15 },
{ path: ["maxTurnsPerRun"], value: 80 },
],
openclaw: [
{ path: ["method"], value: "POST" },
{ path: ["timeoutSec"], value: 30 },
],
openclaw_gateway: [
{ path: ["timeoutSec"], value: 120 },
{ path: ["waitTimeoutMs"], value: 120000 },