Merge upstream/master into add-gpt-5-4-xhigh-effort

This commit is contained in:
Kevin Mok 2026-03-08 12:10:59 -05:00
commit 432d7e72fa
227 changed files with 31564 additions and 2543 deletions

View file

@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local";
import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-local";
import { models as opencodeFallbackModels } from "@paperclipai/adapter-opencode-local";
import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server";
import { listAdapterModels } from "../adapters/index.js";
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js";
@ -9,9 +10,11 @@ import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from ".
describe("adapter model listing", () => {
beforeEach(() => {
delete process.env.OPENAI_API_KEY;
delete process.env.PAPERCLIP_OPENCODE_COMMAND;
resetCodexModelsCacheForTests();
resetCursorModelsCacheForTests();
setCursorModelsRunnerForTests(null);
resetOpenCodeModelsCacheForTests();
vi.restoreAllMocks();
});
@ -61,6 +64,7 @@ describe("adapter model listing", () => {
expect(models).toEqual(codexFallbackModels);
});
it("returns cursor fallback models when CLI discovery is unavailable", async () => {
setCursorModelsRunnerForTests(() => ({
status: null,
@ -74,6 +78,8 @@ describe("adapter model listing", () => {
});
it("returns opencode fallback models including gpt-5.4", async () => {
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
const models = await listAdapterModels("opencode_local");
expect(models).toEqual(opencodeFallbackModels);
@ -97,4 +103,5 @@ describe("adapter model listing", () => {
expect(first.some((model) => model.id === "gpt-5.3-codex-high")).toBe(true);
expect(first.some((model) => model.id === "composer-1")).toBe(true);
});
});

View file

@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { hasAgentShortnameCollision, deduplicateAgentName } from "../services/agents.ts";
describe("hasAgentShortnameCollision", () => {
it("detects collisions by normalized shortname", () => {
const collision = hasAgentShortnameCollision("Codex Coder", [
{ id: "a1", name: "codex-coder", status: "idle" },
]);
expect(collision).toBe(true);
});
it("ignores terminated agents", () => {
const collision = hasAgentShortnameCollision("Codex Coder", [
{ id: "a1", name: "codex-coder", status: "terminated" },
]);
expect(collision).toBe(false);
});
it("ignores the excluded agent id", () => {
const collision = hasAgentShortnameCollision(
"Codex Coder",
[
{ id: "a1", name: "codex-coder", status: "idle" },
{ id: "a2", name: "other-agent", status: "idle" },
],
{ excludeAgentId: "a1" },
);
expect(collision).toBe(false);
});
it("does not collide when candidate has no shortname", () => {
const collision = hasAgentShortnameCollision("!!!", [
{ id: "a1", name: "codex-coder", status: "idle" },
]);
expect(collision).toBe(false);
});
});
describe("deduplicateAgentName", () => {
it("returns original name when no collision", () => {
const name = deduplicateAgentName("OpenClaw", [
{ id: "a1", name: "other-agent", status: "idle" },
]);
expect(name).toBe("OpenClaw");
});
it("appends suffix when name collides", () => {
const name = deduplicateAgentName("OpenClaw", [
{ id: "a1", name: "openclaw", status: "idle" },
]);
expect(name).toBe("OpenClaw 2");
});
it("increments suffix until unique", () => {
const name = deduplicateAgentName("OpenClaw", [
{ id: "a1", name: "openclaw", status: "idle" },
{ id: "a2", name: "openclaw-2", status: "idle" },
{ id: "a3", name: "openclaw-3", status: "idle" },
]);
expect(name).toBe("OpenClaw 4");
});
it("ignores terminated agents for collision", () => {
const name = deduplicateAgentName("OpenClaw", [
{ id: "a1", name: "openclaw", status: "terminated" },
]);
expect(name).toBe("OpenClaw");
});
});

View file

@ -0,0 +1,49 @@
import express from "express";
import request from "supertest";
import { describe, expect, it, vi } from "vitest";
import { companyRoutes } from "../routes/companies.js";
vi.mock("../services/index.js", () => ({
companyService: () => ({
list: vi.fn(),
stats: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
archive: vi.fn(),
remove: vi.fn(),
}),
companyPortabilityService: () => ({
exportBundle: vi.fn(),
previewImport: vi.fn(),
importBundle: vi.fn(),
}),
accessService: () => ({
canUser: vi.fn(),
ensureMembership: vi.fn(),
}),
logActivity: vi.fn(),
}));
describe("company routes malformed issue path guard", () => {
it("returns a clear error when companyId is missing for issues list path", async () => {
const app = express();
app.use((req, _res, next) => {
(req as any).actor = {
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
};
next();
});
app.use("/api/companies", companyRoutes({} as any));
const res = await request(app).get("/api/companies/issues");
expect(res.status).toBe(400);
expect(res.body).toEqual({
error: "Missing companyId in path. Use /api/companies/{companyId}/issues.",
});
});
});

View file

@ -0,0 +1,53 @@
import type { NextFunction, Request, Response } from "express";
import { describe, expect, it, vi } from "vitest";
import { HttpError } from "../errors.js";
import { errorHandler } from "../middleware/error-handler.js";
function makeReq(): Request {
return {
method: "GET",
originalUrl: "/api/test",
body: { a: 1 },
params: { id: "123" },
query: { q: "x" },
} as unknown as Request;
}
function makeRes(): Response {
const res = {
status: vi.fn(),
json: vi.fn(),
} as unknown as Response;
(res.status as unknown as ReturnType<typeof vi.fn>).mockReturnValue(res);
return res;
}
describe("errorHandler", () => {
it("attaches the original Error to res.err for 500s", () => {
const req = makeReq();
const res = makeRes() as any;
const next = vi.fn() as unknown as NextFunction;
const err = new Error("boom");
errorHandler(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: "Internal server error" });
expect(res.err).toBe(err);
expect(res.__errorContext?.error?.message).toBe("boom");
});
it("attaches HttpError instances for 500 responses", () => {
const req = makeReq();
const res = makeRes() as any;
const next = vi.fn() as unknown as NextFunction;
const err = new HttpError(500, "db exploded");
errorHandler(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: "db exploded" });
expect(res.err).toBe(err);
expect(res.__errorContext?.error?.message).toBe("db exploded");
});
});

View file

@ -0,0 +1,180 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Db } from "@paperclipai/db";
import { notifyHireApproved } from "../services/hire-hook.js";
// Mock the registry so we control whether the adapter has onHireApproved and what it does.
vi.mock("../adapters/registry.js", () => ({
findServerAdapter: vi.fn(),
}));
vi.mock("../services/activity-log.js", () => ({
logActivity: vi.fn().mockResolvedValue(undefined),
}));
const { findServerAdapter } = await import("../adapters/registry.js");
const { logActivity } = await import("../services/activity-log.js");
function mockDbWithAgent(agent: { id: string; companyId: string; name: string; adapterType: string; adapterConfig?: Record<string, unknown> }): Db {
return {
select: () => ({
from: () => ({
where: () =>
Promise.resolve([
{
id: agent.id,
companyId: agent.companyId,
name: agent.name,
adapterType: agent.adapterType,
adapterConfig: agent.adapterConfig ?? {},
},
]),
}),
}),
} as unknown as Db;
}
afterEach(() => {
vi.clearAllMocks();
});
describe("notifyHireApproved", () => {
it("writes success activity when adapter hook returns ok", async () => {
vi.mocked(findServerAdapter).mockReturnValue({
type: "openclaw_gateway",
onHireApproved: vi.fn().mockResolvedValue({ ok: true }),
} as any);
const db = mockDbWithAgent({
id: "a1",
companyId: "c1",
name: "OpenClaw Agent",
adapterType: "openclaw_gateway",
});
await expect(
notifyHireApproved(db, {
companyId: "c1",
agentId: "a1",
source: "approval",
sourceId: "ap1",
}),
).resolves.toBeUndefined();
expect(logActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "hire_hook.succeeded",
entityId: "a1",
details: expect.objectContaining({ source: "approval", sourceId: "ap1", adapterType: "openclaw_gateway" }),
}),
);
});
it("does nothing when agent is not found", async () => {
const db = {
select: () => ({
from: () => ({
where: () => Promise.resolve([]),
}),
}),
} as unknown as Db;
await expect(
notifyHireApproved(db, {
companyId: "c1",
agentId: "a1",
source: "join_request",
sourceId: "jr1",
}),
).resolves.toBeUndefined();
expect(findServerAdapter).not.toHaveBeenCalled();
});
it("does nothing when adapter has no onHireApproved", async () => {
vi.mocked(findServerAdapter).mockReturnValue({ type: "process" } as any);
const db = mockDbWithAgent({
id: "a1",
companyId: "c1",
name: "Agent",
adapterType: "process",
});
await expect(
notifyHireApproved(db, {
companyId: "c1",
agentId: "a1",
source: "approval",
sourceId: "ap1",
}),
).resolves.toBeUndefined();
expect(findServerAdapter).toHaveBeenCalledWith("process");
expect(logActivity).not.toHaveBeenCalled();
});
it("logs failed result when adapter onHireApproved returns ok=false", async () => {
vi.mocked(findServerAdapter).mockReturnValue({
type: "openclaw_gateway",
onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }),
} as any);
const db = mockDbWithAgent({
id: "a1",
companyId: "c1",
name: "OpenClaw Agent",
adapterType: "openclaw_gateway",
});
await expect(
notifyHireApproved(db, {
companyId: "c1",
agentId: "a1",
source: "join_request",
sourceId: "jr1",
}),
).resolves.toBeUndefined();
expect(logActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "hire_hook.failed",
entityId: "a1",
details: expect.objectContaining({ source: "join_request", sourceId: "jr1", error: "HTTP 500" }),
}),
);
});
it("does not throw when adapter onHireApproved throws (non-fatal)", async () => {
vi.mocked(findServerAdapter).mockReturnValue({
type: "openclaw_gateway",
onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")),
} as any);
const db = mockDbWithAgent({
id: "a1",
companyId: "c1",
name: "OpenClaw Agent",
adapterType: "openclaw_gateway",
});
await expect(
notifyHireApproved(db, {
companyId: "c1",
agentId: "a1",
source: "join_request",
sourceId: "jr1",
}),
).resolves.toBeUndefined();
expect(logActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "hire_hook.error",
entityId: "a1",
details: expect.objectContaining({ source: "join_request", sourceId: "jr1", error: "Network error" }),
}),
);
});
});

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

@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import {
buildJoinDefaultsPayloadForAccept,
canReplayOpenClawGatewayInviteAccept,
mergeJoinDefaultsPayloadForReplay,
} from "../routes/access.js";
describe("canReplayOpenClawGatewayInviteAccept", () => {
it("allows replay only for openclaw_gateway agent joins in pending or approved state", () => {
expect(
canReplayOpenClawGatewayInviteAccept({
requestType: "agent",
adapterType: "openclaw_gateway",
existingJoinRequest: {
requestType: "agent",
adapterType: "openclaw_gateway",
status: "pending_approval",
},
}),
).toBe(true);
expect(
canReplayOpenClawGatewayInviteAccept({
requestType: "agent",
adapterType: "openclaw_gateway",
existingJoinRequest: {
requestType: "agent",
adapterType: "openclaw_gateway",
status: "approved",
},
}),
).toBe(true);
expect(
canReplayOpenClawGatewayInviteAccept({
requestType: "agent",
adapterType: "openclaw_gateway",
existingJoinRequest: {
requestType: "agent",
adapterType: "openclaw_gateway",
status: "rejected",
},
}),
).toBe(false);
expect(
canReplayOpenClawGatewayInviteAccept({
requestType: "human",
adapterType: "openclaw_gateway",
existingJoinRequest: {
requestType: "agent",
adapterType: "openclaw_gateway",
status: "pending_approval",
},
}),
).toBe(false);
});
});
describe("mergeJoinDefaultsPayloadForReplay", () => {
it("merges replay payloads and allows gateway token override", () => {
const merged = mergeJoinDefaultsPayloadForReplay(
{
url: "ws://old.example:18789",
paperclipApiUrl: "http://host.docker.internal:3100",
headers: {
"x-openclaw-token": "old-token-1234567890",
"x-custom": "keep-me",
},
},
{
paperclipApiUrl: "https://paperclip.example.com",
headers: {
"x-openclaw-token": "new-token-1234567890",
},
},
);
const normalized = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw_gateway",
defaultsPayload: merged,
inboundOpenClawAuthHeader: null,
}) as Record<string, unknown>;
expect(normalized.url).toBe("ws://old.example:18789");
expect(normalized.paperclipApiUrl).toBe("https://paperclip.example.com");
expect(normalized.headers).toMatchObject({
"x-openclaw-token": "new-token-1234567890",
"x-custom": "keep-me",
});
});
});

View file

@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { companyInviteExpiresAt } from "../routes/access.js";
describe("companyInviteExpiresAt", () => {
it("sets invite expiration to 10 minutes after invite creation time", () => {
const createdAtMs = Date.parse("2026-03-06T00:00:00.000Z");
const expiresAt = companyInviteExpiresAt(createdAtMs);
expect(expiresAt.toISOString()).toBe("2026-03-06T00:10:00.000Z");
});
});

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { resolveJoinRequestAgentManagerId } from "../routes/access.js";
describe("resolveJoinRequestAgentManagerId", () => {
it("returns null when no CEO exists in the company agent list", () => {
const managerId = resolveJoinRequestAgentManagerId([
{ id: "a1", role: "cto", reportsTo: null },
{ id: "a2", role: "engineer", reportsTo: "a1" },
]);
expect(managerId).toBeNull();
});
it("selects the root CEO when available", () => {
const managerId = resolveJoinRequestAgentManagerId([
{ id: "ceo-child", role: "ceo", reportsTo: "manager-1" },
{ id: "manager-1", role: "cto", reportsTo: null },
{ id: "ceo-root", role: "ceo", reportsTo: null },
]);
expect(managerId).toBe("ceo-root");
});
it("falls back to the first CEO when no root CEO is present", () => {
const managerId = resolveJoinRequestAgentManagerId([
{ id: "ceo-1", role: "ceo", reportsTo: "mgr" },
{ id: "ceo-2", role: "ceo", reportsTo: "mgr" },
{ id: "mgr", role: "cto", reportsTo: null },
]);
expect(managerId).toBe("ceo-1");
});
});

View file

@ -37,10 +37,22 @@ describe("buildInviteOnboardingTextDocument", () => {
allowedHostnames: [],
});
expect(text).toContain("Paperclip OpenClaw Onboarding");
expect(text).toContain("Paperclip OpenClaw Gateway Onboarding");
expect(text).toContain("/api/invites/token-123/accept");
expect(text).toContain("/api/join-requests/{requestId}/claim-api-key");
expect(text).toContain("/api/invites/token-123/onboarding.txt");
expect(text).toContain("Suggested Paperclip base URLs to try");
expect(text).toContain("http://localhost:3100");
expect(text).toContain("host.docker.internal");
expect(text).toContain("paperclipApiUrl");
expect(text).toContain("adapterType \"openclaw_gateway\"");
expect(text).toContain("headers.x-openclaw-token");
expect(text).toContain("Do NOT use /v1/responses or /hooks/*");
expect(text).toContain("set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl");
expect(text).toContain("~/.openclaw/workspace/paperclip-claimed-api-key.json");
expect(text).toContain("PAPERCLIP_API_KEY");
expect(text).toContain("saved token field");
expect(text).toContain("Gateway token unexpectedly short");
});
it("includes loopback diagnostics for authenticated/private onboarding", () => {
@ -69,5 +81,36 @@ describe("buildInviteOnboardingTextDocument", () => {
expect(text).toContain("Connectivity diagnostics");
expect(text).toContain("loopback hostname");
expect(text).toContain("If none are reachable");
});
it("includes inviter message in the onboarding text when provided", () => {
const req = buildReq("localhost:3100");
const invite = {
id: "invite-3",
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "agent",
tokenHash: "hash",
defaultsPayload: {
agentMessage: "Please join as our QA lead and prioritize flaky test triage first.",
},
expiresAt: new Date("2026-03-05T00:00:00.000Z"),
invitedByUserId: null,
revokedAt: null,
acceptedAt: null,
createdAt: new Date("2026-03-04T00:00:00.000Z"),
updatedAt: new Date("2026-03-04T00:00:00.000Z"),
} as const;
const text = buildInviteOnboardingTextDocument(req, "token-789", invite as any, {
deploymentMode: "local_trusted",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
});
expect(text).toContain("Message from inviter");
expect(text).toContain("prioritize flaky test triage first");
});
});

View file

@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { shouldWakeAssigneeOnCheckout } from "../routes/issues-checkout-wakeup.js";
describe("shouldWakeAssigneeOnCheckout", () => {
it("keeps wakeup behavior for board actors", () => {
expect(
shouldWakeAssigneeOnCheckout({
actorType: "board",
actorAgentId: null,
checkoutAgentId: "agent-1",
checkoutRunId: null,
}),
).toBe(true);
});
it("skips wakeup for agent self-checkout in an active run", () => {
expect(
shouldWakeAssigneeOnCheckout({
actorType: "agent",
actorAgentId: "agent-1",
checkoutAgentId: "agent-1",
checkoutRunId: "run-1",
}),
).toBe(false);
});
it("still wakes when checkout run id is missing", () => {
expect(
shouldWakeAssigneeOnCheckout({
actorType: "agent",
actorAgentId: "agent-1",
checkoutAgentId: "agent-1",
checkoutRunId: null,
}),
).toBe(true);
});
it("still wakes when agent checks out on behalf of another agent id", () => {
expect(
shouldWakeAssigneeOnCheckout({
actorType: "agent",
actorAgentId: "agent-1",
checkoutAgentId: "agent-2",
checkoutRunId: "run-1",
}),
).toBe(true);
});
});

View file

@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { deriveIssueUserContext } from "../services/issues.ts";
function makeIssue(overrides?: Partial<{
createdByUserId: string | null;
assigneeUserId: string | null;
createdAt: Date;
updatedAt: Date;
}>) {
return {
createdByUserId: null,
assigneeUserId: null,
createdAt: new Date("2026-03-06T10:00:00.000Z"),
updatedAt: new Date("2026-03-06T11:00:00.000Z"),
...overrides,
};
}
describe("deriveIssueUserContext", () => {
it("marks issue unread when external comments are newer than my latest comment", () => {
const context = deriveIssueUserContext(
makeIssue({ createdByUserId: "user-1" }),
"user-1",
{
myLastCommentAt: new Date("2026-03-06T12:00:00.000Z"),
myLastReadAt: null,
lastExternalCommentAt: new Date("2026-03-06T13:00:00.000Z"),
},
);
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T12:00:00.000Z");
expect(context.lastExternalCommentAt?.toISOString()).toBe("2026-03-06T13:00:00.000Z");
expect(context.isUnreadForMe).toBe(true);
});
it("marks issue read when my latest comment is newest", () => {
const context = deriveIssueUserContext(
makeIssue({ createdByUserId: "user-1" }),
"user-1",
{
myLastCommentAt: new Date("2026-03-06T14:00:00.000Z"),
myLastReadAt: null,
lastExternalCommentAt: new Date("2026-03-06T13:00:00.000Z"),
},
);
expect(context.isUnreadForMe).toBe(false);
});
it("uses issue creation time as fallback touch point for creator", () => {
const context = deriveIssueUserContext(
makeIssue({ createdByUserId: "user-1", createdAt: new Date("2026-03-06T09:00:00.000Z") }),
"user-1",
{
myLastCommentAt: null,
myLastReadAt: null,
lastExternalCommentAt: new Date("2026-03-06T10:00:00.000Z"),
},
);
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T09:00:00.000Z");
expect(context.isUnreadForMe).toBe(true);
});
it("uses issue updated time as fallback touch point for assignee", () => {
const context = deriveIssueUserContext(
makeIssue({ assigneeUserId: "user-1", updatedAt: new Date("2026-03-06T15:00:00.000Z") }),
"user-1",
{
myLastCommentAt: null,
myLastReadAt: null,
lastExternalCommentAt: new Date("2026-03-06T14:59:00.000Z"),
},
);
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T15:00:00.000Z");
expect(context.isUnreadForMe).toBe(false);
});
it("uses latest read timestamp to clear unread without requiring a comment", () => {
const context = deriveIssueUserContext(
makeIssue({ createdByUserId: "user-1", createdAt: new Date("2026-03-06T09:00:00.000Z") }),
"user-1",
{
myLastCommentAt: null,
myLastReadAt: new Date("2026-03-06T11:30:00.000Z"),
lastExternalCommentAt: new Date("2026-03-06T11:00:00.000Z"),
},
);
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T11:30:00.000Z");
expect(context.isUnreadForMe).toBe(false);
});
it("handles SQL timestamp strings without throwing", () => {
const context = deriveIssueUserContext(
makeIssue({
createdByUserId: "user-1",
createdAt: new Date("2026-03-06T09:00:00.000Z"),
}),
"user-1",
{
myLastCommentAt: "2026-03-06T10:00:00.000Z",
myLastReadAt: null,
lastExternalCommentAt: "2026-03-06T11:00:00.000Z",
},
);
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T10:00:00.000Z");
expect(context.lastExternalCommentAt?.toISOString()).toBe("2026-03-06T11:00:00.000Z");
expect(context.isUnreadForMe).toBe(true);
});
});

View file

@ -0,0 +1,493 @@
import { afterEach, describe, expect, it } from "vitest";
import { createServer } from "node:http";
import { WebSocketServer } from "ws";
import { execute, testEnvironment } from "@paperclipai/adapter-openclaw-gateway/server";
import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
function buildContext(
config: Record<string, unknown>,
overrides?: Partial<AdapterExecutionContext>,
): AdapterExecutionContext {
return {
runId: "run-123",
agent: {
id: "agent-123",
companyId: "company-123",
name: "OpenClaw Gateway Agent",
adapterType: "openclaw_gateway",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config,
context: {
taskId: "task-123",
issueId: "issue-123",
wakeReason: "issue_assigned",
issueIds: ["issue-123"],
},
onLog: async () => {},
...overrides,
};
}
async function createMockGatewayServer() {
const server = createServer();
const wss = new WebSocketServer({ server });
let agentPayload: Record<string, unknown> | 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") {
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"], events: ["agent"] },
snapshot: { version: 1, ts: Date.now() },
policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 },
},
}),
);
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: "cha" },
},
}),
);
socket.send(
JSON.stringify({
type: "event",
event: "agent",
payload: {
runId,
seq: 2,
stream: "assistant",
ts: Date.now(),
data: { delta: "chacha" },
},
}),
);
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()));
},
};
}
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
});
describe("openclaw gateway ui stdout parser", () => {
it("parses assistant deltas from gateway event lines", () => {
const ts = "2026-03-06T15:00:00.000Z";
const line =
'[openclaw-gateway:event] run=run-1 stream=assistant data={"delta":"hello"}';
expect(parseOpenClawGatewayStdoutLine(line, ts)).toEqual([
{
kind: "assistant",
ts,
text: "hello",
delta: true,
},
]);
});
});
describe("openclaw gateway adapter execute", () => {
it("runs connect -> agent -> agent.wait and forwards wake payload", async () => {
const gateway = await createMockGatewayServer();
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.timedOut).toBe(false);
expect(result.summary).toContain("chachacha");
expect(result.provider).toBe("openclaw");
const payload = gateway.getAgentPayload();
expect(payload).toBeTruthy();
expect(payload?.idempotencyKey).toBe("run-123");
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");
expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true);
} finally {
await gateway.close();
}
});
it("fails fast when url is missing", async () => {
const result = await execute(buildContext({}));
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", () => {
it("reports missing url as failure", async () => {
const result = await testEnvironment({
companyId: "company-123",
adapterType: "openclaw_gateway",
config: {},
});
expect(result.status).toBe("fail");
expect(result.checks.some((check) => check.code === "openclaw_gateway_url_missing")).toBe(true);
});
});

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

@ -5,7 +5,7 @@ import path from "node:path";
import { testEnvironment } from "@paperclipai/adapter-opencode-local/server";
describe("opencode_local environment diagnostics", () => {
it("creates a missing working directory when cwd is absolute", async () => {
it("reports a missing working directory as an error when cwd is absolute", async () => {
const cwd = path.join(
os.tmpdir(),
`paperclip-opencode-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`,
@ -23,11 +23,9 @@ describe("opencode_local environment diagnostics", () => {
},
});
expect(result.checks.some((check) => check.code === "opencode_cwd_valid")).toBe(true);
expect(result.checks.some((check) => check.level === "error")).toBe(false);
const stats = await fs.stat(cwd);
expect(stats.isDirectory()).toBe(true);
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
expect(result.checks.some((check) => check.code === "opencode_cwd_invalid")).toBe(true);
expect(result.checks.some((check) => check.level === "error")).toBe(true);
expect(result.status).toBe("fail");
});
it("treats an empty OPENAI_API_KEY override as missing", async () => {

View file

@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { resolveProjectNameForUniqueShortname } from "../services/projects.ts";
describe("resolveProjectNameForUniqueShortname", () => {
it("keeps name when shortname is not used", () => {
const resolved = resolveProjectNameForUniqueShortname("Platform", [
{ id: "p1", name: "Growth" },
]);
expect(resolved).toBe("Platform");
});
it("appends numeric suffix when shortname collides", () => {
const resolved = resolveProjectNameForUniqueShortname("Growth Team", [
{ id: "p1", name: "growth-team" },
]);
expect(resolved).toBe("Growth Team 2");
});
it("increments suffix until unique", () => {
const resolved = resolveProjectNameForUniqueShortname("Growth Team", [
{ id: "p1", name: "growth-team" },
{ id: "p2", name: "growth-team-2" },
]);
expect(resolved).toBe("Growth Team 3");
});
it("ignores excluded project id", () => {
const resolved = resolveProjectNameForUniqueShortname(
"Growth Team",
[
{ id: "p1", name: "growth-team" },
{ id: "p2", name: "platform" },
],
{ excludeProjectId: "p1" },
);
expect(resolved).toBe("Growth Team");
});
it("keeps non-normalizable names unchanged", () => {
const resolved = resolveProjectNameForUniqueShortname("!!!", [
{ id: "p1", name: "growth" },
]);
expect(resolved).toBe("!!!");
});
});