mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 10:30:37 +09:00
[codex] harden authenticated routes and issue editor reliability (#3741)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The control plane depends on authenticated routes enforcing company boundaries and role permissions correctly > - This branch also touches the issue detail and markdown editing flows operators use while handling advisory and triage work > - Partial issue cache seeds and fragile rich-editor parsing could leave important issue content missing or blank at the moment an operator needed it > - Blocked issues becoming actionable again should wake their assignee automatically instead of silently staying idle > - This pull request rebases the advisory follow-up branch onto current `master`, hardens authenticated route authorization, and carries the issue-detail/editor reliability fixes forward with regression tests > - The benefit is tighter authz on sensitive routes plus more reliable issue/advisory editing and wakeup behavior on top of the latest base ## What Changed - Hardened authenticated route authorization across agent, activity, approval, access, project, plugin, health, execution-workspace, portability, and related server paths, with new cross-tenant and runtime-authz regression coverage. - Switched issue detail queries from `initialData` to placeholder-based hydration so list/quicklook seeds still refetch full issue bodies. - Normalized advisory-style HTML images before mounting the markdown editor and strengthened fallback behavior when the rich editor silently fails or rejects the content. - Woke assigned agents when blocked issues move back to `todo`, with route coverage for reopen and unblock transitions. - Rebasing note: this branch now sits cleanly on top of the latest `master` tip used for the PR base. ## Verification - `pnpm exec vitest run ui/src/lib/issueDetailQuery.test.tsx ui/src/components/MarkdownEditor.test.tsx server/src/__tests__/issue-comment-reopen-routes.test.ts server/src/__tests__/activity-routes.test.ts server/src/__tests__/agent-cross-tenant-authz-routes.test.ts` - Confirmed `pnpm-lock.yaml` is not part of the PR diff. - Rebased the branch onto current `public-gh/master` before publishing. ## Risks - Broad authz tightening may expose existing flows that were relying on permissive board or agent access and now need explicit grants. - Markdown editor fallback changes could affect focus or rendering in edge-case content that mixes HTML-like advisory markup with normal markdown. - This verification was intentionally scoped to touched regressions and did not run the full repository suite. ## Model Used - OpenAI Codex, GPT-5-based coding agent in the Codex CLI environment with tool use for terminal, git, and GitHub operations. The exact runtime model identifier is not exposed inside this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, it is behavior-only and does not need before/after screenshots - [x] I have updated relevant documentation to reflect my changes, or no documentation changes were needed for these internal fixes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
50cd76d8a3
commit
32a9165ddf
39 changed files with 3014 additions and 153 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
267
server/src/__tests__/agent-cross-tenant-authz-routes.test.ts
Normal file
267
server/src/__tests__/agent-cross-tenant-authz-routes.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
32
server/src/__tests__/app-private-hostname-gate.test.ts
Normal file
32
server/src/__tests__/app-private-hostname-gate.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
},
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,7 +34,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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
165
server/src/__tests__/issue-workspace-command-authz.test.ts
Normal file
165
server/src/__tests__/issue-workspace-command-authz.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
168
server/src/__tests__/plugin-routes-authz.test.ts
Normal file
168
server/src/__tests__/plugin-routes-authz.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
437
server/src/__tests__/workspace-runtime-routes-authz.test.ts
Normal file
437
server/src/__tests__/workspace-runtime-routes-authz.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
279
server/src/__tests__/workspace-runtime-service-authz.test.ts
Normal file
279
server/src/__tests__/workspace-runtime-service-authz.test.ts
Normal file
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue