Merge branch 'master' into canonical-url
This commit is contained in:
@@ -1,6 +1,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 { 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";
|
||||
@@ -8,9 +9,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();
|
||||
});
|
||||
|
||||
@@ -60,6 +63,7 @@ describe("adapter model listing", () => {
|
||||
expect(models).toEqual(codexFallbackModels);
|
||||
});
|
||||
|
||||
|
||||
it("returns cursor fallback models when CLI discovery is unavailable", async () => {
|
||||
setCursorModelsRunnerForTests(() => ({
|
||||
status: null,
|
||||
@@ -90,4 +94,11 @@ 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);
|
||||
});
|
||||
|
||||
it("returns no opencode models when opencode command is unavailable", async () => {
|
||||
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
|
||||
|
||||
const models = await listAdapterModels("opencode_local");
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
37
server/src/__tests__/agent-shortname-collision.test.ts
Normal file
37
server/src/__tests__/agent-shortname-collision.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasAgentShortnameCollision } 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);
|
||||
});
|
||||
});
|
||||
180
server/src/__tests__/hire-hook.test.ts
Normal file
180
server/src/__tests__/hire-hook.test.ts
Normal 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",
|
||||
onHireApproved: vi.fn().mockResolvedValue({ ok: true }),
|
||||
} as any);
|
||||
|
||||
const db = mockDbWithAgent({
|
||||
id: "a1",
|
||||
companyId: "c1",
|
||||
name: "OpenClaw Agent",
|
||||
adapterType: "openclaw",
|
||||
});
|
||||
|
||||
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" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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",
|
||||
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",
|
||||
});
|
||||
|
||||
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",
|
||||
onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")),
|
||||
} as any);
|
||||
|
||||
const db = mockDbWithAgent({
|
||||
id: "a1",
|
||||
companyId: "c1",
|
||||
name: "OpenClaw Agent",
|
||||
adapterType: "openclaw",
|
||||
});
|
||||
|
||||
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" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
84
server/src/__tests__/invite-accept-openclaw-defaults.test.ts
Normal file
84
server/src/__tests__/invite-accept-openclaw-defaults.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildJoinDefaultsPayloadForAccept } 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("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);
|
||||
});
|
||||
});
|
||||
10
server/src/__tests__/invite-expiry.test.ts
Normal file
10
server/src/__tests__/invite-expiry.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,12 @@ describe("buildInviteOnboardingTextDocument", () => {
|
||||
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("/api/invites/token-123/test-resolution");
|
||||
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("set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl");
|
||||
});
|
||||
|
||||
it("includes loopback diagnostics for authenticated/private onboarding", () => {
|
||||
@@ -69,5 +75,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");
|
||||
});
|
||||
});
|
||||
|
||||
48
server/src/__tests__/issues-checkout-wakeup.test.ts
Normal file
48
server/src/__tests__/issues-checkout-wakeup.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
113
server/src/__tests__/issues-user-context.test.ts
Normal file
113
server/src/__tests__/issues-user-context.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
607
server/src/__tests__/openclaw-adapter.test.ts
Normal file
607
server/src/__tests__/openclaw-adapter.test.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { execute, testEnvironment, onHireApproved } from "@paperclipai/adapter-openclaw/server";
|
||||
import { parseOpenClawStdoutLine } from "@paperclipai/adapter-openclaw/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 Agent",
|
||||
adapterType: "openclaw",
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function sseResponse(lines: string[]) {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const line of lines) {
|
||||
controller.enqueue(encoder.encode(line));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
"content-type": "text/event-stream",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("openclaw ui stdout parser", () => {
|
||||
it("parses SSE deltas into assistant streaming entries", () => {
|
||||
const ts = "2026-03-05T23:07:16.296Z";
|
||||
const line =
|
||||
'[openclaw:sse] event=response.output_text.delta data={"type":"response.output_text.delta","delta":"hello"}';
|
||||
|
||||
expect(parseOpenClawStdoutLine(line, ts)).toEqual([
|
||||
{
|
||||
kind: "assistant",
|
||||
ts,
|
||||
text: "hello",
|
||||
delta: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses stdout-prefixed SSE deltas and preserves spacing", () => {
|
||||
const ts = "2026-03-05T23:07:16.296Z";
|
||||
const line =
|
||||
'stdout[openclaw:sse] event=response.output_text.delta data={"type":"response.output_text.delta","delta":" can"}';
|
||||
|
||||
expect(parseOpenClawStdoutLine(line, ts)).toEqual([
|
||||
{
|
||||
kind: "assistant",
|
||||
ts,
|
||||
text: " can",
|
||||
delta: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses response.completed into usage-aware result entries", () => {
|
||||
const ts = "2026-03-05T23:07:20.269Z";
|
||||
const line = JSON.stringify({
|
||||
type: "response.completed",
|
||||
response: {
|
||||
status: "completed",
|
||||
usage: {
|
||||
input_tokens: 12,
|
||||
output_tokens: 34,
|
||||
cached_input_tokens: 5,
|
||||
},
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
content: [
|
||||
{
|
||||
type: "output_text",
|
||||
text: "All done",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parseOpenClawStdoutLine(`[openclaw:sse] event=response.completed data=${line}`, ts)).toEqual([
|
||||
{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: "All done",
|
||||
inputTokens: 12,
|
||||
outputTokens: 34,
|
||||
cachedTokens: 5,
|
||||
costUsd: 0,
|
||||
subtype: "completed",
|
||||
isError: false,
|
||||
errors: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps SSE errors to stderr entries", () => {
|
||||
const ts = "2026-03-05T23:07:20.269Z";
|
||||
const line =
|
||||
'[openclaw:sse] event=response.failed data={"type":"response.failed","error":"timeout"}';
|
||||
|
||||
expect(parseOpenClawStdoutLine(line, ts)).toEqual([
|
||||
{
|
||||
kind: "stderr",
|
||||
ts,
|
||||
text: "timeout",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps stderr-prefixed lines to stderr transcript entries", () => {
|
||||
const ts = "2026-03-05T23:07:20.269Z";
|
||||
const line = "stderr OpenClaw transport error";
|
||||
|
||||
expect(parseOpenClawStdoutLine(line, ts)).toEqual([
|
||||
{
|
||||
kind: "stderr",
|
||||
ts,
|
||||
text: "OpenClaw transport error",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openclaw adapter execute", () => {
|
||||
it("uses strict SSE and includes canonical PAPERCLIP context in text payload", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
sseResponse([
|
||||
"event: response.completed\n",
|
||||
'data: {"type":"response.completed","status":"completed"}\n\n',
|
||||
]),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await execute(
|
||||
buildContext({
|
||||
url: "https://agent.example/sse",
|
||||
method: "POST",
|
||||
payloadTemplate: { foo: "bar", text: "OpenClaw task prompt" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record<string, unknown>;
|
||||
expect(body.foo).toBe("bar");
|
||||
expect(body.stream).toBe(true);
|
||||
expect(body.sessionKey).toBe("paperclip");
|
||||
expect((body.paperclip as Record<string, unknown>).streamTransport).toBe("sse");
|
||||
expect((body.paperclip as Record<string, unknown>).runId).toBe("run-123");
|
||||
expect((body.paperclip as Record<string, unknown>).sessionKey).toBe("paperclip");
|
||||
expect(
|
||||
((body.paperclip as Record<string, unknown>).env as Record<string, unknown>).PAPERCLIP_RUN_ID,
|
||||
).toBe("run-123");
|
||||
const text = String(body.text ?? "");
|
||||
expect(text).toContain("OpenClaw task prompt");
|
||||
expect(text).toContain("PAPERCLIP_RUN_ID=run-123");
|
||||
expect(text).toContain("PAPERCLIP_AGENT_ID=agent-123");
|
||||
expect(text).toContain("PAPERCLIP_COMPANY_ID=company-123");
|
||||
expect(text).toContain("PAPERCLIP_TASK_ID=task-123");
|
||||
expect(text).toContain("PAPERCLIP_WAKE_REASON=issue_assigned");
|
||||
expect(text).toContain("PAPERCLIP_LINKED_ISSUE_IDS=issue-123");
|
||||
});
|
||||
|
||||
it("uses paperclipApiUrl override when provided", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
sseResponse([
|
||||
"event: response.completed\n",
|
||||
'data: {"type":"response.completed","status":"completed"}\n\n',
|
||||
]),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await execute(
|
||||
buildContext({
|
||||
url: "https://agent.example/sse",
|
||||
method: "POST",
|
||||
paperclipApiUrl: "http://dotta-macbook-pro:3100",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record<string, unknown>;
|
||||
const paperclip = body.paperclip as Record<string, unknown>;
|
||||
const env = paperclip.env as Record<string, unknown>;
|
||||
expect(env.PAPERCLIP_API_URL).toBe("http://dotta-macbook-pro:3100/");
|
||||
expect(String(body.text ?? "")).toContain("PAPERCLIP_API_URL=http://dotta-macbook-pro:3100/");
|
||||
});
|
||||
|
||||
it("logs outbound header keys for auth debugging", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
sseResponse([
|
||||
"event: response.completed\n",
|
||||
'data: {"type":"response.completed","status":"completed"}\n\n',
|
||||
]),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const logs: string[] = [];
|
||||
const result = await execute(
|
||||
buildContext(
|
||||
{
|
||||
url: "https://agent.example/sse",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-openclaw-auth": "gateway-token",
|
||||
},
|
||||
},
|
||||
{
|
||||
onLog: async (_stream, chunk) => {
|
||||
logs.push(chunk);
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(
|
||||
logs.some((line) => line.includes("[openclaw] outbound header keys:") && line.includes("x-openclaw-auth")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("derives Authorization header from x-openclaw-auth when webhookAuthHeader is unset", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
sseResponse([
|
||||
"event: response.completed\n",
|
||||
'data: {"type":"response.completed","status":"completed"}\n\n',
|
||||
]),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await execute(
|
||||
buildContext({
|
||||
url: "https://agent.example/sse",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-openclaw-auth": "gateway-token",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const headers = (fetchMock.mock.calls[0]?.[1]?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers["x-openclaw-auth"]).toBe("gateway-token");
|
||||
expect(headers.authorization).toBe("Bearer gateway-token");
|
||||
});
|
||||
|
||||
it("derives issue session keys when configured", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
sseResponse([
|
||||
"event: done\n",
|
||||
"data: [DONE]\n\n",
|
||||
]),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await execute(
|
||||
buildContext({
|
||||
url: "https://agent.example/sse",
|
||||
method: "POST",
|
||||
sessionKeyStrategy: "issue",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record<string, unknown>;
|
||||
expect(body.sessionKey).toBe("paperclip:issue:issue-123");
|
||||
expect((body.paperclip as Record<string, unknown>).sessionKey).toBe("paperclip:issue:issue-123");
|
||||
});
|
||||
|
||||
it("maps requests to OpenResponses schema for /v1/responses endpoints", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
sseResponse([
|
||||
"event: response.completed\n",
|
||||
'data: {"type":"response.completed","status":"completed"}\n\n',
|
||||
]),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await execute(
|
||||
buildContext({
|
||||
url: "https://agent.example/v1/responses",
|
||||
method: "POST",
|
||||
payloadTemplate: {
|
||||
model: "openclaw",
|
||||
user: "paperclip",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record<string, unknown>;
|
||||
expect(body.stream).toBe(true);
|
||||
expect(body.model).toBe("openclaw");
|
||||
expect(typeof body.input).toBe("string");
|
||||
expect(String(body.input)).toContain("PAPERCLIP_RUN_ID=run-123");
|
||||
expect(body.metadata).toBeTypeOf("object");
|
||||
expect((body.metadata as Record<string, unknown>).PAPERCLIP_RUN_ID).toBe("run-123");
|
||||
expect(body.text).toBeUndefined();
|
||||
expect(body.paperclip).toBeUndefined();
|
||||
expect(body.sessionKey).toBeUndefined();
|
||||
|
||||
const headers = (fetchMock.mock.calls[0]?.[1]?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers["x-openclaw-session-key"]).toBe("paperclip");
|
||||
});
|
||||
|
||||
it("appends wake text when OpenResponses input is provided as a message object", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
sseResponse([
|
||||
"event: response.completed\n",
|
||||
'data: {"type":"response.completed","status":"completed"}\n\n',
|
||||
]),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await execute(
|
||||
buildContext({
|
||||
url: "https://agent.example/v1/responses",
|
||||
method: "POST",
|
||||
payloadTemplate: {
|
||||
model: "openclaw",
|
||||
input: {
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: "start with this context",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record<string, unknown>;
|
||||
const input = body.input as Record<string, unknown>;
|
||||
expect(input.type).toBe("message");
|
||||
expect(input.role).toBe("user");
|
||||
expect(Array.isArray(input.content)).toBe(true);
|
||||
|
||||
const content = input.content as Record<string, unknown>[];
|
||||
expect(content).toHaveLength(2);
|
||||
expect(content[0]).toEqual({
|
||||
type: "input_text",
|
||||
text: "start with this context",
|
||||
});
|
||||
expect(content[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "input_text",
|
||||
}),
|
||||
);
|
||||
expect(String(content[1]?.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
|
||||
});
|
||||
|
||||
it("fails when SSE endpoint does not return text/event-stream", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: false, error: "unexpected payload" }), {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await execute(
|
||||
buildContext({
|
||||
url: "https://agent.example/sse",
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorCode).toBe("openclaw_sse_expected_event_stream");
|
||||
});
|
||||
|
||||
it("fails when SSE stream closes without a terminal event", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
sseResponse([
|
||||
"event: response.delta\n",
|
||||
'data: {"type":"response.delta","delta":"partial"}\n\n',
|
||||
]),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await execute(
|
||||
buildContext({
|
||||
url: "https://agent.example/sse",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorCode).toBe("openclaw_sse_stream_incomplete");
|
||||
});
|
||||
|
||||
it("fails with explicit text-required error when endpoint rejects payload", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: "text required" }), {
|
||||
status: 400,
|
||||
statusText: "Bad Request",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await execute(
|
||||
buildContext({
|
||||
url: "https://agent.example/sse",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorCode).toBe("openclaw_text_required");
|
||||
});
|
||||
|
||||
it("rejects non-sse transport configuration", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await execute(
|
||||
buildContext({
|
||||
url: "https://agent.example/sse",
|
||||
streamTransport: "webhook",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorCode).toBe("openclaw_stream_transport_unsupported");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects /hooks/wake compatibility endpoints in strict SSE mode", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await execute(
|
||||
buildContext({
|
||||
url: "https://agent.example/hooks/wake",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorCode).toBe("openclaw_sse_incompatible_endpoint");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("openclaw adapter environment checks", () => {
|
||||
it("reports /hooks/wake endpoints as incompatible for strict SSE mode", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(null, { status: 405, statusText: "Method Not Allowed" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-123",
|
||||
adapterType: "openclaw",
|
||||
config: {
|
||||
url: "https://agent.example/hooks/wake",
|
||||
},
|
||||
deployment: {
|
||||
mode: "authenticated",
|
||||
exposure: "private",
|
||||
bindHost: "paperclip.internal",
|
||||
allowedHostnames: ["paperclip.internal"],
|
||||
},
|
||||
});
|
||||
|
||||
const check = result.checks.find((entry) => entry.code === "openclaw_wake_endpoint_incompatible");
|
||||
expect(check?.level).toBe("error");
|
||||
});
|
||||
|
||||
it("reports unsupported streamTransport settings", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(null, { status: 405, statusText: "Method Not Allowed" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-123",
|
||||
adapterType: "openclaw",
|
||||
config: {
|
||||
url: "https://agent.example/sse",
|
||||
streamTransport: "webhook",
|
||||
},
|
||||
});
|
||||
|
||||
const check = result.checks.find((entry) => entry.code === "openclaw_stream_transport_unsupported");
|
||||
expect(check?.level).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("onHireApproved", () => {
|
||||
it("returns ok when hireApprovedCallbackUrl is not set (no-op)", async () => {
|
||||
const result = await onHireApproved(
|
||||
{
|
||||
companyId: "c1",
|
||||
agentId: "a1",
|
||||
agentName: "Test Agent",
|
||||
adapterType: "openclaw",
|
||||
source: "join_request",
|
||||
sourceId: "jr1",
|
||||
approvedAt: "2026-03-06T00:00:00.000Z",
|
||||
message: "You're hired.",
|
||||
},
|
||||
{},
|
||||
);
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("POSTs payload to hireApprovedCallbackUrl with correct headers and body", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const payload = {
|
||||
companyId: "c1",
|
||||
agentId: "a1",
|
||||
agentName: "OpenClaw Agent",
|
||||
adapterType: "openclaw",
|
||||
source: "approval" as const,
|
||||
sourceId: "ap1",
|
||||
approvedAt: "2026-03-06T12:00:00.000Z",
|
||||
message: "Tell your user that your hire was approved.",
|
||||
};
|
||||
|
||||
const result = await onHireApproved(payload, {
|
||||
hireApprovedCallbackUrl: "https://callback.example/hire-approved",
|
||||
hireApprovedCallbackAuthHeader: "Bearer secret",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("https://callback.example/hire-approved");
|
||||
expect(init?.method).toBe("POST");
|
||||
expect((init?.headers as Record<string, string>)["content-type"]).toBe("application/json");
|
||||
expect((init?.headers as Record<string, string>)["Authorization"]).toBe("Bearer secret");
|
||||
const body = JSON.parse(init?.body as string);
|
||||
expect(body.event).toBe("hire_approved");
|
||||
expect(body.companyId).toBe(payload.companyId);
|
||||
expect(body.agentId).toBe(payload.agentId);
|
||||
expect(body.message).toBe(payload.message);
|
||||
});
|
||||
|
||||
it("returns failure when callback returns non-2xx", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("Server Error", { status: 500 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await onHireApproved(
|
||||
{
|
||||
companyId: "c1",
|
||||
agentId: "a1",
|
||||
agentName: "A",
|
||||
adapterType: "openclaw",
|
||||
source: "join_request",
|
||||
sourceId: "jr1",
|
||||
approvedAt: new Date().toISOString(),
|
||||
message: "Hired",
|
||||
},
|
||||
{ hireApprovedCallbackUrl: "https://example.com/hook" },
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("500");
|
||||
});
|
||||
});
|
||||
@@ -18,14 +18,18 @@ import {
|
||||
} from "@paperclipai/adapter-cursor-local/server";
|
||||
import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local";
|
||||
import {
|
||||
execute as opencodeExecute,
|
||||
testEnvironment as opencodeTestEnvironment,
|
||||
sessionCodec as opencodeSessionCodec,
|
||||
execute as openCodeExecute,
|
||||
testEnvironment as openCodeTestEnvironment,
|
||||
sessionCodec as openCodeSessionCodec,
|
||||
listOpenCodeModels,
|
||||
} from "@paperclipai/adapter-opencode-local/server";
|
||||
import { agentConfigurationDoc as opencodeAgentConfigurationDoc, models as opencodeModels } from "@paperclipai/adapter-opencode-local";
|
||||
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,
|
||||
@@ -57,16 +61,6 @@ const codexLocalAdapter: ServerAdapterModule = {
|
||||
agentConfigurationDoc: codexAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const opencodeLocalAdapter: ServerAdapterModule = {
|
||||
type: "opencode_local",
|
||||
execute: opencodeExecute,
|
||||
testEnvironment: opencodeTestEnvironment,
|
||||
sessionCodec: opencodeSessionCodec,
|
||||
models: opencodeModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: opencodeAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const cursorLocalAdapter: ServerAdapterModule = {
|
||||
type: "cursor",
|
||||
execute: cursorExecute,
|
||||
@@ -82,13 +76,25 @@ const openclawAdapter: ServerAdapterModule = {
|
||||
type: "openclaw",
|
||||
execute: openclawExecute,
|
||||
testEnvironment: openclawTestEnvironment,
|
||||
onHireApproved: openclawOnHireApproved,
|
||||
models: openclawModels,
|
||||
supportsLocalAgentJwt: false,
|
||||
agentConfigurationDoc: openclawAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const openCodeLocalAdapter: ServerAdapterModule = {
|
||||
type: "opencode_local",
|
||||
execute: openCodeExecute,
|
||||
testEnvironment: openCodeTestEnvironment,
|
||||
sessionCodec: openCodeSessionCodec,
|
||||
models: [],
|
||||
listModels: listOpenCodeModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: openCodeAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||
[claudeLocalAdapter, codexLocalAdapter, opencodeLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
|
||||
[claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
|
||||
);
|
||||
|
||||
export function getServerAdapter(type: string): ServerAdapterModule {
|
||||
|
||||
@@ -463,7 +463,7 @@ const app = await createApp(db as any, {
|
||||
betterAuthHandler,
|
||||
resolveSession,
|
||||
});
|
||||
const server = createServer(app);
|
||||
const server = createServer(app as unknown as Parameters<typeof createServer>[0]);
|
||||
const listenPort = await detectPort(config.port);
|
||||
|
||||
if (listenPort !== config.port) {
|
||||
|
||||
@@ -10,6 +10,9 @@ export function errorHandler(
|
||||
_next: NextFunction,
|
||||
) {
|
||||
if (err instanceof HttpError) {
|
||||
if (err.status >= 500) {
|
||||
(res as any).err = err;
|
||||
}
|
||||
res.status(err.status).json({
|
||||
error: err.message,
|
||||
...(err.details ? { details: err.details } : {}),
|
||||
@@ -26,8 +29,10 @@ export function errorHandler(
|
||||
? { message: err.message, stack: err.stack, name: err.name }
|
||||
: { raw: err };
|
||||
|
||||
// Attach the real error so pino-http can include it in its response log
|
||||
res.locals.serverError = errObj;
|
||||
// Attach the real error so pino-http uses it instead of its generic
|
||||
// "failed with status code 500" message in the response-complete log
|
||||
const realError = err instanceof Error ? err : Object.assign(new Error(String(err)), { raw: err });
|
||||
(res as any).err = realError;
|
||||
|
||||
logger.error(
|
||||
{ err: errObj, method: req.method, url: req.originalUrl },
|
||||
|
||||
@@ -55,11 +55,7 @@ export const httpLogger = pinoHttp({
|
||||
customErrorMessage(req, res) {
|
||||
return `${req.method} ${req.url} ${res.statusCode}`;
|
||||
},
|
||||
customProps(_req, res) {
|
||||
const serverError = (res as any).locals?.serverError;
|
||||
if (serverError) {
|
||||
return { serverError };
|
||||
}
|
||||
customProps() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,15 +1,45 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { IncomingMessage, Server as HttpServer } from "node:http";
|
||||
import { createRequire } from "node:module";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agentApiKeys, companyMemberships, instanceUserRoles } from "@paperclipai/db";
|
||||
import type { DeploymentMode } from "@paperclipai/shared";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { subscribeCompanyLiveEvents } from "../services/live-events.js";
|
||||
|
||||
interface WsSocket {
|
||||
readyState: number;
|
||||
ping(): void;
|
||||
send(data: string): void;
|
||||
terminate(): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
on(event: "pong", listener: () => void): void;
|
||||
on(event: "close", listener: () => void): void;
|
||||
on(event: "error", listener: (err: Error) => void): void;
|
||||
}
|
||||
|
||||
interface WsServer {
|
||||
clients: Set<WsSocket>;
|
||||
on(event: "connection", listener: (socket: WsSocket, req: IncomingMessage) => void): void;
|
||||
on(event: "close", listener: () => void): void;
|
||||
handleUpgrade(
|
||||
req: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
callback: (ws: WsSocket) => void,
|
||||
): void;
|
||||
emit(event: "connection", ws: WsSocket, req: IncomingMessage): boolean;
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { WebSocket, WebSocketServer } = require("ws") as {
|
||||
WebSocket: { OPEN: number };
|
||||
WebSocketServer: new (opts: { noServer: boolean }) => WsServer;
|
||||
};
|
||||
|
||||
interface UpgradeContext {
|
||||
companyId: string;
|
||||
actorType: "board" | "agent";
|
||||
@@ -154,8 +184,8 @@ export function setupLiveEventsWebSocketServer(
|
||||
},
|
||||
) {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
const cleanupByClient = new Map<WebSocket, () => void>();
|
||||
const aliveByClient = new Map<WebSocket, boolean>();
|
||||
const cleanupByClient = new Map<WsSocket, () => void>();
|
||||
const aliveByClient = new Map<WsSocket, boolean>();
|
||||
|
||||
const pingInterval = setInterval(() => {
|
||||
for (const socket of wss.clients) {
|
||||
@@ -168,7 +198,7 @@ export function setupLiveEventsWebSocketServer(
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
wss.on("connection", (socket, req) => {
|
||||
wss.on("connection", (socket: WsSocket, req: IncomingMessage) => {
|
||||
const context = (req as IncomingMessageWithContext).paperclipUpgradeContext;
|
||||
if (!context) {
|
||||
socket.close(1008, "missing context");
|
||||
@@ -194,7 +224,7 @@ export function setupLiveEventsWebSocketServer(
|
||||
aliveByClient.delete(socket);
|
||||
});
|
||||
|
||||
socket.on("error", (err) => {
|
||||
socket.on("error", (err: Error) => {
|
||||
logger.warn({ err, companyId: context.companyId }, "live websocket client error");
|
||||
});
|
||||
});
|
||||
@@ -229,7 +259,7 @@ export function setupLiveEventsWebSocketServer(
|
||||
const reqWithContext = req as IncomingMessageWithContext;
|
||||
reqWithContext.paperclipUpgradeContext = context;
|
||||
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.handleUpgrade(req, socket, head, (ws: WsSocket) => {
|
||||
wss.emit("connection", ws, reqWithContext);
|
||||
});
|
||||
})
|
||||
|
||||
@@ -23,8 +23,9 @@ import {
|
||||
} from "@paperclipai/shared";
|
||||
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
|
||||
import { forbidden, conflict, notFound, unauthorized, badRequest } from "../errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { accessService, agentService, logActivity } from "../services/index.js";
|
||||
import { accessService, agentService, logActivity, notifyHireApproved } from "../services/index.js";
|
||||
import { assertCompanyAccess } from "./authz.js";
|
||||
import { claimBoardOwnership, inspectBoardClaimChallenge } from "../board-claim.js";
|
||||
|
||||
@@ -32,14 +33,29 @@ function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
const INVITE_TOKEN_PREFIX = "pcp_invite_";
|
||||
const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const INVITE_TOKEN_SUFFIX_LENGTH = 8;
|
||||
const INVITE_TOKEN_MAX_RETRIES = 5;
|
||||
const COMPANY_INVITE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
function createInviteToken() {
|
||||
return `pcp_invite_${randomBytes(24).toString("hex")}`;
|
||||
const bytes = randomBytes(INVITE_TOKEN_SUFFIX_LENGTH);
|
||||
let suffix = "";
|
||||
for (let idx = 0; idx < INVITE_TOKEN_SUFFIX_LENGTH; idx += 1) {
|
||||
suffix += INVITE_TOKEN_ALPHABET[bytes[idx]! % INVITE_TOKEN_ALPHABET.length];
|
||||
}
|
||||
return `${INVITE_TOKEN_PREFIX}${suffix}`;
|
||||
}
|
||||
|
||||
function createClaimSecret() {
|
||||
return `pcp_claim_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
export function companyInviteExpiresAt(nowMs: number = Date.now()) {
|
||||
return new Date(nowMs + COMPANY_INVITE_TTL_MS);
|
||||
}
|
||||
|
||||
function tokenHashesMatch(left: string, right: string) {
|
||||
const leftBytes = Buffer.from(left, "utf8");
|
||||
const rightBytes = Buffer.from(right, "utf8");
|
||||
@@ -94,6 +110,11 @@ function isLoopbackHost(hostname: string): boolean {
|
||||
return value === "localhost" || value === "127.0.0.1" || value === "::1";
|
||||
}
|
||||
|
||||
function isWakePath(pathname: string): boolean {
|
||||
const value = pathname.trim().toLowerCase();
|
||||
return value === "/hooks/wake" || value.endsWith("/hooks/wake");
|
||||
}
|
||||
|
||||
function normalizeHostname(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
@@ -120,6 +141,131 @@ function normalizeHeaderMap(input: unknown): Record<string, string> | undefined
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
function nonEmptyTrimmedString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function headerMapHasKeyIgnoreCase(headers: Record<string, string>, targetKey: string): boolean {
|
||||
const normalizedTarget = targetKey.trim().toLowerCase();
|
||||
return Object.keys(headers).some((key) => key.trim().toLowerCase() === normalizedTarget);
|
||||
}
|
||||
|
||||
function headerMapGetIgnoreCase(headers: Record<string, string>, targetKey: string): string | null {
|
||||
const normalizedTarget = targetKey.trim().toLowerCase();
|
||||
const key = Object.keys(headers).find((candidate) => candidate.trim().toLowerCase() === normalizedTarget);
|
||||
if (!key) return null;
|
||||
const value = headers[key];
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function toAuthorizationHeaderValue(rawToken: string): string {
|
||||
const trimmed = rawToken.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`;
|
||||
}
|
||||
|
||||
export function buildJoinDefaultsPayloadForAccept(input: {
|
||||
adapterType: string | null;
|
||||
defaultsPayload: unknown;
|
||||
responsesWebhookUrl?: unknown;
|
||||
responsesWebhookMethod?: unknown;
|
||||
responsesWebhookHeaders?: unknown;
|
||||
paperclipApiUrl?: unknown;
|
||||
webhookAuthHeader?: unknown;
|
||||
inboundOpenClawAuthHeader?: string | null;
|
||||
}): unknown {
|
||||
if (input.adapterType !== "openclaw") {
|
||||
return input.defaultsPayload;
|
||||
}
|
||||
|
||||
const merged = isPlainObject(input.defaultsPayload)
|
||||
? { ...(input.defaultsPayload as Record<string, unknown>) }
|
||||
: {} as Record<string, unknown>;
|
||||
|
||||
if (!nonEmptyTrimmedString(merged.url)) {
|
||||
const legacyUrl = nonEmptyTrimmedString(input.responsesWebhookUrl);
|
||||
if (legacyUrl) merged.url = legacyUrl;
|
||||
}
|
||||
|
||||
if (!nonEmptyTrimmedString(merged.method)) {
|
||||
const legacyMethod = nonEmptyTrimmedString(input.responsesWebhookMethod);
|
||||
if (legacyMethod) merged.method = legacyMethod.toUpperCase();
|
||||
}
|
||||
|
||||
if (!nonEmptyTrimmedString(merged.paperclipApiUrl)) {
|
||||
const legacyPaperclipApiUrl = nonEmptyTrimmedString(input.paperclipApiUrl);
|
||||
if (legacyPaperclipApiUrl) merged.paperclipApiUrl = legacyPaperclipApiUrl;
|
||||
}
|
||||
|
||||
if (!nonEmptyTrimmedString(merged.webhookAuthHeader)) {
|
||||
const providedWebhookAuthHeader = nonEmptyTrimmedString(input.webhookAuthHeader);
|
||||
if (providedWebhookAuthHeader) merged.webhookAuthHeader = providedWebhookAuthHeader;
|
||||
}
|
||||
|
||||
const mergedHeaders = normalizeHeaderMap(merged.headers) ?? {};
|
||||
const compatibilityHeaders = normalizeHeaderMap(input.responsesWebhookHeaders);
|
||||
if (compatibilityHeaders) {
|
||||
for (const [key, value] of Object.entries(compatibilityHeaders)) {
|
||||
if (!headerMapHasKeyIgnoreCase(mergedHeaders, key)) {
|
||||
mergedHeaders[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inboundOpenClawAuthHeader = nonEmptyTrimmedString(input.inboundOpenClawAuthHeader);
|
||||
if (inboundOpenClawAuthHeader && !headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-auth")) {
|
||||
mergedHeaders["x-openclaw-auth"] = inboundOpenClawAuthHeader;
|
||||
}
|
||||
|
||||
if (Object.keys(mergedHeaders).length > 0) {
|
||||
merged.headers = mergedHeaders;
|
||||
} else {
|
||||
delete merged.headers;
|
||||
}
|
||||
|
||||
const hasAuthorizationHeader = headerMapHasKeyIgnoreCase(mergedHeaders, "authorization");
|
||||
const hasWebhookAuthHeader = Boolean(nonEmptyTrimmedString(merged.webhookAuthHeader));
|
||||
if (!hasAuthorizationHeader && !hasWebhookAuthHeader) {
|
||||
const openClawAuthToken = headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-auth");
|
||||
if (openClawAuthToken) {
|
||||
merged.webhookAuthHeader = toAuthorizationHeaderValue(openClawAuthToken);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(merged).length > 0 ? merged : null;
|
||||
}
|
||||
|
||||
function summarizeSecretForLog(value: unknown): { present: true; length: number; sha256Prefix: string } | null {
|
||||
const trimmed = nonEmptyTrimmedString(value);
|
||||
if (!trimmed) return null;
|
||||
return {
|
||||
present: true,
|
||||
length: trimmed.length,
|
||||
sha256Prefix: hashToken(trimmed).slice(0, 12),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeOpenClawDefaultsForLog(defaultsPayload: unknown) {
|
||||
const defaults = isPlainObject(defaultsPayload) ? (defaultsPayload as Record<string, unknown>) : null;
|
||||
const headers = defaults ? normalizeHeaderMap(defaults.headers) : undefined;
|
||||
const openClawAuthHeaderValue = headers
|
||||
? Object.entries(headers).find(([key]) => key.trim().toLowerCase() === "x-openclaw-auth")?.[1] ?? null
|
||||
: null;
|
||||
|
||||
return {
|
||||
present: Boolean(defaults),
|
||||
keys: defaults ? Object.keys(defaults).sort() : [],
|
||||
url: defaults ? nonEmptyTrimmedString(defaults.url) : null,
|
||||
method: defaults ? nonEmptyTrimmedString(defaults.method) : null,
|
||||
paperclipApiUrl: defaults ? nonEmptyTrimmedString(defaults.paperclipApiUrl) : null,
|
||||
headerKeys: headers ? Object.keys(headers).sort() : [],
|
||||
webhookAuthHeader: defaults ? summarizeSecretForLog(defaults.webhookAuthHeader) : null,
|
||||
openClawAuthHeader: summarizeSecretForLog(openClawAuthHeaderValue),
|
||||
};
|
||||
}
|
||||
|
||||
function buildJoinConnectivityDiagnostics(input: {
|
||||
deploymentMode: DeploymentMode;
|
||||
deploymentExposure: DeploymentExposure;
|
||||
@@ -207,13 +353,13 @@ function normalizeAgentDefaultsForJoin(input: {
|
||||
code: "openclaw_callback_config_missing",
|
||||
level: "warn",
|
||||
message: "No OpenClaw callback config was provided in agentDefaultsPayload.",
|
||||
hint: "Include agentDefaultsPayload.url so Paperclip can invoke the OpenClaw webhook immediately after approval.",
|
||||
hint: "Include agentDefaultsPayload.url so Paperclip can invoke the OpenClaw SSE endpoint immediately after approval.",
|
||||
});
|
||||
return { normalized: null as Record<string, unknown> | null, diagnostics };
|
||||
}
|
||||
|
||||
const defaults = input.defaultsPayload as Record<string, unknown>;
|
||||
const normalized: Record<string, unknown> = {};
|
||||
const normalized: Record<string, unknown> = { streamTransport: "sse" };
|
||||
|
||||
let callbackUrl: URL | null = null;
|
||||
const rawUrl = typeof defaults.url === "string" ? defaults.url.trim() : "";
|
||||
@@ -222,7 +368,7 @@ function normalizeAgentDefaultsForJoin(input: {
|
||||
code: "openclaw_callback_url_missing",
|
||||
level: "warn",
|
||||
message: "OpenClaw callback URL is missing.",
|
||||
hint: "Set agentDefaultsPayload.url to your OpenClaw webhook endpoint.",
|
||||
hint: "Set agentDefaultsPayload.url to your OpenClaw SSE endpoint.",
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
@@ -242,6 +388,14 @@ function normalizeAgentDefaultsForJoin(input: {
|
||||
message: `Callback endpoint set to ${callbackUrl.toString()}`,
|
||||
});
|
||||
}
|
||||
if (isWakePath(callbackUrl.pathname)) {
|
||||
diagnostics.push({
|
||||
code: "openclaw_callback_wake_path_incompatible",
|
||||
level: "warn",
|
||||
message: "Configured callback path targets /hooks/wake, which is not stream-capable for strict SSE mode.",
|
||||
hint: "Use an endpoint that returns text/event-stream for the full run duration.",
|
||||
});
|
||||
}
|
||||
if (isLoopbackHost(callbackUrl.hostname)) {
|
||||
diagnostics.push({
|
||||
code: "openclaw_callback_loopback",
|
||||
@@ -263,7 +417,7 @@ function normalizeAgentDefaultsForJoin(input: {
|
||||
normalized.method = rawMethod || "POST";
|
||||
|
||||
if (typeof defaults.timeoutSec === "number" && Number.isFinite(defaults.timeoutSec)) {
|
||||
normalized.timeoutSec = Math.max(1, Math.min(120, Math.floor(defaults.timeoutSec)));
|
||||
normalized.timeoutSec = Math.max(0, Math.min(7200, Math.floor(defaults.timeoutSec)));
|
||||
}
|
||||
|
||||
const headers = normalizeHeaderMap(defaults.headers);
|
||||
@@ -277,6 +431,44 @@ function normalizeAgentDefaultsForJoin(input: {
|
||||
normalized.payloadTemplate = defaults.payloadTemplate;
|
||||
}
|
||||
|
||||
const rawPaperclipApiUrl = typeof defaults.paperclipApiUrl === "string"
|
||||
? defaults.paperclipApiUrl.trim()
|
||||
: "";
|
||||
if (rawPaperclipApiUrl) {
|
||||
try {
|
||||
const parsedPaperclipApiUrl = new URL(rawPaperclipApiUrl);
|
||||
if (parsedPaperclipApiUrl.protocol !== "http:" && parsedPaperclipApiUrl.protocol !== "https:") {
|
||||
diagnostics.push({
|
||||
code: "openclaw_paperclip_api_url_protocol",
|
||||
level: "warn",
|
||||
message: `paperclipApiUrl must use http:// or https:// (got ${parsedPaperclipApiUrl.protocol}).`,
|
||||
});
|
||||
} else {
|
||||
normalized.paperclipApiUrl = parsedPaperclipApiUrl.toString();
|
||||
diagnostics.push({
|
||||
code: "openclaw_paperclip_api_url_configured",
|
||||
level: "info",
|
||||
message: `paperclipApiUrl set to ${parsedPaperclipApiUrl.toString()}`,
|
||||
});
|
||||
if (isLoopbackHost(parsedPaperclipApiUrl.hostname)) {
|
||||
diagnostics.push({
|
||||
code: "openclaw_paperclip_api_url_loopback",
|
||||
level: "warn",
|
||||
message:
|
||||
"paperclipApiUrl uses loopback hostname. Remote OpenClaw workers cannot reach localhost on the Paperclip host.",
|
||||
hint: "Use a reachable hostname/IP and keep it in allowed hostnames for authenticated/private deployments.",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
diagnostics.push({
|
||||
code: "openclaw_paperclip_api_url_invalid",
|
||||
level: "warn",
|
||||
message: `Invalid paperclipApiUrl: ${rawPaperclipApiUrl}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
diagnostics.push(
|
||||
...buildJoinConnectivityDiagnostics({
|
||||
deploymentMode: input.deploymentMode,
|
||||
@@ -294,6 +486,7 @@ function toInviteSummaryResponse(req: Request, token: string, invite: typeof inv
|
||||
const baseUrl = requestBaseUrl(req);
|
||||
const onboardingPath = `/api/invites/${token}/onboarding`;
|
||||
const onboardingTextPath = `/api/invites/${token}/onboarding.txt`;
|
||||
const inviteMessage = extractInviteMessage(invite);
|
||||
return {
|
||||
id: invite.id,
|
||||
companyId: invite.companyId,
|
||||
@@ -306,6 +499,7 @@ function toInviteSummaryResponse(req: Request, token: string, invite: typeof inv
|
||||
onboardingTextUrl: baseUrl ? `${baseUrl}${onboardingTextPath}` : onboardingTextPath,
|
||||
skillIndexPath: "/api/skills/index",
|
||||
skillIndexUrl: baseUrl ? `${baseUrl}/api/skills/index` : "/api/skills/index",
|
||||
inviteMessage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -375,6 +569,46 @@ function buildOnboardingDiscoveryDiagnostics(input: {
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function buildOnboardingConnectionCandidates(input: {
|
||||
apiBaseUrl: string;
|
||||
bindHost: string;
|
||||
allowedHostnames: string[];
|
||||
}): string[] {
|
||||
let base: URL | null = null;
|
||||
try {
|
||||
if (input.apiBaseUrl) {
|
||||
base = new URL(input.apiBaseUrl);
|
||||
}
|
||||
} catch {
|
||||
base = null;
|
||||
}
|
||||
|
||||
const protocol = base?.protocol ?? "http:";
|
||||
const port = base?.port ? `:${base.port}` : "";
|
||||
const candidates = new Set<string>();
|
||||
|
||||
if (base) {
|
||||
candidates.add(base.origin);
|
||||
}
|
||||
|
||||
const bindHost = normalizeHostname(input.bindHost);
|
||||
if (bindHost && !isLoopbackHost(bindHost)) {
|
||||
candidates.add(`${protocol}//${bindHost}${port}`);
|
||||
}
|
||||
|
||||
for (const rawHost of input.allowedHostnames) {
|
||||
const host = normalizeHostname(rawHost);
|
||||
if (!host) continue;
|
||||
candidates.add(`${protocol}//${host}${port}`);
|
||||
}
|
||||
|
||||
if (base && isLoopbackHost(base.hostname)) {
|
||||
candidates.add(`${protocol}//host.docker.internal${port}`);
|
||||
}
|
||||
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
function buildInviteOnboardingManifest(
|
||||
req: Request,
|
||||
token: string,
|
||||
@@ -393,6 +627,8 @@ function buildInviteOnboardingManifest(
|
||||
const registrationEndpointUrl = baseUrl ? `${baseUrl}${registrationEndpointPath}` : registrationEndpointPath;
|
||||
const onboardingTextPath = `/api/invites/${token}/onboarding.txt`;
|
||||
const onboardingTextUrl = baseUrl ? `${baseUrl}${onboardingTextPath}` : onboardingTextPath;
|
||||
const testResolutionPath = `/api/invites/${token}/test-resolution`;
|
||||
const testResolutionUrl = baseUrl ? `${baseUrl}${testResolutionPath}` : testResolutionPath;
|
||||
const discoveryDiagnostics = buildOnboardingDiscoveryDiagnostics({
|
||||
apiBaseUrl: baseUrl,
|
||||
deploymentMode: opts.deploymentMode,
|
||||
@@ -400,20 +636,26 @@ function buildInviteOnboardingManifest(
|
||||
bindHost: opts.bindHost,
|
||||
allowedHostnames: opts.allowedHostnames,
|
||||
});
|
||||
const connectionCandidates = buildOnboardingConnectionCandidates({
|
||||
apiBaseUrl: baseUrl,
|
||||
bindHost: opts.bindHost,
|
||||
allowedHostnames: opts.allowedHostnames,
|
||||
});
|
||||
|
||||
return {
|
||||
invite: toInviteSummaryResponse(req, token, invite),
|
||||
onboarding: {
|
||||
instructions:
|
||||
"Join as an agent, save your one-time claim secret, wait for board approval, then claim your API key and install the Paperclip skill before starting heartbeat loops.",
|
||||
inviteMessage: extractInviteMessage(invite),
|
||||
recommendedAdapterType: "openclaw",
|
||||
requiredFields: {
|
||||
requestType: "agent",
|
||||
agentName: "Display name for this agent",
|
||||
adapterType: "Use 'openclaw' for OpenClaw webhook-based agents",
|
||||
adapterType: "Use 'openclaw' for OpenClaw streaming agents",
|
||||
capabilities: "Optional capability summary",
|
||||
agentDefaultsPayload:
|
||||
"Optional adapter config such as url/method/headers/webhookAuthHeader for OpenClaw callback endpoint",
|
||||
"Optional adapter config such as url/method/headers/webhookAuthHeader and paperclipApiUrl for OpenClaw SSE endpoint",
|
||||
},
|
||||
registrationEndpoint: {
|
||||
method: "POST",
|
||||
@@ -432,6 +674,16 @@ function buildInviteOnboardingManifest(
|
||||
deploymentExposure: opts.deploymentExposure,
|
||||
bindHost: opts.bindHost,
|
||||
allowedHostnames: opts.allowedHostnames,
|
||||
connectionCandidates,
|
||||
testResolutionEndpoint: {
|
||||
method: "GET",
|
||||
path: testResolutionPath,
|
||||
url: testResolutionUrl,
|
||||
query: {
|
||||
url: "https://your-openclaw-agent.example/v1/responses",
|
||||
timeoutMs: 5000,
|
||||
},
|
||||
},
|
||||
diagnostics: discoveryDiagnostics,
|
||||
guidance:
|
||||
opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private"
|
||||
@@ -466,11 +718,17 @@ export function buildInviteOnboardingTextDocument(
|
||||
) {
|
||||
const manifest = buildInviteOnboardingManifest(req, token, invite, opts);
|
||||
const onboarding = manifest.onboarding as {
|
||||
inviteMessage?: string | null;
|
||||
registrationEndpoint: { method: string; path: string; url: string };
|
||||
claimEndpointTemplate: { method: string; path: string };
|
||||
textInstructions: { path: string; url: string };
|
||||
skill: { path: string; url: string; installPath: string };
|
||||
connectivity: { diagnostics?: JoinDiagnostic[]; guidance?: string };
|
||||
connectivity: {
|
||||
diagnostics?: JoinDiagnostic[];
|
||||
guidance?: string;
|
||||
connectionCandidates?: string[];
|
||||
testResolutionEndpoint?: { method?: string; path?: string; url?: string };
|
||||
};
|
||||
};
|
||||
const diagnostics = Array.isArray(onboarding.connectivity?.diagnostics)
|
||||
? onboarding.connectivity.diagnostics
|
||||
@@ -486,6 +744,13 @@ export function buildInviteOnboardingTextDocument(
|
||||
`- allowedJoinTypes: ${invite.allowedJoinTypes}`,
|
||||
`- expiresAt: ${invite.expiresAt.toISOString()}`,
|
||||
"",
|
||||
];
|
||||
|
||||
if (onboarding.inviteMessage) {
|
||||
lines.push("## Message from inviter", onboarding.inviteMessage, "");
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"## Step 1: Submit agent join request",
|
||||
`${onboarding.registrationEndpoint.method} ${onboarding.registrationEndpoint.url}`,
|
||||
"",
|
||||
@@ -496,10 +761,12 @@ export function buildInviteOnboardingTextDocument(
|
||||
' "adapterType": "openclaw",',
|
||||
' "capabilities": "Optional summary",',
|
||||
' "agentDefaultsPayload": {',
|
||||
' "url": "https://your-openclaw-webhook.example/webhook",',
|
||||
' "url": "https://your-openclaw-agent.example/v1/responses",',
|
||||
' "paperclipApiUrl": "https://paperclip-hostname-your-agent-can-reach:3100",',
|
||||
' "streamTransport": "sse",',
|
||||
' "method": "POST",',
|
||||
' "headers": { "x-openclaw-auth": "replace-me" },',
|
||||
' "timeoutSec": 30',
|
||||
' "timeoutSec": 0',
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
@@ -533,7 +800,39 @@ export function buildInviteOnboardingTextDocument(
|
||||
"",
|
||||
"## Connectivity guidance",
|
||||
onboarding.connectivity?.guidance ?? "Ensure Paperclip is reachable from your OpenClaw runtime.",
|
||||
];
|
||||
);
|
||||
|
||||
if (onboarding.connectivity?.testResolutionEndpoint?.url) {
|
||||
lines.push(
|
||||
"",
|
||||
"## Optional: test callback resolution from Paperclip",
|
||||
`${onboarding.connectivity.testResolutionEndpoint.method ?? "GET"} ${onboarding.connectivity.testResolutionEndpoint.url}?url=https%3A%2F%2Fyour-openclaw-agent.example%2Fv1%2Fresponses`,
|
||||
"",
|
||||
"This endpoint checks whether Paperclip can reach your OpenClaw endpoint and reports reachable, timeout, or unreachable.",
|
||||
);
|
||||
}
|
||||
|
||||
const connectionCandidates = Array.isArray(onboarding.connectivity?.connectionCandidates)
|
||||
? onboarding.connectivity.connectionCandidates.filter((entry): entry is string => Boolean(entry))
|
||||
: [];
|
||||
|
||||
if (connectionCandidates.length > 0) {
|
||||
lines.push("", "## Suggested Paperclip base URLs to try");
|
||||
for (const candidate of connectionCandidates) {
|
||||
lines.push(`- ${candidate}`);
|
||||
}
|
||||
lines.push(
|
||||
"",
|
||||
"Test each candidate with:",
|
||||
"- GET <candidate>/api/health",
|
||||
"- set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl when submitting your join request",
|
||||
"",
|
||||
"If none are reachable: ask your human operator for a reachable hostname/address and help them update network configuration.",
|
||||
"For authenticated/private mode, they may need:",
|
||||
"- pnpm paperclipai allowed-hostname <host>",
|
||||
"- then restart Paperclip and retry onboarding.",
|
||||
);
|
||||
}
|
||||
|
||||
if (diagnostics.length > 0) {
|
||||
lines.push("", "## Connectivity diagnostics");
|
||||
@@ -551,10 +850,39 @@ export function buildInviteOnboardingTextDocument(
|
||||
`${onboarding.skill.path}`,
|
||||
manifest.invite.onboardingPath,
|
||||
);
|
||||
if (onboarding.connectivity?.testResolutionEndpoint?.path) {
|
||||
lines.push(`${onboarding.connectivity.testResolutionEndpoint.path}`);
|
||||
}
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function extractInviteMessage(invite: typeof invites.$inferSelect): string | null {
|
||||
const rawDefaults = invite.defaultsPayload;
|
||||
if (!rawDefaults || typeof rawDefaults !== "object" || Array.isArray(rawDefaults)) {
|
||||
return null;
|
||||
}
|
||||
const rawMessage = (rawDefaults as Record<string, unknown>).agentMessage;
|
||||
if (typeof rawMessage !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = rawMessage.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
|
||||
function mergeInviteDefaults(
|
||||
defaultsPayload: Record<string, unknown> | null | undefined,
|
||||
agentMessage: string | null,
|
||||
): Record<string, unknown> | null {
|
||||
const merged = defaultsPayload && typeof defaultsPayload === "object"
|
||||
? { ...defaultsPayload }
|
||||
: {};
|
||||
if (agentMessage) {
|
||||
merged.agentMessage = agentMessage;
|
||||
}
|
||||
return Object.keys(merged).length ? merged : null;
|
||||
}
|
||||
|
||||
function requestIp(req: Request) {
|
||||
const forwarded = req.header("x-forwarded-for");
|
||||
if (forwarded) {
|
||||
@@ -614,6 +942,96 @@ function grantsFromDefaults(
|
||||
return result;
|
||||
}
|
||||
|
||||
function isInviteTokenHashCollisionError(error: unknown) {
|
||||
const candidates = [
|
||||
error,
|
||||
(error as { cause?: unknown } | null)?.cause ?? null,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate || typeof candidate !== "object") continue;
|
||||
const code = "code" in candidate && typeof candidate.code === "string" ? candidate.code : null;
|
||||
const message = "message" in candidate && typeof candidate.message === "string" ? candidate.message : "";
|
||||
const constraint = "constraint" in candidate && typeof candidate.constraint === "string"
|
||||
? candidate.constraint
|
||||
: null;
|
||||
if (code !== "23505") continue;
|
||||
if (constraint === "invites_token_hash_unique_idx") return true;
|
||||
if (message.includes("invites_token_hash_unique_idx")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown) {
|
||||
return error instanceof Error && error.name === "AbortError";
|
||||
}
|
||||
|
||||
type InviteResolutionProbe = {
|
||||
status: "reachable" | "timeout" | "unreachable";
|
||||
method: "HEAD";
|
||||
durationMs: number;
|
||||
httpStatus: number | null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
async function probeInviteResolutionTarget(url: URL, timeoutMs: number): Promise<InviteResolutionProbe> {
|
||||
const startedAt = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "HEAD",
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const durationMs = Date.now() - startedAt;
|
||||
if (
|
||||
response.ok ||
|
||||
response.status === 401 ||
|
||||
response.status === 403 ||
|
||||
response.status === 404 ||
|
||||
response.status === 405 ||
|
||||
response.status === 422 ||
|
||||
response.status === 500 ||
|
||||
response.status === 501
|
||||
) {
|
||||
return {
|
||||
status: "reachable",
|
||||
method: "HEAD",
|
||||
durationMs,
|
||||
httpStatus: response.status,
|
||||
message: `Webhook endpoint responded to HEAD with HTTP ${response.status}.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "unreachable",
|
||||
method: "HEAD",
|
||||
durationMs,
|
||||
httpStatus: response.status,
|
||||
message: `Webhook endpoint probe returned HTTP ${response.status}.`,
|
||||
};
|
||||
} catch (error) {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
if (isAbortError(error)) {
|
||||
return {
|
||||
status: "timeout",
|
||||
method: "HEAD",
|
||||
durationMs,
|
||||
httpStatus: null,
|
||||
message: `Webhook endpoint probe timed out after ${timeoutMs}ms.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "unreachable",
|
||||
method: "HEAD",
|
||||
durationMs,
|
||||
httpStatus: null,
|
||||
message: error instanceof Error ? error.message : "Webhook endpoint probe failed.",
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export function accessRoutes(
|
||||
db: Db,
|
||||
opts: {
|
||||
@@ -704,21 +1122,43 @@ export function accessRoutes(
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCompanyPermission(req, companyId, "users:invite");
|
||||
const normalizedAgentMessage = typeof req.body.agentMessage === "string"
|
||||
? req.body.agentMessage.trim() || null
|
||||
: null;
|
||||
const insertValues = {
|
||||
companyId,
|
||||
inviteType: "company_join" as const,
|
||||
allowedJoinTypes: req.body.allowedJoinTypes,
|
||||
defaultsPayload: mergeInviteDefaults(req.body.defaultsPayload ?? null, normalizedAgentMessage),
|
||||
expiresAt: companyInviteExpiresAt(),
|
||||
invitedByUserId: req.actor.userId ?? null,
|
||||
};
|
||||
|
||||
const token = createInviteToken();
|
||||
const created = await db
|
||||
.insert(invites)
|
||||
.values({
|
||||
companyId,
|
||||
inviteType: "company_join",
|
||||
tokenHash: hashToken(token),
|
||||
allowedJoinTypes: req.body.allowedJoinTypes,
|
||||
defaultsPayload: req.body.defaultsPayload ?? null,
|
||||
expiresAt: new Date(Date.now() + req.body.expiresInHours * 60 * 60 * 1000),
|
||||
invitedByUserId: req.actor.userId ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
let token: string | null = null;
|
||||
let created: typeof invites.$inferSelect | null = null;
|
||||
for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) {
|
||||
const candidateToken = createInviteToken();
|
||||
try {
|
||||
const row = await db
|
||||
.insert(invites)
|
||||
.values({
|
||||
...insertValues,
|
||||
tokenHash: hashToken(candidateToken),
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
token = candidateToken;
|
||||
created = row;
|
||||
break;
|
||||
} catch (error) {
|
||||
if (!isInviteTokenHashCollisionError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!token || !created) {
|
||||
throw conflict("Failed to generate a unique invite token. Please retry.");
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
@@ -731,13 +1171,18 @@ export function accessRoutes(
|
||||
inviteType: created.inviteType,
|
||||
allowedJoinTypes: created.allowedJoinTypes,
|
||||
expiresAt: created.expiresAt.toISOString(),
|
||||
hasAgentMessage: Boolean(normalizedAgentMessage),
|
||||
},
|
||||
});
|
||||
|
||||
const inviteSummary = toInviteSummaryResponse(req, token, created);
|
||||
res.status(201).json({
|
||||
...created,
|
||||
token,
|
||||
inviteUrl: `/invite/${token}`,
|
||||
onboardingTextPath: inviteSummary.onboardingTextPath,
|
||||
onboardingTextUrl: inviteSummary.onboardingTextUrl,
|
||||
inviteMessage: inviteSummary.inviteMessage,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -787,6 +1232,44 @@ export function accessRoutes(
|
||||
res.type("text/plain; charset=utf-8").send(buildInviteOnboardingTextDocument(req, token, invite, opts));
|
||||
});
|
||||
|
||||
router.get("/invites/:token/test-resolution", async (req, res) => {
|
||||
const token = (req.params.token as string).trim();
|
||||
if (!token) throw notFound("Invite not found");
|
||||
const invite = await db
|
||||
.select()
|
||||
.from(invites)
|
||||
.where(eq(invites.tokenHash, hashToken(token)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!invite || invite.revokedAt || inviteExpired(invite)) {
|
||||
throw notFound("Invite not found");
|
||||
}
|
||||
|
||||
const rawUrl = typeof req.query.url === "string" ? req.query.url.trim() : "";
|
||||
if (!rawUrl) throw badRequest("url query parameter is required");
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(rawUrl);
|
||||
} catch {
|
||||
throw badRequest("url must be an absolute http(s) URL");
|
||||
}
|
||||
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
||||
throw badRequest("url must use http or https");
|
||||
}
|
||||
|
||||
const parsedTimeoutMs = typeof req.query.timeoutMs === "string" ? Number(req.query.timeoutMs) : NaN;
|
||||
const timeoutMs = Number.isFinite(parsedTimeoutMs)
|
||||
? Math.max(1000, Math.min(15000, Math.floor(parsedTimeoutMs)))
|
||||
: 5000;
|
||||
const probe = await probeInviteResolutionTarget(target, timeoutMs);
|
||||
res.json({
|
||||
inviteId: invite.id,
|
||||
testResolutionPath: `/api/invites/${token}/test-resolution`,
|
||||
requestedUrl: target.toString(),
|
||||
timeoutMs,
|
||||
...probe,
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/invites/:token/accept", validate(acceptInviteSchema), async (req, res) => {
|
||||
const token = (req.params.token as string).trim();
|
||||
if (!token) throw notFound("Invite not found");
|
||||
@@ -844,10 +1327,41 @@ export function accessRoutes(
|
||||
throw badRequest("agentName is required for agent join requests");
|
||||
}
|
||||
|
||||
const openClawDefaultsPayload = requestType === "agent"
|
||||
? buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: req.body.adapterType ?? null,
|
||||
defaultsPayload: req.body.agentDefaultsPayload ?? null,
|
||||
responsesWebhookUrl: req.body.responsesWebhookUrl ?? null,
|
||||
responsesWebhookMethod: req.body.responsesWebhookMethod ?? null,
|
||||
responsesWebhookHeaders: req.body.responsesWebhookHeaders ?? null,
|
||||
paperclipApiUrl: req.body.paperclipApiUrl ?? null,
|
||||
webhookAuthHeader: req.body.webhookAuthHeader ?? null,
|
||||
inboundOpenClawAuthHeader: req.header("x-openclaw-auth") ?? null,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (requestType === "agent" && (req.body.adapterType ?? null) === "openclaw") {
|
||||
logger.info(
|
||||
{
|
||||
inviteId: invite.id,
|
||||
requestType,
|
||||
adapterType: req.body.adapterType ?? null,
|
||||
bodyKeys: isPlainObject(req.body) ? Object.keys(req.body).sort() : [],
|
||||
responsesWebhookUrl: nonEmptyTrimmedString(req.body.responsesWebhookUrl),
|
||||
paperclipApiUrl: nonEmptyTrimmedString(req.body.paperclipApiUrl),
|
||||
webhookAuthHeader: summarizeSecretForLog(req.body.webhookAuthHeader),
|
||||
inboundOpenClawAuthHeader: summarizeSecretForLog(req.header("x-openclaw-auth") ?? null),
|
||||
rawAgentDefaults: summarizeOpenClawDefaultsForLog(req.body.agentDefaultsPayload ?? null),
|
||||
mergedAgentDefaults: summarizeOpenClawDefaultsForLog(openClawDefaultsPayload),
|
||||
},
|
||||
"invite accept received OpenClaw join payload",
|
||||
);
|
||||
}
|
||||
|
||||
const joinDefaults = requestType === "agent"
|
||||
? normalizeAgentDefaultsForJoin({
|
||||
adapterType: req.body.adapterType ?? null,
|
||||
defaultsPayload: req.body.agentDefaultsPayload ?? null,
|
||||
defaultsPayload: openClawDefaultsPayload,
|
||||
deploymentMode: opts.deploymentMode,
|
||||
deploymentExposure: opts.deploymentExposure,
|
||||
bindHost: opts.bindHost,
|
||||
@@ -855,6 +1369,20 @@ export function accessRoutes(
|
||||
})
|
||||
: { normalized: null as Record<string, unknown> | null, diagnostics: [] as JoinDiagnostic[] };
|
||||
|
||||
if (requestType === "agent" && (req.body.adapterType ?? null) === "openclaw") {
|
||||
logger.info(
|
||||
{
|
||||
inviteId: invite.id,
|
||||
joinRequestDiagnostics: joinDefaults.diagnostics.map((diag) => ({
|
||||
code: diag.code,
|
||||
level: diag.level,
|
||||
})),
|
||||
normalizedAgentDefaults: summarizeOpenClawDefaultsForLog(joinDefaults.normalized),
|
||||
},
|
||||
"invite accept normalized OpenClaw defaults",
|
||||
);
|
||||
}
|
||||
|
||||
const claimSecret = requestType === "agent" ? createClaimSecret() : null;
|
||||
const claimSecretHash = claimSecret ? hashToken(claimSecret) : null;
|
||||
const claimSecretExpiresAt = claimSecret
|
||||
@@ -890,6 +1418,54 @@ export function accessRoutes(
|
||||
return row;
|
||||
});
|
||||
|
||||
if (requestType === "agent" && (req.body.adapterType ?? null) === "openclaw") {
|
||||
const expectedDefaults = summarizeOpenClawDefaultsForLog(joinDefaults.normalized);
|
||||
const persistedDefaults = summarizeOpenClawDefaultsForLog(created.agentDefaultsPayload);
|
||||
const missingPersistedFields: string[] = [];
|
||||
|
||||
if (expectedDefaults.url && !persistedDefaults.url) missingPersistedFields.push("url");
|
||||
if (expectedDefaults.paperclipApiUrl && !persistedDefaults.paperclipApiUrl) {
|
||||
missingPersistedFields.push("paperclipApiUrl");
|
||||
}
|
||||
if (expectedDefaults.webhookAuthHeader && !persistedDefaults.webhookAuthHeader) {
|
||||
missingPersistedFields.push("webhookAuthHeader");
|
||||
}
|
||||
if (expectedDefaults.openClawAuthHeader && !persistedDefaults.openClawAuthHeader) {
|
||||
missingPersistedFields.push("headers.x-openclaw-auth");
|
||||
}
|
||||
if (expectedDefaults.headerKeys.length > 0 && persistedDefaults.headerKeys.length === 0) {
|
||||
missingPersistedFields.push("headers");
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
inviteId: invite.id,
|
||||
joinRequestId: created.id,
|
||||
joinRequestStatus: created.status,
|
||||
expectedDefaults,
|
||||
persistedDefaults,
|
||||
diagnostics: joinDefaults.diagnostics.map((diag) => ({
|
||||
code: diag.code,
|
||||
level: diag.level,
|
||||
message: diag.message,
|
||||
hint: diag.hint ?? null,
|
||||
})),
|
||||
},
|
||||
"invite accept persisted OpenClaw join request",
|
||||
);
|
||||
|
||||
if (missingPersistedFields.length > 0) {
|
||||
logger.warn(
|
||||
{
|
||||
inviteId: invite.id,
|
||||
joinRequestId: created.id,
|
||||
missingPersistedFields,
|
||||
},
|
||||
"invite accept detected missing persisted OpenClaw defaults",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: req.actor.type === "agent" ? "agent" : "user",
|
||||
@@ -1053,6 +1629,16 @@ export function accessRoutes(
|
||||
details: { requestType: existing.requestType, createdAgentId },
|
||||
});
|
||||
|
||||
if (createdAgentId) {
|
||||
void notifyHireApproved(db, {
|
||||
companyId,
|
||||
agentId: createdAgentId,
|
||||
source: "join_request",
|
||||
sourceId: requestId,
|
||||
approvedAt: new Date(),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
res.json(toJoinRequestResponse(approved));
|
||||
});
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
logActivity,
|
||||
secretService,
|
||||
} from "../services/index.js";
|
||||
import { conflict, forbidden, unprocessable } from "../errors.js";
|
||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
|
||||
import { redactEventPayload } from "../redaction.js";
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
DEFAULT_CODEX_LOCAL_MODEL,
|
||||
} from "@paperclipai/adapter-codex-local";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
||||
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
|
||||
|
||||
export function agentRoutes(db: Db) {
|
||||
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
|
||||
@@ -152,7 +152,10 @@ export function agentRoutes(db: Db) {
|
||||
if (resolved.ambiguous) {
|
||||
throw conflict("Agent shortname is ambiguous in this company. Use the agent ID.");
|
||||
}
|
||||
return resolved.agent?.id ?? raw;
|
||||
if (!resolved.agent) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
return resolved.agent.id;
|
||||
}
|
||||
|
||||
function parseSourceIssueIds(input: {
|
||||
@@ -195,15 +198,34 @@ export function agentRoutes(db: Db) {
|
||||
}
|
||||
return next;
|
||||
}
|
||||
if (adapterType === "opencode_local" && !asNonEmptyString(next.model)) {
|
||||
next.model = DEFAULT_OPENCODE_LOCAL_MODEL;
|
||||
}
|
||||
// OpenCode requires explicit model selection — no default
|
||||
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
|
||||
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
async function assertAdapterConfigConstraints(
|
||||
companyId: string,
|
||||
adapterType: string | null | undefined,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
) {
|
||||
if (adapterType !== "opencode_local") return;
|
||||
const runtimeConfig = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig);
|
||||
const runtimeEnv = asRecord(runtimeConfig.env) ?? {};
|
||||
try {
|
||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||
model: runtimeConfig.model,
|
||||
command: runtimeConfig.command,
|
||||
cwd: runtimeConfig.cwd,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveInstructionsFilePath(candidatePath: string, adapterConfig: Record<string, unknown>) {
|
||||
const trimmed = candidatePath.trim();
|
||||
if (path.isAbsolute(trimmed)) return trimmed;
|
||||
@@ -335,7 +357,9 @@ export function agentRoutes(db: Db) {
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/adapters/:type/models", async (req, res) => {
|
||||
router.get("/companies/:companyId/adapters/:type/models", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const type = req.params.type as string;
|
||||
const models = await listAdapterModels(type);
|
||||
res.json(models);
|
||||
@@ -589,6 +613,11 @@ export function agentRoutes(db: Db) {
|
||||
requestedAdapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
await assertAdapterConfigConstraints(
|
||||
companyId,
|
||||
hireInput.adapterType,
|
||||
normalizedAdapterConfig,
|
||||
);
|
||||
const normalizedHireInput = {
|
||||
...hireInput,
|
||||
adapterConfig: normalizedAdapterConfig,
|
||||
@@ -724,6 +753,11 @@ export function agentRoutes(db: Db) {
|
||||
requestedAdapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
await assertAdapterConfigConstraints(
|
||||
companyId,
|
||||
req.body.adapterType,
|
||||
normalizedAdapterConfig,
|
||||
);
|
||||
|
||||
const agent = await svc.create(companyId, {
|
||||
...req.body,
|
||||
@@ -903,6 +937,27 @@ export function agentRoutes(db: Db) {
|
||||
);
|
||||
}
|
||||
|
||||
const requestedAdapterType =
|
||||
typeof patchData.adapterType === "string" ? patchData.adapterType : existing.adapterType;
|
||||
const touchesAdapterConfiguration =
|
||||
Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
|
||||
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
|
||||
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
|
||||
const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
|
||||
? (asRecord(patchData.adapterConfig) ?? {})
|
||||
: (asRecord(existing.adapterConfig) ?? {});
|
||||
const effectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
existing.companyId,
|
||||
rawEffectiveAdapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
await assertAdapterConfigConstraints(
|
||||
existing.companyId,
|
||||
requestedAdapterType,
|
||||
effectiveAdapterConfig,
|
||||
);
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const agent = await svc.update(id, patchData, {
|
||||
recordRevision: {
|
||||
|
||||
14
server/src/routes/issues-checkout-wakeup.ts
Normal file
14
server/src/routes/issues-checkout-wakeup.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
type CheckoutWakeInput = {
|
||||
actorType: "board" | "agent" | "none";
|
||||
actorAgentId: string | null;
|
||||
checkoutAgentId: string;
|
||||
checkoutRunId: string | null;
|
||||
};
|
||||
|
||||
export function shouldWakeAssigneeOnCheckout(input: CheckoutWakeInput): boolean {
|
||||
if (input.actorType !== "agent") return true;
|
||||
if (!input.actorAgentId) return true;
|
||||
if (input.actorAgentId !== input.checkoutAgentId) return true;
|
||||
if (!input.checkoutRunId) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { forbidden, HttpError, unauthorized } from "../errors.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
|
||||
const ALLOWED_ATTACHMENT_CONTENT_TYPES = new Set([
|
||||
@@ -187,20 +188,40 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const assigneeUserFilterRaw = req.query.assigneeUserId as string | undefined;
|
||||
const touchedByUserFilterRaw = req.query.touchedByUserId as string | undefined;
|
||||
const unreadForUserFilterRaw = req.query.unreadForUserId as string | undefined;
|
||||
const assigneeUserId =
|
||||
assigneeUserFilterRaw === "me" && req.actor.type === "board"
|
||||
? req.actor.userId
|
||||
: assigneeUserFilterRaw;
|
||||
const touchedByUserId =
|
||||
touchedByUserFilterRaw === "me" && req.actor.type === "board"
|
||||
? req.actor.userId
|
||||
: touchedByUserFilterRaw;
|
||||
const unreadForUserId =
|
||||
unreadForUserFilterRaw === "me" && req.actor.type === "board"
|
||||
? req.actor.userId
|
||||
: unreadForUserFilterRaw;
|
||||
|
||||
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
|
||||
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
|
||||
return;
|
||||
}
|
||||
if (touchedByUserFilterRaw === "me" && (!touchedByUserId || req.actor.type !== "board")) {
|
||||
res.status(403).json({ error: "touchedByUserId=me requires board authentication" });
|
||||
return;
|
||||
}
|
||||
if (unreadForUserFilterRaw === "me" && (!unreadForUserId || req.actor.type !== "board")) {
|
||||
res.status(403).json({ error: "unreadForUserId=me requires board authentication" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await svc.list(companyId, {
|
||||
status: req.query.status as string | undefined,
|
||||
assigneeAgentId: req.query.assigneeAgentId as string | undefined,
|
||||
assigneeUserId,
|
||||
touchedByUserId,
|
||||
unreadForUserId,
|
||||
projectId: req.query.projectId as string | undefined,
|
||||
labelId: req.query.labelId as string | undefined,
|
||||
q: req.query.q as string | undefined,
|
||||
@@ -282,6 +303,38 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
res.json({ ...issue, ancestors, project: project ?? null, goal: goal ?? null, mentionedProjects });
|
||||
});
|
||||
|
||||
router.post("/issues/:id/read", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (req.actor.type !== "board") {
|
||||
res.status(403).json({ error: "Board authentication required" });
|
||||
return;
|
||||
}
|
||||
if (!req.actor.userId) {
|
||||
res.status(403).json({ error: "Board user context required" });
|
||||
return;
|
||||
}
|
||||
const readState = await svc.markRead(issue.companyId, issue.id, req.actor.userId, new Date());
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.read_marked",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: { userId: req.actor.userId, lastReadAt: readState.lastReadAt },
|
||||
});
|
||||
res.json(readState);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/approvals", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
@@ -634,17 +687,26 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
details: { agentId: req.body.agentId },
|
||||
});
|
||||
|
||||
void heartbeat
|
||||
.wakeup(req.body.agentId, {
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_checked_out",
|
||||
payload: { issueId: issue.id, mutation: "checkout" },
|
||||
requestedByActorType: actor.actorType,
|
||||
requestedByActorId: actor.actorId,
|
||||
contextSnapshot: { issueId: issue.id, source: "issue.checkout" },
|
||||
if (
|
||||
shouldWakeAssigneeOnCheckout({
|
||||
actorType: req.actor.type,
|
||||
actorAgentId: req.actor.type === "agent" ? req.actor.agentId ?? null : null,
|
||||
checkoutAgentId: req.body.agentId,
|
||||
checkoutRunId,
|
||||
})
|
||||
.catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue checkout"));
|
||||
) {
|
||||
void heartbeat
|
||||
.wakeup(req.body.agentId, {
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_checked_out",
|
||||
payload: { issueId: issue.id, mutation: "checkout" },
|
||||
requestedByActorType: actor.actorType,
|
||||
requestedByActorId: actor.actorId,
|
||||
contextSnapshot: { issueId: issue.id, source: "issue.checkout" },
|
||||
})
|
||||
.catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue checkout"));
|
||||
}
|
||||
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { issues, joinRequests } from "@paperclipai/db";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { joinRequests } from "@paperclipai/db";
|
||||
import { sidebarBadgeService } from "../services/sidebar-badges.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { dashboardService } from "../services/dashboard.js";
|
||||
import { assertCompanyAccess } from "./authz.js";
|
||||
|
||||
const INBOX_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked"] as const;
|
||||
|
||||
export function sidebarBadgeRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = sidebarBadgeService(db);
|
||||
const issueSvc = issueService(db);
|
||||
const access = accessService(db);
|
||||
const dashboard = dashboardService(db);
|
||||
|
||||
router.get("/companies/:companyId/sidebar-badges", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
@@ -34,26 +36,16 @@ export function sidebarBadgeRoutes(db: Db) {
|
||||
.then((rows) => Number(rows[0]?.count ?? 0))
|
||||
: 0;
|
||||
|
||||
const assignedIssueCount =
|
||||
req.actor.type === "board" && req.actor.userId
|
||||
? await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.assigneeUserId, req.actor.userId),
|
||||
inArray(issues.status, [...INBOX_ISSUE_STATUSES]),
|
||||
isNull(issues.hiddenAt),
|
||||
),
|
||||
)
|
||||
.then((rows) => Number(rows[0]?.count ?? 0))
|
||||
: 0;
|
||||
|
||||
const badges = await svc.get(companyId, {
|
||||
joinRequests: joinRequestCount,
|
||||
assignedIssues: assignedIssueCount,
|
||||
});
|
||||
const summary = await dashboard.summary(companyId);
|
||||
const staleIssueCount = await issueSvc.staleCount(companyId, 24 * 60);
|
||||
const alertsCount =
|
||||
(summary.agents.error > 0 ? 1 : 0) +
|
||||
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
|
||||
badges.inbox = badges.failedRuns + alertsCount + staleIssueCount;
|
||||
|
||||
res.json(badges);
|
||||
});
|
||||
|
||||
|
||||
@@ -51,6 +51,16 @@ interface UpdateAgentOptions {
|
||||
recordRevision?: RevisionMetadata;
|
||||
}
|
||||
|
||||
interface AgentShortnameRow {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface AgentShortnameCollisionOptions {
|
||||
excludeAgentId?: string | null;
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -140,6 +150,21 @@ function configPatchFromSnapshot(snapshot: unknown): Partial<typeof agents.$infe
|
||||
};
|
||||
}
|
||||
|
||||
export function hasAgentShortnameCollision(
|
||||
candidateName: string,
|
||||
existingAgents: AgentShortnameRow[],
|
||||
options?: AgentShortnameCollisionOptions,
|
||||
): boolean {
|
||||
const candidateShortname = normalizeAgentUrlKey(candidateName);
|
||||
if (!candidateShortname) return false;
|
||||
|
||||
return existingAgents.some((agent) => {
|
||||
if (agent.status === "terminated") return false;
|
||||
if (options?.excludeAgentId && agent.id === options.excludeAgentId) return false;
|
||||
return normalizeAgentUrlKey(agent.name) === candidateShortname;
|
||||
});
|
||||
}
|
||||
|
||||
export function agentService(db: Db) {
|
||||
function withUrlKey<T extends { id: string; name: string }>(row: T) {
|
||||
return {
|
||||
@@ -185,6 +210,31 @@ export function agentService(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
async function assertCompanyShortnameAvailable(
|
||||
companyId: string,
|
||||
candidateName: string,
|
||||
options?: AgentShortnameCollisionOptions,
|
||||
) {
|
||||
const candidateShortname = normalizeAgentUrlKey(candidateName);
|
||||
if (!candidateShortname) return;
|
||||
|
||||
const existingAgents = await db
|
||||
.select({
|
||||
id: agents.id,
|
||||
name: agents.name,
|
||||
status: agents.status,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, companyId));
|
||||
|
||||
const hasCollision = hasAgentShortnameCollision(candidateName, existingAgents, options);
|
||||
if (hasCollision) {
|
||||
throw conflict(
|
||||
`Agent shortname '${candidateShortname}' is already in use in this company`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAgent(
|
||||
id: string,
|
||||
data: Partial<typeof agents.$inferInsert>,
|
||||
@@ -212,6 +262,14 @@ export function agentService(db: Db) {
|
||||
await assertNoCycle(id, data.reportsTo);
|
||||
}
|
||||
|
||||
if (data.name !== undefined) {
|
||||
const previousShortname = normalizeAgentUrlKey(existing.name);
|
||||
const nextShortname = normalizeAgentUrlKey(data.name);
|
||||
if (previousShortname !== nextShortname) {
|
||||
await assertCompanyShortnameAvailable(existing.companyId, data.name, { excludeAgentId: id });
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedPatch = { ...data } as Partial<typeof agents.$inferInsert>;
|
||||
if (data.permissions !== undefined) {
|
||||
const role = (data.role ?? existing.role) as string;
|
||||
@@ -267,6 +325,8 @@ export function agentService(db: Db) {
|
||||
await ensureManager(companyId, data.reportsTo);
|
||||
}
|
||||
|
||||
await assertCompanyShortnameAvailable(companyId, data.name);
|
||||
|
||||
const role = data.role ?? "general";
|
||||
const normalizedPermissions = normalizeAgentPermissions(data.permissions, role);
|
||||
const created = await db
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db";
|
||||
import { approvalComments, approvals } from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { agentService } from "./agents.js";
|
||||
import { notifyHireApproved } from "./hire-hook.js";
|
||||
|
||||
export function approvalService(db: Db) {
|
||||
const agentsSvc = agentService(db);
|
||||
@@ -59,13 +60,15 @@ export function approvalService(db: Db) {
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
let hireApprovedAgentId: string | null = null;
|
||||
if (updated.type === "hire_agent") {
|
||||
const payload = updated.payload as Record<string, unknown>;
|
||||
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
|
||||
if (payloadAgentId) {
|
||||
await agentsSvc.activatePendingApproval(payloadAgentId);
|
||||
hireApprovedAgentId = payloadAgentId;
|
||||
} else {
|
||||
await agentsSvc.create(updated.companyId, {
|
||||
const created = await agentsSvc.create(updated.companyId, {
|
||||
name: String(payload.name ?? "New Agent"),
|
||||
role: String(payload.role ?? "general"),
|
||||
title: typeof payload.title === "string" ? payload.title : null,
|
||||
@@ -87,6 +90,16 @@ export function approvalService(db: Db) {
|
||||
permissions: undefined,
|
||||
lastHeartbeatAt: null,
|
||||
});
|
||||
hireApprovedAgentId = created?.id ?? null;
|
||||
}
|
||||
if (hireApprovedAgentId) {
|
||||
void notifyHireApproved(db, {
|
||||
companyId: updated.companyId,
|
||||
agentId: hireApprovedAgentId,
|
||||
source: "approval",
|
||||
sourceId: id,
|
||||
approvedAt: now,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
113
server/src/services/hire-hook.ts
Normal file
113
server/src/services/hire-hook.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents } from "@paperclipai/db";
|
||||
import type { HireApprovedPayload } from "@paperclipai/adapter-utils";
|
||||
import { findServerAdapter } from "../adapters/registry.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
|
||||
const HIRE_APPROVED_MESSAGE =
|
||||
"Tell your user that your hire was approved, now they should assign you a task in Paperclip or ask you to create issues.";
|
||||
|
||||
export interface NotifyHireApprovedInput {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
source: "join_request" | "approval";
|
||||
sourceId: string;
|
||||
approvedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the adapter's onHireApproved hook when an agent is approved (join-request or hire_agent approval).
|
||||
* Failures are non-fatal: we log and write to activity, never throw.
|
||||
*/
|
||||
export async function notifyHireApproved(
|
||||
db: Db,
|
||||
input: NotifyHireApprovedInput,
|
||||
): Promise<void> {
|
||||
const { companyId, agentId, source, sourceId } = input;
|
||||
const approvedAt = input.approvedAt ?? new Date();
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(and(eq(agents.id, agentId), eq(agents.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!row) {
|
||||
logger.warn({ companyId, agentId, source, sourceId }, "hire hook: agent not found in company, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const adapterType = row.adapterType ?? "process";
|
||||
const adapter = findServerAdapter(adapterType);
|
||||
const onHireApproved = adapter?.onHireApproved;
|
||||
if (!onHireApproved) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: HireApprovedPayload = {
|
||||
companyId,
|
||||
agentId,
|
||||
agentName: row.name,
|
||||
adapterType,
|
||||
source,
|
||||
sourceId,
|
||||
approvedAt: approvedAt.toISOString(),
|
||||
message: HIRE_APPROVED_MESSAGE,
|
||||
};
|
||||
|
||||
const adapterConfig =
|
||||
typeof row.adapterConfig === "object" && row.adapterConfig !== null && !Array.isArray(row.adapterConfig)
|
||||
? (row.adapterConfig as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
try {
|
||||
const result = await onHireApproved(payload, adapterConfig);
|
||||
if (result.ok) {
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "system",
|
||||
actorId: "hire_hook",
|
||||
action: "hire_hook.succeeded",
|
||||
entityType: "agent",
|
||||
entityId: agentId,
|
||||
details: { source, sourceId, adapterType },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
{ companyId, agentId, adapterType, source, sourceId, error: result.error, detail: result.detail },
|
||||
"hire hook: adapter returned failure",
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "system",
|
||||
actorId: "hire_hook",
|
||||
action: "hire_hook.failed",
|
||||
entityType: "agent",
|
||||
entityId: agentId,
|
||||
details: { source, sourceId, adapterType, error: result.error, detail: result.detail },
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, companyId, agentId, adapterType, source, sourceId },
|
||||
"hire hook: adapter threw",
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "system",
|
||||
actorId: "hire_hook",
|
||||
action: "hire_hook.error",
|
||||
entityType: "agent",
|
||||
entityId: agentId,
|
||||
details: {
|
||||
source,
|
||||
sourceId,
|
||||
adapterType,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -15,5 +15,6 @@ export { sidebarBadgeService } from "./sidebar-badges.js";
|
||||
export { accessService } from "./access.js";
|
||||
export { companyPortabilityService } from "./company-portability.js";
|
||||
export { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||
export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js";
|
||||
export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js";
|
||||
export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js";
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
issueAttachments,
|
||||
issueLabels,
|
||||
issueComments,
|
||||
issueReadStates,
|
||||
issues,
|
||||
labels,
|
||||
projectWorkspaces,
|
||||
@@ -49,6 +50,8 @@ export interface IssueFilters {
|
||||
status?: string;
|
||||
assigneeAgentId?: string;
|
||||
assigneeUserId?: string;
|
||||
touchedByUserId?: string;
|
||||
unreadForUserId?: string;
|
||||
projectId?: string;
|
||||
labelId?: string;
|
||||
q?: string;
|
||||
@@ -68,6 +71,17 @@ type IssueActiveRunRow = {
|
||||
};
|
||||
type IssueWithLabels = IssueRow & { labels: IssueLabelRow[]; labelIds: string[] };
|
||||
type IssueWithLabelsAndRun = IssueWithLabels & { activeRun: IssueActiveRunRow | null };
|
||||
type IssueUserCommentStats = {
|
||||
issueId: string;
|
||||
myLastCommentAt: Date | null;
|
||||
lastExternalCommentAt: Date | null;
|
||||
};
|
||||
type IssueUserContextInput = {
|
||||
createdByUserId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
};
|
||||
|
||||
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
|
||||
if (actorRunId) return checkoutRunId === actorRunId;
|
||||
@@ -80,6 +94,127 @@ function escapeLikePattern(value: string): string {
|
||||
return value.replace(/[\\%_]/g, "\\$&");
|
||||
}
|
||||
|
||||
function touchedByUserCondition(companyId: string, userId: string) {
|
||||
return sql<boolean>`
|
||||
(
|
||||
${issues.createdByUserId} = ${userId}
|
||||
OR ${issues.assigneeUserId} = ${userId}
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM ${issueReadStates}
|
||||
WHERE ${issueReadStates.issueId} = ${issues.id}
|
||||
AND ${issueReadStates.companyId} = ${companyId}
|
||||
AND ${issueReadStates.userId} = ${userId}
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM ${issueComments}
|
||||
WHERE ${issueComments.issueId} = ${issues.id}
|
||||
AND ${issueComments.companyId} = ${companyId}
|
||||
AND ${issueComments.authorUserId} = ${userId}
|
||||
)
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
function myLastCommentAtExpr(companyId: string, userId: string) {
|
||||
return sql<Date | null>`
|
||||
(
|
||||
SELECT MAX(${issueComments.createdAt})
|
||||
FROM ${issueComments}
|
||||
WHERE ${issueComments.issueId} = ${issues.id}
|
||||
AND ${issueComments.companyId} = ${companyId}
|
||||
AND ${issueComments.authorUserId} = ${userId}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
function myLastReadAtExpr(companyId: string, userId: string) {
|
||||
return sql<Date | null>`
|
||||
(
|
||||
SELECT MAX(${issueReadStates.lastReadAt})
|
||||
FROM ${issueReadStates}
|
||||
WHERE ${issueReadStates.issueId} = ${issues.id}
|
||||
AND ${issueReadStates.companyId} = ${companyId}
|
||||
AND ${issueReadStates.userId} = ${userId}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
function myLastTouchAtExpr(companyId: string, userId: string) {
|
||||
const myLastCommentAt = myLastCommentAtExpr(companyId, userId);
|
||||
const myLastReadAt = myLastReadAtExpr(companyId, userId);
|
||||
return sql<Date | null>`
|
||||
GREATEST(
|
||||
COALESCE(${myLastCommentAt}, to_timestamp(0)),
|
||||
COALESCE(${myLastReadAt}, to_timestamp(0)),
|
||||
COALESCE(CASE WHEN ${issues.createdByUserId} = ${userId} THEN ${issues.createdAt} ELSE NULL END, to_timestamp(0)),
|
||||
COALESCE(CASE WHEN ${issues.assigneeUserId} = ${userId} THEN ${issues.updatedAt} ELSE NULL END, to_timestamp(0))
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
function unreadForUserCondition(companyId: string, userId: string) {
|
||||
const touchedCondition = touchedByUserCondition(companyId, userId);
|
||||
const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
|
||||
return sql<boolean>`
|
||||
(
|
||||
${touchedCondition}
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM ${issueComments}
|
||||
WHERE ${issueComments.issueId} = ${issues.id}
|
||||
AND ${issueComments.companyId} = ${companyId}
|
||||
AND (
|
||||
${issueComments.authorUserId} IS NULL
|
||||
OR ${issueComments.authorUserId} <> ${userId}
|
||||
)
|
||||
AND ${issueComments.createdAt} > ${myLastTouchAt}
|
||||
)
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
export function deriveIssueUserContext(
|
||||
issue: IssueUserContextInput,
|
||||
userId: string,
|
||||
stats:
|
||||
| {
|
||||
myLastCommentAt: Date | string | null;
|
||||
myLastReadAt: Date | string | null;
|
||||
lastExternalCommentAt: Date | string | null;
|
||||
}
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
const normalizeDate = (value: Date | string | null | undefined) => {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
const myLastCommentAt = normalizeDate(stats?.myLastCommentAt);
|
||||
const myLastReadAt = normalizeDate(stats?.myLastReadAt);
|
||||
const createdTouchAt = issue.createdByUserId === userId ? normalizeDate(issue.createdAt) : null;
|
||||
const assignedTouchAt = issue.assigneeUserId === userId ? normalizeDate(issue.updatedAt) : null;
|
||||
const myLastTouchAt = [myLastCommentAt, myLastReadAt, createdTouchAt, assignedTouchAt]
|
||||
.filter((value): value is Date => value instanceof Date)
|
||||
.sort((a, b) => b.getTime() - a.getTime())[0] ?? null;
|
||||
const lastExternalCommentAt = normalizeDate(stats?.lastExternalCommentAt);
|
||||
const isUnreadForMe = Boolean(
|
||||
myLastTouchAt &&
|
||||
lastExternalCommentAt &&
|
||||
lastExternalCommentAt.getTime() > myLastTouchAt.getTime(),
|
||||
);
|
||||
|
||||
return {
|
||||
myLastTouchAt,
|
||||
lastExternalCommentAt,
|
||||
isUnreadForMe,
|
||||
};
|
||||
}
|
||||
|
||||
async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise<Map<string, IssueLabelRow[]>> {
|
||||
const map = new Map<string, IssueLabelRow[]>();
|
||||
if (issueIds.length === 0) return map;
|
||||
@@ -284,6 +419,9 @@ export function issueService(db: Db) {
|
||||
return {
|
||||
list: async (companyId: string, filters?: IssueFilters) => {
|
||||
const conditions = [eq(issues.companyId, companyId)];
|
||||
const touchedByUserId = filters?.touchedByUserId?.trim() || undefined;
|
||||
const unreadForUserId = filters?.unreadForUserId?.trim() || undefined;
|
||||
const contextUserId = unreadForUserId ?? touchedByUserId;
|
||||
const rawSearch = filters?.q?.trim() ?? "";
|
||||
const hasSearch = rawSearch.length > 0;
|
||||
const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : "";
|
||||
@@ -313,6 +451,12 @@ export function issueService(db: Db) {
|
||||
if (filters?.assigneeUserId) {
|
||||
conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
|
||||
}
|
||||
if (touchedByUserId) {
|
||||
conditions.push(touchedByUserCondition(companyId, touchedByUserId));
|
||||
}
|
||||
if (unreadForUserId) {
|
||||
conditions.push(unreadForUserCondition(companyId, unreadForUserId));
|
||||
}
|
||||
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
|
||||
if (filters?.labelId) {
|
||||
const labeledIssueIds = await db
|
||||
@@ -353,7 +497,102 @@ export function issueService(db: Db) {
|
||||
.orderBy(hasSearch ? asc(searchOrder) : asc(priorityOrder), asc(priorityOrder), desc(issues.updatedAt));
|
||||
const withLabels = await withIssueLabels(db, rows);
|
||||
const runMap = await activeRunMapForIssues(db, withLabels);
|
||||
return withActiveRuns(withLabels, runMap);
|
||||
const withRuns = withActiveRuns(withLabels, runMap);
|
||||
if (!contextUserId || withRuns.length === 0) {
|
||||
return withRuns;
|
||||
}
|
||||
|
||||
const issueIds = withRuns.map((row) => row.id);
|
||||
const statsRows = await db
|
||||
.select({
|
||||
issueId: issueComments.issueId,
|
||||
myLastCommentAt: sql<Date | null>`
|
||||
MAX(CASE WHEN ${issueComments.authorUserId} = ${contextUserId} THEN ${issueComments.createdAt} END)
|
||||
`,
|
||||
lastExternalCommentAt: sql<Date | null>`
|
||||
MAX(
|
||||
CASE
|
||||
WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${contextUserId}
|
||||
THEN ${issueComments.createdAt}
|
||||
END
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, companyId),
|
||||
inArray(issueComments.issueId, issueIds),
|
||||
),
|
||||
)
|
||||
.groupBy(issueComments.issueId);
|
||||
const readRows = await db
|
||||
.select({
|
||||
issueId: issueReadStates.issueId,
|
||||
myLastReadAt: issueReadStates.lastReadAt,
|
||||
})
|
||||
.from(issueReadStates)
|
||||
.where(
|
||||
and(
|
||||
eq(issueReadStates.companyId, companyId),
|
||||
eq(issueReadStates.userId, contextUserId),
|
||||
inArray(issueReadStates.issueId, issueIds),
|
||||
),
|
||||
);
|
||||
const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
|
||||
const readByIssueId = new Map(readRows.map((row) => [row.issueId, row.myLastReadAt]));
|
||||
|
||||
return withRuns.map((row) => ({
|
||||
...row,
|
||||
...deriveIssueUserContext(row, contextUserId, {
|
||||
myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
|
||||
myLastReadAt: readByIssueId.get(row.id) ?? null,
|
||||
lastExternalCommentAt: statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
|
||||
countUnreadTouchedByUser: async (companyId: string, userId: string, status?: string) => {
|
||||
const conditions = [
|
||||
eq(issues.companyId, companyId),
|
||||
isNull(issues.hiddenAt),
|
||||
unreadForUserCondition(companyId, userId),
|
||||
];
|
||||
if (status) {
|
||||
const statuses = status.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (statuses.length === 1) {
|
||||
conditions.push(eq(issues.status, statuses[0]));
|
||||
} else if (statuses.length > 1) {
|
||||
conditions.push(inArray(issues.status, statuses));
|
||||
}
|
||||
}
|
||||
const [row] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(issues)
|
||||
.where(and(...conditions));
|
||||
return Number(row?.count ?? 0);
|
||||
},
|
||||
|
||||
markRead: async (companyId: string, issueId: string, userId: string, readAt: Date = new Date()) => {
|
||||
const now = new Date();
|
||||
const [row] = await db
|
||||
.insert(issueReadStates)
|
||||
.values({
|
||||
companyId,
|
||||
issueId,
|
||||
userId,
|
||||
lastReadAt: readAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [issueReadStates.companyId, issueReadStates.issueId, issueReadStates.userId],
|
||||
set: {
|
||||
lastReadAt: readAt,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
return row;
|
||||
},
|
||||
|
||||
getById: async (id: string) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createReadStream, createWriteStream, promises as fs } from "node:fs";
|
||||
import { createReadStream, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { notFound } from "../errors.js";
|
||||
@@ -113,11 +113,7 @@ function createLocalFileRunLogStore(basePath: string): RunLogStore {
|
||||
stream: event.stream,
|
||||
chunk: event.chunk,
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const stream = createWriteStream(absPath, { flags: "a", encoding: "utf8" });
|
||||
stream.on("error", reject);
|
||||
stream.end(`${line}\n`, () => resolve());
|
||||
});
|
||||
await fs.appendFile(absPath, `${line}\n`, "utf8");
|
||||
},
|
||||
|
||||
async finalize(handle) {
|
||||
|
||||
@@ -10,7 +10,7 @@ export function sidebarBadgeService(db: Db) {
|
||||
return {
|
||||
get: async (
|
||||
companyId: string,
|
||||
extra?: { joinRequests?: number; assignedIssues?: number },
|
||||
extra?: { joinRequests?: number; unreadTouchedIssues?: number },
|
||||
): Promise<SidebarBadges> => {
|
||||
const actionableApprovals = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
@@ -43,9 +43,9 @@ export function sidebarBadgeService(db: Db) {
|
||||
).length;
|
||||
|
||||
const joinRequests = extra?.joinRequests ?? 0;
|
||||
const assignedIssues = extra?.assignedIssues ?? 0;
|
||||
const unreadTouchedIssues = extra?.unreadTouchedIssues ?? 0;
|
||||
return {
|
||||
inbox: actionableApprovals + failedRuns + joinRequests + assignedIssues,
|
||||
inbox: actionableApprovals + failedRuns + joinRequests + unreadTouchedIssues,
|
||||
approvals: actionableApprovals,
|
||||
failedRuns,
|
||||
joinRequests,
|
||||
|
||||
Reference in New Issue
Block a user