mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
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:
commit
ea7066709b
121 files changed
+6172
-660
No files matched your search
@@ -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,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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user