Merge public-gh/master into PAP-989-multi-user-implementation-using-plan-from-pap-958

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* public-gh/master:
  release: v2026.416.0 notes (#3782)
  Sync/master post pap1497 followups 2026 04 15 (#3779)
  [codex] improve issue and routine UI responsiveness (#3744)
  fix: disable HTTP caching on run log endpoints (#3724)
  [codex] harden heartbeat run summaries and recovery context (#3742)
  [codex] fix worktree dev dependency ergonomics (#3743)
  chore(ui): drop console.* and legal comments in production builds (#3728)
  fix: proper cache headers for static assets and SPA fallback (#3734)
  fix(a11y): remove maximum-scale and user-scalable=no from viewport (#3726)
  fix: trust PAPERCLIP_PUBLIC_URL in board mutation guard (#3731)
  [codex] harden authenticated routes and issue editor reliability (#3741)
  feat(adapters): add capability flags to ServerAdapterModule (#3540)
  fix: Anthropic subscription quota always shows 100% used (#3589)
  fix(server): respect externally set PAPERCLIP_API_URL env var (#3472)
  fix(routines): include cronExpression and timezone in list trigger response (#3209)
  fix: include routine-execution issues in agent inbox-lite (#3329)

# Conflicts:
#	server/src/__tests__/issues-service.test.ts
#	server/src/routes/access.ts
#	server/src/routes/plugins.ts
#	ui/src/components/IssueChatThread.tsx
#	ui/src/pages/Inbox.tsx
This commit is contained in:
Dotta committed 2026-04-15 21:58:12 -05:00
commit ea7066709b
121 files changed
+6172 -660

No files matched your search

+19 -8
View File
@@ -28,7 +28,15 @@ vi.mock("../services/index.js", () => ({
heartbeatService: () => mockHeartbeatService,
}));
async function createApp() {
async function createApp(
actor: Record<string, unknown> = {
type: "board",
userId: "user-1",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
},
) {
const [{ errorHandler }, { activityRoutes }] = await Promise.all([
import("../middleware/index.js"),
import("../routes/activity.js"),
@@ -36,13 +44,7 @@ async function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "user-1",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
};
(req as any).actor = actor;
next();
});
app.use("/api", activityRoutes({} as any));
@@ -105,4 +107,13 @@ describe("activity routes", () => {
expect(res.status).toBe(403);
expect(mockActivityService.issuesForRun).not.toHaveBeenCalled();
});
it("rejects anonymous heartbeat run issue lookups before run existence checks", async () => {
const app = await createApp({ type: "none", source: "none" });
const res = await request(app).get("/api/heartbeat-runs/missing-run/issues");
expect(res.status).toBe(401);
expect(mockHeartbeatService.getRun).not.toHaveBeenCalled();
expect(mockActivityService.issuesForRun).not.toHaveBeenCalled();
});
});
@@ -95,6 +95,51 @@ describe("server adapter registry", () => {
]);
});
it("exposes capability flags from registered adapters", () => {
const adapterWithCaps: ServerAdapterModule = {
type: "external_test",
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
testEnvironment: async () => ({
adapterType: "external_test",
status: "pass" as const,
checks: [],
testedAt: new Date(0).toISOString(),
}),
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "customPathKey",
requiresMaterializedRuntimeSkills: true,
};
registerServerAdapter(adapterWithCaps);
const resolved = findActiveServerAdapter("external_test");
expect(resolved).not.toBeNull();
expect(resolved!.supportsInstructionsBundle).toBe(true);
expect(resolved!.instructionsPathKey).toBe("customPathKey");
expect(resolved!.requiresMaterializedRuntimeSkills).toBe(true);
expect(resolved!.supportsLocalAgentJwt).toBe(true);
});
it("returns undefined for capability flags on adapters that do not set them", () => {
registerServerAdapter(externalAdapter);
const resolved = findActiveServerAdapter("external_test");
expect(resolved).not.toBeNull();
expect(resolved!.supportsInstructionsBundle).toBeUndefined();
expect(resolved!.instructionsPathKey).toBeUndefined();
expect(resolved!.requiresMaterializedRuntimeSkills).toBeUndefined();
});
it("built-in claude_local adapter declares capability flags", () => {
const adapter = findActiveServerAdapter("claude_local");
expect(adapter).not.toBeNull();
expect(adapter!.supportsInstructionsBundle).toBe(true);
expect(adapter!.instructionsPathKey).toBe("instructionsFilePath");
expect(adapter!.requiresMaterializedRuntimeSkills).toBe(false);
expect(adapter!.supportsLocalAgentJwt).toBe(true);
});
it("switches active adapter behavior back to the builtin when an override is paused", async () => {
const builtIn = findServerAdapter("claude_local");
expect(builtIn).not.toBeNull();
@@ -78,6 +78,75 @@ describe("adapter routes", () => {
unregisterServerAdapter("claude_local");
});
it("GET /api/adapters includes capabilities object for each adapter", async () => {
const app = createApp();
const res = await request(app).get("/api/adapters");
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body.length).toBeGreaterThan(0);
// Every adapter should have a capabilities object
for (const adapter of res.body) {
expect(adapter.capabilities).toBeDefined();
expect(typeof adapter.capabilities.supportsInstructionsBundle).toBe("boolean");
expect(typeof adapter.capabilities.supportsSkills).toBe("boolean");
expect(typeof adapter.capabilities.supportsLocalAgentJwt).toBe("boolean");
expect(typeof adapter.capabilities.requiresMaterializedRuntimeSkills).toBe("boolean");
}
});
it("GET /api/adapters returns correct capabilities for built-in adapters", async () => {
const app = createApp();
const res = await request(app).get("/api/adapters");
expect(res.status).toBe(200);
// codex_local has instructions bundle + skills + jwt, no materialized skills
// (claude_local is overridden by beforeEach, so check codex_local instead)
const codexLocal = res.body.find((a: any) => a.type === "codex_local");
expect(codexLocal).toBeDefined();
expect(codexLocal.capabilities).toMatchObject({
supportsInstructionsBundle: true,
supportsSkills: true,
supportsLocalAgentJwt: true,
requiresMaterializedRuntimeSkills: false,
});
// process adapter should have no local capabilities
const processAdapter = res.body.find((a: any) => a.type === "process");
expect(processAdapter).toBeDefined();
expect(processAdapter.capabilities).toMatchObject({
supportsInstructionsBundle: false,
supportsSkills: false,
supportsLocalAgentJwt: false,
requiresMaterializedRuntimeSkills: false,
});
// cursor adapter should require materialized runtime skills
const cursorAdapter = res.body.find((a: any) => a.type === "cursor");
expect(cursorAdapter).toBeDefined();
expect(cursorAdapter.capabilities.requiresMaterializedRuntimeSkills).toBe(true);
expect(cursorAdapter.capabilities.supportsInstructionsBundle).toBe(true);
});
it("GET /api/adapters derives supportsSkills from listSkills/syncSkills presence", async () => {
const app = createApp();
const res = await request(app).get("/api/adapters");
expect(res.status).toBe(200);
// http adapter has no listSkills/syncSkills
const httpAdapter = res.body.find((a: any) => a.type === "http");
expect(httpAdapter).toBeDefined();
expect(httpAdapter.capabilities.supportsSkills).toBe(false);
// codex_local has listSkills/syncSkills
const codexLocal = res.body.find((a: any) => a.type === "codex_local");
expect(codexLocal).toBeDefined();
expect(codexLocal.capabilities.supportsSkills).toBe(true);
});
it("uses the active adapter when resolving config schema for a paused builtin override", async () => {
const app = createApp();
@@ -0,0 +1,267 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { agentRoutes } from "../routes/agents.js";
const agentId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
const keyId = "33333333-3333-4333-8333-333333333333";
const baseAgent = {
id: agentId,
companyId,
name: "Builder",
urlKey: "builder",
role: "engineer",
title: "Builder",
icon: null,
status: "idle",
reportsTo: null,
capabilities: null,
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date("2026-04-11T00:00:00.000Z"),
updatedAt: new Date("2026-04-11T00:00:00.000Z"),
};
const baseKey = {
id: keyId,
agentId,
companyId,
name: "exploit",
createdAt: new Date("2026-04-11T00:00:00.000Z"),
revokedAt: null,
};
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
terminate: vi.fn(),
remove: vi.fn(),
listKeys: vi.fn(),
createApiKey: vi.fn(),
getKeyById: vi.fn(),
revokeKey: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
getMembership: vi.fn(),
ensureMembership: vi.fn(),
listPrincipalGrants: vi.fn(),
setPrincipalPermission: vi.fn(),
}));
const mockApprovalService = vi.hoisted(() => ({
create: vi.fn(),
getById: vi.fn(),
}));
const mockBudgetService = vi.hoisted(() => ({
upsertPolicy: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
cancelActiveForAgent: vi.fn(),
}));
const mockIssueApprovalService = vi.hoisted(() => ({
linkManyForApproval: vi.fn(),
}));
const mockIssueService = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockSecretService = vi.hoisted(() => ({
normalizeAdapterConfigForPersistence: vi.fn(),
resolveAdapterConfigForRuntime: vi.fn(),
}));
const mockAgentInstructionsService = vi.hoisted(() => ({
materializeManagedBundle: vi.fn(),
}));
const mockCompanySkillService = vi.hoisted(() => ({
listRuntimeSkillEntries: vi.fn(),
resolveRequestedSkillKeys: vi.fn(),
}));
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentCreated: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService,
approvalService: () => mockApprovalService,
companySkillService: () => mockCompanySkillService,
budgetService: () => mockBudgetService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
issueService: () => mockIssueService,
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.mock("../services/instance-settings.js", () => ({
instanceSettingsService: () => ({
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
}),
}));
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", agentRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("agent cross-tenant route authorization", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockAgentService.getById.mockResolvedValue(baseAgent);
mockAgentService.pause.mockResolvedValue(baseAgent);
mockAgentService.resume.mockResolvedValue(baseAgent);
mockAgentService.terminate.mockResolvedValue(baseAgent);
mockAgentService.remove.mockResolvedValue(baseAgent);
mockAgentService.listKeys.mockResolvedValue([]);
mockAgentService.createApiKey.mockResolvedValue({
id: keyId,
name: baseKey.name,
token: "pcp_test_token",
createdAt: baseKey.createdAt,
});
mockAgentService.getKeyById.mockResolvedValue(baseKey);
mockAgentService.revokeKey.mockResolvedValue({
...baseKey,
revokedAt: new Date("2026-04-11T00:05:00.000Z"),
});
mockHeartbeatService.cancelActiveForAgent.mockResolvedValue(undefined);
mockLogActivity.mockResolvedValue(undefined);
});
it("rejects cross-tenant board pause before mutating the agent", async () => {
const app = createApp({
type: "board",
userId: "mallory",
companyIds: [],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app).post(`/api/agents/${agentId}/pause`).send({});
expect(res.status).toBe(403);
expect(res.body.error).toContain("User does not have access to this company");
expect(mockAgentService.getById).toHaveBeenCalledWith(agentId);
expect(mockAgentService.pause).not.toHaveBeenCalled();
expect(mockHeartbeatService.cancelActiveForAgent).not.toHaveBeenCalled();
});
it("rejects cross-tenant board key listing before reading any keys", async () => {
const app = createApp({
type: "board",
userId: "mallory",
companyIds: [],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app).get(`/api/agents/${agentId}/keys`);
expect(res.status).toBe(403);
expect(res.body.error).toContain("User does not have access to this company");
expect(mockAgentService.getById).toHaveBeenCalledWith(agentId);
expect(mockAgentService.listKeys).not.toHaveBeenCalled();
});
it("rejects cross-tenant board key creation before minting a token", async () => {
const app = createApp({
type: "board",
userId: "mallory",
companyIds: [],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app)
.post(`/api/agents/${agentId}/keys`)
.send({ name: "exploit" });
expect(res.status).toBe(403);
expect(res.body.error).toContain("User does not have access to this company");
expect(mockAgentService.getById).toHaveBeenCalledWith(agentId);
expect(mockAgentService.createApiKey).not.toHaveBeenCalled();
});
it("rejects cross-tenant board key revocation before touching the key", async () => {
const app = createApp({
type: "board",
userId: "mallory",
companyIds: [],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app).delete(`/api/agents/${agentId}/keys/${keyId}`);
expect(res.status).toBe(403);
expect(res.body.error).toContain("User does not have access to this company");
expect(mockAgentService.getById).toHaveBeenCalledWith(agentId);
expect(mockAgentService.getKeyById).not.toHaveBeenCalled();
expect(mockAgentService.revokeKey).not.toHaveBeenCalled();
});
it("requires the key to belong to the route agent before revocation", async () => {
mockAgentService.getKeyById.mockResolvedValue({
...baseKey,
agentId: "44444444-4444-4444-8444-444444444444",
});
mockAccessService.canUser.mockResolvedValue(true);
const app = createApp({
type: "board",
userId: "board-user",
companyIds: [companyId],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app).delete(`/api/agents/${agentId}/keys/${keyId}`);
expect(res.status).toBe(404);
expect(res.body.error).toContain("Key not found");
expect(mockAgentService.getKeyById).toHaveBeenCalledWith(keyId);
expect(mockAgentService.revokeKey).not.toHaveBeenCalled();
});
});
@@ -9,6 +9,8 @@ const mockAgentService = vi.hoisted(() => ({
const mockHeartbeatService = vi.hoisted(() => ({
getRunIssueSummary: vi.fn(),
getActiveRunIssueSummaryForAgent: vi.fn(),
getRunLogAccess: vi.fn(),
readLog: vi.fn(),
}));
const mockIssueService = vi.hoisted(() => ({
@@ -100,6 +102,19 @@ describe("agent live run routes", () => {
issueId: "issue-1",
});
mockHeartbeatService.getActiveRunIssueSummaryForAgent.mockResolvedValue(null);
mockHeartbeatService.getRunLogAccess.mockResolvedValue({
id: "run-1",
companyId: "company-1",
logStore: "local_file",
logRef: "logs/run-1.ndjson",
});
mockHeartbeatService.readLog.mockResolvedValue({
runId: "run-1",
store: "local_file",
logRef: "logs/run-1.ndjson",
content: "chunk",
nextOffset: 5,
});
});
it("returns a compact active run payload for issue polling", async () => {
@@ -163,4 +178,27 @@ describe("agent live run routes", () => {
adapterType: "codex_local",
});
});
it("uses narrow run log metadata lookups for log polling", async () => {
const res = await request(await createApp()).get("/api/heartbeat-runs/run-1/log?offset=12&limitBytes=64");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockHeartbeatService.getRunLogAccess).toHaveBeenCalledWith("run-1");
expect(mockHeartbeatService.readLog).toHaveBeenCalledWith({
id: "run-1",
companyId: "company-1",
logStore: "local_file",
logRef: "logs/run-1.ndjson",
}, {
offset: 12,
limitBytes: 64,
});
expect(res.body).toEqual({
runId: "run-1",
store: "local_file",
logRef: "logs/run-1.ndjson",
content: "chunk",
nextOffset: 5,
});
});
});
@@ -205,6 +205,196 @@ describe("agent permission routes", () => {
mockLogActivity.mockResolvedValue(undefined);
});
it("redacts agent detail for authenticated company members without agent admin permission", async () => {
mockAccessService.canUser.mockResolvedValue(false);
const app = await createApp({
type: "board",
userId: "member-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app).get(`/api/agents/${agentId}`);
expect(res.status).toBe(200);
expect(res.body.adapterConfig).toEqual({});
expect(res.body.runtimeConfig).toEqual({});
});
it("redacts company agent list for authenticated company members without agent admin permission", async () => {
mockAccessService.canUser.mockResolvedValue(false);
const app = await createApp({
type: "board",
userId: "member-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app).get(`/api/companies/${companyId}/agents`);
expect(res.status).toBe(200);
expect(res.body).toEqual([
expect.objectContaining({
id: agentId,
adapterConfig: {},
runtimeConfig: {},
}),
]);
});
it("blocks agent updates for authenticated company members without agent admin permission", async () => {
mockAccessService.canUser.mockResolvedValue(false);
const app = await createApp({
type: "board",
userId: "member-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.patch(`/api/agents/${agentId}`)
.send({ title: "Compromised" });
expect(res.status).toBe(403);
});
it("blocks api key creation for authenticated company members without agent admin permission", async () => {
mockAccessService.canUser.mockResolvedValue(false);
const app = await createApp({
type: "board",
userId: "member-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.post(`/api/agents/${agentId}/keys`)
.send({ name: "backdoor" });
expect(res.status).toBe(403);
});
it("blocks wakeups for authenticated company members without agent admin permission", async () => {
mockAccessService.canUser.mockResolvedValue(false);
const app = await createApp({
type: "board",
userId: "member-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.post(`/api/agents/${agentId}/wakeup`)
.send({});
expect(res.status).toBe(403);
});
it("blocks agent-authenticated self-updates that set host-executed workspace commands", async () => {
const app = await createApp({
type: "agent",
agentId,
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch(`/api/agents/${agentId}`)
.send({
adapterConfig: {
workspaceStrategy: {
type: "git_worktree",
provisionCommand: "touch /tmp/paperclip-rce",
},
},
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed workspace commands");
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("blocks agent-authenticated self-updates that set instructions bundle roots", async () => {
const app = await createApp({
type: "agent",
agentId,
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch(`/api/agents/${agentId}`)
.send({
adapterConfig: {
instructionsRootPath: "/etc",
instructionsEntryFile: "passwd",
},
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("instructions path or bundle configuration");
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("blocks agent-authenticated instructions-path updates", async () => {
const app = await createApp({
type: "agent",
agentId,
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch(`/api/agents/${agentId}/instructions-path`)
.send({ path: "/etc/passwd" });
expect(res.status).toBe(403);
expect(res.body.error).toContain("instructions path or bundle configuration");
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("blocks agent-authenticated hires that set instructions bundle config", async () => {
mockAccessService.hasPermission.mockResolvedValue(true);
const app = await createApp({
type: "agent",
agentId,
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post(`/api/companies/${companyId}/agent-hires`)
.send({
name: "Injected",
role: "engineer",
adapterType: "codex_local",
adapterConfig: {
instructionsRootPath: "/etc",
instructionsEntryFile: "passwd",
},
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("instructions path or bundle configuration");
expect(mockAgentService.create).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("grants tasks:assign by default when board creates a new agent", async () => {
const app = await createApp({
type: "board",
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { shouldEnablePrivateHostnameGuard } from "../app.ts";
describe("shouldEnablePrivateHostnameGuard", () => {
it("enables the hostname guard for local_trusted private deployments", () => {
expect(shouldEnablePrivateHostnameGuard({
deploymentMode: "local_trusted",
deploymentExposure: "private",
})).toBe(true);
});
it("does not enable the hostname guard for local_trusted public deployments", () => {
expect(shouldEnablePrivateHostnameGuard({
deploymentMode: "local_trusted",
deploymentExposure: "public",
})).toBe(false);
});
it("enables the hostname guard for authenticated private deployments", () => {
expect(shouldEnablePrivateHostnameGuard({
deploymentMode: "authenticated",
deploymentExposure: "private",
})).toBe(true);
});
it("does not enable the hostname guard for authenticated public deployments", () => {
expect(shouldEnablePrivateHostnameGuard({
deploymentMode: "authenticated",
deploymentExposure: "public",
})).toBe(false);
});
});
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import type { Request } from "express";
import { shouldServeViteDevHtml } from "../app.js";
function createRequest(path: string, acceptsResult: string | false): Request {
return {
path,
accepts: () => acceptsResult,
} as unknown as Request;
}
describe("shouldServeViteDevHtml", () => {
it("serves HTML shell for document requests", () => {
expect(shouldServeViteDevHtml(createRequest("/", "html"))).toBe(true);
expect(shouldServeViteDevHtml(createRequest("/issues/abc", "html"))).toBe(true);
});
it("skips public assets even when the client accepts */*", () => {
expect(shouldServeViteDevHtml(createRequest("/sw.js", "html"))).toBe(false);
expect(shouldServeViteDevHtml(createRequest("/site.webmanifest", "html"))).toBe(false);
});
it("skips vite asset requests", () => {
expect(shouldServeViteDevHtml(createRequest("/@vite/client", "html"))).toBe(false);
expect(shouldServeViteDevHtml(createRequest("/src/main.tsx", "html"))).toBe(false);
});
});
@@ -196,6 +196,90 @@ describe("approval routes idempotent retries", () => {
expect(mockApprovalService.requestRevision).not.toHaveBeenCalled();
});
it("derives approval attribution from the authenticated actor on approve", async () => {
mockApprovalService.getById.mockResolvedValue({
id: "approval-4",
companyId: "company-1",
type: "hire_agent",
status: "pending",
payload: {},
requestedByAgentId: null,
});
mockApprovalService.approve.mockResolvedValue({
approval: {
id: "approval-4",
companyId: "company-1",
type: "hire_agent",
status: "approved",
payload: {},
requestedByAgentId: null,
},
applied: true,
});
const res = await request(await createApp())
.post("/api/approvals/approval-4/approve")
.send({ decidedByUserId: "forged-user", decisionNote: "ship it" });
expect(res.status).toBe(200);
expect(mockApprovalService.approve).toHaveBeenCalledWith("approval-4", "user-1", "ship it");
});
it("derives approval attribution from the authenticated actor on reject", async () => {
mockApprovalService.getById.mockResolvedValue({
id: "approval-5",
companyId: "company-1",
type: "hire_agent",
status: "pending",
payload: {},
});
mockApprovalService.reject.mockResolvedValue({
approval: {
id: "approval-5",
companyId: "company-1",
type: "hire_agent",
status: "rejected",
payload: {},
},
applied: true,
});
const res = await request(await createApp())
.post("/api/approvals/approval-5/reject")
.send({ decidedByUserId: "forged-user", decisionNote: "not now" });
expect(res.status).toBe(200);
expect(mockApprovalService.reject).toHaveBeenCalledWith("approval-5", "user-1", "not now");
});
it("derives approval attribution from the authenticated actor on request revision", async () => {
mockApprovalService.getById.mockResolvedValue({
id: "approval-6",
companyId: "company-1",
type: "hire_agent",
status: "pending",
payload: {},
});
mockApprovalService.requestRevision.mockResolvedValue({
id: "approval-6",
companyId: "company-1",
type: "hire_agent",
status: "revision_requested",
payload: {},
});
const res = await request(await createApp())
.post("/api/approvals/approval-6/request-revision")
.send({ decidedByUserId: "forged-user", decisionNote: "Need changes" });
expect(res.status).toBe(200);
expect(mockApprovalService.requestRevision).toHaveBeenCalledWith(
"approval-6",
"user-1",
"Need changes",
);
});
it("lets agents create generic issue-linked board approval requests", async () => {
mockApprovalService.create.mockResolvedValue({
id: "approval-1",
+45 -3
View File
@@ -45,7 +45,7 @@ function registerModuleMocks() {
}));
}
async function createApp(actor: any) {
async function createApp(actor: any, db: any = {} as any) {
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
@@ -58,7 +58,7 @@ async function createApp(actor: any) {
});
app.use(
"/api",
accessRoutes({} as any, {
accessRoutes(db, {
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
@@ -101,14 +101,56 @@ describe("cli auth routes", () => {
expect(res.body).toMatchObject({
id: "challenge-1",
token: "pcp_cli_auth_secret",
boardApiToken: "pcp_board_token",
approvalPath: "/cli-auth/challenge-1?token=pcp_cli_auth_secret",
pollPath: "/cli-auth/challenges/challenge-1",
expiresAt: "2026-03-23T13:00:00.000Z",
});
expect(res.body.boardApiToken).toBe("pcp_board_token");
expect(res.body.approvalUrl).toContain("/cli-auth/challenge-1?token=pcp_cli_auth_secret");
});
it("rejects anonymous access to generic skill documents", async () => {
const app = await createApp({ type: "none", source: "none" });
const [indexRes, skillRes] = await Promise.all([
request(app).get("/api/skills/index"),
request(app).get("/api/skills/paperclip"),
]);
expect(indexRes.status).toBe(401);
expect(skillRes.status).toBe(401);
});
it("serves the invite-scoped paperclip skill anonymously for active invites", async () => {
const invite = {
id: "invite-1",
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "agent",
tokenHash: "hash",
defaultsPayload: null,
expiresAt: new Date(Date.now() + 60_000),
invitedByUserId: null,
revokedAt: null,
acceptedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const db = {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn().mockResolvedValue([invite]),
})),
})),
};
const app = await createApp({ type: "none", source: "none" }, db);
const res = await request(app).get("/api/invites/token-123/skills/paperclip");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toContain("text/markdown");
expect(res.text).toContain("# Paperclip Skill");
});
it("marks challenge status as requiring sign-in for anonymous viewers", async () => {
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({
id: "challenge-1",
@@ -59,6 +59,11 @@ const assetSvc = {
create: vi.fn(),
};
const secretSvc = {
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record<string, unknown>) => ({ config, secretKeys: new Set<string>() })),
};
const agentInstructionsSvc = {
exportFiles: vi.fn(),
materializeManagedBundle: vi.fn(),
@@ -96,6 +101,10 @@ vi.mock("../services/assets.js", () => ({
assetService: () => assetSvc,
}));
vi.mock("../services/secrets.js", () => ({
secretService: () => secretSvc,
}));
vi.mock("../services/agent-instructions.js", () => ({
agentInstructionsService: () => agentInstructionsSvc,
}));
@@ -117,6 +126,11 @@ describe("company portability", () => {
beforeEach(() => {
vi.clearAllMocks();
secretSvc.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config);
secretSvc.resolveAdapterConfigForRuntime.mockImplementation(async (_companyId, config) => ({
config,
secretKeys: new Set<string>(),
}));
companySvc.getById.mockResolvedValue({
id: "company-1",
name: "Paperclip",
@@ -127,6 +141,11 @@ describe("company portability", () => {
logoUrl: null,
requireBoardApprovalForNewAgents: true,
});
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
requireBoardApprovalForNewAgents: true,
});
agentSvc.list.mockResolvedValue([
{
id: "agent-1",
@@ -1509,7 +1528,7 @@ describe("company portability", () => {
}),
]);
await portability.importBundle({
const result = await portability.importBundle({
source: { type: "inline", rootPath: "paperclip-demo", files },
include: { company: true, agents: true, projects: true, issues: true, skills: false },
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
@@ -1520,12 +1539,15 @@ describe("company portability", () => {
expect(routineSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
projectId: "project-created",
title: "Monday Review",
assigneeAgentId: "agent-created",
assigneeAgentId: null,
priority: "high",
status: "paused",
concurrencyPolicy: "always_enqueue",
catchUpPolicy: "enqueue_missed_with_cap",
}), expect.any(Object));
expect(result.warnings).toContain(
"Task monday-review assignee claudecoder is pending_approval; imported work was left unassigned.",
);
expect(routineSvc.createTrigger).toHaveBeenCalledTimes(2);
expect(routineSvc.createTrigger).toHaveBeenCalledWith("routine-created", expect.objectContaining({
kind: "schedule",
@@ -2418,4 +2440,178 @@ describe("company portability", () => {
expect(nestedMaterializedFiles?.["AGENTS.md"]).not.toMatch(/^---\n/);
expect(nestedMaterializedFiles?.["AGENTS.md"]).not.toContain('name: "ClaudeCoder"');
});
it("rejects dangerous adapter types on agent-safe imports", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
agentSvc.list.mockResolvedValue([]);
await expect(portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: false,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "existing_company",
companyId: "company-1",
},
agents: ["claudecoder"],
collisionStrategy: "rename",
adapterOverrides: {
claudecoder: {
adapterType: "process",
adapterConfig: {
command: "/bin/sh",
args: ["-c", "id"],
},
},
},
}, "user-1", {
mode: "agent_safe",
sourceCompanyId: "company-1",
})).rejects.toThrow('Adapter type "process" is not allowed in safe imports');
expect(agentSvc.create).not.toHaveBeenCalled();
});
it("imports new agents through approval and adapter-config normalization", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
agentSvc.list.mockResolvedValue([]);
secretSvc.normalizeAdapterConfigForPersistence.mockResolvedValueOnce({
normalized: true,
env: {
OPENAI_API_KEY: {
type: "secret_ref",
secretId: "secret-1",
version: "latest",
},
},
});
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: "agent-created",
name: String(input.name),
adapterType: input.adapterType,
adapterConfig: input.adapterConfig,
status: input.status,
}));
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: ["claudecoder"],
collisionStrategy: "rename",
}, "user-1");
expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith(
"company-imported",
expect.any(Object),
{ strictMode: false },
);
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
adapterType: "claude_local",
adapterConfig: expect.objectContaining({
normalized: true,
}),
status: "pending_approval",
}));
});
it("normalizes adapter config on replace imports before updating existing agents", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
secretSvc.normalizeAdapterConfigForPersistence.mockResolvedValueOnce({
normalized: "updated",
});
agentSvc.update.mockImplementation(async (id: string, patch: Record<string, unknown>) => ({
id,
name: "ClaudeCoder",
adapterType: patch.adapterType,
adapterConfig: patch.adapterConfig,
}));
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: false,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "existing_company",
companyId: "company-1",
},
agents: ["claudecoder"],
collisionStrategy: "replace",
adapterOverrides: {
claudecoder: {
adapterType: "codex_local",
adapterConfig: {
model: "gpt-5.4",
},
},
},
}, "user-1");
expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith(
"company-1",
expect.any(Object),
{ strictMode: false },
);
expect(agentSvc.update).toHaveBeenCalledWith("agent-1", expect.objectContaining({
adapterType: "codex_local",
adapterConfig: {
normalized: "updated",
},
}));
});
});
@@ -0,0 +1,92 @@
import { randomUUID } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { promises as fs } from "node:fs";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { companies, companySkills, createDb } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { companySkillService } from "../services/company-skills.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres company skill service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("companySkillService.list", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof companySkillService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const cleanupDirs = new Set<string>();
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-skills-service-");
db = createDb(tempDb.connectionString);
svc = companySkillService(db);
}, 20_000);
afterEach(async () => {
await db.delete(companySkills);
await db.delete(companies);
await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true })));
cleanupDirs.clear();
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("lists skills without exposing markdown content", async () => {
const companyId = randomUUID();
const skillId = randomUUID();
const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-heavy-skill-"));
cleanupDirs.add(skillDir);
await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Heavy Skill\n", "utf8");
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(companySkills).values({
id: skillId,
companyId,
key: `company/${companyId}/heavy-skill`,
slug: "heavy-skill",
name: "Heavy Skill",
description: "Large skill used for list projection regression coverage.",
markdown: `# Heavy Skill\n\n${"x".repeat(250_000)}`,
sourceType: "local_path",
sourceLocator: skillDir,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
metadata: { sourceKind: "local_path" },
});
const listed = await svc.list(companyId);
const skill = listed.find((entry) => entry.id === skillId);
expect(skill).toBeDefined();
expect(skill).not.toHaveProperty("markdown");
expect(skill).toMatchObject({
id: skillId,
key: `company/${companyId}/heavy-skill`,
slug: "heavy-skill",
name: "Heavy Skill",
sourceType: "local_path",
sourceLocator: skillDir,
attachedAgentCount: 0,
sourceBadge: "local",
editable: true,
});
});
});
+160
View File
@@ -1,6 +1,15 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll } from "vitest";
import { randomUUID } from "node:crypto";
import { createDb, companies, agents, costEvents, financeEvents, projects } from "@paperclipai/db";
import { costService } from "../services/costs.ts";
import { financeService } from "../services/finance.ts";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
function makeDb(overrides: Record<string, unknown> = {}) {
const selectChain = {
@@ -230,3 +239,154 @@ describe("cost routes", () => {
expect(mockAgentService.update).not.toHaveBeenCalled();
});
});
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => {
let db!: ReturnType<typeof createDb>;
let costs!: ReturnType<typeof costService>;
let finance!: ReturnType<typeof financeService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-costs-service-");
db = createDb(tempDb.connectionString);
costs = costService(db);
finance = financeService(db);
}, 20_000);
afterEach(async () => {
await db.delete(financeEvents);
await db.delete(costEvents);
await db.delete(projects);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("aggregates cost event sums above int32 without raising Postgres integer overflow", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Cost Agent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Overflow Project",
status: "active",
});
await db.insert(costEvents).values([
{
companyId,
agentId,
projectId,
provider: "openai",
biller: "openai",
billingType: "metered_api",
model: "gpt-5",
inputTokens: 2_000_000_000,
cachedInputTokens: 0,
outputTokens: 200_000_000,
costCents: 2_000_000_000,
occurredAt: new Date("2026-04-10T00:00:00.000Z"),
},
{
companyId,
agentId,
projectId,
provider: "openai",
biller: "openai",
billingType: "metered_api",
model: "gpt-5",
inputTokens: 2_000_000_000,
cachedInputTokens: 10,
outputTokens: 200_000_000,
costCents: 2_000_000_000,
occurredAt: new Date("2026-04-11T00:00:00.000Z"),
},
]);
const range = {
from: new Date("2026-04-01T00:00:00.000Z"),
to: new Date("2026-04-15T23:59:59.999Z"),
};
const [byAgentRow] = await costs.byAgent(companyId, range);
const [byProjectRow] = await costs.byProject(companyId, range);
const [byAgentModelRow] = await costs.byAgentModel(companyId, range);
expect(byAgentRow?.costCents).toBe(4_000_000_000);
expect(byAgentRow?.inputTokens).toBe(4_000_000_000);
expect(byProjectRow?.costCents).toBe(4_000_000_000);
expect(byAgentModelRow?.costCents).toBe(4_000_000_000);
});
it("aggregates finance event sums above int32 without raising Postgres integer overflow", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(financeEvents).values([
{
companyId,
biller: "openai",
eventKind: "invoice",
amountCents: 2_000_000_000,
currency: "USD",
direction: "debit",
estimated: false,
occurredAt: new Date("2026-04-10T00:00:00.000Z"),
},
{
companyId,
biller: "openai",
eventKind: "invoice",
amountCents: 2_000_000_000,
currency: "USD",
direction: "debit",
estimated: true,
occurredAt: new Date("2026-04-11T00:00:00.000Z"),
},
]);
const range = {
from: new Date("2026-04-01T00:00:00.000Z"),
to: new Date("2026-04-15T23:59:59.999Z"),
};
const summary = await finance.summary(companyId, range);
const [byKindRow] = await finance.byKind(companyId, range);
expect(summary.debitCents).toBe(4_000_000_000);
expect(summary.estimatedDebitCents).toBe(2_000_000_000);
expect(byKindRow?.debitCents).toBe(4_000_000_000);
expect(byKindRow?.netCents).toBe(4_000_000_000);
});
});
@@ -0,0 +1,90 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockExecutionWorkspaceService = vi.hoisted(() => ({
list: vi.fn(),
listSummaries: vi.fn(),
getById: vi.fn(),
getCloseReadiness: vi.fn(),
update: vi.fn(),
}));
const mockWorkspaceOperationService = vi.hoisted(() => ({
listForExecutionWorkspace: vi.fn(),
createRecorder: vi.fn(),
}));
function registerServiceMocks() {
vi.doMock("../services/index.js", () => ({
executionWorkspaceService: () => mockExecutionWorkspaceService,
logActivity: vi.fn(async () => undefined),
workspaceOperationService: () => mockWorkspaceOperationService,
}));
}
async function createApp() {
const [{ executionWorkspaceRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/execution-workspaces.js")>("../routes/execution-workspaces.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", executionWorkspaceRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("execution workspace routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/execution-workspaces.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerServiceMocks();
vi.resetAllMocks();
mockExecutionWorkspaceService.list.mockResolvedValue([]);
mockExecutionWorkspaceService.listSummaries.mockResolvedValue([
{
id: "workspace-1",
name: "Alpha",
mode: "isolated_workspace",
projectWorkspaceId: null,
},
]);
});
it("uses summary mode for lightweight workspace lookups", async () => {
const res = await request(await createApp())
.get("/api/companies/company-1/execution-workspaces?summary=true&reuseEligible=true");
expect(res.status).toBe(200);
expect(res.body).toEqual([
{
id: "workspace-1",
name: "Alpha",
mode: "isolated_workspace",
projectWorkspaceId: null,
},
]);
expect(mockExecutionWorkspaceService.listSummaries).toHaveBeenCalledWith("company-1", {
projectId: undefined,
projectWorkspaceId: undefined,
issueId: undefined,
status: undefined,
reuseEligible: true,
});
expect(mockExecutionWorkspaceService.list).not.toHaveBeenCalled();
});
});
+117 -1
View File
@@ -33,7 +33,7 @@ describe("GET /health", () => {
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: "ok", version: serverVersion });
});
}, 15_000);
it("returns 200 when the database probe succeeds", async () => {
const db = {
@@ -63,4 +63,120 @@ describe("GET /health", () => {
error: "database_unreachable"
});
});
it("redacts detailed metadata for anonymous requests in authenticated mode", async () => {
const devServerStatus = await import("../dev-server-status.js");
vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined);
const { healthRoutes } = await import("../routes/health.js");
const db = {
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn().mockResolvedValue([{ count: 1 }]),
})),
})),
} as unknown as Db;
const app = express();
app.use((req, _res, next) => {
(req as any).actor = { type: "none", source: "none" };
next();
});
app.use(
"/health",
healthRoutes(db, {
deploymentMode: "authenticated",
deploymentExposure: "public",
authReady: true,
companyDeletionEnabled: false,
}),
);
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(res.body).toEqual({
status: "ok",
deploymentMode: "authenticated",
bootstrapStatus: "ready",
bootstrapInviteActive: false,
});
});
it("redacts detailed metadata when authenticated mode is reached without auth middleware", async () => {
const devServerStatus = await import("../dev-server-status.js");
vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined);
const { healthRoutes } = await import("../routes/health.js");
const db = {
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn().mockResolvedValue([{ count: 1 }]),
})),
})),
} as unknown as Db;
const app = express();
app.use(
"/health",
healthRoutes(db, {
deploymentMode: "authenticated",
deploymentExposure: "public",
authReady: true,
companyDeletionEnabled: false,
}),
);
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(res.body).toEqual({
status: "ok",
deploymentMode: "authenticated",
bootstrapStatus: "ready",
bootstrapInviteActive: false,
});
});
it("keeps detailed metadata for authenticated requests in authenticated mode", async () => {
const devServerStatus = await import("../dev-server-status.js");
vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined);
const { healthRoutes } = await import("../routes/health.js");
const db = {
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn().mockResolvedValue([{ count: 1 }]),
})),
})),
} as unknown as Db;
const app = express();
app.use((req, _res, next) => {
(req as any).actor = { type: "board", userId: "user-1", source: "session" };
next();
});
app.use(
"/health",
healthRoutes(db, {
deploymentMode: "authenticated",
deploymentExposure: "public",
authReady: true,
companyDeletionEnabled: false,
}),
);
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
status: "ok",
version: serverVersion,
deploymentMode: "authenticated",
deploymentExposure: "public",
authReady: true,
bootstrapStatus: "ready",
bootstrapInviteActive: false,
features: {
companyDeletionEnabled: false,
},
});
});
});
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import {
summarizeHeartbeatRunContextSnapshot,
summarizeHeartbeatRunListResultJson,
} from "../services/heartbeat.js";
describe("summarizeHeartbeatRunContextSnapshot", () => {
it("keeps only the small retry/linking fields needed by the client", () => {
const summarized = summarizeHeartbeatRunContextSnapshot({
issueId: "issue-1",
taskId: "task-1",
taskKey: "PAP-1",
commentId: "comment-1",
wakeCommentId: "comment-2",
wakeReason: "retry_failed_run",
wakeSource: "on_demand",
wakeTriggerDetail: "manual",
paperclipWake: {
comments: [
{
body: "x".repeat(50_000),
},
],
},
executionStage: {
summary: "large nested object that should not be sent back in run lists",
},
});
expect(summarized).toEqual({
issueId: "issue-1",
taskId: "task-1",
taskKey: "PAP-1",
commentId: "comment-1",
wakeCommentId: "comment-2",
wakeReason: "retry_failed_run",
wakeSource: "on_demand",
wakeTriggerDetail: "manual",
});
});
it("returns null when no allowed fields are present", () => {
expect(
summarizeHeartbeatRunContextSnapshot({
paperclipWake: { comments: [{ body: "hello" }] },
}),
).toBeNull();
});
});
describe("summarizeHeartbeatRunListResultJson", () => {
it("keeps only summary fields and parses numeric cost aliases", () => {
expect(
summarizeHeartbeatRunListResultJson({
summary: "Completed the task",
result: "Updated three files",
message: "",
error: null,
totalCostUsd: "1.25",
costUsd: "0.75",
costUsdCamel: "0.5",
}),
).toEqual({
summary: "Completed the task",
result: "Updated three files",
total_cost_usd: 1.25,
cost_usd: 0.75,
costUsd: 0.5,
});
});
it("returns null when projected fields are empty", () => {
expect(
summarizeHeartbeatRunListResultJson({
summary: "",
result: null,
message: undefined,
error: " ",
totalCostUsd: "abc",
}),
).toBeNull();
});
});
+101
View File
@@ -88,4 +88,105 @@ describeEmbeddedPostgres("heartbeat list", () => {
}
}
});
it("returns small result json payloads unchanged from getRun", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "running",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "assignment",
status: "succeeded",
resultJson: {
summary: "done",
structured: { ok: true },
},
});
const run = await heartbeatService(db).getRun(runId);
expect(run?.resultJson).toEqual({
summary: "done",
structured: { ok: true },
});
});
it("bounds oversized legacy result json payloads on getRun", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
const oversizedStdout = Array.from({ length: 8_000 }, (_, index) =>
`${index.toString(16).padStart(4, "0")}-${randomUUID()}`,
).join("|");
const oversizedNestedPayload = Array.from({ length: 6_000 }, (_, index) =>
`${index.toString(16).padStart(4, "0")}:${randomUUID()}`,
).join("|");
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "running",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "assignment",
status: "succeeded",
resultJson: {
summary: "completed",
stdout: oversizedStdout,
nestedHuge: { payload: oversizedNestedPayload },
},
});
const run = await heartbeatService(db).getRun(runId);
const result = run?.resultJson as Record<string, unknown> | null;
expect(result).toMatchObject({
summary: "completed",
truncated: true,
truncationReason: "oversized_result_json",
stdoutTruncated: true,
});
expect(typeof result?.stdout).toBe("string");
expect((result?.stdout as string).length).toBeLessThan(oversizedStdout.length);
expect(result).not.toHaveProperty("nestedHuge");
});
});
@@ -599,6 +599,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("retried dispatch");
expect(comments[0]?.body).toContain("Latest retry failure: `process_lost` - run failed before issue advanced.");
});
it("re-enqueues continuation for stranded in-progress work with no active run", async () => {
@@ -647,6 +648,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("retried continuation");
expect(comments[0]?.body).toContain("Latest retry failure: `process_lost` - run failed before issue advanced.");
});
it("does not reconcile user-assigned work through the agent stranded-work recovery path", async () => {
@@ -56,6 +56,7 @@ describe("shouldSilenceHttpSuccessLog", () => {
expect(shouldSilenceHttpSuccessLog("GET", "/@fs/Users/dotta/paperclip/ui/src/main.tsx", 200)).toBe(true);
expect(shouldSilenceHttpSuccessLog("GET", "/src/App.tsx?t=123", 200)).toBe(true);
expect(shouldSilenceHttpSuccessLog("GET", "/site.webmanifest", 200)).toBe(true);
expect(shouldSilenceHttpSuccessLog("GET", "/sw.js", 200)).toBe(true);
});
it("keeps normal successful application requests", () => {
@@ -41,6 +41,7 @@ describe("buildInviteOnboardingTextDocument", () => {
expect(text).toContain("/api/invites/token-123/accept");
expect(text).toContain("/api/join-requests/{requestId}/claim-api-key");
expect(text).toContain("/api/invites/token-123/onboarding.txt");
expect(text).toContain("/api/invites/token-123/skills/paperclip");
expect(text).toContain("Suggested Paperclip base URLs to try");
expect(text).toContain("http://localhost:3100");
expect(text).toContain("host.docker.internal");
@@ -148,7 +148,7 @@ async function normalizePolicy(input: {
return normalizeIssueExecutionPolicy(input);
}
function makeIssue(status: "todo" | "done") {
function makeIssue(status: "todo" | "done" | "blocked") {
return {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
@@ -430,6 +430,34 @@ describe("issue comment reopen routes", () => {
expect(res.status).toBe(201);
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("wakes the assignee when an assigned blocked issue moves back to todo", async () => {
const issue = makeIssue("blocked");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ status: "todo" });
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
source: "automation",
triggerDetail: "system",
reason: "issue_status_changed",
payload: expect.objectContaining({
issueId: "11111111-1111-4111-8111-111111111111",
mutation: "update",
}),
}),
);
});
it("interrupts an active run before a combined comment update", async () => {
const issue = {
...makeIssue("todo"),
@@ -0,0 +1,165 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
const mockIssueService = vi.hoisted(() => ({
addComment: vi.fn(),
assertCheckoutOwner: vi.fn(),
create: vi.fn(),
findMentionedAgents: vi.fn(),
getByIdentifier: vi.fn(),
getById: vi.fn(),
getRelationSummaries: vi.fn(),
getWakeableParentAfterChildCompletion: vi.fn(),
listWakeableBlockedDependents: vi.fn(),
update: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(async () => true),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
getById: vi.fn(async () => null),
}),
documentService: () => ({}),
executionWorkspaceService: () => ({
getById: vi.fn(async () => null),
}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}),
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use(errorHandler);
return app;
}
function makeIssue(overrides: Record<string, unknown> = {}) {
return {
id: "issue-1",
companyId: "company-1",
status: "todo",
priority: "medium",
projectId: null,
goalId: null,
parentId: null,
assigneeAgentId: null,
assigneeUserId: null,
createdByUserId: "board-user",
identifier: "PAP-1000",
title: "Workspace authz",
executionPolicy: null,
executionState: null,
executionWorkspaceId: null,
hiddenAt: null,
...overrides,
};
}
describe("issue workspace command authorization", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIssueService.addComment.mockResolvedValue(null);
mockIssueService.create.mockResolvedValue(makeIssue());
mockIssueService.findMentionedAgents.mockResolvedValue([]);
mockIssueService.getByIdentifier.mockResolvedValue(null);
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockIssueService.update.mockResolvedValue(makeIssue());
});
it("rejects agent callers that create issue workspace provision commands", async () => {
const app = createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post("/api/companies/company-1/issues")
.send({
title: "Exploit",
executionWorkspaceSettings: {
workspaceStrategy: {
type: "git_worktree",
provisionCommand: "touch /tmp/paperclip-rce",
},
},
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed workspace commands");
expect(mockIssueService.create).not.toHaveBeenCalled();
});
it("rejects agent callers that patch assignee adapter workspace teardown commands", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue());
const app = createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch("/api/issues/issue-1")
.send({
assigneeAdapterOverrides: {
adapterConfig: {
workspaceStrategy: {
type: "git_worktree",
teardownCommand: "rm -rf /tmp/paperclip-rce",
},
},
},
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed workspace commands");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
});
@@ -190,6 +190,10 @@ describe("issue goal context routes", () => {
title: projectGoal.title,
}),
);
expect(mockIssueService.findMentionedProjectIds).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
{ includeCommentBodies: false },
);
expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled();
});
+117
View File
@@ -22,6 +22,7 @@ import {
} from "./helpers/embedded-postgres.js";
import { instanceSettingsService } from "../services/instance-settings.ts";
import { issueService } from "../services/issues.ts";
import { buildProjectMentionHref } from "@paperclipai/shared";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -696,6 +697,39 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
"2026-03-26T10:00:00.000Z",
);
});
it("trims list payload fields that can grow large on issue index routes", async () => {
const companyId = randomUUID();
const issueId = randomUUID();
const longDescription = "x".repeat(5_000);
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Large issue",
description: longDescription,
status: "todo",
priority: "medium",
executionPolicy: { stages: Array.from({ length: 20 }, (_, index) => ({ index, kind: "review", notes: "y".repeat(400) })) },
executionState: { history: Array.from({ length: 20 }, (_, index) => ({ index, body: "z".repeat(400) })) },
executionWorkspaceSettings: { notes: "w".repeat(2_000) },
});
const [result] = await svc.list(companyId);
expect(result).toBeTruthy();
expect(result?.description).toHaveLength(1200);
expect(result?.executionPolicy).toBeNull();
expect(result?.executionState).toBeNull();
expect(result?.executionWorkspaceSettings).toBeNull();
});
});
describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
@@ -1191,10 +1225,12 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-");
db = createDb(tempDb.connectionString);
svc = issueService(db);
await ensureIssueRelationsTable(db);
}, 20_000);
afterEach(async () => {
await db.delete(issueComments);
await db.delete(issueRelations);
await db.delete(issueInboxArchives);
await db.delete(activityLog);
await db.delete(issues);
@@ -1456,3 +1492,84 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
});
});
});
describeEmbeddedPostgres("issueService.findMentionedProjectIds", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof issueService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-mentioned-projects-");
db = createDb(tempDb.connectionString);
svc = issueService(db);
await ensureIssueRelationsTable(db);
}, 20_000);
afterEach(async () => {
await db.delete(issueComments);
await db.delete(issueRelations);
await db.delete(issueInboxArchives);
await db.delete(activityLog);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(agents);
await db.delete(instanceSettings);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("can skip comment-body scans for bounded issue detail reads", async () => {
const companyId = randomUUID();
const issueId = randomUUID();
const titleProjectId = randomUUID();
const commentProjectId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values([
{
id: titleProjectId,
companyId,
name: "Title project",
status: "in_progress",
},
{
id: commentProjectId,
companyId,
name: "Comment project",
status: "in_progress",
},
]);
await db.insert(issues).values({
id: issueId,
companyId,
title: `Link [Title](${buildProjectMentionHref(titleProjectId)})`,
description: null,
status: "todo",
priority: "medium",
});
await db.insert(issueComments).values({
companyId,
issueId,
body: `Comment link [Comment](${buildProjectMentionHref(commentProjectId)})`,
});
expect(await svc.findMentionedProjectIds(issueId, { includeCommentBodies: false })).toEqual([titleProjectId]);
expect(await svc.findMentionedProjectIds(issueId)).toEqual([
titleProjectId,
commentProjectId,
]);
});
});
@@ -0,0 +1,168 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockRegistry = vi.hoisted(() => ({
getById: vi.fn(),
getByKey: vi.fn(),
}));
const mockLifecycle = vi.hoisted(() => ({
load: vi.fn(),
upgrade: vi.fn(),
}));
vi.mock("../services/plugin-registry.js", () => ({
pluginRegistryService: () => mockRegistry,
}));
vi.mock("../services/plugin-lifecycle.js", () => ({
pluginLifecycleManager: () => mockLifecycle,
}));
vi.mock("../services/activity-log.js", () => ({
logActivity: vi.fn(),
}));
vi.mock("../services/live-events.js", () => ({
publishGlobalLiveEvent: vi.fn(),
}));
async function createApp(actor: Record<string, unknown>, loaderOverrides: Record<string, unknown> = {}) {
const [{ pluginRoutes }, { errorHandler }] = await Promise.all([
import("../routes/plugins.js"),
import("../middleware/index.js"),
]);
const loader = {
installPlugin: vi.fn(),
...loaderOverrides,
};
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor as typeof req.actor;
next();
});
app.use("/api", pluginRoutes({} as never, loader as never));
app.use(errorHandler);
return { app, loader };
}
describe("plugin install and upgrade authz", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("rejects plugin installation for non-admin board users", async () => {
const { app, loader } = await createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
});
const res = await request(app)
.post("/api/plugins/install")
.send({ packageName: "paperclip-plugin-example" });
expect(res.status).toBe(403);
expect(loader.installPlugin).not.toHaveBeenCalled();
}, 20_000);
it("allows instance admins to install plugins", async () => {
const pluginId = "11111111-1111-4111-8111-111111111111";
const pluginKey = "paperclip.example";
const discovered = {
manifest: {
id: pluginKey,
},
};
mockRegistry.getByKey.mockResolvedValue({
id: pluginId,
pluginKey,
packageName: "paperclip-plugin-example",
version: "1.0.0",
});
mockRegistry.getById.mockResolvedValue({
id: pluginId,
pluginKey,
packageName: "paperclip-plugin-example",
version: "1.0.0",
});
mockLifecycle.load.mockResolvedValue(undefined);
const { app, loader } = await createApp(
{
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: [],
},
{ installPlugin: vi.fn().mockResolvedValue(discovered) },
);
const res = await request(app)
.post("/api/plugins/install")
.send({ packageName: "paperclip-plugin-example" });
expect(res.status).toBe(200);
expect(loader.installPlugin).toHaveBeenCalledWith({
packageName: "paperclip-plugin-example",
version: undefined,
});
expect(mockLifecycle.load).toHaveBeenCalledWith(pluginId);
}, 20_000);
it("rejects plugin upgrades for non-admin board users", async () => {
const pluginId = "11111111-1111-4111-8111-111111111111";
const { app } = await createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/upgrade`)
.send({});
expect(res.status).toBe(403);
expect(mockRegistry.getById).not.toHaveBeenCalled();
expect(mockLifecycle.upgrade).not.toHaveBeenCalled();
}, 20_000);
it("allows instance admins to upgrade plugins", async () => {
const pluginId = "11111111-1111-4111-8111-111111111111";
mockRegistry.getById.mockResolvedValue({
id: pluginId,
pluginKey: "paperclip.example",
version: "1.0.0",
});
mockLifecycle.upgrade.mockResolvedValue({
id: pluginId,
version: "1.1.0",
});
const { app } = await createApp({
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: [],
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/upgrade`)
.send({ version: "1.1.0" });
expect(res.status).toBe(200);
expect(mockLifecycle.upgrade).toHaveBeenCalledWith(pluginId, "1.1.0");
}, 20_000);
});
+60 -19
View File
@@ -39,24 +39,34 @@ describe("toPercent", () => {
expect(toPercent(0)).toBe(0);
});
it("converts 0.5 to 50", () => {
it("treats values < 1 as fraction and multiplies by 100 (0.5 → 50%)", () => {
expect(toPercent(0.5)).toBe(50);
});
it("converts 1.0 to 100", () => {
expect(toPercent(1.0)).toBe(100);
it("treats values >= 1 as already-percentage (34 → 34%)", () => {
expect(toPercent(34.0)).toBe(34);
expect(toPercent(91.0)).toBe(91);
});
it("treats value exactly 1.0 as 1% (not 100%) — the < 1 heuristic boundary", () => {
// 1.0 is NOT < 1, so it is treated as already-percentage → 1%
expect(toPercent(1.0)).toBe(1);
});
it("clamps overshoot to 100", () => {
// floating-point utilization can slightly exceed 1.0
expect(toPercent(1.001)).toBe(100);
expect(toPercent(1.01)).toBe(100);
expect(toPercent(105)).toBe(100);
expect(toPercent(101)).toBe(100);
});
it("rounds to nearest integer", () => {
it("rounds to nearest integer for fractions", () => {
expect(toPercent(0.333)).toBe(33);
expect(toPercent(0.666)).toBe(67);
});
it("rounds to nearest integer for percentages", () => {
expect(toPercent(48.52)).toBe(49);
expect(toPercent(23.4)).toBe(23);
});
});
// ---------------------------------------------------------------------------
@@ -516,37 +526,48 @@ describe("fetchClaudeQuota", () => {
expect(windows).toEqual([]);
});
it("parses five_hour window", async () => {
mockFetch({ five_hour: { utilization: 0.4, resets_at: "2026-01-01T00:00:00Z" } });
it("parses five_hour window with percentage-range utilization", async () => {
mockFetch({ five_hour: { utilization: 34.0, resets_at: "2026-01-01T00:00:00Z" } });
const windows = await fetchClaudeQuota("token");
expect(windows).toHaveLength(1);
expect(windows[0]).toMatchObject({
label: "Current session",
usedPercent: 40,
usedPercent: 34,
resetsAt: "2026-01-01T00:00:00Z",
});
});
it("parses seven_day window", async () => {
mockFetch({ seven_day: { utilization: 0.75, resets_at: null } });
it("parses seven_day window with percentage-range utilization", async () => {
mockFetch({ seven_day: { utilization: 91.0, resets_at: null } });
const windows = await fetchClaudeQuota("token");
expect(windows).toHaveLength(1);
expect(windows[0]).toMatchObject({
label: "Current week (all models)",
usedPercent: 75,
usedPercent: 91,
resetsAt: null,
});
});
it("still handles legacy 0-1 fraction utilization", async () => {
mockFetch({ five_hour: { utilization: 0.4, resets_at: null } });
const windows = await fetchClaudeQuota("token");
expect(windows[0]).toMatchObject({
label: "Current session",
usedPercent: 40,
});
});
it("parses seven_day_sonnet and seven_day_opus windows", async () => {
mockFetch({
seven_day_sonnet: { utilization: 0.2, resets_at: null },
seven_day_opus: { utilization: 0.9, resets_at: null },
seven_day_sonnet: { utilization: 23.0, resets_at: null },
seven_day_opus: { utilization: 85.0, resets_at: null },
});
const windows = await fetchClaudeQuota("token");
expect(windows).toHaveLength(2);
expect(windows[0]!.label).toBe("Current week (Sonnet only)");
expect(windows[0]!.usedPercent).toBe(23);
expect(windows[1]!.label).toBe("Current week (Opus only)");
expect(windows[1]!.usedPercent).toBe(85);
});
it("sets usedPercent to null when utilization is absent", async () => {
@@ -557,10 +578,10 @@ describe("fetchClaudeQuota", () => {
it("includes all four windows when all are present", async () => {
mockFetch({
five_hour: { utilization: 0.1, resets_at: null },
seven_day: { utilization: 0.2, resets_at: null },
seven_day_sonnet: { utilization: 0.3, resets_at: null },
seven_day_opus: { utilization: 0.4, resets_at: null },
five_hour: { utilization: 10.0, resets_at: null },
seven_day: { utilization: 20.0, resets_at: null },
seven_day_sonnet: { utilization: 30.0, resets_at: null },
seven_day_opus: { utilization: 40.0, resets_at: null },
});
const windows = await fetchClaudeQuota("token");
expect(windows).toHaveLength(4);
@@ -571,6 +592,7 @@ describe("fetchClaudeQuota", () => {
"Current week (Sonnet only)",
"Current week (Opus only)",
]);
expect(windows.map((w: QuotaWindow) => w.usedPercent)).toEqual([10, 20, 30, 40]);
});
it("parses extra usage when the OAuth response includes it", async () => {
@@ -591,6 +613,25 @@ describe("fetchClaudeQuota", () => {
},
]);
});
it("formats extra usage credits from cents to dollars", async () => {
mockFetch({
extra_usage: {
is_enabled: true,
monthly_limit: 14000,
used_credits: 6793,
utilization: 48.52,
},
});
const windows = await fetchClaudeQuota("token");
expect(windows).toHaveLength(1);
expect(windows[0]).toMatchObject({
label: "Extra usage",
usedPercent: 49,
valueLabel: "$67.93 / $140.00",
detail: "Monthly extra usage pool",
});
});
});
// ---------------------------------------------------------------------------
@@ -267,6 +267,14 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
expect(runRes.body.source).toBe("manual");
expect(runRes.body.linkedIssueId).toBeTruthy();
const listRes = await request(app).get(`/api/companies/${companyId}/routines`);
expect(listRes.status).toBe(200);
const listed = listRes.body.find((r: { id: string }) => r.id === routineId);
expect(listed).toBeDefined();
expect(listed.triggers).toHaveLength(1);
expect(listed.triggers[0].cronExpression).toBe("0 10 * * 1-5");
expect(listed.triggers[0].timezone).toBe("UTC");
const detailRes = await request(app).get(`/api/routines/${routineId}`);
expect(detailRes.status).toBe(200);
expect(detailRes.body.triggers).toHaveLength(1);
@@ -180,3 +180,27 @@ describe("startServer feedback export wiring", () => {
});
});
});
describe("startServer PAPERCLIP_API_URL handling", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.BETTER_AUTH_SECRET = "test-secret";
delete process.env.PAPERCLIP_API_URL;
});
it("uses the externally set PAPERCLIP_API_URL when provided", async () => {
process.env.PAPERCLIP_API_URL = "http://custom-api:3100";
const started = await startServer();
expect(started.apiUrl).toBe("http://custom-api:3100");
expect(process.env.PAPERCLIP_API_URL).toBe("http://custom-api:3100");
});
it("falls back to host-based URL when PAPERCLIP_API_URL is not set", async () => {
const started = await startServer();
expect(started.apiUrl).toBe("http://127.0.0.1:3210");
expect(process.env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:3210");
});
});
@@ -0,0 +1,437 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockProjectService = vi.hoisted(() => ({
create: vi.fn(),
createWorkspace: vi.fn(),
getById: vi.fn(),
listWorkspaces: vi.fn(),
resolveByReference: vi.fn(),
update: vi.fn(),
updateWorkspace: vi.fn(),
}));
const mockExecutionWorkspaceService = vi.hoisted(() => ({
getById: vi.fn(),
update: vi.fn(),
}));
const mockSecretService = vi.hoisted(() => ({
normalizeEnvBindingsForPersistence: vi.fn(),
}));
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
const mockAssertCanManageProjectWorkspaceRuntimeServices = vi.hoisted(() => vi.fn());
const mockAssertCanManageExecutionWorkspaceRuntimeServices = vi.hoisted(() => vi.fn());
function registerModuleMocks() {
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../services/index.js", () => ({
executionWorkspaceService: () => mockExecutionWorkspaceService,
logActivity: mockLogActivity,
projectService: () => mockProjectService,
secretService: () => mockSecretService,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.doMock("../services/workspace-runtime.js", () => ({
cleanupExecutionWorkspaceArtifacts: vi.fn(),
startRuntimeServicesForWorkspaceControl: vi.fn(),
stopRuntimeServicesForExecutionWorkspace: vi.fn(),
stopRuntimeServicesForProjectWorkspace: vi.fn(),
}));
vi.doMock("../routes/workspace-runtime-service-authz.js", () => ({
assertCanManageProjectWorkspaceRuntimeServices: mockAssertCanManageProjectWorkspaceRuntimeServices,
assertCanManageExecutionWorkspaceRuntimeServices: mockAssertCanManageExecutionWorkspaceRuntimeServices,
}));
}
async function createProjectApp(actor: Record<string, unknown>) {
const { projectRoutes } = await import("../routes/projects.js");
const { errorHandler } = await import("../middleware/index.js");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", projectRoutes({} as any));
app.use(errorHandler);
return app;
}
async function createExecutionWorkspaceApp(actor: Record<string, unknown>) {
const { executionWorkspaceRoutes } = await import("../routes/execution-workspaces.js");
const { errorHandler } = await import("../middleware/index.js");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", executionWorkspaceRoutes({} as any));
app.use(errorHandler);
return app;
}
function buildProject(overrides: Record<string, unknown> = {}) {
return {
id: "project-1",
companyId: "company-1",
urlKey: "project-1",
goalId: null,
goalIds: [],
goals: [],
name: "Project",
description: null,
status: "backlog",
leadAgentId: null,
targetDate: null,
color: null,
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: null,
workspaces: [],
primaryWorkspace: null,
archivedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function buildExecutionWorkspace(overrides: Record<string, unknown> = {}) {
return {
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
sourceIssueId: null,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Workspace",
status: "active",
cwd: "/tmp/workspace",
repoUrl: null,
baseRef: "main",
branchName: "feature/test",
providerType: "git_worktree",
providerRef: null,
derivedFromExecutionWorkspaceId: null,
lastUsedAt: new Date(),
openedAt: new Date(),
closedAt: null,
cleanupEligibleAt: null,
cleanupReason: null,
config: null,
metadata: null,
runtimeServices: [],
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
describe("workspace runtime service route authorization", () => {
const projectId = "11111111-1111-4111-8111-111111111111";
const workspaceId = "22222222-2222-4222-8222-222222222222";
const executionWorkspaceId = "33333333-3333-4333-8333-333333333333";
beforeEach(() => {
vi.resetModules();
registerModuleMocks();
vi.clearAllMocks();
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
mockProjectService.create.mockResolvedValue(buildProject());
mockProjectService.update.mockResolvedValue(buildProject());
mockProjectService.createWorkspace.mockResolvedValue({
id: workspaceId,
companyId: "company-1",
projectId,
name: "Workspace",
sourceType: "local_path",
cwd: "/tmp/project",
repoUrl: null,
repoRef: null,
defaultRef: null,
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
runtimeConfig: null,
isPrimary: false,
runtimeServices: [],
createdAt: new Date(),
updatedAt: new Date(),
});
mockProjectService.listWorkspaces.mockResolvedValue([{
id: workspaceId,
companyId: "company-1",
projectId,
name: "Workspace",
sourceType: "local_path",
cwd: "/tmp/project",
repoUrl: null,
repoRef: null,
defaultRef: null,
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
runtimeConfig: null,
isPrimary: false,
runtimeServices: [],
createdAt: new Date(),
updatedAt: new Date(),
}]);
mockProjectService.updateWorkspace.mockResolvedValue({
id: workspaceId,
companyId: "company-1",
projectId,
name: "Workspace",
sourceType: "local_path",
cwd: "/tmp/project",
repoUrl: null,
repoRef: null,
defaultRef: null,
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
runtimeConfig: null,
isPrimary: false,
runtimeServices: [],
createdAt: new Date(),
updatedAt: new Date(),
});
mockExecutionWorkspaceService.update.mockResolvedValue(buildExecutionWorkspace());
mockAssertCanManageProjectWorkspaceRuntimeServices.mockResolvedValue(undefined);
mockAssertCanManageExecutionWorkspaceRuntimeServices.mockResolvedValue(undefined);
});
it("rejects agent callers for project workspace runtime service mutations when workspace auth denies access", async () => {
const { forbidden } = await import("../errors.js");
mockProjectService.getById.mockResolvedValue(buildProject({
id: projectId,
workspaces: [{
id: workspaceId,
companyId: "company-1",
projectId,
name: "Workspace",
sourceType: "local_path",
cwd: "/tmp/project",
repoUrl: null,
repoRef: null,
defaultRef: null,
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
runtimeConfig: null,
isPrimary: false,
runtimeServices: [],
createdAt: new Date(),
updatedAt: new Date(),
}],
}));
mockAssertCanManageProjectWorkspaceRuntimeServices.mockRejectedValue(
forbidden("Missing permission to manage workspace runtime services"),
);
const app = await createProjectApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post(`/api/projects/${projectId}/workspaces/${workspaceId}/runtime-services/start`)
.send({});
expect(res.status).toBe(403);
expect(res.body.error).toContain("Missing permission");
expect(mockProjectService.getById).toHaveBeenCalledWith(projectId);
expect(mockAssertCanManageProjectWorkspaceRuntimeServices).toHaveBeenCalled();
}, 15000);
it("rejects agent callers that create project execution workspace commands", async () => {
const app = await createProjectApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post("/api/companies/company-1/projects")
.send({
name: "Exploit",
executionWorkspacePolicy: {
enabled: true,
workspaceStrategy: {
type: "git_worktree",
provisionCommand: "touch /tmp/paperclip-rce",
},
},
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed workspace commands");
expect(mockProjectService.create).not.toHaveBeenCalled();
});
it("rejects agent callers that update project workspace cleanup commands", async () => {
mockProjectService.getById.mockResolvedValue(buildProject());
const app = await createProjectApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch(`/api/projects/${projectId}/workspaces/${workspaceId}`)
.send({
cleanupCommand: "rm -rf /tmp/paperclip-rce",
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed workspace commands");
expect(mockProjectService.updateWorkspace).not.toHaveBeenCalled();
});
it("allows board callers through the project workspace runtime auth gate", async () => {
mockProjectService.getById.mockResolvedValue(null);
const app = await createProjectApp({
type: "board",
userId: "board-1",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app)
.post(`/api/projects/${projectId}/workspaces/${workspaceId}/runtime-services/start`)
.send({});
expect(res.status).toBe(404);
expect(res.body.error).toContain("Project not found");
expect(mockProjectService.getById).toHaveBeenCalledWith(projectId);
});
it("rejects agent callers for execution workspace runtime service mutations when workspace auth denies access", async () => {
const { forbidden } = await import("../errors.js");
mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ id: executionWorkspaceId }));
mockAssertCanManageExecutionWorkspaceRuntimeServices.mockRejectedValue(
forbidden("Missing permission to manage workspace runtime services"),
);
const app = await createExecutionWorkspaceApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/restart`)
.send({});
expect(res.status).toBe(403);
expect(res.body.error).toContain("Missing permission");
expect(mockExecutionWorkspaceService.getById).toHaveBeenCalledWith(executionWorkspaceId);
expect(mockAssertCanManageExecutionWorkspaceRuntimeServices).toHaveBeenCalled();
}, 15000);
it("rejects agent callers that patch execution workspace command config", async () => {
mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ id: executionWorkspaceId }));
const app = await createExecutionWorkspaceApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch(`/api/execution-workspaces/${executionWorkspaceId}`)
.send({
config: {
cleanupCommand: "rm -rf /tmp/paperclip-rce",
},
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed workspace commands");
expect(mockExecutionWorkspaceService.update).not.toHaveBeenCalled();
});
it("rejects agent callers that smuggle execution workspace commands through metadata.config", async () => {
mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ id: executionWorkspaceId }));
const app = await createExecutionWorkspaceApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch(`/api/execution-workspaces/${executionWorkspaceId}`)
.send({
metadata: {
config: {
provisionCommand: "touch /tmp/paperclip-rce",
},
},
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed workspace commands");
expect(mockExecutionWorkspaceService.update).not.toHaveBeenCalled();
});
it("allows board callers through the execution workspace runtime auth gate", async () => {
mockExecutionWorkspaceService.getById.mockResolvedValue(null);
const app = await createExecutionWorkspaceApp({
type: "board",
userId: "board-1",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app)
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/restart`)
.send({});
expect(res.status).toBe(404);
expect(res.body.error).toContain("Execution workspace not found");
expect(mockExecutionWorkspaceService.getById).toHaveBeenCalledWith(executionWorkspaceId);
});
});
@@ -0,0 +1,279 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
companies,
createDb,
executionWorkspaces,
issues,
projectWorkspaces,
projects,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import {
assertCanManageExecutionWorkspaceRuntimeServices,
assertCanManageProjectWorkspaceRuntimeServices,
} from "../routes/workspace-runtime-service-authz.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres workspace runtime auth tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("workspace runtime service authz helper", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-workspace-runtime-authz-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompany() {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `PAP-${companyId.slice(0, 8)}`,
requireBoardApprovalForNewAgents: false,
});
return companyId;
}
async function seedProjectWorkspace(companyId: string) {
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace authz",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
sourceType: "local_path",
cwd: "/tmp/paperclip-authz-project",
isPrimary: true,
});
return { projectId, projectWorkspaceId };
}
async function seedExecutionWorkspace(companyId: string, projectId: string, projectWorkspaceId: string) {
const executionWorkspaceId = randomUUID();
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Execution workspace",
status: "active",
providerType: "local_fs",
cwd: "/tmp/paperclip-authz-execution",
});
return executionWorkspaceId;
}
async function seedAgent(
companyId: string,
input: { role?: string; reportsTo?: string | null; name?: string } = {},
) {
const agentId = randomUUID();
await db.insert(agents).values({
id: agentId,
companyId,
name: input.name ?? "Agent",
role: input.role ?? "engineer",
reportsTo: input.reportsTo ?? null,
});
return agentId;
}
it("allows board actors to manage project workspace runtime services", async () => {
const companyId = await seedCompany();
const { projectWorkspaceId } = await seedProjectWorkspace(companyId);
await expect(assertCanManageProjectWorkspaceRuntimeServices(db, {
actor: {
type: "board",
userId: "board-1",
companyIds: [companyId],
source: "session",
isInstanceAdmin: false,
},
} as any, {
companyId,
projectWorkspaceId,
})).resolves.toBeUndefined();
});
it("allows CEO agents to manage any project workspace runtime services in their company", async () => {
const companyId = await seedCompany();
const { projectWorkspaceId } = await seedProjectWorkspace(companyId);
const ceoAgentId = await seedAgent(companyId, { role: "ceo", name: "CEO" });
await expect(assertCanManageProjectWorkspaceRuntimeServices(db, {
actor: {
type: "agent",
agentId: ceoAgentId,
companyId,
source: "agent_key",
},
} as any, {
companyId,
projectWorkspaceId,
})).resolves.toBeUndefined();
});
it("allows agents with a non-terminal assigned issue in the target project workspace", async () => {
const companyId = await seedCompany();
const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId);
const agentId = await seedAgent(companyId, { name: "Engineer" });
await db.insert(issues).values({
id: randomUUID(),
companyId,
projectId,
projectWorkspaceId,
title: "Use this workspace",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
});
await expect(assertCanManageProjectWorkspaceRuntimeServices(db, {
actor: {
type: "agent",
agentId,
companyId,
source: "agent_key",
},
} as any, {
companyId,
projectWorkspaceId,
})).resolves.toBeUndefined();
});
it("allows managers to manage execution workspace runtime services for their reporting subtree", async () => {
const companyId = await seedCompany();
const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId);
const executionWorkspaceId = await seedExecutionWorkspace(companyId, projectId, projectWorkspaceId);
const managerId = await seedAgent(companyId, { role: "cto", name: "Manager" });
const reportId = await seedAgent(companyId, { reportsTo: managerId, name: "Report" });
await db.insert(issues).values({
id: randomUUID(),
companyId,
projectId,
projectWorkspaceId,
executionWorkspaceId,
title: "Use execution workspace",
status: "in_progress",
priority: "medium",
assigneeAgentId: reportId,
});
await expect(assertCanManageExecutionWorkspaceRuntimeServices(db, {
actor: {
type: "agent",
agentId: managerId,
companyId,
source: "agent_key",
},
} as any, {
companyId,
executionWorkspaceId,
})).resolves.toBeUndefined();
});
it("rejects unrelated same-company agents without matching workspace assignments", async () => {
const companyId = await seedCompany();
const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId);
const executionWorkspaceId = await seedExecutionWorkspace(companyId, projectId, projectWorkspaceId);
const assignedAgentId = await seedAgent(companyId, { name: "Assigned" });
const unrelatedAgentId = await seedAgent(companyId, { name: "Unrelated" });
await db.insert(issues).values({
id: randomUUID(),
companyId,
projectId,
projectWorkspaceId,
executionWorkspaceId,
title: "Assigned issue",
status: "todo",
priority: "medium",
assigneeAgentId: assignedAgentId,
});
await expect(assertCanManageExecutionWorkspaceRuntimeServices(db, {
actor: {
type: "agent",
agentId: unrelatedAgentId,
companyId,
source: "agent_key",
},
} as any, {
companyId,
executionWorkspaceId,
})).rejects.toMatchObject({
status: 403,
message: "Missing permission to manage workspace runtime services",
});
});
it("rejects completed workspace assignments so stale issues do not keep access alive", async () => {
const companyId = await seedCompany();
const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId);
const agentId = await seedAgent(companyId, { name: "Engineer" });
await db.insert(issues).values({
id: randomUUID(),
companyId,
projectId,
projectWorkspaceId,
title: "Completed issue",
status: "done",
priority: "medium",
assigneeAgentId: agentId,
});
await expect(assertCanManageProjectWorkspaceRuntimeServices(db, {
actor: {
type: "agent",
agentId,
companyId,
source: "agent_key",
},
} as any, {
companyId,
projectWorkspaceId,
})).rejects.toMatchObject({
status: 403,
message: "Missing permission to manage workspace runtime services",
});
});
});
+23
View File
@@ -97,6 +97,9 @@ const claudeLocalAdapter: ServerAdapterModule = {
models: claudeModels,
listModels: listClaudeModels,
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: false,
agentConfigurationDoc: claudeAgentConfigurationDoc,
getQuotaWindows: claudeGetQuotaWindows,
};
@@ -112,6 +115,9 @@ const codexLocalAdapter: ServerAdapterModule = {
models: codexModels,
listModels: listCodexModels,
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: false,
agentConfigurationDoc: codexAgentConfigurationDoc,
getQuotaWindows: codexGetQuotaWindows,
};
@@ -127,6 +133,9 @@ const cursorLocalAdapter: ServerAdapterModule = {
models: cursorModels,
listModels: listCursorModels,
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: true,
agentConfigurationDoc: cursorAgentConfigurationDoc,
};
@@ -140,6 +149,9 @@ const geminiLocalAdapter: ServerAdapterModule = {
sessionManagement: getAdapterSessionManagement("gemini_local") ?? undefined,
models: geminiModels,
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: true,
agentConfigurationDoc: geminiAgentConfigurationDoc,
};
@@ -149,6 +161,8 @@ const openclawGatewayAdapter: ServerAdapterModule = {
testEnvironment: openclawGatewayTestEnvironment,
models: openclawGatewayModels,
supportsLocalAgentJwt: false,
supportsInstructionsBundle: false,
requiresMaterializedRuntimeSkills: false,
agentConfigurationDoc: openclawGatewayAgentConfigurationDoc,
};
@@ -163,6 +177,9 @@ const openCodeLocalAdapter: ServerAdapterModule = {
sessionManagement: getAdapterSessionManagement("opencode_local") ?? undefined,
listModels: listOpenCodeModels,
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: true,
agentConfigurationDoc: openCodeAgentConfigurationDoc,
};
@@ -177,6 +194,9 @@ const piLocalAdapter: ServerAdapterModule = {
models: [],
listModels: listPiModels,
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: true,
agentConfigurationDoc: piAgentConfigurationDoc,
};
@@ -189,6 +209,9 @@ const hermesLocalAdapter: ServerAdapterModule = {
syncSkills: hermesSyncSkills,
models: hermesModels,
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: false,
agentConfigurationDoc: hermesAgentConfigurationDoc,
detectModel: () => detectModelFromHermes(),
};
+60 -6
View File
@@ -71,6 +71,7 @@ const VITE_DEV_STATIC_PATHS = new Set([
"/favicon.ico",
"/favicon.svg",
"/site.webmanifest",
"/sw.js",
]);
export function resolveViteHmrPort(serverPort: number): number {
@@ -80,13 +81,23 @@ export function resolveViteHmrPort(serverPort: number): number {
return Math.max(1_024, serverPort - 10_000);
}
function shouldServeViteDevHtml(req: ExpressRequest): boolean {
export function shouldServeViteDevHtml(req: ExpressRequest): boolean {
const pathname = req.path;
if (VITE_DEV_STATIC_PATHS.has(pathname)) return false;
if (VITE_DEV_ASSET_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return false;
return req.accepts(["html"]) === "html";
}
export function shouldEnablePrivateHostnameGuard(opts: {
deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure;
}): boolean {
return (
opts.deploymentExposure === "private" &&
(opts.deploymentMode === "local_trusted" || opts.deploymentMode === "authenticated")
);
}
export async function createApp(
db: Db,
opts: {
@@ -124,8 +135,10 @@ export async function createApp(
},
}));
app.use(httpLogger);
const privateHostnameGateEnabled =
opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private";
const privateHostnameGateEnabled = shouldEnablePrivateHostnameGuard({
deploymentMode: opts.deploymentMode,
deploymentExposure: opts.deploymentExposure,
});
const privateHostnameAllowSet = resolvePrivateHostnameAllowSet({
allowedHostnames: opts.allowedHostnames,
bindHost: opts.bindHost,
@@ -272,9 +285,46 @@ export async function createApp(
const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html")));
if (uiDist) {
const indexHtml = applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8"));
app.use(express.static(uiDist));
app.get(/.*/, (_req, res) => {
res.status(200).set("Content-Type", "text/html").end(indexHtml);
// Hashed asset files (Vite emits them under /assets/<name>.<hash>.<ext>)
// never change once built, so they can be cached aggressively.
app.use(
"/assets",
express.static(path.join(uiDist, "assets"), {
maxAge: "1y",
immutable: true,
}),
);
// Non-hashed static files (favicon.ico, manifest, robots.txt, etc.):
// short cache so operators who swap them out see the new version
// reasonably fast. Override for `index.html` specifically — it is
// served by this middleware for `/` and `/index.html`, and it must
// never outlive the asset hashes it points at.
app.use(
express.static(uiDist, {
maxAge: "1h",
setHeaders(res, filePath) {
if (path.basename(filePath) === "index.html") {
res.set("Cache-Control", "no-cache");
}
},
}),
);
// SPA fallback. Only for non-asset routes — if the browser asks for
// /assets/something.js that doesn't exist, we must NOT serve the HTML
// shell: the browser would try to load it as a JavaScript module, fail
// with a MIME-type error, and cache that broken response. Return 404
// instead. The index.html response itself is no-cache so a subsequent
// deploy's updated asset hashes are picked up on next load.
app.get(/.*/, (req, res) => {
if (req.path.startsWith("/assets/")) {
res.status(404).end();
return;
}
res
.status(200)
.set("Content-Type", "text/html")
.set("Cache-Control", "no-cache")
.end(indexHtml);
});
} else {
console.warn("[paperclip] UI dist not found; running in API-only mode");
@@ -283,6 +333,7 @@ export async function createApp(
if (opts.uiMode === "vite-dev") {
const uiRoot = path.resolve(__dirname, "../../ui");
const publicUiRoot = path.resolve(uiRoot, "public");
const hmrPort = resolveViteHmrPort(opts.serverPort);
const { createServer: createViteServer } = await import("vite");
const vite = await createViteServer({
@@ -305,6 +356,9 @@ export async function createApp(
});
const renderViteHtml = viteHtmlRenderer;
if (fs.existsSync(publicUiRoot)) {
app.use(express.static(publicUiRoot, { index: false }));
}
app.get(/.*/, async (req, res, next) => {
if (!shouldServeViteDevHtml(req)) {
next();
+4 -2
View File
@@ -554,7 +554,9 @@ export async function startServer(): Promise<StartedServer> {
: runtimeListenHost;
process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost;
process.env.PAPERCLIP_LISTEN_PORT = String(listenPort);
process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`;
if (!process.env.PAPERCLIP_API_URL) {
process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`;
}
setupLiveEventsWebSocketServer(server, db as any, {
deploymentMode: config.deploymentMode,
@@ -792,7 +794,7 @@ export async function startServer(): Promise<StartedServer> {
server,
host: config.host,
listenPort,
apiUrl: process.env.PAPERCLIP_API_URL ?? `http://${runtimeApiHost}:${listenPort}`,
apiUrl: process.env.PAPERCLIP_API_URL!,
databaseUrl: activeDatabaseConnectionString,
};
}
+2
View File
@@ -112,6 +112,7 @@ export function redactCurrentUserText(input: string, opts?: CurrentUserRedaction
let result = input;
for (const homeDir of [...homeDirs].sort((a, b) => b.length - a.length)) {
if (!result.includes(homeDir)) continue;
const lastSegment = splitPathSegments(homeDir).pop() ?? "";
const replacementDir = lastSegment
? replaceLastPathSegment(homeDir, maskUserNameForLogs(lastSegment, replacement))
@@ -120,6 +121,7 @@ export function redactCurrentUserText(input: string, opts?: CurrentUserRedaction
}
for (const userName of [...userNames].sort((a, b) => b.length - a.length)) {
if (!result.includes(userName)) continue;
const pattern = new RegExp(`(?<![A-Za-z0-9._-])${escapeRegExp(userName)}(?![A-Za-z0-9._-])`, "g");
result = result.replace(pattern, maskUserNameForLogs(userName, replacement));
}
@@ -24,6 +24,12 @@ function trustedOriginsForRequest(req: Request) {
origins.add(`http://${host}`.toLowerCase());
origins.add(`https://${host}`.toLowerCase());
}
// Behind some reverse proxies the Host / X-Forwarded-Host header may
// not match the public URL (for example when TLS terminates at the
// edge and the inbound Host is an internal service name). Trust the
// explicitly-configured PAPERCLIP_PUBLIC_URL when it's set.
const publicUrl = parseOrigin(process.env.PAPERCLIP_PUBLIC_URL?.trim());
if (publicUrl) origins.add(publicUrl);
return origins;
}
+1
View File
@@ -25,6 +25,7 @@ const SILENCED_SUCCESS_STATIC_PREFIXES = [
const SILENCED_SUCCESS_STATIC_PATHS = new Set([
"/favicon.ico",
"/site.webmanifest",
"/sw.js",
]);
function normalizePath(url: string): string {
+52 -7
View File
@@ -64,7 +64,7 @@ import {
collapseDuplicatePendingHumanJoinRequests,
findReusableHumanJoinRequest,
} from "../lib/join-request-dedupe.js";
import { assertCompanyAccess } from "./authz.js";
import { assertAuthenticated, assertCompanyAccess } from "./authz.js";
import {
claimBoardOwnership,
inspectBoardClaimChallenge
@@ -891,6 +891,7 @@ function toInviteSummaryResponse(
const invitePath = `/invite/${token}`;
const onboardingPath = `/api/invites/${token}/onboarding`;
const onboardingTextPath = `/api/invites/${token}/onboarding.txt`;
const skillIndexPath = `/api/invites/${token}/skills/index`;
const inviteMessage = extractInviteMessage(invite);
return {
id: invite.id,
@@ -910,10 +911,10 @@ function toInviteSummaryResponse(
onboardingTextUrl: baseUrl
? `${baseUrl}${onboardingTextPath}`
: onboardingTextPath,
skillIndexPath: "/api/skills/index",
skillIndexPath,
skillIndexUrl: baseUrl
? `${baseUrl}/api/skills/index`
: "/api/skills/index",
? `${baseUrl}${skillIndexPath}`
: skillIndexPath,
inviteMessage
};
}
@@ -1384,7 +1385,7 @@ function buildInviteOnboardingManifest(
}
) {
const baseUrl = requestBaseUrl(req);
const skillPath = "/api/skills/paperclip";
const skillPath = `/api/invites/${token}/skills/paperclip`;
const skillUrl = baseUrl ? `${baseUrl}${skillPath}` : skillPath;
const registrationEndpointPath = `/api/invites/${token}/accept`;
const registrationEndpointUrl = baseUrl
@@ -2462,11 +2463,13 @@ export function accessRoutes(
};
}
router.get("/skills/available", (_req, res) => {
router.get("/skills/available", (req, res) => {
assertAuthenticated(req);
res.json({ skills: listAvailableSkills() });
});
router.get("/skills/index", (_req, res) => {
router.get("/skills/index", (req, res) => {
assertAuthenticated(req);
res.json({
skills: [
{ name: "paperclip", path: "/api/skills/paperclip" },
@@ -2483,6 +2486,7 @@ export function accessRoutes(
});
router.get("/skills/:skillName", (req, res) => {
assertAuthenticated(req);
const skillName = (req.params.skillName as string).trim().toLowerCase();
const markdown = readSkillMarkdown(skillName);
if (!markdown) throw notFound("Skill not found");
@@ -2726,6 +2730,47 @@ export function accessRoutes(
);
});
router.get("/invites/:token/skills/index", async (req, res) => {
const token = (req.params.token as string).trim();
if (!token) throw notFound("Invite not found");
const invite = await db
.select()
.from(invites)
.where(eq(invites.tokenHash, hashToken(token)))
.then((rows) => rows[0] ?? null);
if (!invite || invite.revokedAt || inviteExpired(invite)) {
throw notFound("Invite not found");
}
res.json({
skills: [
{
name: "paperclip",
path: `/api/invites/${token}/skills/paperclip`,
},
],
});
});
router.get("/invites/:token/skills/:skillName", async (req, res) => {
const token = (req.params.token as string).trim();
if (!token) throw notFound("Invite not found");
const invite = await db
.select()
.from(invites)
.where(eq(invites.tokenHash, hashToken(token)))
.then((rows) => rows[0] ?? null);
if (!invite || invite.revokedAt || inviteExpired(invite)) {
throw notFound("Invite not found");
}
const skillName = (req.params.skillName as string).trim().toLowerCase();
if (skillName !== "paperclip") throw notFound("Skill not found");
const markdown = readSkillMarkdown(skillName);
if (!markdown) throw notFound("Skill not found");
res.type("text/markdown").send(markdown);
});
router.get("/invites/:token/test-resolution", async (req, res) => {
const token = (req.params.token as string).trim();
if (!token) throw notFound("Invite not found");
+2 -1
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
import type { Db } from "@paperclipai/db";
import { validate } from "../middleware/validate.js";
import { activityService } from "../services/activity.js";
import { assertBoard, assertCompanyAccess } from "./authz.js";
import { assertAuthenticated, assertBoard, assertCompanyAccess } from "./authz.js";
import { heartbeatService, issueService } from "../services/index.js";
import { sanitizeRecord } from "../redaction.js";
@@ -81,6 +81,7 @@ export function activityRoutes(db: Db) {
});
router.get("/heartbeat-runs/:runId/issues", async (req, res) => {
assertAuthenticated(req);
const runId = req.params.runId as string;
const run = await heartbeat.getRun(runId);
if (!run) {
+18
View File
@@ -59,6 +59,13 @@ interface AdapterInstallRequest {
version?: string;
}
interface AdapterCapabilities {
supportsInstructionsBundle: boolean;
supportsSkills: boolean;
supportsLocalAgentJwt: boolean;
requiresMaterializedRuntimeSkills: boolean;
}
interface AdapterInfo {
type: string;
label: string;
@@ -66,6 +73,7 @@ interface AdapterInfo {
modelsCount: number;
loaded: boolean;
disabled: boolean;
capabilities: AdapterCapabilities;
/** True when an external plugin has replaced a built-in adapter of the same type. */
overriddenBuiltin?: boolean;
/** True when the external override for a builtin type is currently paused. */
@@ -103,6 +111,15 @@ function readAdapterPackageVersionFromDisk(record: AdapterPluginRecord): string
}
}
function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterCapabilities {
return {
supportsInstructionsBundle: adapter.supportsInstructionsBundle ?? false,
supportsSkills: Boolean(adapter.listSkills || adapter.syncSkills),
supportsLocalAgentJwt: adapter.supportsLocalAgentJwt ?? false,
requiresMaterializedRuntimeSkills: adapter.requiresMaterializedRuntimeSkills ?? false,
};
}
function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterPluginRecord | undefined, disabledSet: Set<string>): AdapterInfo {
const fromDisk = externalRecord ? readAdapterPackageVersionFromDisk(externalRecord) : undefined;
return {
@@ -112,6 +129,7 @@ function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterP
modelsCount: (adapter.models ?? []).length,
loaded: true, // If it's in the registry, it's loaded
disabled: disabledSet.has(adapter.type),
capabilities: buildAdapterCapabilities(adapter),
overriddenBuiltin: externalRecord ? BUILTIN_ADAPTER_TYPES.has(adapter.type) : undefined,
overridePaused: BUILTIN_ADAPTER_TYPES.has(adapter.type) ? isOverridePaused(adapter.type) : undefined,
// Prefer on-disk package.json so the UI reflects bumps without relying on store-only fields.
+182 -49
View File
@@ -1,4 +1,4 @@
import { Router, type Request } from "express";
import { Router, type Request, type Response } from "express";
import { generateKeyPairSync, randomUUID } from "node:crypto";
import path from "node:path";
import type { Db } from "@paperclipai/db";
@@ -46,6 +46,10 @@ import {
} from "../services/index.js";
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
import {
assertNoAgentHostWorkspaceCommandMutation,
collectAgentAdapterWorkspaceCommandPaths,
} from "./workspace-command-authz.js";
import {
detectAdapterModel,
findActiveServerAdapter,
@@ -72,6 +76,8 @@ import {
import { getTelemetryClient } from "../telemetry.js";
export function agentRoutes(db: Db) {
// Legacy hardcoded maps — used as fallback when adapter module does not
// declare capability flags explicitly.
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
claude_local: "instructionsFilePath",
codex_local: "instructionsFilePath",
@@ -83,6 +89,22 @@ export function agentRoutes(db: Db) {
pi_local: "instructionsFilePath",
};
const DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES = new Set(Object.keys(DEFAULT_INSTRUCTIONS_PATH_KEYS));
/** Check if an adapter supports the managed instructions bundle. */
function adapterSupportsInstructionsBundle(adapterType: string): boolean {
const adapter = findActiveServerAdapter(adapterType);
if (adapter?.supportsInstructionsBundle !== undefined) return adapter.supportsInstructionsBundle;
return DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES.has(adapterType);
}
/** Resolve the adapter config key for the instructions file path. */
function resolveInstructionsPathKey(adapterType: string): string | null {
const adapter = findActiveServerAdapter(adapterType);
if (adapter?.instructionsPathKey) return adapter.instructionsPathKey;
if (adapter?.supportsInstructionsBundle === true) return "instructionsFilePath";
if (adapter?.supportsInstructionsBundle === false) return null;
return DEFAULT_INSTRUCTIONS_PATH_KEYS[adapterType] ?? null;
}
const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]);
const KNOWN_INSTRUCTIONS_BUNDLE_KEYS = [
"instructionsBundleMode",
@@ -213,10 +235,33 @@ export function agentRoutes(db: Db) {
return actorAgent;
}
async function assertBoardCanManageAgentsForCompany(req: Request, companyId: string) {
assertBoard(req);
assertCompanyAccess(req, companyId);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
const allowed = await access.canUser(companyId, req.actor.userId, "agents:create");
if (!allowed) {
throw forbidden("Missing permission: agents:create");
}
}
async function assertCanReadConfigurations(req: Request, companyId: string) {
return assertCanCreateAgentsForCompany(req, companyId);
}
async function getAccessibleAgent(req: Request, res: Response, id: string) {
const agent = await svc.getById(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return null;
}
assertCompanyAccess(req, agent.companyId);
if (req.actor.type === "board") {
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
}
return agent;
}
async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "board") {
@@ -299,7 +344,10 @@ export function agentRoutes(db: Db) {
async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) {
assertCompanyAccess(req, targetAgent.companyId);
if (req.actor.type === "board") return;
if (req.actor.type === "board") {
await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId);
return;
}
if (!req.actor.agentId) throw forbidden("Agent authentication required");
const actorAgent = await svc.getById(req.actor.agentId);
@@ -321,7 +369,10 @@ export function agentRoutes(db: Db) {
async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) {
assertCompanyAccess(req, targetAgent.companyId);
if (req.actor.type === "board") return;
if (req.actor.type === "board") {
await assertCanReadConfigurations(req, targetAgent.companyId);
return;
}
if (!req.actor.agentId) throw forbidden("Agent authentication required");
const actorAgent = await svc.getById(req.actor.agentId);
@@ -557,7 +608,7 @@ export function agentRoutes(db: Db) {
adapterType: string;
adapterConfig: unknown;
}>(agent: T): Promise<T> {
if (!DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES.has(agent.adapterType)) {
if (!adapterSupportsInstructionsBundle(agent.adapterType)) {
return agent;
}
@@ -592,19 +643,24 @@ export function agentRoutes(db: Db) {
async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) {
assertCompanyAccess(req, targetAgent.companyId);
if (req.actor.type === "board") return;
if (!req.actor.agentId) throw forbidden("Agent authentication required");
const actorAgent = await svc.getById(req.actor.agentId);
if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) {
throw forbidden("Agent key cannot access another company");
if (req.actor.type !== "board") {
throw forbidden(
"Only board-authenticated callers can manage instructions path or bundle configuration",
);
}
if (actorAgent.id === targetAgent.id) return;
await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId);
}
const chainOfCommand = await svc.getChainOfCommand(targetAgent.id);
if (chainOfCommand.some((manager) => manager.id === actorAgent.id)) return;
throw forbidden("Only the target agent or an ancestor manager can update instructions path");
function assertNoAgentInstructionsConfigMutation(
req: Request,
adapterConfig: Record<string, unknown> | null | undefined,
) {
if (req.actor.type !== "agent" || !adapterConfig) return;
const changedSensitiveKeys = KNOWN_INSTRUCTIONS_BUNDLE_KEYS.filter((key) => adapterConfig[key] !== undefined);
if (changedSensitiveKeys.length === 0) return;
throw forbidden(
`Agent-authenticated callers cannot modify instructions path or bundle configuration (${changedSensitiveKeys.join(", ")})`,
);
}
function summarizeAgentUpdateDetails(patch: Record<string, unknown>) {
@@ -638,7 +694,9 @@ export function agentRoutes(db: Db) {
};
}
const ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS = new Set([
// Legacy hardcoded set — used as fallback when adapter module does not
// declare requiresMaterializedRuntimeSkills explicitly.
const LEGACY_MATERIALIZED_SKILLS_SET = new Set([
"cursor",
"gemini_local",
"opencode_local",
@@ -646,7 +704,11 @@ export function agentRoutes(db: Db) {
]);
function shouldMaterializeRuntimeSkillsForAdapter(adapterType: string) {
return ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS.has(adapterType);
const adapter = findActiveServerAdapter(adapterType);
if (adapter?.requiresMaterializedRuntimeSkills !== undefined) {
return adapter.requiresMaterializedRuntimeSkills;
}
return LEGACY_MATERIALIZED_SKILLS_SET.has(adapterType);
}
async function buildRuntimeSkillConfig(
@@ -973,7 +1035,7 @@ export function agentRoutes(db: Db) {
}
const result = await svc.list(companyId);
const canReadConfigs = await actorCanReadConfigurationsForCompany(req, companyId);
if (canReadConfigs || req.actor.type === "board") {
if (canReadConfigs) {
res.json(result);
return;
}
@@ -1105,6 +1167,7 @@ export function agentRoutes(db: Db) {
const rows = await issuesSvc.list(req.actor.companyId, {
assigneeAgentId: req.actor.agentId,
status: "todo,in_progress,blocked",
includeRoutineExecutions: true,
});
res.json(
@@ -1148,12 +1211,13 @@ export function agentRoutes(db: Db) {
return;
}
assertCompanyAccess(req, agent.companyId);
if (req.actor.type === "agent" && req.actor.agentId !== id) {
const canRead = await actorCanReadConfigurationsForCompany(req, agent.companyId);
if (!canRead) {
res.json(await buildAgentDetail(agent, { restricted: true }));
return;
}
const isSelf = req.actor.type === "agent" && req.actor.agentId === id;
const canReadSensitiveDetail = isSelf
? true
: await actorCanReadConfigurationsForCompany(req, agent.companyId);
if (!canReadSensitiveDetail) {
res.json(await buildAgentDetail(agent, { restricted: true }));
return;
}
res.json(await buildAgentDetail(agent));
});
@@ -1241,6 +1305,7 @@ export function agentRoutes(db: Db) {
res.status(404).json({ error: "Agent not found" });
return;
}
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
assertCompanyAccess(req, agent.companyId);
const state = await heartbeat.getRuntimeState(id);
@@ -1255,6 +1320,7 @@ export function agentRoutes(db: Db) {
res.status(404).json({ error: "Agent not found" });
return;
}
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
assertCompanyAccess(req, agent.companyId);
const sessions = await heartbeat.listTaskSessions(id);
@@ -1274,6 +1340,7 @@ export function agentRoutes(db: Db) {
res.status(404).json({ error: "Agent not found" });
return;
}
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
assertCompanyAccess(req, agent.companyId);
const taskKey =
@@ -1306,6 +1373,14 @@ export function agentRoutes(db: Db) {
...hireInput
} = req.body;
hireInput.adapterType = assertKnownAdapterType(hireInput.adapterType);
assertNoAgentHostWorkspaceCommandMutation(
req,
collectAgentAdapterWorkspaceCommandPaths(hireInput.adapterConfig),
);
assertNoAgentInstructionsConfigMutation(
req,
(hireInput.adapterConfig ?? {}) as Record<string, unknown>,
);
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
hireInput.adapterType,
((hireInput.adapterConfig ?? {}) as Record<string, unknown>),
@@ -1565,6 +1640,8 @@ export function agentRoutes(db: Db) {
res.status(403).json({ error: "Only CEO can manage permissions" });
return;
}
} else {
await assertBoardCanManageAgentsForCompany(req, existing.companyId);
}
const agent = await svc.updatePermissions(id, req.body);
@@ -1616,7 +1693,7 @@ export function agentRoutes(db: Db) {
const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {};
const explicitKey = asNonEmptyString(req.body.adapterConfigKey);
const defaultKey = DEFAULT_INSTRUCTIONS_PATH_KEYS[existing.adapterType] ?? null;
const defaultKey = resolveInstructionsPathKey(existing.adapterType);
const adapterConfigKey = explicitKey ?? defaultKey;
if (!adapterConfigKey) {
res.status(422).json({
@@ -1865,10 +1942,15 @@ export function agentRoutes(db: Db) {
res.status(422).json({ error: "adapterConfig must be an object" });
return;
}
const changingInstructionsPath = Object.keys(adapterConfig).some((key) =>
KNOWN_INSTRUCTIONS_PATH_KEYS.has(key),
assertNoAgentInstructionsConfigMutation(req, adapterConfig);
assertNoAgentHostWorkspaceCommandMutation(
req,
collectAgentAdapterWorkspaceCommandPaths(adapterConfig),
);
if (changingInstructionsPath) {
const changingInstructionsConfig = Object.keys(adapterConfig).some((key) =>
KNOWN_INSTRUCTIONS_BUNDLE_KEYS.includes(key as (typeof KNOWN_INSTRUCTIONS_BUNDLE_KEYS)[number]),
);
if (changingInstructionsConfig) {
await assertCanManageInstructionsPath(req, existing);
}
patchData.adapterConfig = adapterConfig;
@@ -1969,6 +2051,9 @@ export function agentRoutes(db: Db) {
router.post("/agents/:id/pause", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
if (!(await getAccessibleAgent(req, res, id))) {
return;
}
const agent = await svc.pause(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
@@ -1992,6 +2077,9 @@ export function agentRoutes(db: Db) {
router.post("/agents/:id/resume", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
if (!(await getAccessibleAgent(req, res, id))) {
return;
}
const agent = await svc.resume(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
@@ -2013,6 +2101,9 @@ export function agentRoutes(db: Db) {
router.post("/agents/:id/terminate", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
if (!(await getAccessibleAgent(req, res, id))) {
return;
}
const agent = await svc.terminate(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
@@ -2036,6 +2127,9 @@ export function agentRoutes(db: Db) {
router.delete("/agents/:id", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
if (!(await getAccessibleAgent(req, res, id))) {
return;
}
const agent = await svc.remove(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
@@ -2057,6 +2151,10 @@ export function agentRoutes(db: Db) {
router.get("/agents/:id/keys", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const agent = await getAccessibleAgent(req, res, id);
if (!agent) {
return;
}
const keys = await svc.listKeys(id);
res.json(keys);
});
@@ -2064,32 +2162,56 @@ export function agentRoutes(db: Db) {
router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const agent = await getAccessibleAgent(req, res, id);
if (!agent) {
return;
}
const key = await svc.createApiKey(id, req.body.name);
const agent = await svc.getById(id);
if (agent) {
await logActivity(db, {
companyId: agent.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "agent.key_created",
entityType: "agent",
entityId: agent.id,
details: { keyId: key.id, name: key.name },
});
}
await logActivity(db, {
companyId: agent.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "agent.key_created",
entityType: "agent",
entityId: agent.id,
details: { keyId: key.id, name: key.name },
});
res.status(201).json(key);
});
router.delete("/agents/:id/keys/:keyId", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const keyId = req.params.keyId as string;
const revoked = await svc.revokeKey(keyId);
const agent = await getAccessibleAgent(req, res, id);
if (!agent) {
return;
}
const key = await svc.getKeyById(keyId);
if (!key || key.agentId !== agent.id) {
res.status(404).json({ error: "Key not found" });
return;
}
const revoked = await svc.revokeKey(agent.id, keyId);
if (!revoked) {
res.status(404).json({ error: "Key not found" });
return;
}
await logActivity(db, {
companyId: agent.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "agent.key_revoked",
entityType: "agent",
entityId: agent.id,
details: { keyId: key.id, name: key.name },
});
res.json({ ok: true });
});
@@ -2102,9 +2224,13 @@ export function agentRoutes(db: Db) {
}
assertCompanyAccess(req, agent.companyId);
if (req.actor.type === "agent" && req.actor.agentId !== id) {
res.status(403).json({ error: "Agent can only invoke itself" });
return;
if (req.actor.type === "agent") {
if (req.actor.agentId !== id) {
res.status(403).json({ error: "Agent can only invoke itself" });
return;
}
} else {
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
}
const run = await heartbeat.wakeup(id, {
@@ -2152,9 +2278,13 @@ export function agentRoutes(db: Db) {
}
assertCompanyAccess(req, agent.companyId);
if (req.actor.type === "agent" && req.actor.agentId !== id) {
res.status(403).json({ error: "Agent can only invoke itself" });
return;
if (req.actor.type === "agent") {
if (req.actor.agentId !== id) {
res.status(403).json({ error: "Agent can only invoke itself" });
return;
}
} else {
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
}
const run = await heartbeat.invoke(
@@ -2200,6 +2330,7 @@ export function agentRoutes(db: Db) {
res.status(404).json({ error: "Agent not found" });
return;
}
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
assertCompanyAccess(req, agent.companyId);
if (agent.adapterType !== "claude_local") {
res.status(400).json({ error: "Login is only supported for claude_local agents" });
@@ -2348,7 +2479,7 @@ export function agentRoutes(db: Db) {
router.get("/heartbeat-runs/:runId/log", async (req, res) => {
const runId = req.params.runId as string;
const run = await heartbeat.getRun(runId);
const run = await heartbeat.getRunLogAccess(runId);
if (!run) {
res.status(404).json({ error: "Heartbeat run not found" });
return;
@@ -2357,11 +2488,12 @@ export function agentRoutes(db: Db) {
const offset = Number(req.query.offset ?? 0);
const limitBytes = Number(req.query.limitBytes ?? 256000);
const result = await heartbeat.readLog(runId, {
const result = await heartbeat.readLog(run, {
offset: Number.isFinite(offset) ? offset : 0,
limitBytes: Number.isFinite(limitBytes) ? limitBytes : 256000,
});
res.set("Cache-Control", "no-cache, no-store");
res.json(result);
});
@@ -2396,6 +2528,7 @@ export function agentRoutes(db: Db) {
limitBytes: Number.isFinite(limitBytes) ? limitBytes : 256000,
});
res.set("Cache-Control", "no-cache, no-store");
res.json(result);
});
+6 -15
View File
@@ -134,11 +134,8 @@ export function approvalRoutes(db: Db) {
res.status(404).json({ error: "Approval not found" });
return;
}
const { approval, applied } = await svc.approve(
id,
req.body.decidedByUserId ?? "board",
req.body.decisionNote,
);
const decidedByUserId = req.actor.userId ?? "board";
const { approval, applied } = await svc.approve(id, decidedByUserId, req.body.decisionNote);
if (applied) {
const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id);
@@ -233,11 +230,8 @@ export function approvalRoutes(db: Db) {
res.status(404).json({ error: "Approval not found" });
return;
}
const { approval, applied } = await svc.reject(
id,
req.body.decidedByUserId ?? "board",
req.body.decisionNote,
);
const decidedByUserId = req.actor.userId ?? "board";
const { approval, applied } = await svc.reject(id, decidedByUserId, req.body.decisionNote);
if (applied) {
await logActivity(db, {
@@ -264,11 +258,8 @@ export function approvalRoutes(db: Db) {
res.status(404).json({ error: "Approval not found" });
return;
}
const approval = await svc.requestRevision(
id,
req.body.decidedByUserId ?? "board",
req.body.decisionNote,
);
const decidedByUserId = req.actor.userId ?? "board";
const approval = await svc.requestRevision(id, decidedByUserId, req.body.decisionNote);
await logActivity(db, {
companyId: approval.companyId,
+8 -6
View File
@@ -1,6 +1,12 @@
import type { Request } from "express";
import { forbidden, unauthorized } from "../errors.js";
export function assertAuthenticated(req: Request) {
if (req.actor.type === "none") {
throw unauthorized();
}
}
export function assertBoard(req: Request) {
if (req.actor.type !== "board") {
throw forbidden("Board access required");
@@ -34,9 +40,7 @@ export function assertInstanceAdmin(req: Request) {
}
export function assertCompanyAccess(req: Request, companyId: string) {
if (req.actor.type === "none") {
throw unauthorized();
}
assertAuthenticated(req);
if (req.actor.type === "agent" && req.actor.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
@@ -59,9 +63,7 @@ export function assertCompanyAccess(req: Request, companyId: string) {
}
export function getActorInfo(req: Request) {
if (req.actor.type === "none") {
throw unauthorized();
}
assertAuthenticated(req);
if (req.actor.type === "agent") {
return {
actorType: "agent" as const,
+23 -2
View File
@@ -23,6 +23,11 @@ import {
stopRuntimeServicesForExecutionWorkspace,
} from "../services/workspace-runtime.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import {
assertNoAgentHostWorkspaceCommandMutation,
collectExecutionWorkspaceCommandPaths,
} from "./workspace-command-authz.js";
import { assertCanManageExecutionWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js";
export function executionWorkspaceRoutes(db: Db) {
const router = Router();
@@ -32,13 +37,16 @@ export function executionWorkspaceRoutes(db: Db) {
router.get("/companies/:companyId/execution-workspaces", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const workspaces = await svc.list(companyId, {
const filters = {
projectId: req.query.projectId as string | undefined,
projectWorkspaceId: req.query.projectWorkspaceId as string | undefined,
issueId: req.query.issueId as string | undefined,
status: req.query.status as string | undefined,
reuseEligible: req.query.reuseEligible === "true",
});
};
const workspaces = req.query.summary === "true"
? await svc.listSummaries(companyId, filters)
: await svc.list(companyId, filters);
res.json(workspaces);
});
@@ -96,6 +104,12 @@ export function executionWorkspaceRoutes(db: Db) {
}
assertCompanyAccess(req, existing.companyId);
await assertCanManageExecutionWorkspaceRuntimeServices(db, req, {
companyId: existing.companyId,
executionWorkspaceId: existing.id,
sourceIssueId: existing.sourceIssueId,
});
const workspaceCwd = existing.cwd;
if (!workspaceCwd) {
res.status(422).json({ error: "Execution workspace needs a local path before Paperclip can run workspace commands" });
@@ -428,6 +442,13 @@ export function executionWorkspaceRoutes(db: Db) {
return;
}
assertCompanyAccess(req, existing.companyId);
assertNoAgentHostWorkspaceCommandMutation(
req,
collectExecutionWorkspaceCommandPaths({
config: req.body.config,
metadata: req.body.metadata,
}),
);
const patch: Record<string, unknown> = {
...(req.body.name === undefined ? {} : { name: req.body.name }),
...(req.body.cwd === undefined ? {} : { cwd: req.body.cwd }),
+31 -3
View File
@@ -8,6 +8,14 @@ import { logger } from "../middleware/logger.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { serverVersion } from "../version.js";
function shouldExposeFullHealthDetails(
actorType: "none" | "board" | "agent" | null | undefined,
deploymentMode: DeploymentMode,
) {
if (deploymentMode !== "authenticated") return true;
return actorType === "board" || actorType === "agent";
}
export function healthRoutes(
db?: Db,
opts: {
@@ -24,9 +32,19 @@ export function healthRoutes(
) {
const router = Router();
router.get("/", async (_req, res) => {
router.get("/", async (req, res) => {
const actorType = "actor" in req ? req.actor?.type : null;
const exposeFullDetails = shouldExposeFullHealthDetails(
actorType,
opts.deploymentMode,
);
if (!db) {
res.json({ status: "ok", version: serverVersion });
res.json(
exposeFullDetails
? { status: "ok", version: serverVersion }
: { status: "ok", deploymentMode: opts.deploymentMode },
);
return;
}
@@ -72,7 +90,7 @@ export function healthRoutes(
const persistedDevServerStatus = readPersistedDevServerStatus();
let devServer: ReturnType<typeof toDevServerHealthStatus> | undefined;
if (persistedDevServerStatus) {
if (persistedDevServerStatus && typeof (db as { select?: unknown }).select === "function") {
const instanceSettings = instanceSettingsService(db);
const experimentalSettings = await instanceSettings.getExperimental();
const activeRunCount = await db
@@ -87,6 +105,16 @@ export function healthRoutes(
});
}
if (!exposeFullDetails) {
res.json({
status: "ok",
deploymentMode: opts.deploymentMode,
bootstrapStatus,
bootstrapInviteActive,
});
return;
}
res.json({
status: "ok",
version: serverVersion,
+9 -1
View File
@@ -48,6 +48,10 @@ import {
import { logger } from "../middleware/logger.js";
import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import {
assertNoAgentHostWorkspaceCommandMutation,
collectIssueWorkspaceCommandPaths,
} from "./workspace-command-authz.js";
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
import {
isInlineAttachmentContentType,
@@ -652,6 +656,8 @@ export function issueRoutes(
originId: req.query.originId as string | undefined,
includeRoutineExecutions:
req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
excludeRoutineExecutions:
req.query.excludeRoutineExecutions === "true" || req.query.excludeRoutineExecutions === "1",
q: req.query.q as string | undefined,
limit,
});
@@ -723,7 +729,7 @@ export function issueRoutes(
const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations] = await Promise.all([
resolveIssueProjectAndGoal(issue),
svc.getAncestors(issue.id),
svc.findMentionedProjectIds(issue.id),
svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false }),
documentsSvc.getIssueDocumentPayload(issue),
svc.getRelationSummaries(issue.id),
]);
@@ -1323,6 +1329,7 @@ export function issueRoutes(
router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
await assertCanAssignTasks(req, companyId);
}
@@ -1373,6 +1380,7 @@ export function issueRoutes(
return;
}
assertCompanyAccess(req, existing.companyId);
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
const actor = getActorInfo(req);
+11 -4
View File
@@ -11,7 +11,8 @@
* - Retrieving UI slot contributions for frontend rendering
* - Discovering and executing plugin-contributed agent tools
*
* All routes require board-level authentication (assertBoard middleware).
* All routes require board-level authentication, and sensitive instance-wide
* mutations such as install/upgrade require instance-admin privileges.
*
* @module server/routes/plugins
* @see doc/plugins/PLUGIN_SPEC.md for the full plugin specification
@@ -47,7 +48,7 @@ import type { PluginStreamBus } from "../services/plugin-stream-bus.js";
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
import type { ToolRunContext } from "@paperclipai/plugin-sdk";
import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
import { assertBoardOrgAccess, assertCompanyAccess, getActorInfo } from "./authz.js";
import { assertBoardOrgAccess, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
import { validateInstanceConfig } from "../services/plugin-config-validator.js";
/** UI slot declaration extracted from plugin manifest */
@@ -583,6 +584,9 @@ export function pluginRoutes(
*
* Install a plugin from npm or a local filesystem path.
*
* Instance-wide plugin installation is restricted to instance admins because
* the install flow fetches and inspects package contents on the host.
*
* Request body:
* - packageName: npm package name or local path (required)
* - version: Target version for npm packages (optional)
@@ -601,7 +605,7 @@ export function pluginRoutes(
* - `500` — installation succeeded but manifest is missing (indicates a loader bug)
*/
router.post("/plugins/install", async (req, res) => {
assertBoardOrgAccess(req);
assertInstanceAdmin(req);
const { packageName, version, isLocalPath } = req.body as PluginInstallRequest;
// Input validation
@@ -1450,6 +1454,9 @@ export function pluginRoutes(
*
* Upgrade a plugin to a newer version.
*
* Upgrades are restricted to instance admins because they fetch and inspect
* new package contents on the host before activation.
*
* Request body (optional):
* - version: Target version (defaults to latest)
*
@@ -1461,7 +1468,7 @@ export function pluginRoutes(
* Errors: 404 if plugin not found, 400 for lifecycle errors
*/
router.post("/plugins/:pluginId/upgrade", async (req, res) => {
assertBoardOrgAccess(req);
assertInstanceAdmin(req);
const { pluginId } = req.params;
const body = req.body as { version?: string } | undefined;
const version = body?.version;
+30
View File
@@ -22,6 +22,12 @@ import {
startRuntimeServicesForWorkspaceControl,
stopRuntimeServicesForProjectWorkspace,
} from "../services/workspace-runtime.js";
import {
assertNoAgentHostWorkspaceCommandMutation,
collectProjectExecutionWorkspaceCommandPaths,
collectProjectWorkspaceCommandPaths,
} from "./workspace-command-authz.js";
import { assertCanManageProjectWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js";
import { getTelemetryClient } from "../telemetry.js";
export function projectRoutes(db: Db) {
@@ -93,6 +99,13 @@ export function projectRoutes(db: Db) {
};
const { workspace, ...projectData } = req.body as CreateProjectPayload;
assertNoAgentHostWorkspaceCommandMutation(
req,
[
...collectProjectExecutionWorkspaceCommandPaths(projectData.executionWorkspacePolicy),
...collectProjectWorkspaceCommandPaths(workspace, "workspace"),
],
);
if (projectData.env !== undefined) {
projectData.env = await secretsSvc.normalizeEnvBindingsForPersistence(
companyId,
@@ -144,6 +157,10 @@ export function projectRoutes(db: Db) {
}
assertCompanyAccess(req, existing.companyId);
const body = { ...req.body };
assertNoAgentHostWorkspaceCommandMutation(
req,
collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy),
);
if (typeof body.archivedAt === "string") {
body.archivedAt = new Date(body.archivedAt);
}
@@ -200,6 +217,10 @@ export function projectRoutes(db: Db) {
return;
}
assertCompanyAccess(req, existing.companyId);
assertNoAgentHostWorkspaceCommandMutation(
req,
collectProjectWorkspaceCommandPaths(req.body),
);
const workspace = await svc.createWorkspace(id, req.body);
if (!workspace) {
res.status(422).json({ error: "Invalid project workspace payload" });
@@ -238,6 +259,10 @@ export function projectRoutes(db: Db) {
return;
}
assertCompanyAccess(req, existing.companyId);
assertNoAgentHostWorkspaceCommandMutation(
req,
collectProjectWorkspaceCommandPaths(req.body),
);
const workspaceExists = (await svc.listWorkspaces(id)).some((workspace) => workspace.id === workspaceId);
if (!workspaceExists) {
res.status(404).json({ error: "Project workspace not found" });
@@ -290,6 +315,11 @@ export function projectRoutes(db: Db) {
return;
}
await assertCanManageProjectWorkspaceRuntimeServices(db, req, {
companyId: project.companyId,
projectWorkspaceId: workspace.id,
});
const workspaceCwd = workspace.cwd;
if (!workspaceCwd) {
res.status(422).json({ error: "Project workspace needs a local path before Paperclip can run workspace commands" });
@@ -0,0 +1,115 @@
import type { Request } from "express";
import { forbidden } from "../errors.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hasOwn(value: Record<string, unknown>, key: string) {
return Object.prototype.hasOwnProperty.call(value, key);
}
function prefixPath(prefix: string, key: string) {
return prefix.length > 0 ? `${prefix}.${key}` : key;
}
function collectWorkspaceStrategyCommandPaths(raw: unknown, prefix: string): string[] {
if (!isRecord(raw)) return [];
const paths: string[] = [];
if (hasOwn(raw, "provisionCommand")) {
paths.push(prefixPath(prefix, "provisionCommand"));
}
if (hasOwn(raw, "teardownCommand")) {
paths.push(prefixPath(prefix, "teardownCommand"));
}
return paths;
}
function collectExecutionWorkspaceConfigCommandPaths(raw: unknown, prefix: string): string[] {
if (!isRecord(raw)) return [];
const paths: string[] = [];
if (hasOwn(raw, "provisionCommand")) {
paths.push(prefixPath(prefix, "provisionCommand"));
}
if (hasOwn(raw, "teardownCommand")) {
paths.push(prefixPath(prefix, "teardownCommand"));
}
if (hasOwn(raw, "cleanupCommand")) {
paths.push(prefixPath(prefix, "cleanupCommand"));
}
return paths;
}
export function assertNoAgentHostWorkspaceCommandMutation(req: Request, paths: string[]) {
if (req.actor.type !== "agent" || paths.length === 0) return;
throw forbidden(
`Agent keys cannot modify host-executed workspace commands (${paths.join(", ")}).`,
);
}
export function collectAgentAdapterWorkspaceCommandPaths(adapterConfig: unknown): string[] {
if (!isRecord(adapterConfig)) return [];
return collectWorkspaceStrategyCommandPaths(
adapterConfig.workspaceStrategy,
"adapterConfig.workspaceStrategy",
);
}
export function collectProjectExecutionWorkspaceCommandPaths(policy: unknown): string[] {
if (!isRecord(policy)) return [];
return collectWorkspaceStrategyCommandPaths(
policy.workspaceStrategy,
"executionWorkspacePolicy.workspaceStrategy",
);
}
export function collectProjectWorkspaceCommandPaths(
workspacePatch: unknown,
prefix = "",
): string[] {
if (!isRecord(workspacePatch)) return [];
return hasOwn(workspacePatch, "cleanupCommand")
? [prefixPath(prefix, "cleanupCommand")]
: [];
}
export function collectIssueWorkspaceCommandPaths(input: {
executionWorkspaceSettings?: unknown;
assigneeAdapterOverrides?: unknown;
}): string[] {
const paths: string[] = [];
if (isRecord(input.executionWorkspaceSettings)) {
paths.push(
...collectWorkspaceStrategyCommandPaths(
input.executionWorkspaceSettings.workspaceStrategy,
"executionWorkspaceSettings.workspaceStrategy",
),
);
}
if (isRecord(input.assigneeAdapterOverrides)) {
const adapterConfig = input.assigneeAdapterOverrides.adapterConfig;
if (isRecord(adapterConfig)) {
paths.push(
...collectWorkspaceStrategyCommandPaths(
adapterConfig.workspaceStrategy,
"assigneeAdapterOverrides.adapterConfig.workspaceStrategy",
),
);
}
}
return paths;
}
export function collectExecutionWorkspaceCommandPaths(input: {
config?: unknown;
metadata?: unknown;
}): string[] {
const paths: string[] = [];
if (input.config !== undefined) {
paths.push(...collectExecutionWorkspaceConfigCommandPaths(input.config, "config"));
}
if (isRecord(input.metadata) && hasOwn(input.metadata, "config")) {
paths.push(...collectExecutionWorkspaceConfigCommandPaths(input.metadata.config, "metadata.config"));
}
return paths;
}
@@ -0,0 +1,138 @@
import { and, eq, inArray, isNull, ne, or } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, issues } from "@paperclipai/db";
import type { Request } from "express";
import { forbidden } from "../errors.js";
import { assertCompanyAccess } from "./authz.js";
const WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES: string[] = [
"backlog",
"todo",
"in_progress",
"in_review",
"blocked",
];
async function listReportingSubtreeAgentIds(db: Db, companyId: string, actorAgentId: string) {
const companyAgents = await db
.select({
id: agents.id,
reportsTo: agents.reportsTo,
})
.from(agents)
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
const reportsByManager = new Map<string, string[]>();
for (const agent of companyAgents) {
if (!agent.reportsTo) continue;
const reports = reportsByManager.get(agent.reportsTo) ?? [];
reports.push(agent.id);
reportsByManager.set(agent.reportsTo, reports);
}
const visited = new Set<string>([actorAgentId]);
const queue = [actorAgentId];
while (queue.length > 0) {
const current = queue.shift();
if (!current) continue;
const reports = reportsByManager.get(current) ?? [];
for (const reportId of reports) {
if (visited.has(reportId)) continue;
visited.add(reportId);
queue.push(reportId);
}
}
return [...visited];
}
async function assertAgentCanManageRuntimeServicesForWorkspace(
db: Db,
req: Request,
input: {
companyId: string;
projectWorkspaceId?: string | null;
executionWorkspaceId?: string | null;
sourceIssueId?: string | null;
},
) {
if (req.actor.type !== "agent" || !req.actor.agentId) {
throw forbidden("Agent authentication required");
}
const actorAgent = await db
.select({
id: agents.id,
companyId: agents.companyId,
role: agents.role,
})
.from(agents)
.where(eq(agents.id, req.actor.agentId))
.then((rows) => rows[0] ?? null);
if (!actorAgent || actorAgent.companyId !== input.companyId) {
throw forbidden("Agent key cannot access another company");
}
if (actorAgent.role === "ceo") {
return;
}
const eligibleAgentIds = await listReportingSubtreeAgentIds(db, input.companyId, actorAgent.id);
const workspaceScopeConditions = [
input.projectWorkspaceId ? eq(issues.projectWorkspaceId, input.projectWorkspaceId) : null,
input.executionWorkspaceId ? eq(issues.executionWorkspaceId, input.executionWorkspaceId) : null,
input.sourceIssueId ? eq(issues.id, input.sourceIssueId) : null,
].filter((condition): condition is NonNullable<typeof condition> => condition !== null);
if (workspaceScopeConditions.length === 0) {
throw forbidden("Missing permission to manage workspace runtime services");
}
const linkedIssue = await db
.select({ id: issues.id })
.from(issues)
.where(and(
eq(issues.companyId, input.companyId),
isNull(issues.hiddenAt),
inArray(issues.status, WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES),
inArray(issues.assigneeAgentId, eligibleAgentIds),
workspaceScopeConditions.length === 1
? workspaceScopeConditions[0]!
: or(...workspaceScopeConditions),
))
.then((rows) => rows[0] ?? null);
if (linkedIssue) {
return;
}
throw forbidden("Missing permission to manage workspace runtime services");
}
export async function assertCanManageProjectWorkspaceRuntimeServices(
db: Db,
req: Request,
input: {
companyId: string;
projectWorkspaceId: string;
},
) {
assertCompanyAccess(req, input.companyId);
if (req.actor.type === "board") return;
await assertAgentCanManageRuntimeServicesForWorkspace(db, req, input);
}
export async function assertCanManageExecutionWorkspaceRuntimeServices(
db: Db,
req: Request,
input: {
companyId: string;
executionWorkspaceId: string;
sourceIssueId?: string | null;
},
) {
assertCompanyAccess(req, input.companyId);
if (req.actor.type === "board") return;
await assertAgentCanManageRuntimeServicesForWorkspace(db, req, input);
}
+17 -3
View File
@@ -216,7 +216,7 @@ export function agentService(db: Db) {
const rows = await db
.select({
agentId: costEvents.agentId,
spentMonthlyCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
spentMonthlyCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
})
.from(costEvents)
.where(
@@ -619,11 +619,25 @@ export function agentService(db: Db) {
.from(agentApiKeys)
.where(eq(agentApiKeys.agentId, id)),
revokeKey: async (keyId: string) => {
getKeyById: async (keyId: string) =>
db
.select({
id: agentApiKeys.id,
agentId: agentApiKeys.agentId,
companyId: agentApiKeys.companyId,
name: agentApiKeys.name,
createdAt: agentApiKeys.createdAt,
revokedAt: agentApiKeys.revokedAt,
})
.from(agentApiKeys)
.where(eq(agentApiKeys.id, keyId))
.then((rows) => rows[0] ?? null),
revokeKey: async (agentId: string, keyId: string) => {
const rows = await db
.update(agentApiKeys)
.set({ revokedAt: new Date() })
.where(eq(agentApiKeys.id, keyId))
.where(and(eq(agentApiKeys.id, keyId), eq(agentApiKeys.agentId, agentId)))
.returning();
return rows[0] ?? null;
},
+1 -1
View File
@@ -156,7 +156,7 @@ async function computeObservedAmount(
const [row] = await db
.select({
total: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
total: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
})
.from(costEvents)
.where(and(...conditions));
+4 -4
View File
@@ -76,10 +76,10 @@ export function companyService(db: Db) {
if (companyIds.length === 0) return new Map<string, number>();
const { start, end } = currentUtcMonthWindow();
const rows = await database
.select({
companyId: costEvents.companyId,
spentMonthlyCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
})
.select({
companyId: costEvents.companyId,
spentMonthlyCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
})
.from(costEvents)
.where(
and(
+137 -23
View File
@@ -47,7 +47,9 @@ import {
readPaperclipSkillSyncPreference,
writePaperclipSkillSyncPreference,
} from "@paperclipai/adapter-utils/server-utils";
import { notFound, unprocessable } from "../errors.js";
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
import { findServerAdapter } from "../adapters/index.js";
import { forbidden, notFound, unprocessable } from "../errors.js";
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
import type { StorageService } from "../storage/types.js";
import { accessService } from "./access.js";
@@ -62,6 +64,7 @@ import { validateCron } from "./cron.js";
import { issueService } from "./issues.js";
import { projectService } from "./projects.js";
import { routineService } from "./routines.js";
import { secretService } from "./secrets.js";
/** Build OrgNode tree from manifest agent list (slug + reportsToSlug). */
function buildOrgTreeFromManifest(agents: CompanyPortabilityManifest["agents"]): OrgNode[] {
@@ -117,6 +120,7 @@ const DEFAULT_INCLUDE: CompanyPortabilityInclude = {
};
const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename";
const IMPORT_FORBIDDEN_ADAPTER_TYPES = new Set(["process", "http"]);
const execFileAsync = promisify(execFile);
let bundledSkillsCommitPromise: Promise<string | null> | null = null;
@@ -2747,6 +2751,94 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const projects = projectService(db);
const issues = issueService(db);
const companySkills = companySkillService(db);
const secrets = secretService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
function assertKnownImportAdapterType(type: string | null | undefined): string {
const adapterType = typeof type === "string" ? type.trim() : "";
if (!adapterType) {
throw unprocessable("Adapter type is required");
}
if (!findServerAdapter(adapterType)) {
throw unprocessable(`Unknown adapter type: ${adapterType}`);
}
return adapterType;
}
async function assertImportAdapterConfigConstraints(
companyId: string,
adapterType: string,
adapterConfig: Record<string, unknown>,
) {
if (adapterType !== "opencode_local") return;
const { config: runtimeConfig } = await secrets.resolveAdapterConfigForRuntime(companyId, adapterConfig);
const runtimeEnv = isPlainRecord(runtimeConfig.env) ? 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}`);
}
}
async function prepareImportedAgentAdapter(
companyId: string,
adapterType: string | null | undefined,
adapterConfig: Record<string, unknown>,
desiredSkills: string[],
mode: ImportMode,
) {
const effectiveAdapterType = assertKnownImportAdapterType(adapterType);
if (mode === "agent_safe" && IMPORT_FORBIDDEN_ADAPTER_TYPES.has(effectiveAdapterType)) {
throw forbidden(`Adapter type "${effectiveAdapterType}" is not allowed in safe imports`);
}
const nextAdapterConfig = writePaperclipSkillSyncPreference({ ...adapterConfig }, desiredSkills);
delete nextAdapterConfig.promptTemplate;
delete nextAdapterConfig.bootstrapPromptTemplate;
delete nextAdapterConfig.instructionsFilePath;
delete nextAdapterConfig.instructionsBundleMode;
delete nextAdapterConfig.instructionsRootPath;
delete nextAdapterConfig.instructionsEntryFile;
const normalizedAdapterConfig = await secrets.normalizeAdapterConfigForPersistence(
companyId,
nextAdapterConfig,
{ strictMode: strictSecretsMode },
);
await assertImportAdapterConfigConstraints(companyId, effectiveAdapterType, normalizedAdapterConfig);
return {
adapterType: effectiveAdapterType,
adapterConfig: normalizedAdapterConfig,
};
}
function resolveImportedAssigneeAgentId(
assigneeSlug: string | null | undefined,
importedSlugToAgentId: Map<string, string>,
existingSlugToAgentId: Map<string, string>,
agentStatusById: Map<string, string | null | undefined>,
warnings: string[],
subjectLabel: string,
) {
if (!assigneeSlug) return null;
const assigneeAgentId =
importedSlugToAgentId.get(assigneeSlug)
?? existingSlugToAgentId.get(assigneeSlug)
?? null;
if (!assigneeAgentId) return null;
const assigneeStatus = agentStatusById.get(assigneeAgentId) ?? null;
if (assigneeStatus === "pending_approval" || assigneeStatus === "terminated") {
warnings.push(
`${subjectLabel} assignee ${assigneeSlug} is ${assigneeStatus}; imported work was left unassigned.`,
);
return null;
}
return assigneeAgentId;
}
async function resolveSource(source: CompanyPortabilityPreview["source"]): Promise<ResolvedSource> {
if (source.type === "inline") {
@@ -3856,7 +3948,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const warnings = [...plan.preview.warnings];
const include = plan.include;
let targetCompany: { id: string; name: string } | null = null;
let targetCompany: {
id: string;
name: string;
requireBoardApprovalForNewAgents?: boolean | null;
} | null = null;
let companyAction: "created" | "updated" | "unchanged" = "unchanged";
if (input.target.mode === "new_company") {
@@ -3977,9 +4073,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const resultProjects: CompanyPortabilityImportResult["projects"] = [];
const importedSlugToAgentId = new Map<string, string>();
const existingSlugToAgentId = new Map<string, string>();
const agentStatusById = new Map<string, string | null | undefined>();
const existingAgents = await agents.list(targetCompany.id);
for (const existing of existingAgents) {
existingSlugToAgentId.set(normalizeAgentUrlKey(existing.name) ?? existing.id, existing.id);
agentStatusById.set(existing.id, existing.status);
}
const importedSlugToProjectId = new Map<string, string>();
const importedProjectWorkspaceIdByProjectSlug = new Map<string, Map<string, string>>();
@@ -4049,22 +4147,18 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
// Apply adapter overrides from request if present
const adapterOverride = input.adapterOverrides?.[planAgent.slug];
const effectiveAdapterType = adapterOverride?.adapterType ?? manifestAgent.adapterType;
const baseAdapterConfig = adapterOverride?.adapterConfig
? { ...adapterOverride.adapterConfig }
: { ...manifestAgent.adapterConfig } as Record<string, unknown>;
const desiredSkills = (manifestAgent.skills ?? []).map((skillRef) => desiredSkillRefMap.get(skillRef) ?? skillRef);
const adapterConfigWithSkills = writePaperclipSkillSyncPreference(
const normalizedAdapter = await prepareImportedAgentAdapter(
targetCompany.id,
adapterOverride?.adapterType ?? manifestAgent.adapterType,
baseAdapterConfig,
desiredSkills,
mode,
);
delete adapterConfigWithSkills.promptTemplate;
delete adapterConfigWithSkills.bootstrapPromptTemplate; // deprecated
delete adapterConfigWithSkills.instructionsFilePath;
delete adapterConfigWithSkills.instructionsBundleMode;
delete adapterConfigWithSkills.instructionsRootPath;
delete adapterConfigWithSkills.instructionsEntryFile;
const patch = {
name: planAgent.plannedName,
role: manifestAgent.role,
@@ -4072,8 +4166,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
icon: manifestAgent.icon,
capabilities: manifestAgent.capabilities,
reportsTo: null,
adapterType: effectiveAdapterType,
adapterConfig: adapterConfigWithSkills,
adapterType: normalizedAdapter.adapterType,
adapterConfig: normalizedAdapter.adapterConfig,
runtimeConfig: disableImportedTimerHeartbeat(manifestAgent.runtimeConfig),
budgetMonthlyCents: manifestAgent.budgetMonthlyCents,
permissions: manifestAgent.permissions,
@@ -4102,6 +4196,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
} catch (err) {
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
}
agentStatusById.set(updated.id, updated.status ?? agentStatusById.get(updated.id) ?? null);
importedSlugToAgentId.set(planAgent.slug, updated.id);
existingSlugToAgentId.set(normalizeAgentUrlKey(updated.name) ?? updated.id, updated.id);
resultAgents.push({
@@ -4114,7 +4209,17 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
continue;
}
let created = await agents.create(targetCompany.id, patch);
const requiresApproval =
typeof targetCompany.requireBoardApprovalForNewAgents === "boolean"
? targetCompany.requireBoardApprovalForNewAgents
: include.company
? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? true)
: true;
const createdStatus = requiresApproval ? "pending_approval" : "idle";
let created = await agents.create(targetCompany.id, {
...patch,
status: createdStatus,
});
await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active");
await access.setPrincipalPermission(
targetCompany.id,
@@ -4133,6 +4238,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
} catch (err) {
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
}
agentStatusById.set(created.id, created.status ?? createdStatus);
importedSlugToAgentId.set(planAgent.slug, created.id);
existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id);
resultAgents.push({
@@ -4275,11 +4381,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const markdownRaw = readPortableTextFile(plan.source.files, manifestIssue.path);
const parsed = markdownRaw ? parseFrontmatterMarkdown(markdownRaw) : null;
const description = parsed?.body || manifestIssue.description || null;
const assigneeAgentId = manifestIssue.assigneeAgentSlug
? importedSlugToAgentId.get(manifestIssue.assigneeAgentSlug)
?? existingSlugToAgentId.get(manifestIssue.assigneeAgentSlug)
?? null
: null;
const assigneeAgentId = resolveImportedAssigneeAgentId(
manifestIssue.assigneeAgentSlug,
importedSlugToAgentId,
existingSlugToAgentId,
agentStatusById,
warnings,
`Task ${manifestIssue.slug}`,
);
const projectId = manifestIssue.projectSlug
? importedSlugToProjectId.get(manifestIssue.projectSlug)
?? existingProjectSlugToId.get(manifestIssue.projectSlug)
@@ -4292,8 +4401,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
warnings.push(`Task ${manifestIssue.slug} references workspace key ${manifestIssue.projectWorkspaceKey}, but that workspace was not imported.`);
}
if (manifestIssue.recurring) {
if (!projectId || !assigneeAgentId) {
throw unprocessable(`Recurring task ${manifestIssue.slug} is missing the project or assignee required to create a routine.`);
if (!projectId) {
throw unprocessable(`Recurring task ${manifestIssue.slug} is missing the project required to create a routine.`);
}
const resolvedRoutine = resolvePortableRoutineDefinition(manifestIssue, parsed?.frontmatter.schedule);
if (resolvedRoutine.errors.length > 0) {
@@ -4373,15 +4482,20 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
}
continue;
}
let issueStatus = manifestIssue.status && ISSUE_STATUSES.includes(manifestIssue.status as any)
? manifestIssue.status as typeof ISSUE_STATUSES[number]
: "backlog";
if (!assigneeAgentId && issueStatus === "in_progress") {
warnings.push(`Task ${manifestIssue.slug} was downgraded to todo because its assignee could not be imported as assignable work.`);
issueStatus = "todo";
}
await issues.create(targetCompany.id, {
projectId,
projectWorkspaceId,
title: manifestIssue.title,
description,
assigneeAgentId,
status: manifestIssue.status && ISSUE_STATUSES.includes(manifestIssue.status as any)
? manifestIssue.status as typeof ISSUE_STATUSES[number]
: "backlog",
status: issueStatus,
priority: manifestIssue.priority && ISSUE_PRIORITIES.includes(manifestIssue.priority as any)
? manifestIssue.priority as typeof ISSUE_PRIORITIES[number]
: "medium",
+96 -8
View File
@@ -36,6 +36,50 @@ import { projectService } from "./projects.js";
import { secretService } from "./secrets.js";
type CompanySkillRow = typeof companySkills.$inferSelect;
type CompanySkillListDbRow = Pick<
CompanySkillRow,
| "id"
| "companyId"
| "key"
| "slug"
| "name"
| "description"
| "sourceType"
| "sourceLocator"
| "sourceRef"
| "trustLevel"
| "compatibility"
| "fileInventory"
| "metadata"
| "createdAt"
| "updatedAt"
>;
type CompanySkillListRow = Pick<
CompanySkill,
| "id"
| "companyId"
| "key"
| "slug"
| "name"
| "description"
| "sourceType"
| "sourceLocator"
| "sourceRef"
| "trustLevel"
| "compatibility"
| "fileInventory"
| "metadata"
| "createdAt"
| "updatedAt"
>;
type SkillReferenceTarget = Pick<CompanySkill, "id" | "key" | "slug">;
type SkillSourceInfoTarget = Pick<
CompanySkill,
| "companyId"
| "sourceType"
| "sourceLocator"
| "metadata"
>;
type ImportedSkill = {
key: string;
@@ -1150,6 +1194,28 @@ function toCompanySkill(row: CompanySkillRow): CompanySkill {
};
}
function toCompanySkillListRow(row: CompanySkillListDbRow): CompanySkillListRow {
return {
...row,
description: row.description ?? null,
sourceType: row.sourceType as CompanySkillSourceType,
sourceLocator: row.sourceLocator ?? null,
sourceRef: row.sourceRef ?? null,
trustLevel: row.trustLevel as CompanySkillTrustLevel,
compatibility: row.compatibility as CompanySkillCompatibility,
fileInventory: Array.isArray(row.fileInventory)
? row.fileInventory.flatMap((entry) => {
if (!isPlainRecord(entry)) return [];
return [{
path: String(entry.path ?? ""),
kind: (String(entry.kind ?? "other") as CompanySkillFileInventoryEntry["kind"]),
}];
})
: [],
metadata: isPlainRecord(row.metadata) ? row.metadata : null,
};
}
function serializeFileInventory(
fileInventory: CompanySkillFileInventoryEntry[],
): Array<Record<string, unknown>> {
@@ -1159,14 +1225,14 @@ function serializeFileInventory(
}));
}
function getSkillMeta(skill: CompanySkill): SkillSourceMeta {
function getSkillMeta(skill: Pick<CompanySkill, "metadata">): SkillSourceMeta {
return isPlainRecord(skill.metadata) ? skill.metadata as SkillSourceMeta : {};
}
function resolveSkillReference(
skills: CompanySkill[],
skills: SkillReferenceTarget[],
reference: string,
): { skill: CompanySkill | null; ambiguous: boolean } {
): { skill: SkillReferenceTarget | null; ambiguous: boolean } {
const trimmed = reference.trim();
if (!trimmed) {
return { skill: null, ambiguous: false };
@@ -1242,7 +1308,7 @@ function resolveRequestedSkillKeysOrThrow(
}
function resolveDesiredSkillKeys(
skills: CompanySkill[],
skills: SkillReferenceTarget[],
config: Record<string, unknown>,
) {
const preference = readPaperclipSkillSyncPreference(config);
@@ -1253,7 +1319,7 @@ function resolveDesiredSkillKeys(
));
}
function normalizeSkillDirectory(skill: CompanySkill) {
function normalizeSkillDirectory(skill: SkillSourceInfoTarget) {
if ((skill.sourceType !== "local_path" && skill.sourceType !== "catalog") || !skill.sourceLocator) return null;
const resolved = path.resolve(skill.sourceLocator);
if (path.basename(resolved).toLowerCase() === "skill.md") {
@@ -1329,7 +1395,7 @@ function isMarkdownPath(filePath: string) {
return fileName === "skill.md" || fileName.endsWith(".md");
}
function deriveSkillSourceInfo(skill: CompanySkill): {
function deriveSkillSourceInfo(skill: SkillSourceInfoTarget): {
editable: boolean;
editableReason: string | null;
sourceLabel: string | null;
@@ -1428,7 +1494,7 @@ function enrichSkill(skill: CompanySkill, attachedAgentCount: number, usedByAgen
};
}
function toCompanySkillListItem(skill: CompanySkill, attachedAgentCount: number): CompanySkillListItem {
function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount: number): CompanySkillListItem {
const source = deriveSkillSourceInfo(skill);
return {
id: skill.id,
@@ -1526,7 +1592,29 @@ export function companySkillService(db: Db) {
}
async function list(companyId: string): Promise<CompanySkillListItem[]> {
const rows = await listFull(companyId);
await ensureSkillInventoryCurrent(companyId);
const rows = await db
.select({
id: companySkills.id,
companyId: companySkills.companyId,
key: companySkills.key,
slug: companySkills.slug,
name: companySkills.name,
description: companySkills.description,
sourceType: companySkills.sourceType,
sourceLocator: companySkills.sourceLocator,
sourceRef: companySkills.sourceRef,
trustLevel: companySkills.trustLevel,
compatibility: companySkills.compatibility,
fileInventory: companySkills.fileInventory,
metadata: companySkills.metadata,
createdAt: companySkills.createdAt,
updatedAt: companySkills.updatedAt,
})
.from(companySkills)
.where(eq(companySkills.companyId, companyId))
.orderBy(asc(companySkills.name), asc(companySkills.key))
.then((entries) => entries.map((entry) => toCompanySkillListRow(entry as CompanySkillListDbRow)));
const agentRows = await agents.list(companyId);
return rows.map((skill) => {
const attachedAgentCount = agentRows.filter((agent) => {
+43 -39
View File
@@ -12,6 +12,10 @@ export interface CostDateRange {
const METERED_BILLING_TYPE = "metered_api";
const SUBSCRIPTION_BILLING_TYPES = ["subscription_included", "subscription_overage"] as const;
function sumAsNumber(column: typeof costEvents.costCents | typeof costEvents.inputTokens | typeof costEvents.cachedInputTokens | typeof costEvents.outputTokens) {
return sql<number>`coalesce(sum(${column}), 0)::double precision`;
}
function currentUtcMonthWindow(now = new Date()) {
const year = now.getUTCFullYear();
const month = now.getUTCMonth();
@@ -36,7 +40,7 @@ async function getMonthlySpendTotal(
}
const [row] = await db
.select({
total: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
total: sumAsNumber(costEvents.costCents),
})
.from(costEvents)
.where(and(...conditions));
@@ -111,7 +115,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
const [{ total }] = await db
.select({
total: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
total: sumAsNumber(costEvents.costCents),
})
.from(costEvents)
.where(and(...conditions));
@@ -140,26 +144,26 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
agentId: costEvents.agentId,
agentName: agents.name,
agentStatus: agents.status,
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
costCents: sumAsNumber(costEvents.costCents),
inputTokens: sumAsNumber(costEvents.inputTokens),
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
outputTokens: sumAsNumber(costEvents.outputTokens),
apiRunCount:
sql<number>`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`,
subscriptionRunCount:
sql<number>`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`,
subscriptionCachedInputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`,
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`,
subscriptionInputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`,
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`,
subscriptionOutputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`,
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`,
})
.from(costEvents)
.leftJoin(agents, eq(costEvents.agentId, agents.id))
.where(and(...conditions))
.groupBy(costEvents.agentId, agents.name, agents.status)
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
.orderBy(desc(sumAsNumber(costEvents.costCents)));
},
byProvider: async (companyId: string, range?: CostDateRange) => {
@@ -173,25 +177,25 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
biller: costEvents.biller,
billingType: costEvents.billingType,
model: costEvents.model,
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
costCents: sumAsNumber(costEvents.costCents),
inputTokens: sumAsNumber(costEvents.inputTokens),
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
outputTokens: sumAsNumber(costEvents.outputTokens),
apiRunCount:
sql<number>`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`,
subscriptionRunCount:
sql<number>`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`,
subscriptionCachedInputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`,
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`,
subscriptionInputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`,
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`,
subscriptionOutputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`,
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`,
})
.from(costEvents)
.where(and(...conditions))
.groupBy(costEvents.provider, costEvents.biller, costEvents.billingType, costEvents.model)
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
.orderBy(desc(sumAsNumber(costEvents.costCents)));
},
byBiller: async (companyId: string, range?: CostDateRange) => {
@@ -202,27 +206,27 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
return db
.select({
biller: costEvents.biller,
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
costCents: sumAsNumber(costEvents.costCents),
inputTokens: sumAsNumber(costEvents.inputTokens),
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
outputTokens: sumAsNumber(costEvents.outputTokens),
apiRunCount:
sql<number>`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`,
subscriptionRunCount:
sql<number>`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`,
subscriptionCachedInputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`,
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`,
subscriptionInputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`,
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`,
subscriptionOutputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`,
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`,
providerCount: sql<number>`count(distinct ${costEvents.provider})::int`,
modelCount: sql<number>`count(distinct ${costEvents.model})::int`,
})
.from(costEvents)
.where(and(...conditions))
.groupBy(costEvents.biller)
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
.orderBy(desc(sumAsNumber(costEvents.costCents)));
},
/**
@@ -244,10 +248,10 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
.select({
provider: costEvents.provider,
biller: sql<string>`case when count(distinct ${costEvents.biller}) = 1 then min(${costEvents.biller}) else 'mixed' end`,
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
costCents: sumAsNumber(costEvents.costCents),
inputTokens: sumAsNumber(costEvents.inputTokens),
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
outputTokens: sumAsNumber(costEvents.outputTokens),
})
.from(costEvents)
.where(
@@ -257,7 +261,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
),
)
.groupBy(costEvents.provider)
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
.orderBy(desc(sumAsNumber(costEvents.costCents)));
return rows.map((row) => ({
provider: row.provider,
@@ -292,10 +296,10 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
biller: costEvents.biller,
billingType: costEvents.billingType,
model: costEvents.model,
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
costCents: sumAsNumber(costEvents.costCents),
inputTokens: sumAsNumber(costEvents.inputTokens),
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
outputTokens: sumAsNumber(costEvents.outputTokens),
})
.from(costEvents)
.leftJoin(agents, eq(costEvents.agentId, agents.id))
@@ -342,16 +346,16 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
const costCentsExpr = sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`;
const costCentsExpr = sumAsNumber(costEvents.costCents);
return db
.select({
projectId: effectiveProjectId,
projectName: projects.name,
costCents: costCentsExpr,
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
inputTokens: sumAsNumber(costEvents.inputTokens),
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
outputTokens: sumAsNumber(costEvents.outputTokens),
})
.from(costEvents)
.leftJoin(runProjectLinks, eq(costEvents.heartbeatRunId, runProjectLinks.runId))
+1 -1
View File
@@ -65,7 +65,7 @@ export function dashboardService(db: Db) {
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const [{ monthSpend }] = await db
.select({
monthSpend: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
monthSpend: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
})
.from(costEvents)
.where(
+59 -15
View File
@@ -7,6 +7,7 @@ import type { Db } from "@paperclipai/db";
import { executionWorkspaces, issues, projects, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
import type {
ExecutionWorkspace,
ExecutionWorkspaceSummary,
ExecutionWorkspaceCloseAction,
ExecutionWorkspaceCloseGitReadiness,
ExecutionWorkspaceCloseReadiness,
@@ -336,6 +337,15 @@ function toExecutionWorkspace(
};
}
function toExecutionWorkspaceSummary(row: Pick<ExecutionWorkspaceRow, "id" | "name" | "mode" | "projectWorkspaceId">): ExecutionWorkspaceSummary {
return {
id: row.id,
name: row.name,
mode: row.mode as ExecutionWorkspaceSummary["mode"],
projectWorkspaceId: row.projectWorkspaceId ?? null,
};
}
function usesInheritedProjectRuntimeServices(row: ExecutionWorkspaceRow) {
if (row.mode !== "shared_workspace" || !row.projectWorkspaceId) return false;
return !readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null)?.workspaceRuntime;
@@ -372,6 +382,33 @@ async function loadEffectiveRuntimeServicesByExecutionWorkspace(
}
export function executionWorkspaceService(db: Db) {
function buildListConditions(
companyId: string,
filters?: {
projectId?: string;
projectWorkspaceId?: string;
issueId?: string;
status?: string;
reuseEligible?: boolean;
},
) {
const conditions = [eq(executionWorkspaces.companyId, companyId)];
if (filters?.projectId) conditions.push(eq(executionWorkspaces.projectId, filters.projectId));
if (filters?.projectWorkspaceId) {
conditions.push(eq(executionWorkspaces.projectWorkspaceId, filters.projectWorkspaceId));
}
if (filters?.issueId) conditions.push(eq(executionWorkspaces.sourceIssueId, filters.issueId));
if (filters?.status) {
const statuses = filters.status.split(",").map((value) => value.trim()).filter(Boolean);
if (statuses.length === 1) conditions.push(eq(executionWorkspaces.status, statuses[0]!));
else if (statuses.length > 1) conditions.push(inArray(executionWorkspaces.status, statuses));
}
if (filters?.reuseEligible) {
conditions.push(inArray(executionWorkspaces.status, ["active", "idle", "in_review"]));
}
return conditions;
}
return {
list: async (companyId: string, filters?: {
projectId?: string;
@@ -380,21 +417,7 @@ export function executionWorkspaceService(db: Db) {
status?: string;
reuseEligible?: boolean;
}) => {
const conditions = [eq(executionWorkspaces.companyId, companyId)];
if (filters?.projectId) conditions.push(eq(executionWorkspaces.projectId, filters.projectId));
if (filters?.projectWorkspaceId) {
conditions.push(eq(executionWorkspaces.projectWorkspaceId, filters.projectWorkspaceId));
}
if (filters?.issueId) conditions.push(eq(executionWorkspaces.sourceIssueId, filters.issueId));
if (filters?.status) {
const statuses = filters.status.split(",").map((value) => value.trim()).filter(Boolean);
if (statuses.length === 1) conditions.push(eq(executionWorkspaces.status, statuses[0]!));
else if (statuses.length > 1) conditions.push(inArray(executionWorkspaces.status, statuses));
}
if (filters?.reuseEligible) {
conditions.push(inArray(executionWorkspaces.status, ["active", "idle", "in_review"]));
}
const conditions = buildListConditions(companyId, filters);
const rows = await db
.select()
.from(executionWorkspaces)
@@ -409,6 +432,27 @@ export function executionWorkspaceService(db: Db) {
);
},
listSummaries: async (companyId: string, filters?: {
projectId?: string;
projectWorkspaceId?: string;
issueId?: string;
status?: string;
reuseEligible?: boolean;
}) => {
const conditions = buildListConditions(companyId, filters);
const rows = await db
.select({
id: executionWorkspaces.id,
name: executionWorkspaces.name,
mode: executionWorkspaces.mode,
projectWorkspaceId: executionWorkspaces.projectWorkspaceId,
})
.from(executionWorkspaces)
.where(and(...conditions))
.orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt));
return rows.map((row) => toExecutionWorkspaceSummary(row));
},
getById: async (id: string) => {
const row = await db
.select()
+7 -7
View File
@@ -35,9 +35,9 @@ function rangeConditions(companyId: string, range?: FinanceDateRange) {
}
export function financeService(db: Db) {
const debitExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::int`;
const creditExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::int`;
const estimatedDebitExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true then ${financeEvents.amountCents} else 0 end), 0)::int`;
const debitExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`;
const creditExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`;
const estimatedDebitExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true then ${financeEvents.amountCents} else 0 end), 0)::double precision`;
return {
createEvent: async (companyId: string, data: Omit<typeof financeEvents.$inferInsert, "companyId">) => {
@@ -95,12 +95,12 @@ export function financeService(db: Db) {
estimatedDebitCents: estimatedDebitExpr,
eventCount: sql<number>`count(*)::int`,
kindCount: sql<number>`count(distinct ${financeEvents.eventKind})::int`,
netCents: sql<number>`(${debitExpr} - ${creditExpr})::int`,
netCents: sql<number>`(${debitExpr} - ${creditExpr})::double precision`,
})
.from(financeEvents)
.where(and(...conditions))
.groupBy(financeEvents.biller)
.orderBy(desc(sql`(${debitExpr} - ${creditExpr})::int`), financeEvents.biller);
.orderBy(desc(sql`(${debitExpr} - ${creditExpr})::double precision`), financeEvents.biller);
},
byKind: async (companyId: string, range?: FinanceDateRange) => {
@@ -113,12 +113,12 @@ export function financeService(db: Db) {
estimatedDebitCents: estimatedDebitExpr,
eventCount: sql<number>`count(*)::int`,
billerCount: sql<number>`count(distinct ${financeEvents.biller})::int`,
netCents: sql<number>`(${debitExpr} - ${creditExpr})::int`,
netCents: sql<number>`(${debitExpr} - ${creditExpr})::double precision`,
})
.from(financeEvents)
.where(and(...conditions))
.groupBy(financeEvents.eventKind)
.orderBy(desc(sql`(${debitExpr} - ${creditExpr})::int`), financeEvents.eventKind);
.orderBy(desc(sql`(${debitExpr} - ${creditExpr})::double precision`), financeEvents.eventKind);
},
list: async (companyId: string, range?: FinanceDateRange, limit: number = 100) => {
+5 -1
View File
@@ -1,4 +1,8 @@
function truncateSummaryText(value: unknown, maxLength = 500) {
export const HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS = 500;
export const HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS = 4_096;
export const HEARTBEAT_RUN_SAFE_RESULT_JSON_MAX_BYTES = 64 * 1024;
function truncateSummaryText(value: unknown, maxLength = HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS) {
if (typeof value !== "string") return null;
return value.length > maxLength ? value.slice(0, maxLength) : value;
}
+267 -21
View File
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import { and, asc, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
import { and, asc, desc, eq, getTableColumns, gt, inArray, isNull, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import type { BillingType, ExecutionWorkspace, ExecutionWorkspaceConfig } from "@paperclipai/shared";
import {
@@ -35,8 +35,10 @@ import { secretService } from "./secrets.js";
import { resolveDefaultAgentWorkspaceDir, resolveManagedProjectWorkspaceDir } from "../home-paths.js";
import {
buildHeartbeatRunIssueComment,
HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS,
HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS,
HEARTBEAT_RUN_SAFE_RESULT_JSON_MAX_BYTES,
mergeHeartbeatRunResultJson,
summarizeHeartbeatRunResultJson,
} from "./heartbeat-run-summary.js";
import { logActivity, type LogActivityInput } from "./activity-log.js";
import {
@@ -378,7 +380,6 @@ const heartbeatRunListColumns = {
exitCode: heartbeatRuns.exitCode,
signal: heartbeatRuns.signal,
usageJson: heartbeatRuns.usageJson,
resultJson: heartbeatRuns.resultJson,
sessionIdBefore: heartbeatRuns.sessionIdBefore,
sessionIdAfter: heartbeatRuns.sessionIdAfter,
logStore: heartbeatRuns.logStore,
@@ -395,11 +396,90 @@ const heartbeatRunListColumns = {
processStartedAt: heartbeatRuns.processStartedAt,
retryOfRunId: heartbeatRuns.retryOfRunId,
processLossRetryCount: heartbeatRuns.processLossRetryCount,
contextSnapshot: heartbeatRuns.contextSnapshot,
createdAt: heartbeatRuns.createdAt,
updatedAt: heartbeatRuns.updatedAt,
} as const;
const heartbeatRunListContextColumns = {
contextIssueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("contextIssueId"),
contextTaskId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'taskId'`.as("contextTaskId"),
contextTaskKey: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'taskKey'`.as("contextTaskKey"),
contextCommentId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"),
contextWakeCommentId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`.as("contextWakeCommentId"),
contextWakeReason: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'wakeReason'`.as("contextWakeReason"),
contextWakeSource: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'wakeSource'`.as("contextWakeSource"),
contextWakeTriggerDetail: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'wakeTriggerDetail'`.as("contextWakeTriggerDetail"),
} as const;
const heartbeatRunListResultColumns = {
resultSummary: sql<string | null>`left(${heartbeatRuns.resultJson} ->> 'summary', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as("resultSummary"),
resultResult: sql<string | null>`left(${heartbeatRuns.resultJson} ->> 'result', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as("resultResult"),
resultMessage: sql<string | null>`left(${heartbeatRuns.resultJson} ->> 'message', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as("resultMessage"),
resultError: sql<string | null>`left(${heartbeatRuns.resultJson} ->> 'error', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as("resultError"),
resultTotalCostUsd: sql<string | null>`${heartbeatRuns.resultJson} ->> 'total_cost_usd'`.as("resultTotalCostUsd"),
resultCostUsd: sql<string | null>`${heartbeatRuns.resultJson} ->> 'cost_usd'`.as("resultCostUsd"),
resultCostUsdCamel: sql<string | null>`${heartbeatRuns.resultJson} ->> 'costUsd'`.as("resultCostUsdCamel"),
} as const;
const heartbeatRunSafeResultJsonColumn = sql<Record<string, unknown> | null>`
case
when ${heartbeatRuns.resultJson} is null then null
when pg_column_size(${heartbeatRuns.resultJson}) <= ${HEARTBEAT_RUN_SAFE_RESULT_JSON_MAX_BYTES}
then ${heartbeatRuns.resultJson}
else jsonb_strip_nulls(
jsonb_build_object(
'summary', left(${heartbeatRuns.resultJson} ->> 'summary', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}),
'result', left(${heartbeatRuns.resultJson} ->> 'result', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}),
'message', left(${heartbeatRuns.resultJson} ->> 'message', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}),
'error', left(${heartbeatRuns.resultJson} ->> 'error', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}),
'stdout', left(${heartbeatRuns.resultJson} ->> 'stdout', ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}),
'stderr', left(${heartbeatRuns.resultJson} ->> 'stderr', ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}),
'stdoutTruncated', case
when length(${heartbeatRuns.resultJson} ->> 'stdout') > ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}
then to_jsonb(true)
else null
end,
'stderrTruncated', case
when length(${heartbeatRuns.resultJson} ->> 'stderr') > ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}
then to_jsonb(true)
else null
end,
'costUsd', coalesce(
${heartbeatRuns.resultJson} -> 'costUsd',
${heartbeatRuns.resultJson} -> 'cost_usd',
${heartbeatRuns.resultJson} -> 'total_cost_usd'
),
'cost_usd', coalesce(
${heartbeatRuns.resultJson} -> 'cost_usd',
${heartbeatRuns.resultJson} -> 'costUsd',
${heartbeatRuns.resultJson} -> 'total_cost_usd'
),
'total_cost_usd', coalesce(
${heartbeatRuns.resultJson} -> 'total_cost_usd',
${heartbeatRuns.resultJson} -> 'cost_usd',
${heartbeatRuns.resultJson} -> 'costUsd'
),
'truncated', true,
'truncationReason', 'oversized_result_json',
'originalSizeBytes', pg_column_size(${heartbeatRuns.resultJson})
)
)
end
`.as("resultJson");
const heartbeatRunSafeColumns = {
...getTableColumns(heartbeatRuns),
processGroupId: heartbeatRunProcessGroupIdColumn,
resultJson: heartbeatRunSafeResultJsonColumn,
} as const;
const heartbeatRunLogAccessColumns = {
id: heartbeatRuns.id,
companyId: heartbeatRuns.companyId,
logStore: heartbeatRuns.logStore,
logRef: heartbeatRuns.logRef,
} as const;
const heartbeatRunIssueSummaryColumns = {
id: heartbeatRuns.id,
status: heartbeatRuns.status,
@@ -519,6 +599,87 @@ function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
export function summarizeHeartbeatRunContextSnapshot(
contextSnapshot: Record<string, unknown> | null | undefined,
): Record<string, unknown> | null {
const summary: Record<string, unknown> = {};
const allowedKeys = [
"issueId",
"taskId",
"taskKey",
"commentId",
"wakeCommentId",
"wakeReason",
"wakeSource",
"wakeTriggerDetail",
] as const;
for (const key of allowedKeys) {
const value = readNonEmptyString(contextSnapshot?.[key]);
if (value) summary[key] = value;
}
return Object.keys(summary).length > 0 ? summary : null;
}
export function summarizeHeartbeatRunListResultJson(input: {
summary?: string | null;
result?: string | null;
message?: string | null;
error?: string | null;
totalCostUsd?: string | null;
costUsd?: string | null;
costUsdCamel?: string | null;
}): Record<string, unknown> | null {
const summary: Record<string, unknown> = {};
for (const [key, value] of [
["summary", input.summary],
["result", input.result],
["message", input.message],
["error", input.error],
] as const) {
const normalized = readNonEmptyString(value);
if (normalized) summary[key] = normalized;
}
for (const [key, value] of [
["total_cost_usd", input.totalCostUsd],
["cost_usd", input.costUsd],
["costUsd", input.costUsdCamel],
] as const) {
const normalized = readNonEmptyString(value);
if (!normalized) continue;
const parsed = Number(normalized);
if (Number.isFinite(parsed)) summary[key] = parsed;
}
return Object.keys(summary).length > 0 ? summary : null;
}
function summarizeRunFailureForIssueComment(
run: Pick<typeof heartbeatRuns.$inferSelect, "error" | "errorCode"> | null | undefined,
) {
if (!run) return null;
const errorCode = readNonEmptyString(run.errorCode)?.trim() ?? null;
const rawError = readNonEmptyString(run.error)?.trim() ?? null;
const apiMessageMatch = rawError?.match(/"message"\s*:\s*"([^"]+)"/);
const firstLine = rawError
?.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? null;
const summarySource = apiMessageMatch?.[1] ?? firstLine;
const summary =
summarySource && summarySource.length > 240
? `${summarySource.slice(0, 237)}...`
: summarySource;
if (errorCode && summary) return ` Latest retry failure: \`${errorCode}\` - ${summary}.`;
if (errorCode) return ` Latest retry failure: \`${errorCode}\`.`;
if (summary) return ` Latest retry failure: ${summary}.`;
return null;
}
function normalizeLedgerBillingType(value: unknown): BillingType {
const raw = readNonEmptyString(value);
switch (raw) {
@@ -1351,9 +1512,17 @@ export function heartbeatService(db: Db) {
.then((rows) => rows[0] ?? null);
}
async function getRun(runId: string) {
async function getRun(runId: string, opts?: { unsafeFullResultJson?: boolean }) {
return db
.select()
.select(opts?.unsafeFullResultJson ? getTableColumns(heartbeatRuns) : heartbeatRunSafeColumns)
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
}
async function getRunLogAccess(runId: string) {
return db
.select(heartbeatRunLogAccessColumns)
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
@@ -1421,7 +1590,10 @@ export function heartbeatService(db: Db) {
conditions.push(sql`${heartbeatRuns.id} <> ${opts.excludeRunId}`);
}
return db
.select()
.select({
id: heartbeatRuns.id,
usageJson: heartbeatRuns.usageJson,
})
.from(heartbeatRuns)
.where(and(...conditions))
.orderBy(desc(heartbeatRuns.createdAt))
@@ -1497,8 +1669,8 @@ export function heartbeatService(db: Db) {
id: heartbeatRuns.id,
createdAt: heartbeatRuns.createdAt,
usageJson: heartbeatRuns.usageJson,
resultJson: heartbeatRuns.resultJson,
error: heartbeatRuns.error,
...heartbeatRunListResultColumns,
})
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.agentId, agent.id), eq(heartbeatRuns.sessionIdAfter, sessionId)))
@@ -1552,7 +1724,15 @@ export function heartbeatService(db: Db) {
};
}
const latestSummary = summarizeHeartbeatRunResultJson(latestRun.resultJson);
const latestSummary = summarizeHeartbeatRunListResultJson({
summary: latestRun?.resultSummary,
result: latestRun?.resultResult,
message: latestRun?.resultMessage,
error: latestRun?.resultError,
totalCostUsd: latestRun?.resultTotalCostUsd,
costUsd: latestRun?.resultCostUsd,
costUsdCamel: latestRun?.resultCostUsdCamel,
});
const latestTextSummary =
readNonEmptyString(latestSummary?.summary) ??
readNonEmptyString(latestSummary?.result) ??
@@ -2681,7 +2861,13 @@ export function heartbeatService(db: Db) {
async function getLatestIssueRun(companyId: string, issueId: string) {
return db
.select()
.select({
id: heartbeatRuns.id,
status: heartbeatRuns.status,
error: heartbeatRuns.error,
errorCode: heartbeatRuns.errorCode,
contextSnapshot: heartbeatRuns.contextSnapshot,
})
.from(heartbeatRuns)
.where(
and(
@@ -2771,7 +2957,10 @@ export function heartbeatService(db: Db) {
async function escalateStrandedAssignedIssue(input: {
issue: typeof issues.$inferSelect;
previousStatus: "todo" | "in_progress";
latestRun: typeof heartbeatRuns.$inferSelect | null;
latestRun: Pick<
typeof heartbeatRuns.$inferSelect,
"id" | "status" | "error" | "errorCode" | "contextSnapshot"
> | null;
comment: string;
}) {
const updated = await issuesSvc.update(input.issue.id, {
@@ -2857,13 +3046,15 @@ export function heartbeatService(db: Db) {
}
if (latestRetryReason === "assignment_recovery") {
const failureSummary = summarizeRunFailureForIssueComment(latestRun);
const updated = await escalateStrandedAssignedIssue({
issue,
previousStatus: "todo",
latestRun,
comment:
"Paperclip automatically retried dispatch for this assigned `todo` issue after a lost wake/run, " +
"but it still has no live execution path. Moving it to `blocked` so it is visible for intervention.",
`but it still has no live execution path.${failureSummary ?? ""} ` +
"Moving it to `blocked` so it is visible for intervention.",
});
if (updated) {
result.escalated += 1;
@@ -2892,14 +3083,15 @@ export function heartbeatService(db: Db) {
}
if (latestRetryReason === "issue_continuation_needed") {
const failureSummary = summarizeRunFailureForIssueComment(latestRun);
const updated = await escalateStrandedAssignedIssue({
issue,
previousStatus: "in_progress",
latestRun,
comment:
"Paperclip automatically retried continuation for this assigned `in_progress` issue after its live " +
"execution disappeared, but it still has no live execution path. Moving it to `blocked` so it is " +
"visible for intervention.",
`execution disappeared, but it still has no live execution path.${failureSummary ?? ""} ` +
"Moving it to `blocked` so it is visible for intervention.",
});
if (updated) {
result.escalated += 1;
@@ -4940,7 +5132,11 @@ export function heartbeatService(db: Db) {
return {
list: async (companyId: string, agentId?: string, limit?: number) => {
const query = db
.select(heartbeatRunListColumns)
.select({
...heartbeatRunListColumns,
...heartbeatRunListContextColumns,
...heartbeatRunListResultColumns,
})
.from(heartbeatRuns)
.where(
agentId
@@ -4950,14 +5146,55 @@ export function heartbeatService(db: Db) {
.orderBy(desc(heartbeatRuns.createdAt));
const rows = limit ? await query.limit(limit) : await query;
return rows.map((row) => ({
...row,
resultJson: summarizeHeartbeatRunResultJson(row.resultJson),
}));
return rows.map((row) => {
const {
contextIssueId,
contextTaskId,
contextTaskKey,
contextCommentId,
contextWakeCommentId,
contextWakeReason,
contextWakeSource,
contextWakeTriggerDetail,
resultSummary,
resultResult,
resultMessage,
resultError,
resultTotalCostUsd,
resultCostUsd,
resultCostUsdCamel,
...rest
} = row;
return {
...rest,
contextSnapshot: summarizeHeartbeatRunContextSnapshot({
issueId: contextIssueId,
taskId: contextTaskId,
taskKey: contextTaskKey,
commentId: contextCommentId,
wakeCommentId: contextWakeCommentId,
wakeReason: contextWakeReason,
wakeSource: contextWakeSource,
wakeTriggerDetail: contextWakeTriggerDetail,
}),
resultJson: summarizeHeartbeatRunListResultJson({
summary: resultSummary,
result: resultResult,
message: resultMessage,
error: resultError,
totalCostUsd: resultTotalCostUsd,
costUsd: resultCostUsd,
costUsdCamel: resultCostUsdCamel,
}),
};
});
},
getRun,
getRunLogAccess,
getRuntimeState: async (agentId: string) => {
const state = await getRuntimeState(agentId);
const agent = await getAgent(agentId);
@@ -5031,8 +5268,17 @@ export function heartbeatService(db: Db) {
.orderBy(asc(heartbeatRunEvents.seq))
.limit(Math.max(1, Math.min(limit, 1000))),
readLog: async (runId: string, opts?: { offset?: number; limitBytes?: number }) => {
const run = await getRun(runId);
readLog: async (
runOrLookup: string | {
id: string;
companyId: string;
logStore: string | null;
logRef: string | null;
},
opts?: { offset?: number; limitBytes?: number },
) => {
const run = typeof runOrLookup === "string" ? await getRunLogAccess(runOrLookup) : runOrLookup;
const runId = typeof runOrLookup === "string" ? runOrLookup : runOrLookup.id;
if (!run) throw notFound("Heartbeat run not found");
if (!run.logStore || !run.logRef) throw notFound("Run log not found");
+68 -14
View File
@@ -79,6 +79,7 @@ export interface IssueFilters {
originKind?: string;
originId?: string;
includeRoutineExecutions?: boolean;
excludeRoutineExecutions?: boolean;
q?: string;
limit?: number;
}
@@ -131,6 +132,7 @@ function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
}
const TERMINAL_HEARTBEAT_RUN_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out"]);
const ISSUE_LIST_DESCRIPTION_MAX_CHARS = 1200;
function escapeLikePattern(value: string): string {
return value.replace(/[\\%_]/g, "\\$&");
@@ -526,6 +528,51 @@ async function activeRunMapForIssues(
return map;
}
const issueListSelect = {
id: issues.id,
companyId: issues.companyId,
projectId: issues.projectId,
projectWorkspaceId: issues.projectWorkspaceId,
goalId: issues.goalId,
parentId: issues.parentId,
title: issues.title,
description: sql<string | null>`
CASE
WHEN ${issues.description} IS NULL THEN NULL
ELSE substring(${issues.description} FROM 1 FOR ${ISSUE_LIST_DESCRIPTION_MAX_CHARS})
END
`,
status: issues.status,
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
executionAgentNameKey: issues.executionAgentNameKey,
executionLockedAt: issues.executionLockedAt,
createdByAgentId: issues.createdByAgentId,
createdByUserId: issues.createdByUserId,
issueNumber: issues.issueNumber,
identifier: issues.identifier,
originKind: issues.originKind,
originId: issues.originId,
originRunId: issues.originRunId,
requestDepth: issues.requestDepth,
billingCode: issues.billingCode,
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
executionPolicy: sql<null>`null`,
executionState: sql<null>`null`,
executionWorkspaceId: issues.executionWorkspaceId,
executionWorkspacePreference: issues.executionWorkspacePreference,
executionWorkspaceSettings: sql<null>`null`,
startedAt: issues.startedAt,
completedAt: issues.completedAt,
cancelledAt: issues.cancelledAt,
hiddenAt: issues.hiddenAt,
createdAt: issues.createdAt,
updatedAt: issues.updatedAt,
};
function withActiveRuns(
issueRows: IssueWithLabels[],
runMap: Map<string, IssueActiveRunRow>,
@@ -985,7 +1032,7 @@ export function issueService(db: Db) {
)!,
);
}
if (!filters?.includeRoutineExecutions && !filters?.originKind && !filters?.originId) {
if (filters?.excludeRoutineExecutions && !filters?.originKind && !filters?.originId) {
conditions.push(ne(issues.originKind, "routine_execution"));
}
conditions.push(isNull(issues.hiddenAt));
@@ -1004,7 +1051,7 @@ export function issueService(db: Db) {
`;
const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId);
const baseQuery = db
.select()
.select(issueListSelect)
.from(issues)
.where(and(...conditions))
.orderBy(
@@ -1162,7 +1209,6 @@ export function issueService(db: Db) {
eq(issues.companyId, companyId),
isNull(issues.hiddenAt),
unreadForUserCondition(companyId, userId),
ne(issues.originKind, "routine_execution"),
];
if (status) {
const statuses = status.split(",").map((s) => s.trim()).filter(Boolean);
@@ -2350,7 +2396,10 @@ export function issueService(db: Db) {
return [...resolved];
},
findMentionedProjectIds: async (issueId: string) => {
findMentionedProjectIds: async (
issueId: string,
opts?: { includeCommentBodies?: boolean },
) => {
const issue = await db
.select({
companyId: issues.companyId,
@@ -2362,21 +2411,26 @@ export function issueService(db: Db) {
.then((rows) => rows[0] ?? null);
if (!issue) return [];
const comments = await db
.select({ body: issueComments.body })
.from(issueComments)
.where(eq(issueComments.issueId, issueId));
const mentionedIds = new Set<string>();
for (const source of [
issue.title,
issue.description ?? "",
...comments.map((comment) => comment.body),
]) {
for (const source of [issue.title, issue.description ?? ""]) {
for (const projectId of extractProjectMentionIds(source)) {
mentionedIds.add(projectId);
}
}
if (opts?.includeCommentBodies !== false) {
const comments = await db
.select({ body: issueComments.body })
.from(issueComments)
.where(eq(issueComments.issueId, issueId));
for (const comment of comments) {
for (const projectId of extractProjectMentionIds(comment.body)) {
mentionedIds.add(projectId);
}
}
}
if (mentionedIds.size === 0) return [];
const rows = await db
+2
View File
@@ -921,6 +921,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
kind: trigger.kind as RoutineListItem["triggers"][number]["kind"],
label: trigger.label,
enabled: trigger.enabled,
cronExpression: trigger.cronExpression,
timezone: trigger.timezone,
nextRunAt: trigger.nextRunAt,
lastFiredAt: trigger.lastFiredAt,
lastResult: trigger.lastResult,