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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,493 @@
import { afterEach, describe, expect, it } from "vitest";
import { createServer } from "node:http";
import { WebSocketServer } from "ws";
import { execute, testEnvironment } from "@paperclipai/adapter-openclaw-gateway/server";
import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
function buildContext(
config: Record<string, unknown>,
overrides?: Partial<AdapterExecutionContext>,
): AdapterExecutionContext {
return {
runId: "run-123",
agent: {
id: "agent-123",
companyId: "company-123",
name: "OpenClaw Gateway Agent",
adapterType: "openclaw_gateway",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config,
context: {
taskId: "task-123",
issueId: "issue-123",
wakeReason: "issue_assigned",
issueIds: ["issue-123"],
},
onLog: async () => {},
...overrides,
};
}
async function createMockGatewayServer() {
const server = createServer();
const wss = new WebSocketServer({ server });
let agentPayload: Record<string, unknown> | null = null;
wss.on("connection", (socket) => {
socket.send(
JSON.stringify({
type: "event",
event: "connect.challenge",
payload: { nonce: "nonce-123" },
}),
);
socket.on("message", (raw) => {
const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
const frame = JSON.parse(text) as {
type: string;
id: string;
method: string;
params?: Record<string, unknown>;
};
if (frame.type !== "req") return;
if (frame.method === "connect") {
socket.send(
JSON.stringify({
type: "res",
id: frame.id,
ok: true,
payload: {
type: "hello-ok",
protocol: 3,
server: { version: "test", connId: "conn-1" },
features: { methods: ["connect", "agent", "agent.wait"], events: ["agent"] },
snapshot: { version: 1, ts: Date.now() },
policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 },
},
}),
);
return;
}
if (frame.method === "agent") {
agentPayload = frame.params ?? null;
const runId =
typeof frame.params?.idempotencyKey === "string"
? frame.params.idempotencyKey
: "run-123";
socket.send(
JSON.stringify({
type: "res",
id: frame.id,
ok: true,
payload: {
runId,
status: "accepted",
acceptedAt: Date.now(),
},
}),
);
socket.send(
JSON.stringify({
type: "event",
event: "agent",
payload: {
runId,
seq: 1,
stream: "assistant",
ts: Date.now(),
data: { delta: "cha" },
},
}),
);
socket.send(
JSON.stringify({
type: "event",
event: "agent",
payload: {
runId,
seq: 2,
stream: "assistant",
ts: Date.now(),
data: { delta: "chacha" },
},
}),
);
return;
}
if (frame.method === "agent.wait") {
socket.send(
JSON.stringify({
type: "res",
id: frame.id,
ok: true,
payload: {
runId: frame.params?.runId,
status: "ok",
startedAt: 1,
endedAt: 2,
},
}),
);
}
});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Failed to resolve test server address");
}
return {
url: `ws://127.0.0.1:${address.port}`,
getAgentPayload: () => agentPayload,
close: async () => {
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}
async function createMockGatewayServerWithPairing() {
const server = createServer();
const wss = new WebSocketServer({ server });
let agentPayload: Record<string, unknown> | null = null;
let approved = false;
let pendingRequestId = "req-1";
let lastSeenDeviceId: string | null = null;
wss.on("connection", (socket) => {
socket.send(
JSON.stringify({
type: "event",
event: "connect.challenge",
payload: { nonce: "nonce-123" },
}),
);
socket.on("message", (raw) => {
const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
const frame = JSON.parse(text) as {
type: string;
id: string;
method: string;
params?: Record<string, unknown>;
};
if (frame.type !== "req") return;
if (frame.method === "connect") {
const device = frame.params?.device as Record<string, unknown> | undefined;
const deviceId = typeof device?.id === "string" ? device.id : null;
if (deviceId) {
lastSeenDeviceId = deviceId;
}
if (deviceId && !approved) {
socket.send(
JSON.stringify({
type: "res",
id: frame.id,
ok: false,
error: {
code: "NOT_PAIRED",
message: "pairing required",
details: {
code: "PAIRING_REQUIRED",
requestId: pendingRequestId,
reason: "not-paired",
},
},
}),
);
socket.close(1008, "pairing required");
return;
}
socket.send(
JSON.stringify({
type: "res",
id: frame.id,
ok: true,
payload: {
type: "hello-ok",
protocol: 3,
server: { version: "test", connId: "conn-1" },
features: {
methods: ["connect", "agent", "agent.wait", "device.pair.list", "device.pair.approve"],
events: ["agent"],
},
snapshot: { version: 1, ts: Date.now() },
policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 },
},
}),
);
return;
}
if (frame.method === "device.pair.list") {
socket.send(
JSON.stringify({
type: "res",
id: frame.id,
ok: true,
payload: {
pending: approved
? []
: [
{
requestId: pendingRequestId,
deviceId: lastSeenDeviceId ?? "device-unknown",
},
],
paired: approved && lastSeenDeviceId ? [{ deviceId: lastSeenDeviceId }] : [],
},
}),
);
return;
}
if (frame.method === "device.pair.approve") {
const requestId = frame.params?.requestId;
if (requestId !== pendingRequestId) {
socket.send(
JSON.stringify({
type: "res",
id: frame.id,
ok: false,
error: { code: "INVALID_REQUEST", message: "unknown requestId" },
}),
);
return;
}
approved = true;
socket.send(
JSON.stringify({
type: "res",
id: frame.id,
ok: true,
payload: {
requestId: pendingRequestId,
device: {
deviceId: lastSeenDeviceId ?? "device-unknown",
},
},
}),
);
return;
}
if (frame.method === "agent") {
agentPayload = frame.params ?? null;
const runId =
typeof frame.params?.idempotencyKey === "string"
? frame.params.idempotencyKey
: "run-123";
socket.send(
JSON.stringify({
type: "res",
id: frame.id,
ok: true,
payload: {
runId,
status: "accepted",
acceptedAt: Date.now(),
},
}),
);
socket.send(
JSON.stringify({
type: "event",
event: "agent",
payload: {
runId,
seq: 1,
stream: "assistant",
ts: Date.now(),
data: { delta: "ok" },
},
}),
);
return;
}
if (frame.method === "agent.wait") {
socket.send(
JSON.stringify({
type: "res",
id: frame.id,
ok: true,
payload: {
runId: frame.params?.runId,
status: "ok",
startedAt: 1,
endedAt: 2,
},
}),
);
}
});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Failed to resolve test server address");
}
return {
url: `ws://127.0.0.1:${address.port}`,
getAgentPayload: () => agentPayload,
close: async () => {
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}
afterEach(() => {
// no global mocks
});
describe("openclaw gateway ui stdout parser", () => {
it("parses assistant deltas from gateway event lines", () => {
const ts = "2026-03-06T15:00:00.000Z";
const line =
'[openclaw-gateway:event] run=run-1 stream=assistant data={"delta":"hello"}';
expect(parseOpenClawGatewayStdoutLine(line, ts)).toEqual([
{
kind: "assistant",
ts,
text: "hello",
delta: true,
},
]);
});
});
describe("openclaw gateway adapter execute", () => {
it("runs connect -> agent -> agent.wait and forwards wake payload", async () => {
const gateway = await createMockGatewayServer();
const logs: string[] = [];
try {
const result = await execute(
buildContext(
{
url: gateway.url,
headers: {
"x-openclaw-token": "gateway-token",
},
payloadTemplate: {
message: "wake now",
},
waitTimeoutMs: 2000,
},
{
onLog: async (_stream, chunk) => {
logs.push(chunk);
},
},
),
);
expect(result.exitCode).toBe(0);
expect(result.timedOut).toBe(false);
expect(result.summary).toContain("chachacha");
expect(result.provider).toBe("openclaw");
const payload = gateway.getAgentPayload();
expect(payload).toBeTruthy();
expect(payload?.idempotencyKey).toBe("run-123");
expect(payload?.sessionKey).toBe("paperclip:issue:issue-123");
expect(String(payload?.message ?? "")).toContain("wake now");
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123");
expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true);
} finally {
await gateway.close();
}
});
it("fails fast when url is missing", async () => {
const result = await execute(buildContext({}));
expect(result.exitCode).toBe(1);
expect(result.errorCode).toBe("openclaw_gateway_url_missing");
});
it("auto-approves pairing once and retries the run", async () => {
const gateway = await createMockGatewayServerWithPairing();
const logs: string[] = [];
try {
const result = await execute(
buildContext(
{
url: gateway.url,
headers: {
"x-openclaw-token": "gateway-token",
},
payloadTemplate: {
message: "wake now",
},
waitTimeoutMs: 2000,
},
{
onLog: async (_stream, chunk) => {
logs.push(chunk);
},
},
),
);
expect(result.exitCode).toBe(0);
expect(result.summary).toContain("ok");
expect(logs.some((entry) => entry.includes("pairing required; attempting automatic pairing approval"))).toBe(
true,
);
expect(logs.some((entry) => entry.includes("auto-approved pairing request"))).toBe(true);
expect(gateway.getAgentPayload()).toBeTruthy();
} finally {
await gateway.close();
}
});
});
describe("openclaw gateway testEnvironment", () => {
it("reports missing url as failure", async () => {
const result = await testEnvironment({
companyId: "company-123",
adapterType: "openclaw_gateway",
config: {},
});
expect(result.status).toBe("fail");
expect(result.checks.some((check) => check.code === "openclaw_gateway_url_missing")).toBe(true);
});
});

View file

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

View file

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

View file

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

View file

@ -18,21 +18,34 @@ 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 {
execute as openclawExecute,
testEnvironment as openclawTestEnvironment,
} from "@paperclipai/adapter-openclaw/server";
agentConfigurationDoc as openCodeAgentConfigurationDoc,
models as openCodeModels,
} from "@paperclipai/adapter-opencode-local";
import {
agentConfigurationDoc as openclawAgentConfigurationDoc,
models as openclawModels,
} from "@paperclipai/adapter-openclaw";
execute as openclawGatewayExecute,
testEnvironment as openclawGatewayTestEnvironment,
} from "@paperclipai/adapter-openclaw-gateway/server";
import {
agentConfigurationDoc as openclawGatewayAgentConfigurationDoc,
models as openclawGatewayModels,
} from "@paperclipai/adapter-openclaw-gateway";
import { listCodexModels } from "./codex-models.js";
import { listCursorModels } from "./cursor-models.js";
import {
execute as piExecute,
testEnvironment as piTestEnvironment,
sessionCodec as piSessionCodec,
listPiModels,
} from "@paperclipai/adapter-pi-local/server";
import {
agentConfigurationDoc as piAgentConfigurationDoc,
} from "@paperclipai/adapter-pi-local";
import { processAdapter } from "./process/index.js";
import { httpAdapter } from "./http/index.js";
@ -57,16 +70,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,
@ -78,17 +81,48 @@ const cursorLocalAdapter: ServerAdapterModule = {
agentConfigurationDoc: cursorAgentConfigurationDoc,
};
const openclawAdapter: ServerAdapterModule = {
type: "openclaw",
execute: openclawExecute,
testEnvironment: openclawTestEnvironment,
models: openclawModels,
const openclawGatewayAdapter: ServerAdapterModule = {
type: "openclaw_gateway",
execute: openclawGatewayExecute,
testEnvironment: openclawGatewayTestEnvironment,
models: openclawGatewayModels,
supportsLocalAgentJwt: false,
agentConfigurationDoc: openclawAgentConfigurationDoc,
agentConfigurationDoc: openclawGatewayAgentConfigurationDoc,
};
const openCodeLocalAdapter: ServerAdapterModule = {
type: "opencode_local",
execute: openCodeExecute,
testEnvironment: openCodeTestEnvironment,
sessionCodec: openCodeSessionCodec,
models: openCodeModels,
listModels: listOpenCodeModels,
supportsLocalAgentJwt: true,
agentConfigurationDoc: openCodeAgentConfigurationDoc,
};
const piLocalAdapter: ServerAdapterModule = {
type: "pi_local",
execute: piExecute,
testEnvironment: piTestEnvironment,
sessionCodec: piSessionCodec,
models: [],
listModels: listPiModels,
supportsLocalAgentJwt: true,
agentConfigurationDoc: piAgentConfigurationDoc,
};
const adaptersByType = new Map<string, ServerAdapterModule>(
[claudeLocalAdapter, codexLocalAdapter, opencodeLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
[
claudeLocalAdapter,
codexLocalAdapter,
openCodeLocalAdapter,
piLocalAdapter,
cursorLocalAdapter,
openclawGatewayAdapter,
processAdapter,
httpAdapter,
].map((a) => [a.type, a]),
);
export function getServerAdapter(type: string): ServerAdapterModule {

View file

@ -121,6 +121,9 @@ export async function createApp(
}),
);
app.use("/api", api);
app.use("/api", (_req, res) => {
res.status(404).json({ error: "API route not found" });
});
const __dirname = path.dirname(fileURLToPath(import.meta.url));
if (opts.uiMode === "static") {
@ -131,9 +134,10 @@ export async function createApp(
];
const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html")));
if (uiDist) {
const indexHtml = fs.readFileSync(path.join(uiDist, "index.html"), "utf-8");
app.use(express.static(uiDist));
app.get(/.*/, (_req, res) => {
res.sendFile(path.join(uiDist, "index.html"));
res.status(200).set("Content-Type", "text/html").end(indexHtml);
});
} else {
console.warn("[paperclip] UI dist not found; running in API-only mode");

View file

@ -42,13 +42,38 @@ function headersFromExpressRequest(req: Request): Headers {
return headersFromNodeHeaders(req.headers);
}
export function createBetterAuthInstance(db: Db, config: Config): BetterAuthInstance {
export function deriveAuthTrustedOrigins(config: Config): string[] {
const baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined;
const trustedOrigins = new Set<string>();
if (baseUrl) {
try {
trustedOrigins.add(new URL(baseUrl).origin);
} catch {
// Better Auth will surface invalid base URL separately.
}
}
if (config.deploymentMode === "authenticated") {
for (const hostname of config.allowedHostnames) {
const trimmed = hostname.trim().toLowerCase();
if (!trimmed) continue;
trustedOrigins.add(`https://${trimmed}`);
trustedOrigins.add(`http://${trimmed}`);
}
}
return Array.from(trustedOrigins);
}
export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins?: string[]): BetterAuthInstance {
const baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined;
const secret = process.env.BETTER_AUTH_SECRET ?? process.env.PAPERCLIP_AGENT_JWT_SECRET ?? "paperclip-dev-secret";
const effectiveTrustedOrigins = trustedOrigins ?? deriveAuthTrustedOrigins(config);
const authConfig = {
baseURL: baseUrl,
secret,
trustedOrigins: effectiveTrustedOrigins,
database: drizzleAdapter(db, {
provider: "pg",
schema: {
@ -61,6 +86,7 @@ export function createBetterAuthInstance(db: Db, config: Config): BetterAuthInst
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
disableSignUp: config.authDisableSignUp,
},
};

View file

@ -37,6 +37,7 @@ export interface Config {
allowedHostnames: string[];
authBaseUrlMode: AuthBaseUrlMode;
authPublicBaseUrl: string | undefined;
authDisableSignUp: boolean;
databaseMode: DatabaseMode;
databaseUrl: string | undefined;
embeddedPostgresDataDir: string;
@ -130,15 +131,23 @@ export function loadConfig(): Config {
AUTH_BASE_URL_MODES.includes(authBaseUrlModeFromEnvRaw as AuthBaseUrlMode)
? (authBaseUrlModeFromEnvRaw as AuthBaseUrlMode)
: null;
const publicUrlFromEnv = process.env.PAPERCLIP_PUBLIC_URL;
const authPublicBaseUrlRaw =
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
process.env.BETTER_AUTH_URL ??
process.env.BETTER_AUTH_BASE_URL ??
publicUrlFromEnv ??
fileConfig?.auth?.publicBaseUrl;
const authPublicBaseUrl = authPublicBaseUrlRaw?.trim() || undefined;
const authBaseUrlMode: AuthBaseUrlMode =
authBaseUrlModeFromEnv ??
fileConfig?.auth?.baseUrlMode ??
(authPublicBaseUrl ? "explicit" : "auto");
const disableSignUpFromEnv = process.env.PAPERCLIP_AUTH_DISABLE_SIGN_UP;
const authDisableSignUp: boolean =
disableSignUpFromEnv !== undefined
? disableSignUpFromEnv === "true"
: (fileConfig?.auth?.disableSignUp ?? false);
const allowedHostnamesFromEnvRaw = process.env.PAPERCLIP_ALLOWED_HOSTNAMES;
const allowedHostnamesFromEnv = allowedHostnamesFromEnvRaw
? allowedHostnamesFromEnvRaw
@ -146,8 +155,24 @@ export function loadConfig(): Config {
.map((value) => value.trim().toLowerCase())
.filter((value) => value.length > 0)
: null;
const publicUrlHostname = authPublicBaseUrl
? (() => {
try {
return new URL(authPublicBaseUrl).hostname.trim().toLowerCase();
} catch {
return null;
}
})()
: null;
const allowedHostnames = Array.from(
new Set((allowedHostnamesFromEnv ?? fileConfig?.server.allowedHostnames ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean)),
new Set(
[
...(allowedHostnamesFromEnv ?? fileConfig?.server.allowedHostnames ?? []),
...(publicUrlHostname ? [publicUrlHostname] : []),
]
.map((value) => value.trim().toLowerCase())
.filter(Boolean),
),
);
const companyDeletionEnvRaw = process.env.PAPERCLIP_ENABLE_COMPANY_DELETION;
const companyDeletionEnabled =
@ -184,6 +209,7 @@ export function loadConfig(): Config {
allowedHostnames,
authBaseUrlMode,
authPublicBaseUrl,
authDisableSignUp,
databaseMode: fileDatabaseMode,
databaseUrl: process.env.DATABASE_URL ?? fileDbUrl,
embeddedPostgresDataDir: resolveHomeAwarePath(

View file

@ -412,6 +412,7 @@ if (config.deploymentMode === "authenticated") {
const {
createBetterAuthHandler,
createBetterAuthInstance,
deriveAuthTrustedOrigins,
resolveBetterAuthSession,
resolveBetterAuthSessionFromHeaders,
} = await import("./auth/better-auth.js");
@ -422,7 +423,25 @@ if (config.deploymentMode === "authenticated") {
"authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET) to be set",
);
}
const auth = createBetterAuthInstance(db as any, config);
const derivedTrustedOrigins = deriveAuthTrustedOrigins(config);
const envTrustedOrigins = (process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? "")
.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0);
const effectiveTrustedOrigins = Array.from(new Set([...derivedTrustedOrigins, ...envTrustedOrigins]));
logger.info(
{
authBaseUrlMode: config.authBaseUrlMode,
authPublicBaseUrl: config.authPublicBaseUrl ?? null,
trustedOrigins: effectiveTrustedOrigins,
trustedOriginsSource: {
derived: derivedTrustedOrigins.length,
env: envTrustedOrigins.length,
},
},
"Authenticated mode auth origin configuration",
);
const auth = createBetterAuthInstance(db as any, config, effectiveTrustedOrigins);
betterAuthHandler = createBetterAuthHandler(auth);
resolveSession = (req) => resolveBetterAuthSession(auth, req);
resolveSessionFromHeaders = (headers) => resolveBetterAuthSessionFromHeaders(auth, headers);
@ -444,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) {

View file

@ -1,8 +1,35 @@
import type { Request, Response, NextFunction } from "express";
import { ZodError } from "zod";
import { logger } from "./logger.js";
import { HttpError } from "../errors.js";
export interface ErrorContext {
error: { message: string; stack?: string; name?: string; details?: unknown; raw?: unknown };
method: string;
url: string;
reqBody?: unknown;
reqParams?: unknown;
reqQuery?: unknown;
}
function attachErrorContext(
req: Request,
res: Response,
payload: ErrorContext["error"],
rawError?: Error,
) {
(res as any).__errorContext = {
error: payload,
method: req.method,
url: req.originalUrl,
reqBody: req.body,
reqParams: req.params,
reqQuery: req.query,
} satisfies ErrorContext;
if (rawError) {
(res as any).err = rawError;
}
}
export function errorHandler(
err: unknown,
req: Request,
@ -10,6 +37,14 @@ export function errorHandler(
_next: NextFunction,
) {
if (err instanceof HttpError) {
if (err.status >= 500) {
attachErrorContext(
req,
res,
{ message: err.message, stack: err.stack, name: err.name, details: err.details },
err,
);
}
res.status(err.status).json({
error: err.message,
...(err.details ? { details: err.details } : {}),
@ -22,19 +57,15 @@ export function errorHandler(
return;
}
const errObj = err instanceof Error
? { 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;
logger.error(
{ err: errObj, method: req.method, url: req.originalUrl },
"Unhandled error: %s %s — %s",
req.method,
req.originalUrl,
err instanceof Error ? err.message : String(err),
const rootError = err instanceof Error ? err : new Error(String(err));
attachErrorContext(
req,
res,
err instanceof Error
? { message: err.message, stack: err.stack, name: err.name }
: { message: String(err), raw: err, stack: rootError.stack, name: rootError.name },
rootError,
);
res.status(500).json({ error: "Internal server error" });
}

View file

@ -52,13 +52,37 @@ export const httpLogger = pinoHttp({
customSuccessMessage(req, res) {
return `${req.method} ${req.url} ${res.statusCode}`;
},
customErrorMessage(req, res) {
return `${req.method} ${req.url} ${res.statusCode}`;
customErrorMessage(req, res, err) {
const ctx = (res as any).__errorContext;
const errMsg = ctx?.error?.message || err?.message || (res as any).err?.message || "unknown error";
return `${req.method} ${req.url} ${res.statusCode}${errMsg}`;
},
customProps(_req, res) {
const serverError = (res as any).locals?.serverError;
if (serverError) {
return { serverError };
customProps(req, res) {
if (res.statusCode >= 400) {
const ctx = (res as any).__errorContext;
if (ctx) {
return {
errorContext: ctx.error,
reqBody: ctx.reqBody,
reqParams: ctx.reqParams,
reqQuery: ctx.reqQuery,
};
}
const props: Record<string, unknown> = {};
const { body, params, query } = req as any;
if (body && typeof body === "object" && Object.keys(body).length > 0) {
props.reqBody = body;
}
if (params && typeof params === "object" && Object.keys(params).length > 0) {
props.reqParams = params;
}
if (query && typeof query === "object" && Object.keys(query).length > 0) {
props.reqQuery = query;
}
if ((req as any).route?.path) {
props.routePath = (req as any).route.path;
}
return props;
}
return {};
},

View file

@ -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);
});
})

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import { Router, type Request } from "express";
import { randomUUID } from "node:crypto";
import { generateKeyPairSync, randomUUID } from "node:crypto";
import path from "node:path";
import type { Db } from "@paperclipai/db";
import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db";
@ -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: {
@ -178,6 +181,40 @@ export function agentRoutes(db: Db) {
return trimmed.length > 0 ? trimmed : null;
}
function parseBooleanLike(value: unknown): boolean | null {
if (typeof value === "boolean") return value;
if (typeof value === "number") {
if (value === 1) return true;
if (value === 0) return false;
return null;
}
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") {
return true;
}
if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") {
return false;
}
return null;
}
function generateEd25519PrivateKeyPem(): string {
const { privateKey } = generateKeyPairSync("ed25519");
return privateKey.export({ type: "pkcs8", format: "pem" }).toString();
}
function ensureGatewayDeviceKey(
adapterType: string | null | undefined,
adapterConfig: Record<string, unknown>,
): Record<string, unknown> {
if (adapterType !== "openclaw_gateway") return adapterConfig;
const disableDeviceAuth = parseBooleanLike(adapterConfig.disableDeviceAuth) === true;
if (disableDeviceAuth) return adapterConfig;
if (asNonEmptyString(adapterConfig.devicePrivateKeyPem)) return adapterConfig;
return { ...adapterConfig, devicePrivateKeyPem: generateEd25519PrivateKeyPem() };
}
function applyCreateDefaultsByAdapterType(
adapterType: string | null | undefined,
adapterConfig: Record<string, unknown>,
@ -193,15 +230,34 @@ export function agentRoutes(db: Db) {
if (!hasBypassFlag) {
next.dangerouslyBypassApprovalsAndSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
}
return next;
}
if (adapterType === "opencode_local" && !asNonEmptyString(next.model)) {
next.model = DEFAULT_OPENCODE_LOCAL_MODEL;
return ensureGatewayDeviceKey(adapterType, next);
}
// OpenCode requires explicit model selection — no default
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
}
return next;
return ensureGatewayDeviceKey(adapterType, next);
}
async function assertAdapterConfigConstraints(
companyId: string,
adapterType: string | null | undefined,
adapterConfig: Record<string, unknown>,
) {
if (adapterType !== "opencode_local") return;
const { config: 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>) {
@ -335,7 +391,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);
@ -362,7 +420,7 @@ export function agentRoutes(db: Db) {
inputAdapterConfig,
{ strictMode: strictSecretsMode },
);
const runtimeAdapterConfig = await secretsSvc.resolveAdapterConfigForRuntime(
const { config: runtimeAdapterConfig } = await secretsSvc.resolveAdapterConfigForRuntime(
companyId,
normalizedAdapterConfig,
);
@ -589,6 +647,11 @@ export function agentRoutes(db: Db) {
requestedAdapterConfig,
{ strictMode: strictSecretsMode },
);
await assertAdapterConfigConstraints(
companyId,
hireInput.adapterType,
normalizedAdapterConfig,
);
const normalizedHireInput = {
...hireInput,
adapterConfig: normalizedAdapterConfig,
@ -724,6 +787,11 @@ export function agentRoutes(db: Db) {
requestedAdapterConfig,
{ strictMode: strictSecretsMode },
);
await assertAdapterConfigConstraints(
companyId,
req.body.adapterType,
normalizedAdapterConfig,
);
const agent = await svc.create(companyId, {
...req.body,
@ -896,11 +964,36 @@ export function agentRoutes(db: Db) {
if (changingInstructionsPath) {
await assertCanManageInstructionsPath(req, existing);
}
patchData.adapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
patchData.adapterConfig = adapterConfig;
}
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) {
const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
? (asRecord(patchData.adapterConfig) ?? {})
: (asRecord(existing.adapterConfig) ?? {});
const effectiveAdapterConfig = applyCreateDefaultsByAdapterType(
requestedAdapterType,
rawEffectiveAdapterConfig,
);
const normalizedEffectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
existing.companyId,
adapterConfig,
effectiveAdapterConfig,
{ strictMode: strictSecretsMode },
);
patchData.adapterConfig = normalizedEffectiveAdapterConfig;
}
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {};
await assertAdapterConfigConstraints(
existing.companyId,
requestedAdapterType,
effectiveAdapterConfig,
);
}
const actor = getActorInfo(req);
@ -1171,7 +1264,7 @@ export function agentRoutes(db: Db) {
}
const config = asRecord(agent.adapterConfig) ?? {};
const runtimeConfig = await secretsSvc.resolveAdapterConfigForRuntime(agent.companyId, config);
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(agent.companyId, config);
const result = await runClaudeLogin({
runId: `claude-login-${randomUUID()}`,
agent: {

View file

@ -43,6 +43,13 @@ export function companyRoutes(db: Db) {
res.json(filtered);
});
// Common malformed path when companyId is empty in "/api/companies/{companyId}/issues".
router.get("/issues", (_req, res) => {
res.status(400).json({
error: "Missing companyId in path. Use /api/companies/{companyId}/issues.",
});
});
router.get("/:companyId", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;

View 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;
}

View file

@ -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([
@ -183,24 +184,51 @@ export function issueRoutes(db: Db, storage: StorageService) {
}
});
// Common malformed path when companyId is empty in "/api/companies/{companyId}/issues".
router.get("/issues", (_req, res) => {
res.status(400).json({
error: "Missing companyId in path. Use /api/companies/{companyId}/issues.",
});
});
router.get("/companies/:companyId/issues", async (req, res) => {
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 +310,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);
@ -379,7 +439,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
details: { title: issue.title, identifier: issue.identifier },
});
if (issue.assigneeAgentId) {
if (issue.assigneeAgentId && issue.status !== "backlog") {
void heartbeat
.wakeup(issue.assigneeAgentId, {
source: "assignment",
@ -469,6 +529,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
}
const actor = getActorInfo(req);
const hasFieldChanges = Object.keys(previous).length > 0;
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
@ -478,7 +539,12 @@ export function issueRoutes(db: Db, storage: StorageService) {
action: "issue.updated",
entityType: "issue",
entityId: issue.id,
details: { ...updateFields, identifier: issue.identifier, _previous: Object.keys(previous).length > 0 ? previous : undefined },
details: {
...updateFields,
identifier: issue.identifier,
...(commentBody ? { source: "comment" } : {}),
_previous: hasFieldChanges ? previous : undefined,
},
});
let comment = null;
@ -502,18 +568,23 @@ export function issueRoutes(db: Db, storage: StorageService) {
bodySnippet: comment.body.slice(0, 120),
identifier: issue.identifier,
issueTitle: issue.title,
...(hasFieldChanges ? { updated: true } : {}),
},
});
}
const assigneeChanged = assigneeWillChange;
const statusChangedFromBacklog =
existing.status === "backlog" &&
issue.status !== "backlog" &&
req.body.status !== undefined;
// Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs.
void (async () => {
const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();
if (assigneeChanged && issue.assigneeAgentId) {
if (assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog") {
wakeups.set(issue.assigneeAgentId, {
source: "assignment",
triggerDetail: "system",
@ -525,6 +596,18 @@ export function issueRoutes(db: Db, storage: StorageService) {
});
}
if (!assigneeChanged && statusChangedFromBacklog && issue.assigneeAgentId) {
wakeups.set(issue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_status_changed",
payload: { issueId: issue.id, mutation: "update" },
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: { issueId: issue.id, source: "issue.status_change" },
});
}
if (commentBody && comment) {
let mentionedIds: string[] = [];
try {
@ -535,6 +618,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
for (const mentionedId of mentionedIds) {
if (wakeups.has(mentionedId)) continue;
if (actor.actorType === "agent" && actor.actorId === mentionedId) continue;
wakeups.set(mentionedId, {
source: "automation",
triggerDetail: "system",
@ -634,17 +718,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);
});
@ -837,7 +930,10 @@ export function issueRoutes(db: Db, storage: StorageService) {
void (async () => {
const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();
const assigneeId = currentIssue.assigneeAgentId;
if (assigneeId) {
const actorIsAgent = actor.actorType === "agent";
const selfComment = actorIsAgent && actor.actorId === assigneeId;
const skipWake = selfComment || isClosed;
if (assigneeId && (reopened || !skipWake)) {
if (reopened) {
wakeups.set(assigneeId, {
source: "automation",
@ -896,6 +992,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
for (const mentionedId of mentionedIds) {
if (wakeups.has(mentionedId)) continue;
if (actorIsAgent && actor.actorId === mentionedId) continue;
wakeups.set(mentionedId, {
source: "automation",
triggerDetail: "system",

View file

@ -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,17 @@ 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 hasFailedRuns = badges.failedRuns > 0;
const alertsCount =
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
badges.inbox = badges.failedRuns + alertsCount + staleIssueCount + joinRequestCount + badges.approvals;
res.json(badges);
});

View file

@ -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,37 @@ 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 deduplicateAgentName(
candidateName: string,
existingAgents: AgentShortnameRow[],
): string {
if (!hasAgentShortnameCollision(candidateName, existingAgents)) {
return candidateName;
}
for (let i = 2; i <= 100; i++) {
const suffixed = `${candidateName} ${i}`;
if (!hasAgentShortnameCollision(suffixed, existingAgents)) {
return suffixed;
}
}
return `${candidateName} ${Date.now()}`;
}
export function agentService(db: Db) {
function withUrlKey<T extends { id: string; name: string }>(row: T) {
return {
@ -185,6 +226,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 +278,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,11 +341,17 @@ export function agentService(db: Db) {
await ensureManager(companyId, data.reportsTo);
}
const existingAgents = await db
.select({ id: agents.id, name: agents.name, status: agents.status })
.from(agents)
.where(eq(agents.companyId, companyId));
const uniqueName = deduplicateAgentName(data.name, existingAgents);
const role = data.role ?? "general";
const normalizedPermissions = normalizeAgentPermissions(data.permissions, role);
const created = await db
.insert(agents)
.values({ ...data, companyId, role, permissions: normalizedPermissions })
.values({ ...data, name: uniqueName, companyId, role, permissions: normalizedPermissions })
.returning()
.then((rows) => rows[0]);

View file

@ -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(() => {});
}
}

View file

@ -83,9 +83,13 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
{ path: ["graceSec"], value: 15 },
{ path: ["maxTurnsPerRun"], value: 80 },
],
openclaw: [
{ path: ["method"], value: "POST" },
{ path: ["timeoutSec"], value: 30 },
openclaw_gateway: [
{ path: ["timeoutSec"], value: 120 },
{ path: ["waitTimeoutMs"], value: 120000 },
{ path: ["sessionKeyStrategy"], value: "fixed" },
{ path: ["sessionKey"], value: "paperclip" },
{ path: ["role"], value: "operator" },
{ path: ["scopes"], value: ["operator.admin"] },
],
};

View file

@ -1240,11 +1240,16 @@ export function heartbeatService(db: Db) {
const mergedConfig = issueAssigneeOverrides?.adapterConfig
? { ...config, ...issueAssigneeOverrides.adapterConfig }
: config;
const resolvedConfig = await secretsSvc.resolveAdapterConfigForRuntime(
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
agent.companyId,
mergedConfig,
);
const onAdapterMeta = async (meta: AdapterInvocationMeta) => {
if (meta.env && secretKeys.size > 0) {
for (const key of secretKeys) {
if (key in meta.env) meta.env[key] = "***REDACTED***";
}
}
await appendRunEvent(currentRun, seq++, {
eventType: "adapter.invoke",
stream: "system",

View 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),
},
});
}
}

View file

@ -1,5 +1,5 @@
export { companyService } from "./companies.js";
export { agentService } from "./agents.js";
export { agentService, deduplicateAgentName } from "./agents.js";
export { assetService } from "./assets.js";
export { projectService } from "./projects.js";
export { issueService, type IssueFilters } from "./issues.js";
@ -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";

View file

@ -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) => {

View file

@ -31,6 +31,15 @@ interface ProjectWithGoals extends ProjectRow {
primaryWorkspace: ProjectWorkspace | null;
}
interface ProjectShortnameRow {
id: string;
name: string;
}
interface ResolveProjectNameOptions {
excludeProjectId?: string | null;
}
/** Batch-load goal refs for a set of projects. */
async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals[]> {
if (rows.length === 0) return [];
@ -192,6 +201,34 @@ function deriveWorkspaceName(input: {
return "Workspace";
}
export function resolveProjectNameForUniqueShortname(
requestedName: string,
existingProjects: ProjectShortnameRow[],
options?: ResolveProjectNameOptions,
): string {
const requestedShortname = normalizeProjectUrlKey(requestedName);
if (!requestedShortname) return requestedName;
const usedShortnames = new Set(
existingProjects
.filter((project) => !(options?.excludeProjectId && project.id === options.excludeProjectId))
.map((project) => normalizeProjectUrlKey(project.name))
.filter((value): value is string => value !== null),
);
if (!usedShortnames.has(requestedShortname)) return requestedName;
for (let suffix = 2; suffix < 10_000; suffix += 1) {
const candidateName = `${requestedName} ${suffix}`;
const candidateShortname = normalizeProjectUrlKey(candidateName);
if (candidateShortname && !usedShortnames.has(candidateShortname)) {
return candidateName;
}
}
// Fallback guard for pathological naming collisions.
return `${requestedName} ${Date.now()}`;
}
async function ensureSinglePrimaryWorkspace(
dbOrTx: any,
input: {
@ -271,6 +308,12 @@ export function projectService(db: Db) {
projectData.color = nextColor;
}
const existingProjects = await db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(eq(projects.companyId, companyId));
projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects);
// Also write goalId to the legacy column (first goal or null)
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;
@ -295,6 +338,26 @@ export function projectService(db: Db) {
): Promise<ProjectWithGoals | null> => {
const { goalIds: inputGoalIds, ...projectData } = data;
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
const existingProject = await db
.select({ id: projects.id, companyId: projects.companyId, name: projects.name })
.from(projects)
.where(eq(projects.id, id))
.then((rows) => rows[0] ?? null);
if (!existingProject) return null;
if (projectData.name !== undefined) {
const existingShortname = normalizeProjectUrlKey(existingProject.name);
const nextShortname = normalizeProjectUrlKey(projectData.name);
if (existingShortname !== nextShortname) {
const existingProjects = await db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(eq(projects.companyId, existingProject.companyId));
projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects, {
excludeProjectId: id,
});
}
}
// Keep legacy goalId column in sync
const updates: Partial<typeof projects.$inferInsert> = {

View file

@ -1,7 +1,8 @@
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";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
export type RunLogStoreType = "local_file";
@ -113,11 +114,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) {
@ -152,7 +149,7 @@ let cachedStore: RunLogStore | null = null;
export function getRunLogStore() {
if (cachedStore) return cachedStore;
const basePath = process.env.RUN_LOG_BASE_PATH ?? path.resolve(process.cwd(), "data/run-logs");
const basePath = process.env.RUN_LOG_BASE_PATH ?? path.resolve(resolvePaperclipInstanceRoot(), "data", "run-logs");
cachedStore = createLocalFileRunLogStore(basePath);
return cachedStore;
}

View file

@ -308,10 +308,11 @@ export function secretService(db: Db) {
return normalized;
},
resolveEnvBindings: async (companyId: string, envValue: unknown) => {
resolveEnvBindings: async (companyId: string, envValue: unknown): Promise<{ env: Record<string, string>; secretKeys: Set<string> }> => {
const record = asRecord(envValue);
if (!record) return {} as Record<string, string>;
if (!record) return { env: {} as Record<string, string>, secretKeys: new Set<string>() };
const resolved: Record<string, string> = {};
const secretKeys = new Set<string>();
for (const [key, rawBinding] of Object.entries(record)) {
if (!ENV_KEY_RE.test(key)) {
@ -326,20 +327,22 @@ export function secretService(db: Db) {
resolved[key] = binding.value;
} else {
resolved[key] = await resolveSecretValue(companyId, binding.secretId, binding.version);
secretKeys.add(key);
}
}
return resolved;
return { env: resolved, secretKeys };
},
resolveAdapterConfigForRuntime: async (companyId: string, adapterConfig: Record<string, unknown>) => {
resolveAdapterConfigForRuntime: async (companyId: string, adapterConfig: Record<string, unknown>): Promise<{ config: Record<string, unknown>; secretKeys: Set<string> }> => {
const resolved = { ...adapterConfig };
const secretKeys = new Set<string>();
if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) {
return resolved;
return { config: resolved, secretKeys };
}
const record = asRecord(adapterConfig.env);
if (!record) {
resolved.env = {};
return resolved;
return { config: resolved, secretKeys };
}
const env: Record<string, string> = {};
for (const [key, rawBinding] of Object.entries(record)) {
@ -355,10 +358,11 @@ export function secretService(db: Db) {
env[key] = binding.value;
} else {
env[key] = await resolveSecretValue(companyId, binding.secretId, binding.version);
secretKeys.add(key);
}
}
resolved.env = env;
return resolved;
return { config: resolved, secretKeys };
},
};
}

View file

@ -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,