Merge upstream/master (53 commits) into local
Build: Production / build (push) Failing after 13m4s

Resolved conflicts:
- ui CompanySettingsSidebar.tsx: keep both Secrets (local) and Cloud upstream (master) nav items
- ui CompanySettingsNav.tsx + test: take master's cloud-upstream/members (drops deprecated `access` tab now consolidated into `members`)
- server plugin-worker-manager.ts: take master's 15min RPC timeout cap
- pnpm-lock.yaml: regenerated via `pnpm install` against merged package.json files

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 08:01:31 -04:00
536 changed files with 60296 additions and 2542 deletions
+1
View File
@@ -50,6 +50,7 @@
"@paperclipai/adapter-cursor-cloud": "workspace:*",
"@paperclipai/adapter-cursor-local": "workspace:*",
"@paperclipai/adapter-gemini-local": "workspace:*",
"@paperclipai/adapter-grok-local": "workspace:*",
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
"@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*",
@@ -0,0 +1,167 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
companies,
companyMemberships,
createDb,
principalPermissionGrants,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
vi.hoisted(() => {
process.env.PAPERCLIP_HOME = "/tmp/paperclip-test-home";
process.env.PAPERCLIP_INSTANCE_ID = "vitest";
process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs";
process.env.PAPERCLIP_IN_WORKTREE = "false";
});
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
type Db = ReturnType<typeof createDb>;
async function createApp(db: Db, companyId: string, userId: string) {
process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs";
process.env.PAPERCLIP_IN_WORKTREE = "false";
const { accessRoutes } = await import("../routes/access.js");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = {
type: "board",
userId,
source: "local_implicit",
companyIds: [companyId],
memberships: [{ companyId, membershipRole: "owner", status: "active" }],
isInstanceAdmin: true,
};
next();
});
app.use("/api", accessRoutes(db, {
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
}));
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(err.status ?? 500).json({ error: err.message ?? "Internal server error" });
});
return app;
}
async function createCompanyWithOwner(db: Db) {
const company = await db
.insert(companies)
.values({
name: `Access Routes ${randomUUID()}`,
issuePrefix: `AR${randomUUID().replace(/-/g, "").slice(0, 6).toUpperCase()}`,
})
.returning()
.then((rows) => rows[0]!);
const owner = await db
.insert(companyMemberships)
.values({
companyId: company.id,
principalType: "user",
principalId: `owner-${randomUUID()}`,
status: "active",
membershipRole: "owner",
})
.returning()
.then((rows) => rows[0]!);
return { company, owner };
}
describeEmbeddedPostgres("access routes permissions upgrade compatibility", () => {
let db!: Db;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-access-routes-permissions-upgrade-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(principalPermissionGrants);
await db.delete(companyMemberships);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("rejects owner self-lockout through the member route after the permissions upgrade", async () => {
const { company, owner } = await createCompanyWithOwner(db);
const res = await request(await createApp(db, company.id, owner.principalId))
.patch(`/api/companies/${company.id}/members/${owner.id}`)
.send({ membershipRole: "admin" });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toContain("You cannot remove yourself");
const unchanged = await db
.select()
.from(companyMemberships)
.where(eq(companyMemberships.id, owner.id))
.then((rows) => rows[0]!);
expect(unchanged.membershipRole).toBe("owner");
}, 10_000);
it("keeps custom grants when the role-only member route changes a member role", async () => {
const { company, owner } = await createCompanyWithOwner(db);
const member = await db
.insert(companyMemberships)
.values({
companyId: company.id,
principalType: "user",
principalId: `admin-${randomUUID()}`,
status: "active",
membershipRole: "admin",
})
.returning()
.then((rows) => rows[0]!);
const customScope = { projectIds: ["project-1"] };
await db.insert(principalPermissionGrants).values({
companyId: company.id,
principalType: "user",
principalId: member.principalId,
permissionKey: "tasks:assign_scope",
scope: customScope,
grantedByUserId: owner.principalId,
});
const res = await request(await createApp(db, company.id, owner.principalId))
.patch(`/api/companies/${company.id}/members/${member.id}`)
.send({ membershipRole: "operator" });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body.membershipRole).toBe("operator");
const grants = await db
.select()
.from(principalPermissionGrants)
.where(
and(
eq(principalPermissionGrants.companyId, company.id),
eq(principalPermissionGrants.principalType, "user"),
eq(principalPermissionGrants.principalId, member.principalId),
),
);
expect(grants).toHaveLength(1);
expect(grants[0]).toMatchObject({
permissionKey: "tasks:assign_scope",
scope: customScope,
grantedByUserId: owner.principalId,
});
});
});
+286 -1
View File
@@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
companies,
companyMemberships,
createDb,
@@ -14,6 +15,8 @@ import {
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { accessService } from "../services/access.js";
import { grantsForHumanRole } from "../services/company-member-roles.js";
import { backfillPrincipalAccessCompatibility } from "../services/principal-access-compatibility.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -56,6 +59,7 @@ describeEmbeddedPostgres("access service", () => {
await db.delete(issues);
await db.delete(principalPermissionGrants);
await db.delete(instanceUserRoles);
await db.delete(agents);
await db.delete(companyMemberships);
await db.delete(companies);
});
@@ -221,4 +225,285 @@ describeEmbeddedPostgres("access service", () => {
access.setUserCompanyAccess(operator.principalId, [], { actorUserId: owner.principalId }),
).rejects.toThrow("Instance admins cannot be removed from company access");
});
it("allows owner and admin role-default grants to manage environments", async () => {
const { company, owner } = await createCompanyWithOwner(db);
const access = accessService(db);
const roles = ["admin", "operator", "viewer"] as const;
const members = await db
.insert(companyMemberships)
.values(
roles.map((role) => ({
companyId: company.id,
principalType: "user" as const,
principalId: `${role}-${randomUUID()}`,
status: "active" as const,
membershipRole: role,
})),
)
.returning();
await access.setPrincipalGrants(
company.id,
"user",
owner.principalId,
grantsForHumanRole("owner"),
owner.principalId,
);
for (const member of members) {
await access.setPrincipalGrants(
company.id,
"user",
member.principalId,
grantsForHumanRole(member.membershipRole as "admin" | "operator" | "viewer"),
owner.principalId,
);
}
const admin = members.find((member) => member.membershipRole === "admin")!;
const operator = members.find((member) => member.membershipRole === "operator")!;
const viewer = members.find((member) => member.membershipRole === "viewer")!;
await expect(access.canUser(company.id, owner.principalId, "environments:manage")).resolves.toBe(true);
await expect(access.canUser(company.id, admin.principalId, "environments:manage")).resolves.toBe(true);
await expect(access.canUser(company.id, operator.principalId, "environments:manage")).resolves.toBe(false);
await expect(access.canUser(company.id, viewer.principalId, "environments:manage")).resolves.toBe(false);
});
it("backfills pre-upgrade human memberships with missing role grants without replacing custom grants", async () => {
const { company, owner } = await createCompanyWithOwner(db);
const scopedEnvironmentGrant = { environmentId: "env-1" };
const humanRows = await db
.insert(companyMemberships)
.values([
{
companyId: company.id,
principalType: "user",
principalId: `admin-${randomUUID()}`,
status: "active",
membershipRole: "admin",
},
{
companyId: company.id,
principalType: "user",
principalId: `operator-${randomUUID()}`,
status: "active",
membershipRole: "operator",
},
{
companyId: company.id,
principalType: "user",
principalId: `viewer-${randomUUID()}`,
status: "active",
membershipRole: "viewer",
},
{
companyId: company.id,
principalType: "user",
principalId: `legacy-${randomUUID()}`,
status: "active",
membershipRole: null,
},
])
.returning();
const admin = humanRows[0]!;
const operator = humanRows[1]!;
const viewer = humanRows[2]!;
const legacyMember = humanRows[3]!;
await db.insert(principalPermissionGrants).values({
companyId: company.id,
principalType: "user",
principalId: owner.principalId,
permissionKey: "environments:manage",
scope: scopedEnvironmentGrant,
grantedByUserId: "custom-author",
});
const first = await backfillPrincipalAccessCompatibility(db);
const second = await backfillPrincipalAccessCompatibility(db);
expect(first.humanGrantsInserted).toBeGreaterThan(0);
expect(second.humanGrantsInserted).toBe(0);
await expect(accessService(db).canUser(company.id, admin.principalId, "environments:manage")).resolves.toBe(true);
await expect(accessService(db).canUser(company.id, operator.principalId, "tasks:assign")).resolves.toBe(true);
await expect(accessService(db).canUser(company.id, legacyMember.principalId, "tasks:assign")).resolves.toBe(true);
await expect(accessService(db).canUser(company.id, viewer.principalId, "tasks:assign")).resolves.toBe(false);
const ownerEnvironmentGrants = await db
.select()
.from(principalPermissionGrants)
.where(
and(
eq(principalPermissionGrants.companyId, company.id),
eq(principalPermissionGrants.principalId, owner.principalId),
eq(principalPermissionGrants.permissionKey, "environments:manage"),
),
);
expect(ownerEnvironmentGrants).toHaveLength(1);
expect(ownerEnvironmentGrants[0]?.scope).toEqual(scopedEnvironmentGrant);
expect(ownerEnvironmentGrants[0]?.grantedByUserId).toBe("custom-author");
});
it("backfills non-terminal agents as active company members without reviving pending or terminated agents", async () => {
const { company } = await createCompanyWithOwner(db);
const agentRows = await db
.insert(agents)
.values([
{
companyId: company.id,
name: `Idle ${randomUUID()}`,
role: "engineer",
status: "idle",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
},
{
companyId: company.id,
name: `Running ${randomUUID()}`,
role: "engineer",
status: "running",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
},
{
companyId: company.id,
name: `Pending ${randomUUID()}`,
role: "engineer",
status: "pending_approval",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
},
{
companyId: company.id,
name: `Terminated ${randomUUID()}`,
role: "engineer",
status: "terminated",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
},
])
.returning();
const idleAgent = agentRows[0]!;
const runningAgent = agentRows[1]!;
const pendingAgent = agentRows[2]!;
const terminatedAgent = agentRows[3]!;
const first = await backfillPrincipalAccessCompatibility(db);
const second = await backfillPrincipalAccessCompatibility(db);
expect(first.agentMembershipsInserted).toBe(2);
expect(second.agentMembershipsInserted).toBe(0);
const memberships = await db
.select()
.from(companyMemberships)
.where(eq(companyMemberships.principalType, "agent"));
expect(memberships.map((membership) => membership.principalId).sort()).toEqual([
idleAgent.id,
runningAgent.id,
].sort());
expect(memberships.every((membership) => membership.status === "active")).toBe(true);
expect(memberships.every((membership) => membership.membershipRole === "member")).toBe(true);
expect(memberships.some((membership) => membership.principalId === pendingAgent.id)).toBe(false);
expect(memberships.some((membership) => membership.principalId === terminatedAgent.id)).toBe(false);
});
it("copies active user memberships with role-default grants for safe company imports", async () => {
const source = await createCompanyWithOwner(db);
const target = await createCompanyWithOwner(db);
const admin = await db
.insert(companyMemberships)
.values({
companyId: source.company.id,
principalType: "user",
principalId: `admin-${randomUUID()}`,
status: "active",
membershipRole: "admin",
})
.returning()
.then((rows) => rows[0]!);
const access = accessService(db);
await access.copyActiveUserMemberships(source.company.id, target.company.id);
const copiedOwnerGrants = await access.listPrincipalGrants(
target.company.id,
"user",
source.owner.principalId,
);
const copiedAdminGrants = await access.listPrincipalGrants(
target.company.id,
"user",
admin.principalId,
);
expect(copiedOwnerGrants.map((grant) => grant.permissionKey)).toEqual(
grantsForHumanRole("owner").map((grant) => grant.permissionKey).sort(),
);
expect(copiedAdminGrants.map((grant) => grant.permissionKey)).toEqual(
grantsForHumanRole("admin").map((grant) => grant.permissionKey).sort(),
);
});
it("preserves explicit scoped environment grants when backfilling owner and admin defaults", async () => {
const { company, owner } = await createCompanyWithOwner(db);
const scopedGrant = { environmentId: "env-1" };
await db.insert(principalPermissionGrants).values({
companyId: company.id,
principalType: "user",
principalId: owner.principalId,
permissionKey: "environments:manage",
scope: scopedGrant,
grantedByUserId: "custom-grant-author",
});
await db.execute(sql.raw(`
INSERT INTO "principal_permission_grants" (
"company_id",
"principal_type",
"principal_id",
"permission_key",
"scope",
"granted_by_user_id",
"created_at",
"updated_at"
)
SELECT
"company_id",
'user',
"principal_id",
'environments:manage',
NULL,
NULL,
now(),
now()
FROM "company_memberships"
WHERE "principal_type" = 'user'
AND "status" = 'active'
AND "membership_role" IN ('owner', 'admin')
ON CONFLICT (
"company_id",
"principal_type",
"principal_id",
"permission_key"
) DO NOTHING
`));
const grants = await db
.select()
.from(principalPermissionGrants)
.where(
and(
eq(principalPermissionGrants.companyId, company.id),
eq(principalPermissionGrants.principalId, owner.principalId),
eq(principalPermissionGrants.permissionKey, "environments:manage"),
),
);
expect(grants).toHaveLength(1);
expect(grants[0]?.scope).toEqual(scopedGrant);
expect(grants[0]?.grantedByUserId).toBe("custom-grant-author");
});
});
@@ -574,7 +574,7 @@ describe("acpx_local execute", () => {
const execute = createAcpxLocalExecutor({ createRuntime: () => runtime });
const result = await execute(buildContext(root));
expect(result.exitCode).toBe(1);
expect(result.errorCode).toBe("acpx_protocol_error");
expect(result.errorCode).toBe("acpx_session_init_failed");
expect(result.errorMeta).toMatchObject({
category: "protocol",
acpCode: "ACP_SESSION_INIT_FAILED",
@@ -174,6 +174,15 @@ describe("adapter routes", () => {
expect(cursorAdapter.capabilities.requiresMaterializedRuntimeSkills).toBe(true);
expect(cursorAdapter.capabilities.supportsInstructionsBundle).toBe(true);
const grokAdapter = res.body.find((a: any) => a.type === "grok_local");
expect(grokAdapter).toBeDefined();
expect(grokAdapter.capabilities).toMatchObject({
supportsInstructionsBundle: true,
supportsSkills: true,
supportsLocalAgentJwt: true,
requiresMaterializedRuntimeSkills: true,
});
// hermes_local currently supports skills + local JWT, but not the managed
// instructions bundle flow because the bundled adapter does not consume
// instructionsFilePath at runtime.
@@ -10,6 +10,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
ensureMembership: vi.fn(),
setPrincipalPermission: vi.fn(),
@@ -192,6 +193,11 @@ describe("agent routes adapter validation", () => {
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
mockCompanySkillService.resolveRequestedSkillKeys.mockResolvedValue([]);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.decide.mockResolvedValue({
allowed: true,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant",
});
mockAccessService.hasPermission.mockResolvedValue(true);
mockAccessService.ensureMembership.mockResolvedValue(undefined);
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
@@ -60,6 +60,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
getMembership: vi.fn(),
ensureMembership: vi.fn(),
@@ -293,6 +294,17 @@ function resetMockDefaults() {
revokedAt: new Date("2026-04-11T00:05:00.000Z"),
}));
mockAccessService.canUser.mockImplementation(async () => currentAccessCanUser);
mockAccessService.decide.mockImplementation(async (input: { actor?: { type?: string; source?: string }; action?: string }) => {
const allowed = input.actor?.type === "board" && input.actor.source === "local_implicit"
? true
: currentAccessCanUser;
return {
allowed,
action: input.action,
reason: allowed ? "allow_explicit_grant" : "deny_missing_grant",
explanation: allowed ? "Allowed by test grant." : `Missing permission: ${input.action ?? "action"}`,
};
});
mockAccessService.hasPermission.mockImplementation(async () => false);
mockAccessService.getMembership.mockImplementation(async () => null);
mockAccessService.listPrincipalGrants.mockImplementation(async () => []);
@@ -21,6 +21,7 @@ const mockAgentInstructionsService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
}));
@@ -175,6 +176,11 @@ describe("agent instructions bundle routes", () => {
vi.clearAllMocks();
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
mockFindServerAdapter.mockImplementation((_type: string) => ({ type: _type }));
mockAccessService.decide.mockResolvedValue({
allowed: true,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant",
});
mockAgentService.getById.mockResolvedValue(makeAgent());
mockAgentService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeAgent(),
@@ -51,7 +51,16 @@ function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => ({}),
accessService: () => ({}),
accessService: () => ({
canUser: vi.fn(async () => true),
decide: vi.fn(async (input: { action?: string }) => ({
allowed: true,
action: input.action,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant.",
})),
hasPermission: vi.fn(async () => true),
}),
approvalService: () => ({}),
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
budgetService: () => ({}),
@@ -51,6 +51,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
getMembership: vi.fn(),
ensureMembership: vi.fn(),
@@ -302,6 +303,7 @@ describe.sequential("agent permission routes", () => {
mockAgentService.getChainOfCommand.mockReset();
mockAgentService.resolveByReference.mockReset();
mockAccessService.canUser.mockReset();
mockAccessService.decide.mockReset();
mockAccessService.hasPermission.mockReset();
mockAccessService.getMembership.mockReset();
mockAccessService.ensureMembership.mockReset();
@@ -342,6 +344,14 @@ describe.sequential("agent permission routes", () => {
mockAgentService.update.mockResolvedValue(baseAgent);
mockAgentService.updatePermissions.mockResolvedValue(baseAgent);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.decide.mockImplementation(async (input: { action?: string }) => {
const allowed = Boolean(await mockAccessService.canUser());
return {
allowed,
reason: allowed ? "allow_explicit_grant" : "deny_missing_grant",
explanation: allowed ? "Allowed by test grant" : `Missing test grant for ${input.action ?? "action"}`,
};
});
mockAccessService.hasPermission.mockResolvedValue(false);
mockAccessService.getMembership.mockResolvedValue({
id: "membership-1",
@@ -1342,6 +1352,24 @@ describe.sequential("agent permission routes", () => {
expect(res.body.access.taskAssignSource).toBe("explicit_grant");
}, 15_000);
it("reports simple-mode task assignment as enabled for active company agent members", async () => {
mockAccessService.listPrincipalGrants.mockResolvedValue([]);
const app = await createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
isInstanceAdmin: true,
companyIds: [companyId],
});
const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}`));
expect(res.status).toBe(200);
expect(res.body.access.canAssignTasks).toBe(true);
expect(res.body.access.taskAssignSource).toBe("simple_default");
}, 15_000);
it("keeps task assignment enabled when agent creation privilege is enabled", async () => {
mockAgentService.updatePermissions.mockResolvedValue({
...baseAgent,
@@ -11,6 +11,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
getMembership: vi.fn(),
listPrincipalGrants: vi.fn(),
@@ -315,6 +316,11 @@ describe.sequential("agent skill routes", () => {
);
mockLogActivity.mockResolvedValue(undefined);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.decide.mockResolvedValue({
allowed: true,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant",
});
mockAccessService.hasPermission.mockResolvedValue(true);
mockAccessService.getMembership.mockResolvedValue(null);
mockAccessService.listPrincipalGrants.mockResolvedValue([]);
@@ -10,6 +10,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
getMembership: vi.fn(async () => null),
listPrincipalGrants: vi.fn(async () => []),
@@ -120,6 +121,11 @@ describe("agent test-environment route", () => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
mockAccessService.decide.mockResolvedValue({
allowed: true,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant",
});
mockEnvironmentService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
+13 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveViteHmrPort } from "../app.ts";
import { resolveViteHmrHost, resolveViteHmrPort } from "../app.ts";
describe("resolveViteHmrPort", () => {
it("uses serverPort + 10000 when the result stays in range", () => {
@@ -17,3 +17,15 @@ describe("resolveViteHmrPort", () => {
expect(resolveViteHmrPort(9_000)).toBe(19_000);
});
});
describe("resolveViteHmrHost", () => {
it("omits wildcard bind hosts so Vite uses the browser hostname", () => {
expect(resolveViteHmrHost("0.0.0.0")).toBeUndefined();
expect(resolveViteHmrHost("::")).toBeUndefined();
});
it("keeps concrete bind hosts", () => {
expect(resolveViteHmrHost("127.0.0.1")).toBe("127.0.0.1");
expect(resolveViteHmrHost("paperclip-dev")).toBe("paperclip-dev");
});
});
@@ -0,0 +1,547 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
companies,
companyMemberships,
createDb,
instanceUserRoles,
principalPermissionGrants,
projects,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { authorizationService } from "../services/authorization.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
async function createCompany(db: ReturnType<typeof createDb>, label: string) {
return db
.insert(companies)
.values({
name: `Authorization ${label} ${randomUUID()}`,
issuePrefix: `AZ${randomUUID().slice(0, 6).toUpperCase()}`,
})
.returning()
.then((rows) => rows[0]!);
}
async function createAgent(
db: ReturnType<typeof createDb>,
companyId: string,
input: { role?: string; reportsTo?: string | null; permissions?: Record<string, unknown> } = {},
) {
return db
.insert(agents)
.values({
companyId,
name: `Agent ${randomUUID()}`,
role: input.role ?? "engineer",
reportsTo: input.reportsTo ?? null,
permissions: input.permissions ?? {},
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
})
.returning()
.then((rows) => rows[0]!);
}
async function createProject(db: ReturnType<typeof createDb>, companyId: string, label: string) {
return db
.insert(projects)
.values({
companyId,
name: `Project ${label} ${randomUUID()}`,
})
.returning()
.then((rows) => rows[0]!);
}
async function grantAgentPermission(
db: ReturnType<typeof createDb>,
companyId: string,
agentId: string,
permissionKey: "tasks:assign" | "tasks:assign_scope",
scope: Record<string, unknown> | null = null,
) {
await db.insert(companyMemberships).values({
companyId,
principalType: "agent",
principalId: agentId,
status: "active",
membershipRole: "member",
});
await db.insert(principalPermissionGrants).values({
companyId,
principalType: "agent",
principalId: agentId,
permissionKey,
scope,
grantedByUserId: null,
});
}
describeEmbeddedPostgres("authorization service", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-authorization-service-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(principalPermissionGrants);
await db.delete(companyMemberships);
await db.delete(instanceUserRoles);
await db.delete(agents);
await db.delete(projects);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("allows active user role grants and explains the grant source", async () => {
const company = await createCompany(db, "UserGrant");
const userId = `user-${randomUUID()}`;
await db.insert(companyMemberships).values({
companyId: company.id,
principalType: "user",
principalId: userId,
status: "active",
membershipRole: "operator",
});
await db.insert(principalPermissionGrants).values({
companyId: company.id,
principalType: "user",
principalId: userId,
permissionKey: "tasks:assign",
grantedByUserId: "owner",
});
const decision = await authorizationService(db).decidePrincipalGrant({
companyId: company.id,
principalType: "user",
principalId: userId,
action: "tasks:assign",
permissionKey: "tasks:assign",
});
expect(decision).toMatchObject({
allowed: true,
reason: "allow_explicit_grant",
grant: {
principalType: "user",
principalId: userId,
permissionKey: "tasks:assign",
},
});
expect(decision.explanation).toContain("Allowed by explicit grant tasks:assign");
});
it("allows agent grants for agent configuration decisions", async () => {
const company = await createCompany(db, "AgentGrant");
const actorAgent = await createAgent(db, company.id);
const targetAgent = await createAgent(db, company.id);
await db.insert(companyMemberships).values({
companyId: company.id,
principalType: "agent",
principalId: actorAgent.id,
status: "active",
membershipRole: "member",
});
await db.insert(principalPermissionGrants).values({
companyId: company.id,
principalType: "agent",
principalId: actorAgent.id,
permissionKey: "agents:create",
grantedByUserId: null,
});
const decision = await authorizationService(db).decide({
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
action: "agent_config:read",
resource: { type: "agent", companyId: company.id, agentId: targetAgent.id },
});
expect(decision.allowed).toBe(true);
expect(decision.grant?.permissionKey).toBe("agents:create");
});
it("denies cross-company agent decisions before grant evaluation", async () => {
const sourceCompany = await createCompany(db, "Source");
const targetCompany = await createCompany(db, "Target");
const actorAgent = await createAgent(db, sourceCompany.id);
const decision = await authorizationService(db).decide({
actor: { type: "agent", agentId: actorAgent.id, companyId: sourceCompany.id, source: "agent_jwt" },
action: "tasks:assign",
resource: { type: "company", companyId: targetCompany.id },
});
expect(decision).toMatchObject({
allowed: false,
reason: "deny_company_boundary",
});
expect(decision.explanation).toContain("Agent key cannot access another company");
});
it("allows simple-mode task assignment between same-company agents without explicit grants", async () => {
const company = await createCompany(db, "AssignmentDefault");
const actorAgent = await createAgent(db, company.id, { role: "engineer" });
const targetAgent = await createAgent(db, company.id, { role: "engineer" });
await db.insert(companyMemberships).values({
companyId: company.id,
principalType: "agent",
principalId: actorAgent.id,
status: "active",
membershipRole: "member",
});
const decision = await authorizationService(db).decide({
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
action: "tasks:assign",
resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
scope: { assigneeAgentId: targetAgent.id },
});
expect(decision).toMatchObject({
allowed: true,
reason: "allow_simple_company_member",
});
expect(decision.explanation).toContain("simple mode");
});
it("denies simple-mode assignment when the target agent requires protected-assignment approval", async () => {
const company = await createCompany(db, "ProtectedAssignment");
const actorAgent = await createAgent(db, company.id, { role: "engineer" });
const targetAgent = await createAgent(db, company.id, {
role: "engineer",
permissions: {
authorizationPolicy: {
assignmentPolicy: {
mode: "protected",
protectedAgentRequiresApproval: true,
},
protectedAgent: {
requiresApproval: true,
approvalReason: "Production deployment authority",
},
managedBy: "permissions-extension",
},
},
});
const decision = await authorizationService(db).decide({
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
action: "tasks:assign",
resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
scope: { assigneeAgentId: targetAgent.id },
});
expect(decision).toMatchObject({
allowed: false,
reason: "deny_policy_restricted",
});
expect(decision.explanation).toContain("requires approval");
});
it("requires an explicit grant before assigning to a private target agent", async () => {
const company = await createCompany(db, "PrivateAssignment");
const actorAgent = await createAgent(db, company.id, { role: "engineer" });
const targetAgent = await createAgent(db, company.id, {
role: "engineer",
permissions: {
authorizationPolicy: {
agentVisibility: {
mode: "private",
hiddenFromDefaultDirectory: true,
},
assignmentPolicy: {
mode: "company_default",
protectedAgentRequiresApproval: false,
},
protectedAgent: {
requiresApproval: false,
},
managedBy: "permissions-extension",
},
},
});
const denied = await authorizationService(db).decide({
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
action: "tasks:assign",
resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
scope: { assigneeAgentId: targetAgent.id },
});
await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", {
assigneeAgentId: targetAgent.id,
});
const allowed = await authorizationService(db).decide({
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
action: "tasks:assign",
resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
scope: { assigneeAgentId: targetAgent.id },
});
expect(denied).toMatchObject({
allowed: false,
reason: "deny_policy_restricted",
});
expect(denied.explanation).toContain("private");
expect(allowed).toMatchObject({
allowed: true,
reason: "allow_explicit_grant",
grant: { permissionKey: "tasks:assign_scope" },
});
});
it("allows simple-mode task assignment for active same-company board operators without explicit grants", async () => {
const company = await createCompany(db, "BoardAssignmentDefault");
const userId = `user-${randomUUID()}`;
const targetAgent = await createAgent(db, company.id, { role: "engineer" });
await db.insert(companyMemberships).values({
companyId: company.id,
principalType: "user",
principalId: userId,
status: "active",
membershipRole: "operator",
});
const decision = await authorizationService(db).decide({
actor: { type: "board", userId, source: "session" },
action: "tasks:assign",
resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
scope: { assigneeAgentId: targetAgent.id },
});
expect(decision).toMatchObject({
allowed: true,
reason: "allow_simple_company_member",
});
});
it("denies legacy board assignment context for viewers", async () => {
const company = await createCompany(db, "BoardViewerAssignment");
const userId = `user-${randomUUID()}`;
const targetAgent = await createAgent(db, company.id, { role: "engineer" });
await db.insert(companyMemberships).values({
companyId: company.id,
principalType: "user",
principalId: userId,
status: "active",
membershipRole: "viewer",
});
const decision = await authorizationService(db).decide({
actor: { type: "board", userId, companyIds: [company.id], source: "session" },
action: "tasks:assign",
resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
scope: { assigneeAgentId: targetAgent.id },
});
expect(decision).toMatchObject({
allowed: false,
reason: "deny_missing_grant",
});
});
it("denies simple-mode assignment to a target agent from another company", async () => {
const sourceCompany = await createCompany(db, "AssignmentSource");
const targetCompany = await createCompany(db, "AssignmentTarget");
const actorAgent = await createAgent(db, sourceCompany.id, { role: "engineer" });
const targetAgent = await createAgent(db, targetCompany.id, { role: "engineer" });
await db.insert(companyMemberships).values({
companyId: sourceCompany.id,
principalType: "agent",
principalId: actorAgent.id,
status: "active",
membershipRole: "member",
});
const decision = await authorizationService(db).decide({
actor: { type: "agent", agentId: actorAgent.id, companyId: sourceCompany.id, source: "agent_key" },
action: "tasks:assign",
resource: { type: "issue", companyId: sourceCompany.id, assigneeAgentId: targetAgent.id },
scope: { assigneeAgentId: targetAgent.id },
});
expect(decision).toMatchObject({
allowed: false,
reason: "deny_company_boundary",
});
});
it("preserves legacy CEO agent creator authority", async () => {
const company = await createCompany(db, "Legacy");
const actorAgent = await createAgent(db, company.id, { role: "ceo" });
const decision = await authorizationService(db).decide({
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_jwt" },
action: "agents:create",
resource: { type: "company", companyId: company.id },
});
expect(decision).toMatchObject({
allowed: true,
reason: "allow_legacy_agent_creator",
});
});
it("allows scoped assignment inside a granted project and denies other projects", async () => {
const company = await createCompany(db, "ProjectScope");
const project = await createProject(db, company.id, "Allowed");
const otherProject = await createProject(db, company.id, "Denied");
const actorAgent = await createAgent(db, company.id);
const targetAgent = await createAgent(db, company.id);
await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", {
projectIds: [project.id],
});
const allowed = await authorizationService(db).decidePrincipalGrant({
companyId: company.id,
principalType: "agent",
principalId: actorAgent.id,
action: "tasks:assign",
permissionKey: "tasks:assign_scope",
scope: { projectId: project.id, assigneeAgentId: targetAgent.id },
});
const denied = await authorizationService(db).decidePrincipalGrant({
companyId: company.id,
principalType: "agent",
principalId: actorAgent.id,
action: "tasks:assign",
permissionKey: "tasks:assign_scope",
scope: { projectId: otherProject.id, assigneeAgentId: targetAgent.id },
});
expect(allowed).toMatchObject({
allowed: true,
grant: { permissionKey: "tasks:assign_scope" },
});
expect(denied).toMatchObject({
allowed: false,
reason: "deny_scope",
});
expect(denied.explanation).toContain("does not cover the requested scope");
});
it("treats unknown grant scope metadata as unconstrained", async () => {
const company = await createCompany(db, "UnknownScopeMetadata");
const actorAgent = await createAgent(db, company.id);
const targetAgent = await createAgent(db, company.id);
await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", {
note: "CEO-approved",
});
const decision = await authorizationService(db).decidePrincipalGrant({
companyId: company.id,
principalType: "agent",
principalId: actorAgent.id,
action: "tasks:assign",
permissionKey: "tasks:assign_scope",
scope: { assigneeAgentId: targetAgent.id },
});
expect(decision).toMatchObject({
allowed: true,
grant: { permissionKey: "tasks:assign_scope" },
});
});
it("allows scoped assignment to agents inside a managed subtree only", async () => {
const company = await createCompany(db, "SubtreeScope");
const actorAgent = await createAgent(db, company.id);
const managerAgent = await createAgent(db, company.id);
const childAgent = await createAgent(db, company.id, { reportsTo: managerAgent.id });
const grandchildAgent = await createAgent(db, company.id, { reportsTo: childAgent.id });
const outsideAgent = await createAgent(db, company.id);
await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", {
managedSubtreeAgentIds: [managerAgent.id],
});
const allowed = await authorizationService(db).decidePrincipalGrant({
companyId: company.id,
principalType: "agent",
principalId: actorAgent.id,
action: "tasks:assign",
permissionKey: "tasks:assign_scope",
scope: { assigneeAgentId: grandchildAgent.id },
});
const denied = await authorizationService(db).decidePrincipalGrant({
companyId: company.id,
principalType: "agent",
principalId: actorAgent.id,
action: "tasks:assign",
permissionKey: "tasks:assign_scope",
scope: { assigneeAgentId: outsideAgent.id },
});
expect(allowed.allowed).toBe(true);
expect(allowed.grant?.permissionKey).toBe("tasks:assign_scope");
expect(denied).toMatchObject({
allowed: false,
reason: "deny_scope",
});
});
it("allows scoped assignment to an explicit target-agent allowlist only", async () => {
const company = await createCompany(db, "AllowlistScope");
const actorAgent = await createAgent(db, company.id);
const allowedTarget = await createAgent(db, company.id);
const deniedTarget = await createAgent(db, company.id);
await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", {
assigneeAgentIds: [allowedTarget.id],
});
const allowed = await authorizationService(db).decidePrincipalGrant({
companyId: company.id,
principalType: "agent",
principalId: actorAgent.id,
action: "tasks:assign",
permissionKey: "tasks:assign_scope",
scope: { assigneeAgentId: allowedTarget.id },
});
const denied = await authorizationService(db).decidePrincipalGrant({
companyId: company.id,
principalType: "agent",
principalId: actorAgent.id,
action: "tasks:assign",
permissionKey: "tasks:assign_scope",
scope: { assigneeAgentId: deniedTarget.id },
});
expect(allowed.allowed).toBe(true);
expect(denied.allowed).toBe(false);
});
it("preserves unscoped tasks:assign compatibility for assignment decisions", async () => {
const company = await createCompany(db, "BroadAssign");
const actorAgent = await createAgent(db, company.id);
const targetAgent = await createAgent(db, company.id);
await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign");
const decision = await authorizationService(db).decidePrincipalGrant({
companyId: company.id,
principalType: "agent",
principalId: actorAgent.id,
action: "tasks:assign",
permissionKey: "tasks:assign",
scope: { assigneeAgentId: targetAgent.id },
});
expect(decision).toMatchObject({
allowed: true,
grant: { permissionKey: "tasks:assign" },
});
});
});
@@ -454,6 +454,103 @@ describe("awsSecretsManagerProvider", () => {
expect(JSON.stringify(listed)).not.toContain("team");
});
it("discovers AWS provider vault prefill candidates from metadata without reading values", async () => {
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
gateway: {
async createSecret() {
throw new Error("not used");
},
async putSecretValue() {
throw new Error("not used");
},
async getSecretValue() {
throw new Error("GetSecretValue must not be used for provider vault discovery");
},
async deleteSecret() {
throw new Error("not used");
},
async listSecrets(input) {
calls.push({ op: "listSecrets", input });
return {
NextToken: "next-page",
SecretList: [
{
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai",
Name: "paperclip/prod-use1/company-1/openai",
KmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/prod",
Tags: [
{ Key: "paperclip:managed-by", Value: "paperclip" },
{ Key: "paperclip:deployment-id", Value: "prod-use1" },
{ Key: "paperclip:company-id", Value: "company-1" },
{ Key: "paperclip:environment", Value: "production" },
{ Key: "paperclip:provider-owner", Value: "platform" },
],
},
{
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-2/stripe",
Name: "paperclip/prod-use1/company-2/stripe",
Tags: [
{ Key: "paperclip:managed-by", Value: "paperclip" },
{ Key: "paperclip:company-id", Value: "company-2" },
],
},
],
};
},
},
});
const preview = await provider.discoverProviderConfigs?.({
companyId: "company-1",
providerConfig: {
id: "draft",
provider: "aws_secrets_manager",
status: "ready",
config: { region: "us-east-1" },
},
query: "paperclip",
pageSize: 25,
});
expect(calls).toEqual([
{
op: "listSecrets",
input: {
MaxResults: 25,
NextToken: undefined,
IncludePlannedDeletion: false,
Filters: [{ Key: "all", Values: ["paperclip"] }],
},
},
]);
expect(preview).toMatchObject({
provider: "aws_secrets_manager",
nextToken: "next-page",
sampledSecretCount: 1,
skippedForeignPaperclipSampleCount: 1,
candidates: [
expect.objectContaining({
displayName: "AWS production",
config: expect.objectContaining({
region: "us-east-1",
namespace: "prod-use1",
secretNamePrefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/prod",
ownerTag: "platform",
environmentTag: "production",
}),
signals: expect.objectContaining({
paperclipManagedSampleCount: 1,
skippedForeignPaperclipSampleCount: 1,
}),
}),
],
});
expect(JSON.stringify(preview)).not.toContain("SecretString");
expect(JSON.stringify(preview)).not.toContain("company-2/stripe");
});
it("redacts AWS provider exception text when remote listing fails", async () => {
const rawProviderMessage =
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets on arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai";
+110 -2
View File
@@ -5,13 +5,17 @@ import {
buildBetterAuthAdvancedOptions,
deriveAuthCookiePrefix,
deriveAuthTrustedOrigins,
shouldDisableSecureAuthCookies,
} from "../auth/better-auth.js";
const ORIGINAL_INSTANCE_ID = process.env.PAPERCLIP_INSTANCE_ID;
const ORIGINAL_PUBLIC_URL = process.env.PAPERCLIP_PUBLIC_URL;
afterEach(() => {
if (ORIGINAL_INSTANCE_ID === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = ORIGINAL_INSTANCE_ID;
if (ORIGINAL_PUBLIC_URL === undefined) delete process.env.PAPERCLIP_PUBLIC_URL;
else process.env.PAPERCLIP_PUBLIC_URL = ORIGINAL_PUBLIC_URL;
});
describe("Better Auth cookie scoping", () => {
@@ -28,8 +32,8 @@ describe("Better Auth cookie scoping", () => {
expect(advanced).toEqual({
cookiePrefix: "paperclip-sat-worktree",
});
expect(getCookies({ advanced } as BetterAuthOptions).sessionToken.name).toBe(
"paperclip-sat-worktree.session_token",
expect(getCookies({ advanced } as BetterAuthOptions).sessionToken.name).toMatch(
/paperclip-sat-worktree\.session_token$/,
);
});
@@ -40,6 +44,110 @@ describe("Better Auth cookie scoping", () => {
cookiePrefix: "paperclip-pap-worktree",
useSecureCookies: false,
});
expect(getCookies({
advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies: true }),
} as BetterAuthOptions).sessionToken.name).toBe("paperclip-pap-worktree.session_token");
});
it("disables secure cookies for authenticated private auto-origin dev servers", () => {
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
deploymentExposure: "private",
authBaseUrlMode: "auto",
authPublicBaseUrl: undefined,
publicUrl: undefined,
})).toBe(true);
});
it("keeps secure cookies for authenticated public auto-origin servers", () => {
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
deploymentExposure: "public",
authBaseUrlMode: "auto",
authPublicBaseUrl: undefined,
publicUrl: undefined,
})).toBe(false);
});
it("uses an explicit public URL when deciding whether secure cookies are required", () => {
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
deploymentExposure: "private",
authBaseUrlMode: "auto",
authPublicBaseUrl: undefined,
publicUrl: "https://paperclip.example.test",
})).toBe(false);
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
deploymentExposure: "public",
authBaseUrlMode: "explicit",
authPublicBaseUrl: "http://paperclip.local.test:3100",
publicUrl: undefined,
})).toBe(true);
});
it("disables secure cookies when no canonical public auth URL is configured", () => {
delete process.env.PAPERCLIP_PUBLIC_URL;
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
authBaseUrlMode: "auto",
authPublicBaseUrl: undefined,
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(true);
});
it("derives secure cookie behavior from the configured public auth URL", () => {
delete process.env.PAPERCLIP_PUBLIC_URL;
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
authBaseUrlMode: "explicit",
authPublicBaseUrl: "http://paperclip-dev:46259",
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(true);
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
authBaseUrlMode: "explicit",
authPublicBaseUrl: "https://paperclip.example.test",
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(false);
});
it("uses the caller-resolved public URL for cookie security", () => {
process.env.PAPERCLIP_PUBLIC_URL = "https://ignored.example.test";
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
authBaseUrlMode: "explicit",
authPublicBaseUrl: "https://paperclip.example.test",
publicUrl: "http://paperclip-dev:46259",
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(true);
});
it("disables secure cookies for private authenticated auto mode without a public URL", () => {
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
deploymentExposure: "private",
authBaseUrlMode: "auto",
authPublicBaseUrl: undefined,
})).toBe(true);
});
it("disables secure cookies for explicit HTTP public URLs", () => {
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
deploymentExposure: "private",
authBaseUrlMode: "explicit",
authPublicBaseUrl: "http://board.example.test:3101",
})).toBe(true);
});
it("keeps secure cookies for explicit HTTPS public URLs", () => {
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
deploymentExposure: "public",
authBaseUrlMode: "explicit",
authPublicBaseUrl: "https://board.example.test",
})).toBe(false);
});
it("adds hostname port variants for authenticated mode on non-default ports", () => {
@@ -5,7 +5,7 @@ import { boardMutationGuard } from "../middleware/board-mutation-guard.js";
function createApp(
actorType: "board" | "agent",
boardSource: "session" | "local_implicit" | "board_key" = "session",
boardSource: "session" | "local_implicit" | "board_key" | "cloud_tenant" = "session",
) {
const app = express();
app.use(express.json());
@@ -66,6 +66,12 @@ describe("boardMutationGuard", () => {
expect([200, 204]).toContain(res.status);
});
it("allows trusted Cloud tenant mutations without origin", async () => {
const app = createApp("board", "cloud_tenant");
const res = await request(app).post("/mutate").send({ ok: true });
expect([200, 204]).toContain(res.status);
});
it("allows board mutations from trusted origin", async () => {
const app = createApp("board");
const res = await request(app)
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_JSON_BODY_LIMIT,
PORTABLE_JSON_BODY_LIMIT,
PORTABLE_JSON_BODY_LIMIT_BYTES,
} from "../http/body-limits.js";
describe("HTTP body limits", () => {
it("keeps the global JSON parser at the established ceiling", () => {
expect(DEFAULT_JSON_BODY_LIMIT).toBe("10mb");
});
it("allows PAP-scale portable import JSON payloads", () => {
expect(PORTABLE_JSON_BODY_LIMIT).toBe("64mb");
expect(PORTABLE_JSON_BODY_LIMIT_BYTES).toBe(64 * 1024 * 1024);
expect(PORTABLE_JSON_BODY_LIMIT_BYTES).toBeGreaterThan(10 * 1024 * 1024);
});
});
@@ -9,6 +9,7 @@ import {
createDb,
documents,
documentRevisions,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
issueDocuments,
@@ -42,6 +43,7 @@ describeEmbeddedPostgres("cleanup removal services", () => {
}, 20_000);
afterEach(async () => {
await db.delete(heartbeatRunEvents);
await db.delete(activityLog);
await db.delete(issueReadStates);
await db.delete(issueComments);
@@ -228,4 +230,32 @@ describeEmbeddedPostgres("cleanup removal services", () => {
await expect(db.select().from(issueReadStates).where(eq(issueReadStates.companyId, companyId))).resolves.toHaveLength(0);
await expect(db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).resolves.toHaveLength(0);
});
it("removes heartbeat events by run id before deleting company-owned runs", async () => {
const { agentId, companyId, runId } = await seedFixture();
const otherCompanyId = randomUUID();
await db.insert(companies).values({
id: otherCompanyId,
name: "Other Company",
issuePrefix: `O${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(heartbeatRunEvents).values({
companyId: otherCompanyId,
runId,
agentId,
seq: 1,
eventType: "output",
message: "event with mismatched company scope",
});
const removed = await companyService(db).remove(companyId);
expect(removed?.id).toBe(companyId);
await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId))).resolves.toHaveLength(0);
await expect(db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, runId))).resolves.toHaveLength(0);
await expect(db.select().from(companies).where(eq(companies.id, otherCompanyId))).resolves.toHaveLength(1);
});
});
@@ -0,0 +1,334 @@
import { generateKeyPairSync, randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { companies, cloudUpstreamConnections, cloudUpstreamRuns, companySkills, createDb } from "@paperclipai/db";
import { HttpError } from "../errors.js";
import {
cloudUpstreamRemoteFailureReport,
cloudUpstreamService,
reconcileCloudUpstreamRunsOnStartup,
sealCloudUpstreamCredential,
unsealCloudUpstreamCredential,
} from "../services/cloud-upstreams.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres cloud upstream tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describe("cloud upstream remote failures", () => {
it("preserves the cloud response body and message on run reports", () => {
const body = {
error: "bad_request",
message: "entities[42].body must be an object",
errors: [{ path: "entities[42].body" }],
};
expect(cloudUpstreamRemoteFailureReport(new HttpError(400, "bad_request", body))).toEqual({
error: "bad_request",
errorMessage: "entities[42].body must be an object",
details: body,
});
});
it("falls back to the thrown error message for non-remote failures", () => {
expect(cloudUpstreamRemoteFailureReport(new Error("network failed"))).toEqual({
error: "network failed",
});
});
});
describe("cloud upstream credential storage", () => {
const previousMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
afterEach(() => {
if (previousMasterKey === undefined) {
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
} else {
process.env.PAPERCLIP_SECRETS_MASTER_KEY = previousMasterKey;
}
});
it("stores new credentials as encrypted envelopes and preserves legacy plaintext reads", async () => {
process.env.PAPERCLIP_SECRETS_MASTER_KEY = "12345678901234567890123456789012";
const sealed = await sealCloudUpstreamCredential("cloud-access-token");
expect(sealed).toMatch(/^paperclip-cloud-credential:/);
expect(sealed).not.toContain("cloud-access-token");
await expect(unsealCloudUpstreamCredential(sealed)).resolves.toBe("cloud-access-token");
await expect(unsealCloudUpstreamCredential("legacy-plaintext-token")).resolves.toBe("legacy-plaintext-token");
});
});
describeEmbeddedPostgres("cloud upstream persistence", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const previousMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
beforeAll(async () => {
process.env.PAPERCLIP_SECRETS_MASTER_KEY = "12345678901234567890123456789012";
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-cloud-upstreams-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
vi.restoreAllMocks();
await db.delete(cloudUpstreamRuns);
await db.delete(cloudUpstreamConnections);
await db.delete(companySkills);
await db.delete(companies);
});
afterAll(async () => {
if (previousMasterKey === undefined) {
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
} else {
process.env.PAPERCLIP_SECRETS_MASTER_KEY = previousMasterKey;
}
await tempDb?.cleanup();
});
it("encrypts stored upstream credentials while keeping connection flows usable", async () => {
const companyId = randomUUID();
await seedCompany(companyId);
const tokenUrl = "https://cloud.example.test/oauth/token";
vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
const url = String(input);
if (url.startsWith("https://cloud.example.test/.well-known/paperclip-upstream")) {
return jsonResponse({
product: "Paperclip Cloud",
stack: {
id: "stack-1",
companyId: "cloud-company-1",
origin: "https://cloud.example.test",
primaryHost: "cloud.example.test",
},
transfer: {
supportedSchemaMajor: 1,
maxChunkBytes: 8192,
},
auth: {
scopes: ["upstream_import:write"],
pkce: {
authorizeUrl: "https://cloud.example.test/oauth/authorize",
tokenUrl,
},
},
});
}
if (url === tokenUrl && init?.method === "POST") {
const payload = JSON.parse(String(init.body));
expect(payload.codeVerifier).toEqual(expect.any(String));
expect(payload.codeVerifier).not.toContain("paperclip-cloud-credential:");
return jsonResponse({
accessToken: "cloud-access-token",
token: {
id: "token-1",
expiresAt: "2026-05-22T13:00:00.000Z",
globalUserId: "user-1",
},
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
const service = cloudUpstreamService(db, { instanceId: "test" });
const started = await service.startConnect({
companyId,
remoteUrl: "https://cloud.example.test",
redirectUri: "http://localhost:3100/callback",
});
await service.finishConnect({
pendingConnectionId: started.pendingConnectionId,
code: "auth-code",
state: new URL(started.authorizationUrl).searchParams.get("state") ?? "",
});
const [row] = await db.select().from(cloudUpstreamConnections);
expect(row.privateKeyPem).toMatch(/^paperclip-cloud-credential:/);
expect(row.privateKeyPem).not.toContain("BEGIN PRIVATE KEY");
expect(row.accessToken).toMatch(/^paperclip-cloud-credential:/);
expect(row.accessToken).not.toContain("cloud-access-token");
});
it("marks orphaned running runs failed during startup reconciliation", async () => {
const companyId = randomUUID();
const connectionId = randomUUID();
const runningRunId = randomUUID();
const succeededRunId = randomUUID();
const reconciledAt = new Date("2026-05-22T13:00:00.000Z");
await seedCompany(companyId);
await db.insert(cloudUpstreamConnections).values({
id: connectionId,
companyId,
remoteUrl: "https://cloud.example.test",
sourceInstanceId: "source-1",
sourceInstanceFingerprint: "sha256:test",
sourcePublicKey: "public-key",
privateKeyPem: "legacy-private-key",
tokenStatus: "connected",
scopes: ["upstream_import:write"],
authorizedGlobalUserId: "user-1",
accessToken: "legacy-token",
tokenId: "token-1",
targetStackId: "stack-1",
targetCompanyId: "cloud-company-1",
targetOrigin: "https://cloud.example.test",
targetPrimaryHost: "cloud.example.test",
targetProduct: "Paperclip Cloud",
targetSchemaMajor: 1,
targetMaxChunkBytes: 8192,
});
await db.insert(cloudUpstreamRuns).values([
cloudRunRow({ id: runningRunId, connectionId, companyId, status: "running" }),
cloudRunRow({ id: succeededRunId, connectionId, companyId, status: "succeeded", completedAt: reconciledAt }),
]);
await expect(reconcileCloudUpstreamRunsOnStartup(db, reconciledAt)).resolves.toEqual({ reconciled: 1 });
const rows = await db.select().from(cloudUpstreamRuns);
const running = rows.find((row) => row.id === runningRunId);
const succeeded = rows.find((row) => row.id === succeededRunId);
expect(running?.status).toBe("failed");
expect(running?.completedAt?.toISOString()).toBe(reconciledAt.toISOString());
expect(running?.events.at(-1)?.message).toContain("server startup");
expect(running?.report).toMatchObject({
error: "orphaned_running_run",
reconciledAt: reconciledAt.toISOString(),
});
expect(succeeded?.status).toBe("succeeded");
});
it("rejects a new run when the connection already has a running run", async () => {
const companyId = randomUUID();
const connectionId = randomUUID();
const runningRunId = randomUUID();
await seedCompany(companyId);
await db.insert(cloudUpstreamConnections).values(cloudConnectionRow({ id: connectionId, companyId }));
await db.insert(cloudUpstreamRuns).values(
cloudRunRow({ id: runningRunId, connectionId, companyId, status: "running" }),
);
await expect(cloudUpstreamService(db).createRun({ connectionId, companyId })).rejects.toMatchObject({
status: 409,
details: { runId: runningRunId },
});
});
it("preserves a cancelled run when an in-flight createRun tries to finish", async () => {
const companyId = randomUUID();
const connectionId = randomUUID();
await seedCompany(companyId);
await db.insert(cloudUpstreamConnections).values(cloudConnectionRow({ id: connectionId, companyId }));
const service = cloudUpstreamService(db);
const remoteCalls: string[] = [];
globalThis.fetch = vi.fn(async (input) => {
const path = new URL(String(input)).pathname;
remoteCalls.push(path);
if (path.endsWith("/upstream-imports/runs")) {
return jsonResponse({ run: { id: "remote-run-1" } });
}
if (path.endsWith("/chunks")) {
const run = await db.select().from(cloudUpstreamRuns).then((rows) => rows[0]);
expect(run?.status).toBe("running");
await service.cancelRun(connectionId, run.id, companyId);
return jsonResponse({ ok: true });
}
if (path.endsWith("/cancel")) {
return jsonResponse({ ok: true });
}
if (path.endsWith("/apply")) {
return jsonResponse({ ok: true });
}
if (path.endsWith("/events")) {
return jsonResponse({ events: [] });
}
return jsonResponse({ error: "not_found" }, 404);
}) as typeof fetch;
const result = await service.createRun({ connectionId, companyId });
expect(result.status).toBe("cancelled");
expect(remoteCalls.some((path) => path.endsWith("/apply"))).toBe(false);
const rows = await db.select().from(cloudUpstreamRuns);
expect(rows).toHaveLength(1);
expect(rows[0]?.status).toBe("cancelled");
});
async function seedCompany(companyId: string) {
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
}
});
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function cloudConnectionRow(input: { id: string; companyId: string }) {
const { privateKey } = generateKeyPairSync("ed25519");
return {
id: input.id,
companyId: input.companyId,
remoteUrl: "https://cloud.example.test",
sourceInstanceId: "source-1",
sourceInstanceFingerprint: "sha256:test",
sourcePublicKey: "public-key",
privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
tokenStatus: "connected",
scopes: ["upstream_import:write"],
authorizedGlobalUserId: "user-1",
accessToken: "legacy-token",
tokenId: "token-1",
targetStackId: "stack-1",
targetCompanyId: "cloud-company-1",
targetOrigin: "https://cloud.example.test",
targetPrimaryHost: "cloud.example.test",
targetProduct: "Paperclip Cloud",
targetSchemaMajor: 1,
targetMaxChunkBytes: 8192,
};
}
function cloudRunRow(input: {
id: string;
connectionId: string;
companyId: string;
status: string;
completedAt?: Date;
}) {
return {
id: input.id,
connectionId: input.connectionId,
companyId: input.companyId,
status: input.status,
activeStep: "push",
progressPercent: input.status === "running" ? 45 : 100,
dryRun: false,
summary: [],
warnings: [],
conflicts: [],
events: [],
report: {},
idempotencyKey: `key-${input.id}`,
manifestHash: `sha256:${input.id.replace(/-/g, "")}`,
targetUrl: "https://cloud.example.test",
completedAt: input.completedAt,
};
}
@@ -0,0 +1,50 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { companies, createDb } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { companyService } from "../services/companies.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres company service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("companyService", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-service-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("retries generated issue prefixes when Drizzle wraps the unique constraint error", async () => {
await db.insert(companies).values({
name: "Aron Existing",
issuePrefix: "ARO",
});
const created = await companyService(db).create({
name: "Aron & Sharon",
});
expect(created.issuePrefix).toBe("AROA");
const rows = await db.select({ issuePrefix: companies.issuePrefix }).from(companies);
expect(rows.map((row) => row.issuePrefix).sort()).toEqual(["ARO", "AROA"]);
});
});
@@ -139,6 +139,58 @@ function createExportResult() {
};
}
const importRequest = {
source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } },
include: { company: true, agents: true, projects: false, issues: false },
target: { mode: "existing_company", companyId },
collisionStrategy: "rename",
};
const cloudHeaders = {
"x-paperclip-cloud-stack-id": "stack-alpha",
"x-paperclip-cloud-paperclip-company-id": companyId,
};
function cloudTenantActor() {
return {
type: "board",
userId: "cloud-user-1",
userName: "Cloud User",
userEmail: "cloud-user@example.com",
companyIds: [companyId],
memberships: [{ companyId, membershipRole: "owner", status: "active" }],
isInstanceAdmin: true,
source: "cloud_tenant",
};
}
function createImportResult(action = "updated") {
return {
company: { id: companyId, action },
agents: [{ id: "agent-1" }],
warnings: [],
};
}
async function waitForImportJobStatus(app: express.Express, statusUrl: string, status: string) {
for (let attempt = 0; attempt < 20; attempt += 1) {
const res = await request(app).get(statusUrl).set(cloudHeaders);
if (res.body.job?.status === status) {
return res;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`Timed out waiting for import job to reach ${status}`);
}
async function waitForCondition(condition: () => boolean, label: string) {
for (let attempt = 0; attempt < 20; attempt += 1) {
if (condition()) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`Timed out waiting for ${label}`);
}
describe.sequential("company portability routes", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -426,4 +478,116 @@ describe.sequential("company portability routes", () => {
expect(res.body.error).toContain("Instance admin");
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
});
it.sequential("accepts trusted Cloud async import jobs and reports success by job id", async () => {
let resolveImport: (value: ReturnType<typeof createImportResult>) => void = () => undefined;
const pendingImport = new Promise<ReturnType<typeof createImportResult>>((resolve) => {
resolveImport = resolve;
});
mockCompanyPortabilityService.importBundle.mockReturnValueOnce(pendingImport);
const app = await createApp(cloudTenantActor());
const accepted = await request(app)
.post("/api/companies/import")
.set("x-paperclip-cloud-async-import", "1")
.set(cloudHeaders)
.send(importRequest);
expect(accepted.status).toBe(202);
expect(accepted.body.job.status).toBe("running");
expect(accepted.body.statusUrl).toMatch(/^\/api\/companies\/import\/jobs\/tenant-import-/);
expect(accepted.body.retryAfterMs).toBe(1000);
await waitForCondition(() => mockCompanyPortabilityService.importBundle.mock.calls.length === 1, "import job start");
expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledWith(importRequest, "cloud-user-1");
expect(mockLogActivity).not.toHaveBeenCalled();
resolveImport(createImportResult("updated"));
const succeeded = await waitForImportJobStatus(app, accepted.body.statusUrl, "succeeded");
expect(succeeded.status).toBe(200);
expect(succeeded.body.job.status).toBe("succeeded");
expect(succeeded.body.job.result.companyId).toBe(companyId);
expect(succeeded.body.retryAfterMs).toBeUndefined();
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "company.imported",
companyId,
details: expect.objectContaining({
agentCount: 1,
warningCount: 0,
companyAction: "updated",
}),
}));
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Date.parse(succeeded.body.job.completedAt) + (5 * 60 * 1000) + 1);
try {
const expired = await request(app).get(accepted.body.statusUrl).set(cloudHeaders);
expect(expired.status).toBe(404);
expect(expired.body.error).toBe("Import job not found");
} finally {
nowSpy.mockRestore();
}
});
it.sequential("reports trusted Cloud async import job failures with the tenant error message", async () => {
mockCompanyPortabilityService.importBundle.mockRejectedValueOnce(new Error("tenant import exploded"));
const app = await createApp(cloudTenantActor());
const accepted = await request(app)
.post("/api/companies/import")
.set("x-paperclip-cloud-async-import", "1")
.set(cloudHeaders)
.send(importRequest);
expect(accepted.status).toBe(202);
const failed = await waitForImportJobStatus(app, accepted.body.statusUrl, "failed");
expect(failed.status).toBe(200);
expect(failed.body.job.status).toBe("failed");
expect(failed.body.job.error.message).toBe("tenant import exploded");
expect(failed.body.retryAfterMs).toBeUndefined();
expect(failed.body.message).toBe("tenant import exploded");
expect(mockLogActivity).not.toHaveBeenCalled();
});
it.sequential("accepts trusted Cloud async import jobs before validating the full import payload", async () => {
const app = await createApp(cloudTenantActor());
const accepted = await request(app)
.post("/api/companies/import")
.set("x-paperclip-cloud-async-import", "1")
.set(cloudHeaders)
.send({ target: { mode: "existing_company", companyId } });
expect(accepted.status).toBe(202);
expect(accepted.body.job.status).toBe("running");
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
const failed = await waitForImportJobStatus(app, accepted.body.statusUrl, "failed");
expect(failed.status).toBe(200);
expect(failed.body.job.status).toBe("failed");
expect(failed.body.job.error.message).toEqual(expect.any(String));
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it.sequential("keeps global import apply synchronous when Cloud async opt-in is absent", async () => {
mockCompanyPortabilityService.importBundle.mockResolvedValueOnce(createImportResult("created"));
const app = await createApp(cloudTenantActor());
const res = await request(app)
.post("/api/companies/import")
.set(cloudHeaders)
.send(importRequest);
expect(res.status).toBe(200);
expect(res.body.company.id).toBe(companyId);
expect(res.body.company.action).toBe("created");
expect(res.body.job).toBeUndefined();
expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledWith(importRequest, "cloud-user-1");
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "company.imported",
companyId,
}));
});
});
@@ -21,6 +21,7 @@ const agentSvc = {
const accessSvc = {
ensureMembership: vi.fn(),
ensureRoleDefaultGrants: vi.fn(),
listActiveUserMemberships: vi.fn(),
copyActiveUserMemberships: vi.fn(),
setPrincipalPermission: vi.fn(),
@@ -63,6 +63,42 @@ describe("dev-runner worktree env bootstrap", () => {
expect(env.PAPERCLIP_OPTIONAL).toBe("");
});
it("repairs stale migrated config paths before loading worktree env", () => {
const root = createTempRoot("paperclip-dev-runner-worktree-migrated-env-");
const localConfigPath = path.join(root, ".paperclip", "config.json");
const worktreesDir = path.join(root, ".paperclip-worktrees");
fs.mkdirSync(path.dirname(localConfigPath), { recursive: true });
fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/feature\n", "utf8");
fs.writeFileSync(localConfigPath, "{}\n", "utf8");
fs.writeFileSync(
resolveWorktreeEnvFilePath(root),
[
"PAPERCLIP_HOME=/old/home/.paperclip-worktrees",
"PAPERCLIP_INSTANCE_ID=feature-worktree",
"PAPERCLIP_CONFIG=/old/home/paperclip/.paperclip/worktrees/feature/.paperclip/config.json",
"PAPERCLIP_CONTEXT=/old/home/.paperclip-worktrees/context.json",
"PAPERCLIP_IN_WORKTREE=true",
"PAPERCLIP_WORKTREE_NAME=feature-worktree",
"",
].join("\n"),
"utf8",
);
const env: NodeJS.ProcessEnv = {
PAPERCLIP_WORKTREES_DIR: worktreesDir,
};
const result = bootstrapDevRunnerWorktreeEnv(root, env);
expect(result).toEqual({
envPath: resolveWorktreeEnvFilePath(root),
missingEnv: false,
});
expect(env.PAPERCLIP_HOME).toBe(worktreesDir);
expect(env.PAPERCLIP_CONFIG).toBe(localConfigPath);
expect(env.PAPERCLIP_CONTEXT).toBe(path.join(worktreesDir, "context.json"));
expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree");
});
it("reports uninitialized linked worktrees so dev runner can fail fast", () => {
const root = createTempRoot("paperclip-dev-runner-worktree-missing-");
fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/feature\n", "utf8");
+29 -2
View File
@@ -1,8 +1,13 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js";
import {
getDevServerRestartRequestFilePath,
readPersistedDevServerStatus,
toDevServerHealthStatus,
writeDevServerRestartRequest,
} from "../dev-server-status.js";
const tempDirs = [];
@@ -73,4 +78,26 @@ describe("dev server status helpers", () => {
expect(readPersistedDevServerStatus({ PAPERCLIP_DEV_SERVER_STATUS_FILE: filePath })).toBeNull();
});
it("writes restart requests next to the persisted status file", () => {
const filePath = createTempStatusFile({
dirty: true,
changedPathsSample: ["server/src/app.ts"],
pendingMigrations: [],
});
const env = { PAPERCLIP_DEV_SERVER_STATUS_FILE: filePath };
expect(writeDevServerRestartRequest({
requestedAt: "2026-03-20T12:05:00.000Z",
reason: "manual_restart_now",
}, env)).toBe(true);
const requestPath = getDevServerRestartRequestFilePath(env);
expect(requestPath).toBe(path.join(path.dirname(filePath), "dev-server-restart-request.json"));
expect(requestPath && existsSync(requestPath)).toBe(true);
expect(JSON.parse(readFileSync(requestPath!, "utf8"))).toEqual({
requestedAt: "2026-03-20T12:05:00.000Z",
reason: "manual_restart_now",
});
});
});
@@ -112,4 +112,85 @@ describeEmbeddedPostgres("documentService system issue documents", () => {
body: "# Handoff",
}));
});
it("locks and unlocks issue documents", async () => {
const { issueId } = await createIssueWithDocuments();
const locked = await svc.lockIssueDocument({
issueId,
key: "plan",
lockedByUserId: "board-user",
});
expect(locked.changed).toBe(true);
expect(locked.document.lockedAt).toBeInstanceOf(Date);
expect(locked.document.lockedByUserId).toBe("board-user");
await expect(svc.upsertIssueDocument({
issueId,
key: "plan",
title: "Plan",
format: "markdown",
body: "# Updated plan",
baseRevisionId: locked.document.latestRevisionId,
createdByUserId: "board-user",
})).rejects.toMatchObject({
status: 409,
message: "Document is locked",
});
const unlocked = await svc.unlockIssueDocument(issueId, "plan");
expect(unlocked.changed).toBe(true);
expect(unlocked.document.lockedAt).toBeNull();
const updated = await svc.upsertIssueDocument({
issueId,
key: "plan",
title: "Plan",
format: "markdown",
body: "# Updated plan",
baseRevisionId: unlocked.document.latestRevisionId,
createdByUserId: "board-user",
});
expect(updated.created).toBe(false);
expect(updated.document.body).toBe("# Updated plan");
});
it("creates a new document instead of updating a locked document when requested", async () => {
const { issueId } = await createIssueWithDocuments();
const locked = await svc.lockIssueDocument({
issueId,
key: "plan",
lockedByUserId: "board-user",
});
const fallback = await svc.upsertIssueDocument({
issueId,
key: "plan",
title: "Plan",
format: "markdown",
body: "# Agent replacement plan",
baseRevisionId: locked.document.latestRevisionId,
lockedDocumentStrategy: "create_new_document",
});
expect(fallback.created).toBe(true);
expect(fallback.document.key).toBe("plan-2");
expect(fallback.document.body).toBe("# Agent replacement plan");
expect("redirectedFromLockedDocument" in fallback ? fallback.redirectedFromLockedDocument : null)
.toEqual({ id: locked.document.id, key: "plan" });
const originalPlan = await svc.getIssueDocumentByKey(issueId, "plan");
expect(originalPlan).toEqual(expect.objectContaining({
body: "# Plan",
lockedAt: expect.any(Date),
}));
const newPlan = await svc.getIssueDocumentByKey(issueId, "plan-2");
expect(newPlan).toEqual(expect.objectContaining({
body: "# Agent replacement plan",
lockedAt: null,
}));
});
});
@@ -84,6 +84,11 @@ vi.mock("../services/index.js", () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueThreadInteractionService: () => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}),
documentService: () => ({}),
routineService: () => ({}),
workProductService: () => ({}),
@@ -37,6 +37,31 @@ describe("errorHandler", () => {
expect(res.__errorContext?.error?.message).toBe("boom");
});
it("exposes raw 500 messages for trusted Cloud tenant imports", () => {
const req = {
...makeReq(),
method: "POST",
originalUrl: "/api/companies/import",
actor: {
type: "board",
userId: "cloud-user",
source: "cloud_tenant",
},
} as unknown as Request;
const res = makeRes() as any;
const next = vi.fn() as unknown as NextFunction;
const err = new Error("portable file references missing upload id");
errorHandler(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: "Internal server error",
message: "portable file references missing upload id",
});
expect(res.err).toBe(err);
});
it("attaches HttpError instances for 500 responses", () => {
const req = makeReq();
const res = makeRes() as any;
@@ -25,15 +25,15 @@ vi.mock("../services/index.js", () => ({
workspaceOperationService: () => mockWorkspaceOperationService,
}));
function createApp() {
function createApp(companyIds = ["company-1"]) {
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",
companyIds,
source: "session",
isInstanceAdmin: false,
};
next();
@@ -55,6 +55,7 @@ describe.sequential("execution workspace routes", () => {
projectWorkspaceId: null,
},
]);
mockExecutionWorkspaceService.getById.mockResolvedValue(null);
});
it("uses summary mode for lightweight workspace lookups", async () => {
@@ -79,4 +80,5 @@ describe.sequential("execution workspace routes", () => {
});
expect(mockExecutionWorkspaceService.list).not.toHaveBeenCalled();
});
});
@@ -343,6 +343,83 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
expect(readExecutionWorkspaceConfig(byId.get(untouchedWorkspaceId) ?? null)).toBeNull();
});
it("limits reusable summaries to open non-shared execution workspaces", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const openWorkspaceId = randomUUID();
const sharedWorkspaceId = randomUUID();
const closedWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Reusable workspaces",
status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
},
});
await db.insert(executionWorkspaces).values([
{
id: openWorkspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Open isolated workspace",
status: "idle",
providerType: "git_worktree",
cwd: "/tmp/open-workspace",
branchName: "paperclip/open",
},
{
id: sharedWorkspaceId,
companyId,
projectId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Shared session",
status: "active",
providerType: "local_fs",
cwd: "/tmp/project-primary",
},
{
id: closedWorkspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Closed isolated workspace",
status: "active",
providerType: "git_worktree",
cwd: "/tmp/closed-workspace",
closedAt: new Date("2026-05-23T20:00:00.000Z"),
},
]);
const summaries = await svc.listSummaries(companyId, {
projectId,
reuseEligible: true,
});
expect(summaries).toEqual([
expect.objectContaining({
id: openWorkspaceId,
name: "Open isolated workspace",
mode: "isolated_workspace",
status: "idle",
cwd: "/tmp/open-workspace",
branchName: "paperclip/open",
}),
]);
});
it("warns about dirty and unmerged git worktrees and reports cleanup actions", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { isDatabaseConnectionUnavailableError } from "../app.js";
describe("feedback export flush error classification", () => {
it("recognizes wrapped database connection-refused errors", () => {
const error = new Error("Failed query: select ...: connect ECONNREFUSED 127.0.0.1:54329");
(error as { cause?: unknown }).cause = Object.assign(
new Error("connect ECONNREFUSED 127.0.0.1:54329"),
{ code: "ECONNREFUSED" },
);
expect(isDatabaseConnectionUnavailableError(error)).toBe(true);
});
it("does not classify ordinary feedback upload failures as database outages", () => {
expect(isDatabaseConnectionUnavailableError(new Error("upstream returned 500"))).toBe(false);
});
it("does not trust unrelated error messages that mention ECONNREFUSED", () => {
expect(isDatabaseConnectionUnavailableError(
new Error("feedback upload payload mentioned ECONNREFUSED in user content"),
)).toBe(false);
});
});
@@ -0,0 +1,100 @@
const readline = require("node:readline");
let nextRequestId = 1;
const pendingNested = new Map();
function send(message) {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function sendNestedHostRequest(originalRequest, invocationId) {
const nestedId = `nested-${nextRequestId++}`;
const params = originalRequest.params?.params ?? {};
const mode = params.mode;
const requestedCompanyId = params.requestedCompanyId;
const nestedRequest = {
jsonrpc: "2.0",
id: nestedId,
method: "companies.get",
params: {
companyId: requestedCompanyId,
},
};
if (mode === "echo") {
nestedRequest.paperclipInvocationId = invocationId;
} else if (mode === "unknown") {
nestedRequest.paperclipInvocationId = "unknown-invocation";
}
pendingNested.set(nestedId, originalRequest.id);
send(nestedRequest);
}
const rl = readline.createInterface({
input: process.stdin,
crlfDelay: Infinity,
});
rl.on("line", (line) => {
if (!line.trim()) return;
const message = JSON.parse(line);
if (message.id && pendingNested.has(message.id)) {
const originalId = pendingNested.get(message.id);
pendingNested.delete(message.id);
if (message.error) {
send({
jsonrpc: "2.0",
id: originalId,
error: message.error,
});
return;
}
send({
jsonrpc: "2.0",
id: originalId,
result: message.result,
});
return;
}
const method = message && typeof message.method === "string" ? message.method : null;
if (method === "initialize") {
send({
jsonrpc: "2.0",
id: message.id,
result: {
ok: true,
supportedMethods: ["getData", "performAction"],
},
});
return;
}
if (method === "getData" || method === "performAction") {
sendNestedHostRequest(message, message.paperclipInvocation?.id);
return;
}
if (method === "shutdown") {
send({
jsonrpc: "2.0",
id: message.id,
result: {},
});
setImmediate(() => process.exit(0));
return;
}
send({
jsonrpc: "2.0",
id: message.id,
error: {
code: -32601,
message: `Unhandled method: ${method}`,
},
});
});
@@ -1,4 +1,4 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import express from "express";
@@ -126,3 +126,80 @@ describe("GET /health dev-server supervisor access", () => {
}
});
});
describe("POST /health/dev-server/restart", () => {
it("records a manual restart request for the dev runner", async () => {
const previousFile = process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE;
process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = createDevServerStatusFile({
dirty: true,
lastChangedAt: "2026-03-20T12:00:00.000Z",
changedPathCount: 1,
changedPathsSample: ["server/src/routes/health.ts"],
pendingMigrations: [],
lastRestartAt: "2026-03-20T11:30:00.000Z",
});
try {
const app = express();
app.use("/health", healthRoutes(undefined));
const res = await request(app).post("/health/dev-server/restart");
expect(res.status).toBe(202);
expect(res.body).toEqual({ status: "restart_requested" });
const requestPath = path.join(
path.dirname(process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE),
"dev-server-restart-request.json",
);
expect(existsSync(requestPath)).toBe(true);
expect(JSON.parse(readFileSync(requestPath, "utf8"))).toMatchObject({
reason: "manual_restart_now",
});
} finally {
if (previousFile === undefined) {
delete process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE;
} else {
process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = previousFile;
}
}
});
it("rejects unauthenticated manual restarts in authenticated mode", async () => {
const previousFile = process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE;
process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = createDevServerStatusFile({
dirty: true,
changedPathCount: 1,
changedPathsSample: ["server/src/routes/health.ts"],
pendingMigrations: [],
});
try {
const app = express();
app.use((req, _res, next) => {
(req as any).actor = { type: "none", source: "none" };
next();
});
app.use(
"/health",
healthRoutes(undefined, {
deploymentMode: "authenticated",
deploymentExposure: "private",
authReady: true,
companyDeletionEnabled: true,
}),
);
const res = await request(app).post("/health/dev-server/restart");
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: "board_access_required" });
} finally {
if (previousFile === undefined) {
delete process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE;
} else {
process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = previousFile;
}
}
});
});
@@ -0,0 +1,279 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { eq, ne } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
agentTaskSessions,
agents,
companies,
createDb,
executionWorkspaces,
heartbeatRuns,
issues,
projects,
projectWorkspaces,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { heartbeatService } from "../services/heartbeat.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
const execFileAsync = promisify(execFile);
const adapterExecute = vi.hoisted(() => vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
sessionParams: { sessionId: "fresh-session" },
sessionDisplayId: "fresh-session",
summary: "Accepted plan workspace refresh test run.",
provider: "test",
model: "test-model",
})));
vi.mock("../adapters/index.js", () => ({
getServerAdapter: () => ({
type: "codex_local",
execute: adapterExecute,
supportsLocalAgentJwt: false,
}),
listAdapterModelProfiles: async () => [],
runningProcesses: new Map(),
}));
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres accepted-plan workspace refresh tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
async function createGitRepo() {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-accepted-plan-repo-"));
await execFileAsync("git", ["init"], { cwd: repoRoot });
await execFileAsync("git", ["config", "user.email", "paperclip-test@example.com"], { cwd: repoRoot });
await execFileAsync("git", ["config", "user.name", "Paperclip Test"], { cwd: repoRoot });
await writeFile(path.join(repoRoot, "README.md"), "accepted plan workspace refresh\n");
await execFileAsync("git", ["add", "README.md"], { cwd: repoRoot });
await execFileAsync("git", ["commit", "-m", "initial"], { cwd: repoRoot });
return repoRoot;
}
describeEmbeddedPostgres("accepted plan workspace refresh", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const tempRoots: string[] = [];
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-accepted-plan-workspace-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
adapterExecute.mockClear();
let idlePolls = 0;
for (let attempt = 0; attempt < 100; attempt += 1) {
const runs = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns);
const hasActiveRun = runs.some((run) => run.status === "queued" || run.status === "running");
if (!hasActiveRun) {
idlePolls += 1;
if (idlePolls >= 5) break;
} else {
idlePolls = 0;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
while (tempRoots.length > 0) {
const root = tempRoots.pop();
if (root) await rm(root, { recursive: true, force: true }).catch(() => undefined);
}
});
afterAll(async () => {
await db.$client.end();
await tempDb?.cleanup();
});
it("realizes an isolated workspace and drops stale shared task-session params before executing", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const sharedExecutionWorkspaceId = randomUUID();
const issueId = randomUUID();
const agentId = randomUUID();
const repoRoot = await createGitRepo();
tempRoots.push(repoRoot);
await instanceSettingsService(db).updateExperimental({
enableIsolatedWorkspaces: true,
});
await db.insert(companies).values({
id: companyId,
name: "Acme",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Accepted Plan Workspace Refresh",
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
cwd: repoRoot,
isPrimary: true,
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(executionWorkspaces).values({
id: sharedExecutionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Shared planning workspace",
status: "active",
cwd: repoRoot,
providerType: "local_fs",
providerRef: repoRoot,
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
projectWorkspaceId,
title: "Implement accepted plan",
status: "in_progress",
workMode: "planning",
priority: "medium",
assigneeAgentId: agentId,
identifier: "PAP-9122",
executionWorkspaceId: sharedExecutionWorkspaceId,
executionWorkspaceSettings: {
mode: "isolated_workspace",
},
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(agentTaskSessions).values({
companyId,
agentId,
adapterType: "codex_local",
taskKey: issueId,
sessionParamsJson: {
sessionId: "stale-shared-session",
cwd: repoRoot,
workspaceId: projectWorkspaceId,
},
sessionDisplayId: "stale-shared-session",
});
adapterExecute.mockImplementationOnce(async () => {
await db.update(issues).set({ status: "done", updatedAt: new Date() }).where(eq(issues.id, issueId));
return {
exitCode: 0,
signal: null,
timedOut: false,
sessionParams: { sessionId: "fresh-session" },
sessionDisplayId: "fresh-session",
summary: "Accepted plan workspace refresh test run.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
const run = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "issue_commented",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
forceFreshSession: true,
workspaceRefreshReason: "accepted_plan_confirmation",
},
});
expect(run).not.toBeNull();
await vi.waitFor(async () => {
const latest = await heartbeat.getRun(run!.id);
expect(latest?.status).toBe("succeeded");
}, { timeout: 10_000 });
expect(adapterExecute).toHaveBeenCalledTimes(1);
const adapterInput = adapterExecute.mock.calls[0]?.[0] as {
runtime: { sessionId: string | null; sessionParams: Record<string, unknown> | null };
context: Record<string, unknown>;
};
expect(adapterInput.runtime.sessionId).toBeNull();
expect(adapterInput.runtime.sessionParams).toBeNull();
expect(adapterInput.context.paperclipWorkspace).toEqual(expect.objectContaining({
mode: "isolated_workspace",
strategy: "git_worktree",
}));
expect((adapterInput.context.paperclipWorkspace as { cwd: string }).cwd).not.toBe(repoRoot);
const refreshedIssue = await db
.select({
executionWorkspaceId: issues.executionWorkspaceId,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0]);
expect(refreshedIssue?.executionWorkspaceId).toBeTruthy();
expect(refreshedIssue?.executionWorkspaceId).not.toBe(sharedExecutionWorkspaceId);
expect(refreshedIssue?.executionWorkspaceSettings).toMatchObject({
mode: "isolated_workspace",
});
const isolatedRows = await db
.select()
.from(executionWorkspaces)
.where(ne(executionWorkspaces.id, sharedExecutionWorkspaceId));
expect(isolatedRows).toHaveLength(1);
expect(isolatedRows[0]).toMatchObject({
mode: "isolated_workspace",
strategyType: "git_worktree",
sourceIssueId: issueId,
});
expect(isolatedRows[0]?.cwd).not.toBe(repoRoot);
}, 20_000);
});
@@ -2,11 +2,15 @@ import { randomUUID } from "node:crypto";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
companies,
createDb,
heartbeatRunEvents,
heartbeatRunWatchdogDecisions,
heartbeatRuns,
issueComments,
issueRecoveryActions,
issueRelations,
issues,
} from "@paperclipai/db";
@@ -94,7 +98,15 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
await tempDb?.cleanup();
});
async function seedRunningRun(opts: { now: Date; ageMs: number; withOutput?: boolean; logChunk?: string }) {
async function seedRunningRun(opts: {
now: Date;
ageMs: number;
withOutput?: boolean;
logChunk?: string;
sourceStatus?: "in_progress" | "done" | "cancelled";
sourceOriginKind?: string;
sameRunTerminalEvidence?: "activity" | "comment";
}) {
const companyId = randomUUID();
const managerId = randomUUID();
const coderId = randomUUID();
@@ -103,6 +115,8 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
const issuePrefix = `W${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const startedAt = new Date(opts.now.getTime() - opts.ageMs);
const lastOutputAt = opts.withOutput ? new Date(opts.now.getTime() - 5 * 60 * 1000) : null;
const sourceStatus = opts.sourceStatus ?? "in_progress";
const terminalEvidenceAt = new Date(startedAt.getTime() + 10 * 60 * 1000);
await db.insert(companies).values({
id: companyId,
@@ -139,11 +153,14 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
id: issueId,
companyId,
title: "Long running implementation",
status: "in_progress",
status: sourceStatus,
priority: "medium",
assigneeAgentId: coderId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
originKind: opts.sourceOriginKind ?? "manual",
completedAt: sourceStatus === "done" ? terminalEvidenceAt : null,
cancelledAt: sourceStatus === "cancelled" ? terminalEvidenceAt : null,
updatedAt: startedAt,
createdAt: startedAt,
});
@@ -181,6 +198,35 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
.where(eq(heartbeatRuns.id, runId));
}
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
if (opts.sameRunTerminalEvidence === "activity") {
await db.insert(activityLog).values({
companyId,
actorType: "agent",
actorId: coderId,
agentId: coderId,
runId,
action: "issue.updated",
entityType: "issue",
entityId: issueId,
details: {
identifier: `${issuePrefix}-1`,
status: sourceStatus,
_previous: { status: "in_progress" },
},
createdAt: terminalEvidenceAt,
});
} else if (opts.sameRunTerminalEvidence === "comment") {
await db.insert(issueComments).values({
companyId,
issueId,
authorAgentId: coderId,
authorType: "agent",
createdByRunId: runId,
body: "Completed and verified.",
createdAt: terminalEvidenceAt,
updatedAt: terminalEvidenceAt,
});
}
return { companyId, managerId, coderId, issueId, runId, issuePrefix };
}
@@ -271,6 +317,211 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
expect(source?.status).toBe("blocked");
});
it("folds terminal source issues with same-run durable evidence instead of creating watchdog work", async () => {
const now = new Date("2026-04-22T20:00:00.000Z");
const { companyId, coderId, issueId, runId } = await seedRunningRun({
now,
ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000,
sourceStatus: "done",
sameRunTerminalEvidence: "activity",
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.scanSilentActiveRuns({ now, companyId });
expect(result).toMatchObject({ created: 0, folded: 1, skipped: 0 });
const evaluations = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stale_active_run_evaluation")));
expect(evaluations).toHaveLength(0);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
expect(run?.status).toBe("succeeded");
expect(run?.errorCode).toBeNull();
expect(run?.finishedAt?.toISOString()).toBe(now.toISOString());
expect(run?.resultJson).toMatchObject({
sourceResolvedWatchdogFold: {
sourceIssueId: issueId,
sourceIssueStatus: "done",
sameRunEvidenceKind: "activity",
evaluationIssueId: null,
evaluationIssueIdentifier: null,
cleanup: { outcome: "no_process_metadata" },
},
});
const [source] = await db.select().from(issues).where(eq(issues.id, issueId));
expect(source?.executionRunId).toBeNull();
const [agent] = await db.select().from(agents).where(eq(agents.id, coderId));
expect(agent?.status).toBe("idle");
const [decision] = await db
.select()
.from(heartbeatRunWatchdogDecisions)
.where(eq(heartbeatRunWatchdogDecisions.runId, runId));
expect(decision?.decision).toBe("dismissed_false_positive");
const [event] = await db
.select()
.from(heartbeatRunEvents)
.where(eq(heartbeatRunEvents.runId, runId));
expect(event?.message).toContain("Source-resolved watchdog fold");
});
it("still escalates terminal source issues without same-run terminal evidence", async () => {
const now = new Date("2026-04-22T20:00:00.000Z");
const { companyId, runId } = await seedRunningRun({
now,
ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000,
sourceStatus: "done",
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.scanSilentActiveRuns({ now, companyId });
expect(result).toMatchObject({ created: 1, folded: 0 });
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
expect(run?.status).toBe("running");
const [evaluation] = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stale_active_run_evaluation")));
expect(evaluation?.originId).toBe(runId);
expect(evaluation?.parentId).toBeNull();
});
it("still escalates when a same-run comment is followed by another actor marking the source done", async () => {
const now = new Date("2026-04-22T20:00:00.000Z");
const { companyId, issueId, runId, issuePrefix } = await seedRunningRun({
now,
ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000,
sourceStatus: "in_progress",
sameRunTerminalEvidence: "comment",
});
const completedAt = new Date(now.getTime() - 5 * 60_000);
await db
.update(issues)
.set({ status: "done", completedAt, updatedAt: completedAt })
.where(eq(issues.id, issueId));
await db.insert(activityLog).values({
companyId,
actorType: "user",
actorId: "board-user",
agentId: null,
runId: null,
action: "issue.updated",
entityType: "issue",
entityId: issueId,
details: {
identifier: `${issuePrefix}-1`,
status: "done",
_previous: { status: "in_progress" },
},
createdAt: completedAt,
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.scanSilentActiveRuns({ now, companyId });
expect(result).toMatchObject({ created: 1, folded: 0 });
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
expect(run?.status).toBe("running");
const [evaluation] = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stale_active_run_evaluation")));
expect(evaluation?.originId).toBe(runId);
expect(evaluation?.parentId).toBeNull();
});
it("folds existing evaluation and active watchdog recovery action idempotently", async () => {
const now = new Date("2026-04-22T20:00:00.000Z");
const { companyId, managerId, issueId, runId, issuePrefix } = await seedRunningRun({
now,
ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000,
sourceStatus: "done",
sameRunTerminalEvidence: "activity",
});
const evaluationIssueId = randomUUID();
await db.insert(issues).values({
id: evaluationIssueId,
companyId,
title: "Existing stale evaluation",
status: "todo",
priority: "high",
assigneeAgentId: managerId,
issueNumber: 2,
identifier: `${issuePrefix}-2`,
originKind: "stale_active_run_evaluation",
originId: runId,
originRunId: runId,
originFingerprint: `stale_active_run:${companyId}:${runId}`,
});
await db.insert(issueRelations).values({
companyId,
issueId: evaluationIssueId,
relatedIssueId: issueId,
type: "blocks",
});
await db.insert(issueRecoveryActions).values({
companyId,
sourceIssueId: issueId,
recoveryIssueId: evaluationIssueId,
kind: "active_run_watchdog",
status: "active",
ownerType: "agent",
ownerAgentId: managerId,
cause: "active_run_watchdog",
fingerprint: `active-run-watchdog:${companyId}:${runId}:${issueId}`,
evidence: { runId },
nextAction: "Review stale active run",
});
const heartbeat = heartbeatService(db);
const first = await heartbeat.scanSilentActiveRuns({ now, companyId });
const second = await heartbeat.scanSilentActiveRuns({ now, companyId });
expect(first).toMatchObject({ created: 0, folded: 1 });
expect(second).toMatchObject({ scanned: 0, created: 0, folded: 0 });
const [evaluation] = await db.select().from(issues).where(eq(issues.id, evaluationIssueId));
expect(evaluation?.status).toBe("done");
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
expect(run?.resultJson).toMatchObject({
sourceResolvedWatchdogFold: {
sourceIssueId: issueId,
sourceIssueStatus: "done",
evaluationIssueId,
evaluationIssueIdentifier: `${issuePrefix}-2`,
},
});
const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId));
expect(action?.status).toBe("resolved");
expect(action?.outcome).toBe("false_positive");
const decisions = await db
.select()
.from(heartbeatRunWatchdogDecisions)
.where(eq(heartbeatRunWatchdogDecisions.runId, runId));
expect(decisions).toHaveLength(1);
});
it("refuses recovery-on-recovery stale-run recursion", async () => {
const now = new Date("2026-04-22T20:00:00.000Z");
const { companyId } = await seedRunningRun({
now,
ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000,
sourceOriginKind: "stale_active_run_evaluation",
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.scanSilentActiveRuns({ now, companyId });
expect(result).toMatchObject({ created: 0, skipped: 1 });
const evaluations = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stale_active_run_evaluation")));
expect(evaluations).toHaveLength(1);
});
it("skips snoozed runs and healthy noisy runs", async () => {
const now = new Date("2026-04-22T20:00:00.000Z");
const stale = await seedRunningRun({
@@ -766,13 +766,19 @@ describe("heartbeat comment wake batching", () => {
gateway.releaseFirstWait();
await waitFor(() => gateway.getAgentPayloads().length === 2, 90_000);
await waitFor(() => gateway.getAgentPayloads().length >= 2, 90_000);
await waitFor(async () => {
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
return runs.length === 2 && runs.every((run) => run.status === "succeeded");
.where(eq(heartbeatRuns.agentId, agentId))
.orderBy(asc(heartbeatRuns.createdAt));
const [initialRun, promotedRun] = runs;
return (
initialRun?.id === firstRun?.id &&
initialRun.status === "succeeded" &&
promotedRun?.status === "succeeded"
);
}, 90_000);
const reopenedIssue = await db
@@ -533,7 +533,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
.where(eq(heartbeatRuns.id, secondWake!.id))
.then((rows) => rows[0] ?? null);
return run?.status === "succeeded";
});
}, 10_000);
expect(secondRunSucceeded).toBe(true);
expect(mockAdapterExecute.mock.calls.length).toBeGreaterThanOrEqual(2);
} finally {
@@ -332,6 +332,82 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
});
});
it("treats open recovery issues as active waiting paths for non-assigned-backlog states", async () => {
await enableAutoRecovery();
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
const existingEscalationId = randomUUID();
await db.insert(issues).values({
id: existingEscalationId,
companyId,
title: "Existing liveness unblock work",
status: "todo",
priority: "high",
parentId: blockerIssueId,
assigneeAgentId: managerId,
issueNumber: 5,
identifier: `${`P${companyId.replace(/-/g, "").slice(0, 4)}`}-5`,
originKind: "harness_liveness_escalation",
originId: [
"harness_liveness",
companyId,
blockedIssueId,
"in_review_without_action_path",
blockerIssueId,
].join(":"),
});
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(result.findings).toBe(0);
expect(result.escalationsCreated).toBe(0);
expect(result.existingEscalations).toBe(0);
const escalations = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation")));
expect(escalations).toHaveLength(1);
});
it("keeps active invalid_review_participant recoveries from being retired", async () => {
await enableAutoRecovery();
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
const existingEscalationId = randomUUID();
await db.insert(issues).values({
id: existingEscalationId,
companyId,
title: "Existing invalid review participant unblock work",
status: "todo",
priority: "high",
parentId: blockedIssueId,
assigneeAgentId: managerId,
issueNumber: 5,
identifier: `${`P${companyId.replace(/-/g, "").slice(0, 4)}`}-5`,
originKind: "harness_liveness_escalation",
originId: [
"harness_liveness",
companyId,
blockedIssueId,
"invalid_review_participant",
blockerIssueId,
].join(":"),
});
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(result.findings).toBe(0);
expect(result.escalationsCreated).toBe(0);
expect(result.existingEscalations).toBe(0);
const escalations = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation")));
expect(escalations).toHaveLength(1);
});
it("creates one manager escalation, preserves blockers, and records owner selection", async () => {
await enableAutoRecovery();
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
@@ -724,4 +800,43 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
expect(blockers.some((row) => row.blockerIssueId === closedEscalationId)).toBe(false);
expect(blockers.some((row) => row.blockerIssueId === freshEscalation?.id)).toBe(true);
});
it("removes closed liveness escalations from blocker relations during reconciliation", async () => {
await enableAutoRecovery();
const { companyId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
const heartbeat = heartbeatService(db);
const first = await heartbeat.reconcileIssueGraphLiveness();
expect(first.escalationsCreated).toBe(1);
const escalations = await db
.select()
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, "harness_liveness_escalation"),
),
);
expect(escalations).toHaveLength(1);
await db
.update(issues)
.set({ status: "done", blockedByIssueIds: [] })
.where(eq(issues.id, escalations[0]!.id));
await db
.update(issues)
.set({ status: "done", blockedByIssueIds: [] })
.where(eq(issues.id, blockerIssueId));
const second = await heartbeat.reconcileIssueGraphLiveness();
expect(second.obsoleteRecoveryBlockerRelationsRemoved).toBe(0);
expect(second.doneRecoveryBlockerRelationsRemoved).toBe(1);
const blockers = await db
.select({ blockerIssueId: issueRelations.issueId })
.from(issueRelations)
.where(eq(issueRelations.relatedIssueId, blockedIssueId));
expect(blockers.some((row) => row.blockerIssueId === escalations[0]!.id)).toBe(false);
});
});
@@ -1,18 +1,12 @@
import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
agentRuntimeState,
agentWakeupRequests,
activityLog,
companies,
companySkills,
createDb,
environmentLeases,
environments,
heartbeatRunEvents,
heartbeatRuns,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
@@ -73,16 +67,20 @@ describeEmbeddedPostgres("heartbeat local environment lifecycle", () => {
}, 20_000);
afterEach(async () => {
await db.delete(environmentLeases);
await db.delete(environments);
await db.delete(activityLog);
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(agentRuntimeState);
await db.delete(companySkills);
await db.delete(agents);
await db.delete(companies);
await db.execute(sql.raw(`
TRUNCATE TABLE
"environment_leases",
"environments",
"activity_log",
"heartbeat_run_events",
"heartbeat_runs",
"agent_wakeup_requests",
"agent_runtime_state",
"company_skills",
"agents",
"companies"
RESTART IDENTITY CASCADE
`));
});
afterAll(async () => {
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import type { AdapterModelProfileDefinition } from "../adapters/index.js";
import {
listAdapterModelProfiles,
type AdapterModelProfileDefinition,
} from "../adapters/index.js";
import {
mergeModelProfileAdapterConfig,
normalizeModelProfileWakeContext,
@@ -17,6 +20,27 @@ const cheapProfile: AdapterModelProfileDefinition = {
};
describe("heartbeat model profile application", () => {
it("uses the Codex local adapter cheap default when the agent has no runtime override", async () => {
const modelProfile = resolveModelProfileApplication({
adapterModelProfiles: await listAdapterModelProfiles("codex_local"),
agentRuntimeConfig: {},
issueModelProfile: "cheap",
contextSnapshot: {},
});
expect(modelProfile).toMatchObject({
requested: "cheap",
requestedBy: "issue_override",
applied: "cheap",
configSource: "adapter_default",
fallbackReason: null,
adapterConfig: {
model: "gpt-5.3-codex-spark",
modelReasoningEffort: "high",
},
});
});
it("applies cheap profile patches before explicit issue adapter config overrides", () => {
const modelProfile = resolveModelProfileApplication({
adapterModelProfiles: [cheapProfile],
@@ -405,6 +405,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
includeIssue?: boolean;
runErrorCode?: string | null;
runError?: string | null;
contextSnapshot?: Record<string, unknown>;
}) {
const companyId = randomUUID();
const agentId = randomUUID();
@@ -454,7 +455,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
triggerDetail: "system",
status: input?.runStatus ?? "running",
wakeupRequestId,
contextSnapshot: input?.includeIssue === false ? {} : { issueId },
contextSnapshot: input?.includeIssue === false
? input?.contextSnapshot ?? {}
: { ...(input?.contextSnapshot ?? {}), issueId },
processPid: input?.processPid ?? null,
processGroupId: input?.processGroupId ?? null,
processLossRetryCount: input?.processLossRetryCount ?? 0,
@@ -765,7 +768,12 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
companyId: input.companyId,
reason: "source_scoped_recovery_action",
source: "assignment",
payload: expect.objectContaining({ modelProfile: "cheap" }),
payload: expect.objectContaining({
modelProfile: "cheap",
allowDeliverableWork: false,
allowDocumentUpdates: false,
resumeRequiresNormalModel: true,
}),
});
const recoveryRun = recoveryWakeup?.runId
@@ -783,6 +791,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
sourceIssueId: input.issueId,
strandedRunId: input.runId,
modelProfile: "cheap",
allowDeliverableWork: false,
allowDocumentUpdates: false,
resumeRequiresNormalModel: true,
});
await waitForHeartbeatIdle(db);
const sourceIssue = await db
@@ -920,6 +931,12 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
it("queues exactly one retry when the recorded local pid is dead", async () => {
const { agentId, runId, issueId } = await seedRunFixture({
processPid: 999_999_999,
contextSnapshot: {
modelProfile: "cheap",
allowDeliverableWork: false,
allowDocumentUpdates: false,
resumeRequiresNormalModel: true,
},
});
const heartbeat = heartbeatService(db);
@@ -947,7 +964,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(retryRun?.status).toBe("queued");
expect(retryRun?.retryOfRunId).toBe(runId);
expect(retryRun?.processLossRetryCount).toBe(1);
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
expect(retryRun?.contextSnapshot as Record<string, unknown>).not.toHaveProperty("modelProfile");
const issue = await db
.select()
@@ -1253,8 +1270,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(retryRun?.scheduledRetryReason).toBe("transient_failure");
expect(retryRun?.contextSnapshot).toMatchObject({
codexTransientFallbackMode: "same_session",
modelProfile: "cheap",
});
expect(retryRun?.contextSnapshot as Record<string, unknown>).not.toHaveProperty("modelProfile");
const issue = await db
.select()
@@ -1789,9 +1806,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
payload: expect.objectContaining({
issueId,
mutation: "assigned_todo_liveness_dispatch",
modelProfile: "cheap",
}),
});
expect(wakeups[0]?.payload as Record<string, unknown>).not.toHaveProperty("modelProfile");
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId));
expect(runs).toHaveLength(1);
@@ -1801,8 +1818,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
taskId: issueId,
wakeReason: "issue_assigned",
source: "issue.assigned_todo_liveness_dispatch",
modelProfile: "cheap",
});
expect(runs[0]?.contextSnapshot as Record<string, unknown>).not.toHaveProperty("modelProfile");
expect((runs[0]?.contextSnapshot as Record<string, unknown>)?.retryReason).toBeUndefined();
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
@@ -1909,9 +1926,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
payload: expect.objectContaining({
issueId: unblocked.issueId,
mutation: "assigned_todo_liveness_dispatch",
modelProfile: "cheap",
}),
});
expect(unblockedWakeups[0]?.payload as Record<string, unknown>).not.toHaveProperty("modelProfile");
const unblockedRuns = await db
.select()
.from(heartbeatRuns)
@@ -1963,7 +1980,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const retryRun = runs.find((row) => row.id !== runId);
expect(retryRun?.id).toBeTruthy();
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("assignment_recovery");
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
expect(retryRun?.contextSnapshot as Record<string, unknown>).not.toHaveProperty("modelProfile");
if (retryRun) {
await waitForRunToSettle(heartbeat, retryRun.id);
}
@@ -2002,8 +2019,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
retryReason: "issue_continuation_needed",
retryOfRunId: runId,
source: "issue.continuation_recovery",
modelProfile: "cheap",
});
expect(retryRun?.contextSnapshot as Record<string, unknown>).not.toHaveProperty("modelProfile");
const recoveries = await db
.select()
@@ -2054,7 +2071,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const retryRun = runs.find((row) => row.id !== runId);
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("assignment_recovery");
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
expect(retryRun?.contextSnapshot as Record<string, unknown>).not.toHaveProperty("modelProfile");
if (retryRun) {
await waitForRunToSettle(heartbeat, retryRun.id);
}
@@ -2296,7 +2313,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const retryRun = runs.find((row) => row.id !== runId);
expect(retryRun?.id).toBeTruthy();
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("issue_continuation_needed");
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
expect(retryRun?.contextSnapshot as Record<string, unknown>).not.toHaveProperty("modelProfile");
if (retryRun) {
await waitForRunToSettle(heartbeat, retryRun.id);
}
@@ -2786,8 +2803,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
retryReason: "issue_continuation_needed",
retryOfRunId: runId,
source: "issue.productive_terminal_continuation_recovery",
modelProfile: "cheap",
});
expect(retryRun?.contextSnapshot as Record<string, unknown>).not.toHaveProperty("modelProfile");
const wakeups = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId));
expect(wakeups).toHaveLength(2);
@@ -2854,8 +2871,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
retryReason: "issue_continuation_needed",
retryOfRunId: runId,
source: "issue.productive_terminal_continuation_recovery",
modelProfile: "cheap",
});
expect(retryRun?.contextSnapshot as Record<string, unknown>).not.toHaveProperty("modelProfile");
});
it("does not treat a productive terminal run as healthy when in-progress work has no live path", async () => {
@@ -2910,8 +2927,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
retryReason: "issue_continuation_needed",
retryOfRunId: runId,
source: "issue.productive_terminal_continuation_recovery",
modelProfile: "cheap",
});
expect(retryRun?.contextSnapshot as Record<string, unknown>).not.toHaveProperty("modelProfile");
});
it("does not reconcile user-assigned work through the agent stranded-work recovery path", async () => {
@@ -7,7 +7,7 @@ import {
} from "../services/heartbeat.ts";
describe("resolveExecutionRunAdapterConfig", () => {
it("overlays project env on top of agent env and unions secret keys", async () => {
it("overlays project and routine env on top of agent env and unions secret keys", async () => {
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
config: {
env: {
@@ -29,29 +29,51 @@ describe("resolveExecutionRunAdapterConfig", () => {
},
],
});
const resolveEnvBindings = vi.fn().mockResolvedValue({
env: {
SHARED_KEY: "project",
PROJECT_ONLY: "project-only",
},
secretKeys: new Set(["PROJECT_SECRET"]),
manifest: [
{
configPath: "env.PROJECT_SECRET",
envKey: "PROJECT_SECRET",
secretId: "secret-project",
secretKey: "project-secret",
version: 1,
provider: "local_encrypted",
outcome: "success",
const resolveEnvBindings = vi
.fn()
.mockResolvedValueOnce({
env: {
SHARED_KEY: "project",
PROJECT_ONLY: "project-only",
},
],
});
secretKeys: new Set(["PROJECT_SECRET"]),
manifest: [
{
configPath: "env.PROJECT_SECRET",
envKey: "PROJECT_SECRET",
secretId: "secret-project",
secretKey: "project-secret",
version: 1,
provider: "local_encrypted",
outcome: "success",
},
],
})
.mockResolvedValueOnce({
env: {
SHARED_KEY: "routine",
ROUTINE_ONLY: "routine-only",
},
secretKeys: new Set(["ROUTINE_SECRET"]),
manifest: [
{
configPath: "env.ROUTINE_SECRET",
envKey: "ROUTINE_SECRET",
secretId: "secret-routine",
secretKey: "routine-secret",
version: 1,
provider: "local_encrypted",
outcome: "success",
},
],
});
const result = await resolveExecutionRunAdapterConfig({
companyId: "company-1",
executionRunConfig: { env: { SHARED_KEY: "agent" } },
projectEnv: { SHARED_KEY: "project" },
routineEnv: { SHARED_KEY: "routine" },
routineId: "routine-1",
secretsSvc: {
resolveAdapterConfigForRuntime,
resolveEnvBindings,
@@ -61,18 +83,88 @@ describe("resolveExecutionRunAdapterConfig", () => {
expect(result.resolvedConfig).toMatchObject({
other: "value",
env: {
SHARED_KEY: "project",
SHARED_KEY: "routine",
AGENT_ONLY: "agent-only",
PROJECT_ONLY: "project-only",
ROUTINE_ONLY: "routine-only",
},
});
expect(Array.from(result.secretKeys).sort()).toEqual(["AGENT_SECRET", "PROJECT_SECRET"]);
expect(Array.from(result.secretKeys).sort()).toEqual(["AGENT_SECRET", "PROJECT_SECRET", "ROUTINE_SECRET"]);
expect(result.secretManifest.map((entry) => entry.secretId).sort()).toEqual([
"secret-agent",
"secret-project",
"secret-routine",
]);
expect(JSON.stringify(result.secretManifest)).not.toContain("agent-only");
expect(JSON.stringify(result.secretManifest)).not.toContain("project-only");
expect(JSON.stringify(result.secretManifest)).not.toContain("routine-only");
expect(resolveEnvBindings.mock.calls[1]?.[2]).toMatchObject({
consumerType: "routine",
consumerId: "routine-1",
});
});
it("drops Paperclip runtime-owned env before resolving agent, project, and routine overlays", async () => {
const resolveAdapterConfigForRuntime = vi.fn(async (_companyId, config: Record<string, unknown>) => ({
config: {
...config,
env: { ...(config.env as Record<string, unknown>) },
},
secretKeys: new Set<string>(),
manifest: [],
}));
const resolveEnvBindings = vi.fn(async (_companyId, env: Record<string, unknown>) => ({
env: Object.fromEntries(
Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
),
secretKeys: new Set<string>(),
manifest: [],
}));
const result = await resolveExecutionRunAdapterConfig({
companyId: "company-1",
agentId: "agent-1",
executionRunConfig: {
env: {
PAPERCLIP_API_KEY: { type: "secret_ref", secretId: "secret-api-key", version: "latest" },
PAPERCLIP_AGENT_ID: "spoofed-agent",
AGENT_ONLY: "agent-only",
},
},
projectEnv: {
PAPERCLIP_API_KEY: "project-api-key",
PAPERCLIP_COMPANY_ID: "spoofed-company",
PROJECT_ONLY: "project-only",
},
routineEnv: {
PAPERCLIP_API_KEY: "routine-api-key",
PAPERCLIP_RUN_ID: "spoofed-run",
ROUTINE_ONLY: "routine-only",
},
routineId: "routine-1",
secretsSvc: {
resolveAdapterConfigForRuntime,
resolveEnvBindings,
} as any,
});
expect(resolveAdapterConfigForRuntime.mock.calls[0]?.[1]).toEqual({
env: {
AGENT_ONLY: "agent-only",
},
});
expect(resolveEnvBindings.mock.calls[0]?.[1]).toEqual({
PROJECT_ONLY: "project-only",
});
expect(resolveEnvBindings.mock.calls[1]?.[1]).toEqual({
ROUTINE_ONLY: "routine-only",
});
expect(result.resolvedConfig.env).toEqual({
AGENT_ONLY: "agent-only",
PROJECT_ONLY: "project-only",
ROUTINE_ONLY: "routine-only",
});
expect(JSON.stringify(result.resolvedConfig.env)).not.toContain("PAPERCLIP_");
});
it("skips project env resolution when the project has no bindings", async () => {
@@ -286,8 +286,8 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
retryOfRunId: sourceRunId,
scheduledRetryAttempt: 1,
scheduledRetryReason: "transient_failure",
contextSnapshot: expect.objectContaining({ modelProfile: "cheap" }),
});
expect(retryRun?.contextSnapshot as Record<string, unknown>).not.toHaveProperty("modelProfile");
expect(retryRun?.scheduledRetryAt?.toISOString()).toBe(expectedDueAt.toISOString());
const earlyPromotion = await heartbeat.promoteDueScheduledRetries(new Date("2026-04-20T12:01:59.000Z"));
@@ -21,4 +21,21 @@ describe("compactRunLogChunk", () => {
expect(compacted).toContain("[paperclip truncated run log chunk:");
expect(compacted.endsWith("tail")).toBe(true);
});
it("redacts Paperclip credential shapes before persisting run-log chunks", () => {
const chunk = [
"Authorization: Bearer live-bearer-token-value",
`export PAPERCLIP_API_KEY='paperclip-shell-secret'`,
`payload {"PAPERCLIP_API_KEY":"paperclip-json-secret"}`,
"--paperclip-api-key=paperclip-flag-secret",
].join("\n");
const compacted = compactRunLogChunk(chunk);
expect(compacted).toContain("***REDACTED***");
expect(compacted).not.toContain("live-bearer-token-value");
expect(compacted).not.toContain("paperclip-shell-secret");
expect(compacted).not.toContain("paperclip-json-secret");
expect(compacted).not.toContain("paperclip-flag-secret");
});
});
@@ -2,21 +2,15 @@ import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
agentRuntimeState,
agentWakeupRequests,
companies,
companySkills,
createDb,
documentRevisions,
documents,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
issueDocuments,
issueRelations,
issueTreeHolds,
issues,
} from "@paperclipai/db";
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared";
@@ -89,35 +83,39 @@ async function waitForCondition(fn: () => Promise<boolean>, timeoutMs = 3_000) {
}
async function cleanupHeartbeatInvalidationFixture(db: ReturnType<typeof createDb>) {
for (let attempt = 0; attempt < 5; attempt += 1) {
for (let attempt = 0; attempt < 10; attempt += 1) {
try {
await db.delete(companySkills);
await db.delete(issueComments);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(issueRelations);
await db.delete(issueTreeHolds);
await db.delete(issues);
await db.delete(heartbeatRunEvents);
await db.delete(activityLog);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(agentRuntimeState);
await db.delete(agents);
await db.delete(companies);
await db.execute(sql.raw(`
TRUNCATE TABLE
"company_skills",
"issue_comments",
"issue_documents",
"document_revisions",
"documents",
"issue_relations",
"issue_tree_holds",
"issues",
"heartbeat_run_events",
"activity_log",
"heartbeat_runs",
"agent_wakeup_requests",
"agent_runtime_state",
"agents",
"companies"
RESTART IDENTITY CASCADE
`));
return;
} catch (error) {
const isLateCommentRace =
error instanceof Error &&
error.message.includes("issue_comments_issue_id_issues_id_fk");
if (!isLateCommentRace || attempt === 4) {
if (!isLateCommentRace || attempt === 9) {
throw error;
}
// Heartbeat completion can write issue-thread comments shortly after the
// run leaves queued/running. Retry the dependent deletes once those land.
await new Promise((resolve) => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
}
@@ -322,6 +322,18 @@ describe("shouldResetTaskSessionForWake", () => {
).toBe(true);
});
it("resets session context for accepted planning confirmations that refresh workspace selection", () => {
expect(
shouldResetTaskSessionForWake({
wakeReason: "issue_commented",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
forceFreshSession: true,
workspaceRefreshReason: "accepted_plan_confirmation",
}),
).toBe(true);
});
it("does not reset session context on mention wake comment", () => {
expect(
shouldResetTaskSessionForWake({
@@ -64,6 +64,7 @@ describe("instance settings routes", () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({
enableEnvironments: false,
enableIsolatedWorkspaces: false,
enableCloudSync: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
@@ -81,6 +82,7 @@ describe("instance settings routes", () => {
experimental: {
enableEnvironments: true,
enableIsolatedWorkspaces: true,
enableCloudSync: true,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
@@ -123,6 +125,7 @@ describe("instance settings routes", () => {
expect(getRes.body).toEqual({
enableEnvironments: false,
enableIsolatedWorkspaces: false,
enableCloudSync: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { normalizeExperimentalSettings } from "../services/instance-settings.js";
describe("instance settings service", () => {
it("ignores retired experimental flags without resetting current settings", () => {
expect(normalizeExperimentalSettings({
enableEnvironments: true,
enableIsolatedWorkspaces: true,
enableCloudSync: true,
autoRestartDevServerWhenIdle: true,
enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: 48,
enableNewestFirstIssueThread: true,
})).toEqual({
enableEnvironments: true,
enableIsolatedWorkspaces: true,
enableCloudSync: true,
autoRestartDevServerWhenIdle: true,
enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: 48,
});
});
});
@@ -4,12 +4,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
const accessServiceMock = vi.hoisted(() => ({
isInstanceAdmin: vi.fn(),
canUser: vi.fn(),
hasPermission: vi.fn(),
ensureMembership: vi.fn(),
setPrincipalGrants: vi.fn(),
}));
const logActivityMock = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
accessService: () => ({
isInstanceAdmin: vi.fn(),
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
accessService: () => accessServiceMock,
agentService: () => ({
getById: vi.fn(),
}),
@@ -20,10 +25,36 @@ vi.mock("../services/index.js", () => ({
revokeBoardApiKey: vi.fn(),
}),
deduplicateAgentName: vi.fn(),
logActivity: vi.fn(),
logActivity: logActivityMock,
notifyHireApproved: vi.fn(),
}));
type QueryHooks = {
onSet?: (value: unknown) => void;
onValues?: (value: unknown) => void;
};
function createQuery(rows: unknown[], hooks: QueryHooks = {}) {
const query = {
from: vi.fn(() => query),
where: vi.fn(() => query),
orderBy: vi.fn(() => query),
set: vi.fn((value: unknown) => {
hooks.onSet?.(value);
return query;
}),
values: vi.fn((value: unknown) => {
hooks.onValues?.(value);
return query;
}),
returning: vi.fn(() => query),
then(resolve: (value: unknown[]) => unknown, reject?: (reason: unknown) => unknown) {
return Promise.resolve(rows).then(resolve, reject);
},
};
return query;
}
function createDbStub() {
const updateMock = vi.fn();
const invite = {
@@ -75,22 +106,26 @@ function createDbStub() {
}
function createApp(db: Record<string, unknown>) {
return createAppWithActor(db, {
type: "board",
source: "session",
userId: "user-1",
companyIds: ["company-1"],
memberships: [
{
companyId: "company-1",
membershipRole: "owner",
status: "active",
},
],
});
}
function createAppWithActor(db: Record<string, unknown>, actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
source: "session",
userId: "user-1",
companyIds: ["company-1"],
memberships: [
{
companyId: "company-1",
membershipRole: "owner",
status: "active",
},
],
};
(req as any).actor = actor;
next();
});
app.use(
@@ -106,6 +141,162 @@ function createApp(db: Record<string, unknown>) {
return app;
}
function createDirectHumanInviteDbStub() {
const insertedValues: unknown[] = [];
const updateValues: unknown[] = [];
const invite = {
id: "invite-1",
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "human",
tokenHash: "hash",
defaultsPayload: { human: { role: "owner" } },
expiresAt: new Date("2027-03-10T00:00:00.000Z"),
invitedByUserId: "inviter-user",
revokedAt: null,
acceptedAt: null,
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
};
const createdJoinRequest = {
id: "join-1",
inviteId: "invite-1",
companyId: "company-1",
requestType: "human",
status: "pending_approval",
requestIp: "::ffff:127.0.0.1",
requestingUserId: "invitee-user",
requestEmailSnapshot: "invitee@example.com",
agentName: null,
adapterType: null,
capabilities: null,
agentDefaultsPayload: null,
claimSecretHash: null,
claimSecretExpiresAt: null,
claimSecretConsumedAt: null,
createdAgentId: null,
approvedByUserId: null,
approvedAt: null,
rejectedByUserId: null,
rejectedAt: null,
createdAt: new Date("2026-03-07T00:01:00.000Z"),
updatedAt: new Date("2026-03-07T00:01:00.000Z"),
};
const approvedJoinRequest = {
...createdJoinRequest,
status: "approved",
approvedByUserId: "inviter-user",
approvedAt: new Date("2026-03-07T00:02:00.000Z"),
updatedAt: new Date("2026-03-07T00:02:00.000Z"),
};
const selectResponses = [
[invite],
[{ email: "invitee@example.com" }],
[],
];
const updateResponses = [[], [approvedJoinRequest]];
const insertResponses = [[createdJoinRequest]];
const db = {
select() {
return createQuery(selectResponses.shift() ?? []);
},
update() {
return createQuery(updateResponses.shift() ?? [], {
onSet: (value) => updateValues.push(value),
});
},
insert() {
return createQuery(insertResponses.shift() ?? [], {
onValues: (value) => insertedValues.push(value),
});
},
transaction(callback: (tx: unknown) => unknown) {
return callback(db);
},
};
return { db, insertedValues, updateValues };
}
function createAcceptedHumanInviteReplayDbStub() {
const updateValues: unknown[] = [];
const invite = {
id: "invite-1",
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "human",
tokenHash: "hash",
defaultsPayload: { human: { role: "operator" } },
expiresAt: new Date("2027-03-10T00:00:00.000Z"),
invitedByUserId: "inviter-user",
revokedAt: null,
acceptedAt: new Date("2026-03-07T00:05:00.000Z"),
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:05:00.000Z"),
};
const pendingJoinRequest = {
id: "join-1",
inviteId: "invite-1",
companyId: "company-1",
requestType: "human",
status: "pending_approval",
requestIp: "::ffff:127.0.0.1",
requestingUserId: "invitee-user",
requestEmailSnapshot: "invitee@example.com",
agentName: null,
adapterType: null,
capabilities: null,
agentDefaultsPayload: null,
claimSecretHash: null,
claimSecretExpiresAt: null,
claimSecretConsumedAt: null,
createdAgentId: null,
approvedByUserId: null,
approvedAt: null,
rejectedByUserId: null,
rejectedAt: null,
createdAt: new Date("2026-03-07T00:01:00.000Z"),
updatedAt: new Date("2026-03-07T00:01:00.000Z"),
};
const replayedJoinRequest = {
...pendingJoinRequest,
requestIp: "::ffff:127.0.0.1",
updatedAt: new Date("2026-03-07T00:06:00.000Z"),
};
const approvedJoinRequest = {
...replayedJoinRequest,
status: "approved",
approvedByUserId: "inviter-user",
approvedAt: new Date("2026-03-07T00:07:00.000Z"),
updatedAt: new Date("2026-03-07T00:07:00.000Z"),
};
const selectResponses = [
[invite],
[pendingJoinRequest],
[{ email: "invitee@example.com" }],
[pendingJoinRequest],
];
const updateResponses = [[replayedJoinRequest], [approvedJoinRequest]];
const db = {
select() {
return createQuery(selectResponses.shift() ?? []);
},
update() {
return createQuery(updateResponses.shift() ?? [], {
onSet: (value) => updateValues.push(value),
});
},
insert: vi.fn(),
transaction(callback: (tx: unknown) => unknown) {
return callback(db);
},
};
return { db, updateValues };
}
describe("POST /invites/:token/accept", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -123,4 +314,126 @@ describe("POST /invites/:token/accept", () => {
expect(res.body.error).toBe("You already belong to this company");
expect(updateMock).not.toHaveBeenCalled();
});
it("grants company access immediately for a human invite", async () => {
const { db, insertedValues, updateValues } = createDirectHumanInviteDbStub();
const app = createAppWithActor(db, {
type: "board",
source: "session",
userId: "invitee-user",
companyIds: [],
memberships: [],
});
const res = await request(app)
.post("/api/invites/pcp_invite_test/accept")
.send({ requestType: "human" });
expect(res.status).toBe(202);
expect(res.body.status).toBe("approved");
expect(insertedValues).toEqual([
expect.objectContaining({
inviteId: "invite-1",
companyId: "company-1",
requestType: "human",
status: "pending_approval",
requestingUserId: "invitee-user",
requestEmailSnapshot: "invitee@example.com",
}),
]);
expect(updateValues).toEqual(
expect.arrayContaining([
expect.objectContaining({
acceptedAt: expect.any(Date),
}),
expect.objectContaining({
status: "approved",
approvedByUserId: "inviter-user",
approvedAt: expect.any(Date),
}),
]),
);
expect(accessServiceMock.ensureMembership).toHaveBeenCalledWith(
"company-1",
"user",
"invitee-user",
"owner",
"active",
);
expect(accessServiceMock.setPrincipalGrants).toHaveBeenCalledWith(
"company-1",
"user",
"invitee-user",
expect.arrayContaining([
expect.objectContaining({ permissionKey: "users:invite" }),
expect.objectContaining({ permissionKey: "users:manage_permissions" }),
]),
"inviter-user",
);
expect(logActivityMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "join.approved",
entityId: "join-1",
details: expect.objectContaining({ source: "human_invite_accept" }),
}),
);
});
it("replays a consumed human invite for the same user and repairs company access", async () => {
const { db, updateValues } = createAcceptedHumanInviteReplayDbStub();
const app = createAppWithActor(db, {
type: "board",
source: "session",
userId: "invitee-user",
companyIds: [],
memberships: [],
});
const res = await request(app)
.post("/api/invites/pcp_invite_test/accept")
.send({ requestType: "human" });
expect(res.status).toBe(202);
expect(res.body.status).toBe("approved");
expect(updateValues).toEqual(
expect.arrayContaining([
expect.objectContaining({
requestIp: expect.any(String),
updatedAt: expect.any(Date),
}),
expect.objectContaining({
status: "approved",
approvedByUserId: "inviter-user",
approvedAt: expect.any(Date),
}),
]),
);
expect(updateValues).not.toEqual(
expect.arrayContaining([expect.objectContaining({ acceptedAt: expect.any(Date) })]),
);
expect(accessServiceMock.ensureMembership).toHaveBeenCalledWith(
"company-1",
"user",
"invitee-user",
"operator",
"active",
);
expect(logActivityMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "join.request_replayed",
entityId: "join-1",
details: expect.objectContaining({ inviteReplay: true }),
}),
);
expect(logActivityMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "join.approved",
entityId: "join-1",
details: expect.objectContaining({ source: "human_invite_accept" }),
}),
);
});
});
@@ -68,6 +68,7 @@ describe("human invite roles", () => {
it("maps owner to the full management grant set", () => {
expect(grantsForHumanRole("owner")).toEqual([
{ permissionKey: "agents:create", scope: null },
{ permissionKey: "environments:manage", scope: null },
{ permissionKey: "users:invite", scope: null },
{ permissionKey: "users:manage_permissions", scope: null },
{ permissionKey: "tasks:assign", scope: null },
@@ -75,6 +76,16 @@ describe("human invite roles", () => {
]);
});
it("maps admin to management grants including environment management", () => {
expect(grantsForHumanRole("admin")).toEqual([
{ permissionKey: "agents:create", scope: null },
{ permissionKey: "environments:manage", scope: null },
{ permissionKey: "users:invite", scope: null },
{ permissionKey: "tasks:assign", scope: null },
{ permissionKey: "joins:approve", scope: null },
]);
});
it("defaults legacy or missing roles to operator", () => {
expect(normalizeHumanRole("member")).toBe("operator");
expect(resolveHumanInviteRole(null)).toBe("operator");
@@ -39,7 +39,7 @@ describe("buildInviteOnboardingTextDocument", () => {
allowedHostnames: [],
});
expect(text).toContain("Paperclip OpenClaw Gateway Onboarding");
expect(text).toContain("Paperclip Agent Onboarding");
expect(text).toContain("/api/invites/token-123/accept");
expect(text).toContain("/api/join-requests/{requestId}/claim-api-key");
expect(text).toContain("/api/invites/token-123/onboarding.txt");
@@ -48,14 +48,13 @@ describe("buildInviteOnboardingTextDocument", () => {
expect(text).toContain("http://localhost:3100");
expect(text).toContain("host.docker.internal");
expect(text).toContain("paperclipApiUrl");
expect(text).toContain("adapterType \"openclaw_gateway\"");
expect(text).toContain('"adapterType": "openclaw_gateway"');
expect(text).toContain("headers.x-openclaw-token");
expect(text).toContain("Do NOT use /v1/responses or /hooks/*");
expect(text).toContain("set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl");
expect(text).toContain("~/.openclaw/workspace/paperclip-claimed-api-key.json");
expect(text).toContain("PAPERCLIP_API_KEY");
expect(text).toContain("saved token field");
expect(text).toContain("Gateway token unexpectedly short");
expect(text).toContain("Use your runtime's normal skill or instruction installation path.");
expect(text).toContain("Decide which Paperclip adapter type matches your runtime.");
});
it("includes loopback diagnostics for authenticated/private onboarding", () => {
@@ -106,6 +106,11 @@ function registerModuleMocks() {
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueThreadInteractionService: () => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
@@ -8,10 +8,13 @@ const companyId = "22222222-2222-4222-8222-222222222222";
const ownerAgentId = "33333333-3333-4333-8333-333333333333";
const peerAgentId = "44444444-4444-4444-8444-444444444444";
const ownerRunId = "55555555-5555-4555-8555-555555555555";
const recoveryActionId = "77777777-7777-4777-8777-777777777777";
const mockIssueService = vi.hoisted(() => ({
addComment: vi.fn(),
assertCheckoutOwner: vi.fn(),
create: vi.fn(),
createChild: vi.fn(),
getAttachmentById: vi.fn(),
getByIdentifier: vi.fn(),
getById: vi.fn(),
@@ -27,6 +30,7 @@ const mockIssueService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
}));
@@ -45,7 +49,9 @@ const mockDocumentService = vi.hoisted(() => ({
}));
const mockWorkProductService = vi.hoisted(() => ({
createForIssue: vi.fn(),
getById: vi.fn(),
remove: vi.fn(),
update: vi.fn(),
}));
@@ -62,6 +68,14 @@ const mockIssueThreadInteractionService = vi.hoisted(() => ({
}));
const mockIssueRecoveryActionService = vi.hoisted(() => ({
getActiveForIssue: vi.fn(async () => null),
resolveActiveForIssue: vi.fn(async () => null),
}));
const mockHeartbeatService = vi.hoisted(() => ({
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),
}));
function registerRouteMocks() {
@@ -109,13 +123,7 @@ function registerRouteMocks() {
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),
}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
@@ -184,7 +192,26 @@ function makeAgent(id: string, overrides: Record<string, unknown> = {}) {
};
}
async function createApp(actor: Record<string, unknown>) {
function createRunContextDb(contextSnapshot: Record<string, unknown> = {}) {
return {
transaction: async (callback: (tx: Record<string, never>) => Promise<unknown>) => callback({}),
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
then: async (resolve: (rows: unknown[]) => unknown) =>
resolve([{
id: ownerRunId,
companyId,
agentId: ownerAgentId,
contextSnapshot,
}]),
})),
})),
})),
};
}
async function createApp(actor: Record<string, unknown>, db: unknown = createRunContextDb()) {
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
@@ -195,7 +222,7 @@ async function createApp(actor: Record<string, unknown>) {
(req as any).actor = actor;
next();
});
app.use("/api", issueRoutes({} as any, mockStorageService as any));
app.use("/api", issueRoutes(db as any, mockStorageService as any));
app.use(errorHandler);
return app;
}
@@ -249,6 +276,13 @@ describe("agent issue mutation checkout ownership", () => {
registerRouteMocks();
vi.clearAllMocks();
mockAccessService.canUser.mockReset();
mockAccessService.decide.mockReset();
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "tasks:assign",
action: input.action,
reason: input.action === "tasks:assign" ? "allow_explicit_grant" : "deny_missing_grant",
explanation: input.action === "tasks:assign" ? "Allowed by test assignment default." : "Missing permission.",
}));
mockAccessService.hasPermission.mockReset();
mockAgentService.getById.mockReset();
mockAgentService.list.mockReset();
@@ -256,6 +290,8 @@ describe("agent issue mutation checkout ownership", () => {
mockCompanyService.getById.mockReset();
mockIssueService.addComment.mockReset();
mockIssueService.assertCheckoutOwner.mockReset();
mockIssueService.create.mockReset();
mockIssueService.createChild.mockReset();
mockIssueService.getAttachmentById.mockReset();
mockIssueService.getByIdentifier.mockReset();
mockIssueService.getById.mockReset();
@@ -265,12 +301,53 @@ describe("agent issue mutation checkout ownership", () => {
mockIssueService.listWakeableBlockedDependents.mockReset();
mockIssueRecoveryActionService.getActiveForIssue.mockReset();
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue(null);
mockIssueRecoveryActionService.resolveActiveForIssue.mockReset();
mockIssueRecoveryActionService.resolveActiveForIssue.mockResolvedValue({
id: recoveryActionId,
companyId,
sourceIssueId: issueId,
recoveryIssueId: null,
kind: "issue_graph_liveness",
status: "resolved",
ownerType: "agent",
ownerAgentId,
ownerUserId: null,
previousOwnerAgentId: null,
returnOwnerAgentId: null,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:test",
evidence: {},
nextAction: "Restore a live execution path.",
wakePolicy: null,
monitorPolicy: null,
attemptCount: 1,
maxAttempts: null,
timeoutAt: null,
lastAttemptAt: new Date("2026-05-13T18:00:00.000Z"),
outcome: "restored",
resolutionNote: "Resolved by recovery owner",
resolvedAt: new Date("2026-05-13T18:05:00.000Z"),
createdAt: new Date("2026-05-13T17:55:00.000Z"),
updatedAt: new Date("2026-05-13T18:05:00.000Z"),
});
mockHeartbeatService.wakeup.mockReset();
mockHeartbeatService.wakeup.mockResolvedValue(undefined);
mockHeartbeatService.reportRunActivity.mockReset();
mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined);
mockHeartbeatService.getRun.mockReset();
mockHeartbeatService.getRun.mockResolvedValue(null);
mockHeartbeatService.getActiveRunForAgent.mockReset();
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
mockHeartbeatService.cancelRun.mockReset();
mockHeartbeatService.cancelRun.mockResolvedValue(null);
mockIssueService.remove.mockReset();
mockIssueService.removeAttachment.mockReset();
mockIssueService.update.mockReset();
mockIssueService.findMentionedAgents.mockReset();
mockDocumentService.upsertIssueDocument.mockReset();
mockWorkProductService.createForIssue.mockReset();
mockWorkProductService.getById.mockReset();
mockWorkProductService.remove.mockReset();
mockWorkProductService.update.mockReset();
mockStorageService.putFile.mockReset();
mockStorageService.getObject.mockReset();
@@ -292,6 +369,28 @@ describe("agent issue mutation checkout ownership", () => {
mockIssueService.getById.mockResolvedValue(makeIssue());
mockIssueService.getByIdentifier.mockResolvedValue(null);
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockIssueService.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
...makeIssue({
id: "88888888-8888-4888-8888-888888888888",
status: "todo",
assigneeAgentId: null,
}),
...input,
companyId,
}));
mockIssueService.createChild.mockImplementation(async (_parentId: string, input: Record<string, unknown>) => ({
issue: {
...makeIssue({
id: "99999999-9999-4999-8999-999999999999",
status: "todo",
parentId: issueId,
assigneeAgentId: null,
}),
...input,
companyId,
},
parentBlockerAdded: false,
}));
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
@@ -333,6 +432,14 @@ describe("agent issue mutation checkout ownership", () => {
latestRevisionNumber: 2,
},
});
mockWorkProductService.createForIssue.mockResolvedValue({
id: "product-2",
issueId,
companyId,
type: "artifact",
provider: "test",
title: "Artifact",
});
mockWorkProductService.getById.mockResolvedValue({
id: "product-1",
issueId,
@@ -346,6 +453,12 @@ describe("agent issue mutation checkout ownership", () => {
type: "artifact",
title: "Updated",
});
mockWorkProductService.remove.mockResolvedValue({
id: "product-1",
issueId,
companyId,
type: "artifact",
});
mockStorageService.putFile.mockResolvedValue({
provider: "local_disk",
objectKey: "issues/upload.txt",
@@ -410,10 +523,158 @@ describe("agent issue mutation checkout ownership", () => {
key: "plan",
createdByAgentId: ownerAgentId,
createdByRunId: ownerRunId,
lockedDocumentStrategy: "create_new_document",
}),
);
});
it.each([
[
"work product create",
(app: express.Express) =>
request(app).post(`/api/issues/${issueId}/work-products`).send({
type: "artifact",
provider: "test",
title: "Artifact",
}),
],
["work product update", (app: express.Express) => request(app).patch("/api/work-products/product-1").send({ title: "Blocked" })],
["work product delete", (app: express.Express) => request(app).delete("/api/work-products/product-1")],
[
"attachment upload",
(app: express.Express) =>
request(app)
.post(`/api/companies/${companyId}/issues/${issueId}/attachments`)
.attach("file", Buffer.from("report"), { filename: "report.txt", contentType: "text/plain" }),
],
["attachment delete", (app: express.Express) => request(app).delete("/api/attachments/attachment-1")],
])("blocks cheap status-only recovery runs from %s", async (_name, sendRequest) => {
const app = await createApp(
ownerActor(),
createRunContextDb({
modelProfile: "cheap",
recoveryIntent: "status_only",
allowDeliverableWork: false,
allowDocumentUpdates: false,
resumeRequiresNormalModel: true,
}),
);
const res = await sendRequest(app);
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toContain("Cheap status-only recovery runs cannot update issue documents");
expect(mockIssueService.assertCheckoutOwner).toHaveBeenCalledWith(issueId, ownerAgentId, ownerRunId);
expect(mockWorkProductService.createForIssue).not.toHaveBeenCalled();
expect(mockWorkProductService.update).not.toHaveBeenCalled();
expect(mockWorkProductService.remove).not.toHaveBeenCalled();
expect(mockStorageService.putFile).not.toHaveBeenCalled();
expect(mockStorageService.deleteObject).not.toHaveBeenCalled();
expect(mockIssueService.removeAttachment).not.toHaveBeenCalled();
});
it.each([
[
"issue create",
(app: express.Express) =>
request(app).post(`/api/companies/${companyId}/issues`).send({
title: "Downstream source work",
assigneeAdapterOverrides: { modelProfile: "cheap" },
}),
],
[
"child issue create",
(app: express.Express) =>
request(app).post(`/api/issues/${issueId}/children`).send({
title: "Downstream child source work",
assigneeAdapterOverrides: { modelProfile: "cheap" },
}),
],
[
"issue update",
(app: express.Express) =>
request(app).patch(`/api/issues/${issueId}`).send({
assigneeAdapterOverrides: { modelProfile: "cheap" },
}),
],
])("blocks cheap status-only recovery runs from propagating cheap profile through %s", async (_name, sendRequest) => {
const app = await createApp(
ownerActor(),
createRunContextDb({
modelProfile: "cheap",
recoveryIntent: "status_only",
allowDeliverableWork: false,
allowDocumentUpdates: false,
resumeRequiresNormalModel: true,
}),
);
const res = await sendRequest(app);
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toContain("cannot assign downstream issue work to the cheap model profile");
expect(mockIssueService.create).not.toHaveBeenCalled();
expect(mockIssueService.createChild).not.toHaveBeenCalled();
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("allows board users to set explicit cheap issue assignee profile overrides", async () => {
const app = await createApp(boardActor());
await request(app)
.patch(`/api/issues/${issueId}`)
.send({ assigneeAdapterOverrides: { modelProfile: "cheap" } })
.expect(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
issueId,
expect.objectContaining({
assigneeAdapterOverrides: { modelProfile: "cheap" },
}),
);
});
it("preserves committed issue updates, comments, documents, and work product writes when recovery revalidation fails", async () => {
const app = await createApp(ownerActor());
mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed"));
await request(app)
.patch(`/api/issues/${issueId}`)
.send({ title: "Updated after commit" })
.expect(200);
mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed"));
await request(app)
.post(`/api/issues/${issueId}/comments`)
.send({ body: "progress update" })
.expect(201);
mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed"));
await request(app)
.put(`/api/issues/${issueId}/documents/plan`)
.send({ format: "markdown", body: "# updated" })
.expect(200);
mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed"));
await request(app)
.patch("/api/work-products/product-1")
.send({ title: "Updated product" })
.expect(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
issueId,
expect.objectContaining({ title: "Updated after commit" }),
);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
issueId,
"progress update",
expect.any(Object),
expect.any(Object),
);
expect(mockDocumentService.upsertIssueDocument).toHaveBeenCalled();
expect(mockWorkProductService.update).toHaveBeenCalledWith("product-1", { title: "Updated product" });
});
it("preserves board mutations on active checkouts", async () => {
const app = await createApp(boardActor());
@@ -429,12 +690,12 @@ describe("agent issue mutation checkout ownership", () => {
});
it("allows agents with the active-checkout management grant to mutate active checkouts", async () => {
mockAccessService.hasPermission.mockImplementation(async (
_companyId: string,
_principalType: string,
principalId: string,
permissionKey: string,
) => principalId === peerAgentId && permissionKey === "tasks:manage_active_checkouts");
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "tasks:manage_active_checkouts",
action: input.action,
reason: input.action === "tasks:manage_active_checkouts" ? "allow_explicit_grant" : "deny_missing_grant",
explanation: input.action === "tasks:manage_active_checkouts" ? "Allowed by checkout management grant." : "Missing permission.",
}));
const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Managed update" });
@@ -476,4 +737,136 @@ describe("agent issue mutation checkout ownership", () => {
title: "Claimable update",
});
});
it("rejects peer-agent status updates that would clear a recovery action they do not own", async () => {
mockIssueService.getById.mockResolvedValue(
makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }),
);
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId,
});
const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ status: "todo" });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("Agent cannot resolve another owner's recovery action");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("rejects peer-agent recovery resolution on a board-owned source issue", async () => {
mockIssueService.getById.mockResolvedValue(
makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }),
);
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId,
});
const res = await request(await createApp(peerActor()))
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({
actionId: recoveryActionId,
outcome: "restored",
sourceIssueStatus: "done",
});
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("Agent cannot resolve another owner's recovery action");
expect(mockIssueRecoveryActionService.resolveActiveForIssue).not.toHaveBeenCalled();
});
it("allows the named recovery owner to resolve a board-owned source issue", async () => {
mockIssueService.getById.mockResolvedValue(
makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }),
);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }),
...patch,
}));
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId,
});
const res = await request(await createApp(ownerActor()))
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({
actionId: recoveryActionId,
outcome: "restored",
sourceIssueStatus: "done",
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.update).toHaveBeenCalled();
expect(mockIssueRecoveryActionService.resolveActiveForIssue).toHaveBeenCalled();
});
it("wakes the assigned agent when recovery resolution restores a source issue to todo", async () => {
mockIssueService.getById.mockResolvedValue(
makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }),
);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }),
...patch,
}));
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId,
});
const res = await request(await createApp(ownerActor()))
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({
actionId: recoveryActionId,
outcome: "restored",
sourceIssueStatus: "todo",
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ownerAgentId,
expect.objectContaining({
reason: "issue_recovery_action_restored",
payload: expect.objectContaining({
issueId,
recoveryActionId,
mutation: "recovery_action_resolution",
}),
}),
);
});
it("uses the authorization decision path for assignment changes", async () => {
const decide = vi.fn(async () => ({
allowed: false,
action: "tasks:assign",
reason: "deny_policy_restricted",
explanation: "Target agent requires approval before task assignment.",
}));
(mockAccessService as any).decide = decide;
mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId }));
mockAgentService.resolveByReference.mockResolvedValue({
ambiguous: false,
agent: makeAgent(peerAgentId),
});
const app = await createApp(ownerActor());
const res = await request(app)
.patch(`/api/issues/${issueId}`)
.send({ assigneeAgentId: peerAgentId });
expect(res.status).toBe(403);
expect(res.body.error).toContain("requires approval");
expect(decide).toHaveBeenCalledWith(expect.objectContaining({
action: "tasks:assign",
resource: expect.objectContaining({
type: "issue",
companyId,
issueId,
assigneeAgentId: peerAgentId,
}),
}));
expect(mockIssueService.update).not.toHaveBeenCalled();
});
});
@@ -22,6 +22,12 @@ const mockIssueService = vi.hoisted(() => ({
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(async () => true),
decide: vi.fn(async (input: { action?: string }) => ({
allowed: true,
action: input.action,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant.",
})),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
@@ -76,6 +82,11 @@ vi.mock("../services/index.js", () => ({
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueThreadInteractionService: () => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({
@@ -81,6 +81,11 @@ function registerRouteMocks() {
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueThreadInteractionService: () => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
@@ -116,6 +116,11 @@ function registerServiceMocks() {
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueThreadInteractionService: () => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
@@ -8,6 +8,7 @@ const mockIssueService = vi.hoisted(() => ({
update: vi.fn(),
addComment: vi.fn(),
getDependencyReadiness: vi.fn(),
getCurrentScheduledRetry: vi.fn(),
findMentionedAgents: vi.fn(),
listWakeableBlockedDependents: vi.fn(),
getWakeableParentAfterChildCompletion: vi.fn(),
@@ -15,6 +16,7 @@ const mockIssueService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
}));
@@ -223,10 +225,12 @@ describe.sequential("issue comment reopen routes", () => {
mockIssueService.update.mockReset();
mockIssueService.addComment.mockReset();
mockIssueService.getDependencyReadiness.mockReset();
mockIssueService.getCurrentScheduledRetry.mockReset();
mockIssueService.findMentionedAgents.mockReset();
mockIssueService.listWakeableBlockedDependents.mockReset();
mockIssueService.getWakeableParentAfterChildCompletion.mockReset();
mockAccessService.canUser.mockReset();
mockAccessService.decide.mockReset();
mockAccessService.hasPermission.mockReset();
mockHeartbeatService.wakeup.mockReset();
mockHeartbeatService.reportRunActivity.mockReset();
@@ -300,10 +304,20 @@ describe.sequential("issue comment reopen routes", () => {
allBlockersDone: true,
isDependencyReady: true,
});
mockIssueService.getCurrentScheduledRetry.mockResolvedValue(null);
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockAccessService.canUser.mockResolvedValue(false);
mockAccessService.decide.mockImplementation(async (input: { action?: string }) => {
const allowed = input.action !== "tasks:manage_active_checkouts";
return {
allowed,
action: input.action,
reason: allowed ? "allow_explicit_grant" : "deny_missing_grant",
explanation: allowed ? "Allowed by test grant." : "Missing active checkout override.",
};
});
mockAccessService.hasPermission.mockResolvedValue(false);
mockAgentService.getById.mockResolvedValue(null);
mockAgentService.list.mockResolvedValue([
@@ -564,6 +578,128 @@ describe.sequential("issue comment reopen routes", () => {
));
});
it("moves in-progress issues with a scheduled retry back to todo via POST human comments", async () => {
const issue = {
...makeIssue("in_progress"),
executionRunId: "retry-run-1",
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getCurrentScheduledRetry.mockResolvedValue({
runId: "retry-run-1",
status: "scheduled_retry",
agentId: "22222222-2222-4222-8222-222222222222",
agentName: "CodexCoder",
retryOfRunId: "source-run-1",
scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"),
scheduledRetryAttempt: 1,
scheduledRetryReason: "transient_failure",
error: null,
errorCode: null,
});
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
mockHeartbeatService.cancelRun.mockResolvedValue({
id: "retry-run-1",
companyId: "company-1",
agentId: "22222222-2222-4222-8222-222222222222",
status: "cancelled",
});
const res = await request(await installActor(createApp()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "I added the missing detail; please continue." });
expect(res.status).toBe(201);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
{ status: "todo" },
);
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1");
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.updated",
details: expect.objectContaining({
status: "todo",
scheduledRetrySupersededByComment: true,
scheduledRetryRunId: "retry-run-1",
cancelledScheduledRetryRunId: "retry-run-1",
}),
}),
);
await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
reason: "issue_commented",
payload: expect.objectContaining({
commentId: "comment-1",
mutation: "comment",
}),
contextSnapshot: expect.objectContaining({
wakeReason: "issue_commented",
source: "issue.comment",
}),
}),
));
});
it("does not move scheduled-retry issues to todo when POST comment retry cancellation fails", async () => {
const issue = {
...makeIssue("in_progress"),
executionRunId: "retry-run-1",
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getCurrentScheduledRetry.mockResolvedValue({
runId: "retry-run-1",
status: "scheduled_retry",
agentId: "22222222-2222-4222-8222-222222222222",
agentName: "CodexCoder",
retryOfRunId: "source-run-1",
scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"),
scheduledRetryAttempt: 1,
scheduledRetryReason: "transient_failure",
error: null,
errorCode: null,
});
mockHeartbeatService.cancelRun.mockRejectedValue(new Error("cancel failed"));
const res = await request(await installActor(createApp()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "I added the missing detail; please continue." });
expect(res.status).toBe(500);
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1");
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ action: "issue.updated" }),
);
});
it("keeps ordinary in-progress POST human comments in progress when no scheduled retry exists", async () => {
const issue = makeIssue("in_progress");
mockIssueService.getById.mockResolvedValue(issue);
const res = await request(await installActor(createApp()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "Checking in without retry state." });
expect(res.status).toBe(201);
expect(mockIssueService.getCurrentScheduledRetry).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111");
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
reason: "issue_commented",
}),
));
});
it("passes validated comment presentation fields to trusted board comment writes", async () => {
const app = await installActor(createApp());
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
@@ -727,6 +863,96 @@ describe.sequential("issue comment reopen routes", () => {
));
});
it("moves in-progress issues with a scheduled retry back to todo via the PATCH comment path", async () => {
const issue = {
...makeIssue("in_progress"),
executionRunId: "retry-run-1",
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getCurrentScheduledRetry.mockResolvedValue({
runId: "retry-run-1",
status: "scheduled_retry",
agentId: "22222222-2222-4222-8222-222222222222",
agentName: "CodexCoder",
retryOfRunId: "source-run-1",
scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"),
scheduledRetryAttempt: 1,
scheduledRetryReason: "transient_failure",
error: null,
errorCode: null,
});
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
mockHeartbeatService.cancelRun.mockResolvedValue({
id: "retry-run-1",
companyId: "company-1",
agentId: "22222222-2222-4222-8222-222222222222",
status: "cancelled",
});
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "Retry window is over; please continue." });
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
status: "todo",
actorAgentId: null,
actorUserId: "local-board",
}),
);
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1");
await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
reason: "issue_commented",
payload: expect.objectContaining({
commentId: "comment-1",
mutation: "comment",
}),
}),
));
});
it("does not move scheduled-retry issues to todo when PATCH comment retry cancellation fails", async () => {
const issue = {
...makeIssue("in_progress"),
executionRunId: "retry-run-1",
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getCurrentScheduledRetry.mockResolvedValue({
runId: "retry-run-1",
status: "scheduled_retry",
agentId: "22222222-2222-4222-8222-222222222222",
agentName: "CodexCoder",
retryOfRunId: "source-run-1",
scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"),
scheduledRetryAttempt: 1,
scheduledRetryReason: "transient_failure",
error: null,
errorCode: null,
});
mockHeartbeatService.cancelRun.mockRejectedValue(new Error("cancel failed"));
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "Retry window is over; please continue." });
expect(res.status).toBe(500);
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1");
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ action: "issue.updated" }),
);
});
it("rejects non-assignee agent PATCH comments on closed issues", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.addComment.mockResolvedValue({
@@ -65,6 +65,11 @@ vi.mock("../services/index.js", () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueThreadInteractionService: () => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({
@@ -146,7 +146,34 @@ function registerModuleMocks() {
}));
}
async function createApp() {
function createRunContextDb(contextSnapshot: Record<string, unknown>) {
return {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
then: async (resolve: (rows: unknown[]) => unknown) =>
resolve([{
id: "run-1",
companyId,
agentId: "agent-1",
contextSnapshot,
}]),
})),
})),
})),
};
}
async function createApp(
actor: Express.Request["actor"] = {
type: "board",
userId: "board-user",
companyIds: [companyId],
source: "local_implicit",
isInstanceAdmin: false,
},
db: unknown = {},
) {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
@@ -154,16 +181,10 @@ async function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "board-user",
companyIds: [companyId],
source: "local_implicit",
isInstanceAdmin: false,
};
(req as any).actor = actor;
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use("/api", issueRoutes(db as any, {} as any));
app.use(errorHandler);
return app;
}
@@ -315,6 +336,40 @@ describe("issue document revision routes", () => {
}));
});
it("blocks cheap status-only recovery runs from restoring issue documents", async () => {
mockIssueService.getById.mockResolvedValueOnce({
id: issueId,
companyId,
identifier: "PAP-881",
title: "Document revisions",
status: "todo",
assigneeAgentId: "agent-1",
});
const res = await request(await createApp(
{
type: "agent",
agentId: "agent-1",
companyId,
runId: "run-1",
source: "agent_jwt",
},
createRunContextDb({
modelProfile: "cheap",
recoveryIntent: "status_only",
allowDeliverableWork: false,
allowDocumentUpdates: false,
resumeRequiresNormalModel: true,
}),
))
.post(`/api/issues/${issueId}/documents/plan/revisions/revision-1/restore`)
.send({});
expect(res.status).toBe(403);
expect(res.body.error).toContain("Cheap status-only recovery runs cannot update issue documents");
expect(mockDocumentsService.restoreIssueDocumentRevision).not.toHaveBeenCalled();
});
it("rejects invalid document keys before attempting restore", async () => {
const res = await request(await createApp())
.post(`/api/issues/${issueId}/documents/INVALID KEY/revisions/revision-1/restore`)
@@ -26,6 +26,7 @@ const mockHeartbeatService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(async () => false),
decide: vi.fn(),
hasPermission: vi.fn(async () => false),
}));
@@ -160,6 +161,17 @@ describe("issue execution policy routes", () => {
parentBlockerAdded: false,
});
mockAccessService.canUser.mockResolvedValue(false);
mockAccessService.decide.mockImplementation(async (input: { actor?: { type?: string; source?: string }; action?: string }) => {
const allowed = input.actor?.type === "board" && input.actor.source === "local_implicit"
? true
: Boolean(await mockAccessService.canUser() || await mockAccessService.hasPermission());
return {
allowed,
action: input.action,
reason: allowed ? "allow_explicit_grant" : "deny_missing_grant",
explanation: allowed ? "Allowed by test grant." : `Missing permission: ${input.action ?? "action"}`,
};
});
mockAccessService.hasPermission.mockResolvedValue(false);
});
@@ -5,9 +5,13 @@ import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
agents,
agentWakeupRequests,
activityLog,
companies,
createDb,
environmentLeases,
environments,
heartbeatRuns,
issueComments,
issueRecoveryActions,
issueRelations,
@@ -130,7 +134,11 @@ describeEmbeddedPostgres("issue recovery actions", () => {
afterEach(async () => {
await db.delete(issueRecoveryActions);
await db.delete(issueComments);
await db.delete(environmentLeases);
await db.delete(activityLog);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(environments);
await db.delete(issues);
await db.delete(agents);
await db.delete(companies);
@@ -191,6 +199,24 @@ describeEmbeddedPostgres("issue recovery actions", () => {
return { companyId, managerId, coderId, sourceIssueId, prefix, sourceIssue: sourceIssue! };
}
async function seedHeartbeatRun(input: {
companyId: string;
agentId: string;
runId: string;
issueId?: string;
status?: string;
}) {
await db.insert(heartbeatRuns).values({
id: input.runId,
companyId: input.companyId,
agentId: input.agentId,
invocationSource: "manual",
status: input.status ?? "running",
startedAt: new Date("2026-05-13T18:00:00.000Z"),
contextSnapshot: input.issueId ? { issueId: input.issueId } : undefined,
});
}
function createApp(actor: any = { type: "board", source: "local_implicit" }) {
const app = express();
app.use(express.json());
@@ -545,6 +571,393 @@ describeEmbeddedPostgres("issue recovery actions", () => {
);
});
it("resolves an active recovery action by returning the source issue to todo", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
await db
.update(issues)
.set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" })
.where(eq(issues.id, sourceIssueId));
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "issue_graph_liveness",
ownerType: "agent",
ownerAgentId: managerId,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:try-again",
evidence: { latestIssueStatus: "blocked" },
nextAction: "Restore a live execution path.",
wakePolicy: { type: "manual" },
});
const app = createApp();
const resolved = await request(app)
.post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`)
.send({
actionId: action.id,
outcome: "restored",
sourceIssueStatus: "todo",
resolutionNote: "Try the source issue again.",
})
.expect(200);
expect(resolved.body.issue).toMatchObject({
id: sourceIssueId,
status: "todo",
activeRecoveryAction: null,
});
expect(resolved.body.recoveryAction).toMatchObject({
id: action.id,
status: "resolved",
outcome: "restored",
resolutionNote: "Try the source issue again.",
});
expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toBeNull();
});
it("marks a recovery action stale when a blocked source issue is manually moved to todo", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
await db
.update(issues)
.set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" })
.where(eq(issues.id, sourceIssueId));
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "issue_graph_liveness",
ownerType: "agent",
ownerAgentId: managerId,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:manual-restore",
evidence: { latestIssueStatus: "blocked" },
nextAction: "Restore a live execution path.",
wakePolicy: { type: "manual" },
});
const app = createApp();
const patched = await request(app)
.patch(`/api/issues/${sourceIssueId}`)
.send({ status: "todo" })
.expect(200);
expect(patched.body).toMatchObject({
id: sourceIssueId,
status: "todo",
activeRecoveryAction: null,
});
const [actionRow] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.id, action.id));
expect(actionRow).toMatchObject({
status: "cancelled",
outcome: "cancelled",
resolutionNote: "Recovery action became stale because the source issue was manually moved from blocked to todo.",
});
expect(actionRow?.resolvedAt).toBeTruthy();
expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toBeNull();
const detail = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200);
expect(detail.body.activeRecoveryAction).toBeNull();
const activityRows = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, sourceIssueId));
expect(activityRows.map((row) => row.action)).toEqual(
expect.arrayContaining(["issue.updated", "issue.recovery_action_resolved"]),
);
expect(activityRows.find((row) => row.action === "issue.recovery_action_resolved")?.details).toMatchObject({
source: "source_revalidation",
trigger: "issue_update",
});
});
it("folds stale recovery during read projection after the source issue reaches done", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "issue_graph_liveness",
ownerType: "agent",
ownerAgentId: managerId,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:done-projection",
evidence: { latestIssueStatus: "in_progress" },
nextAction: "Restore a live execution path.",
wakePolicy: { type: "manual" },
});
await db.update(issues).set({ status: "done" }).where(eq(issues.id, sourceIssueId));
const app = createApp();
const detail = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200);
expect(detail.body).toMatchObject({
id: sourceIssueId,
status: "done",
activeRecoveryAction: null,
});
const [actionRow] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.id, action.id));
expect(actionRow).toMatchObject({
status: "cancelled",
outcome: "cancelled",
resolutionNote: "Recovery action became stale because the source issue reached done.",
});
expect(actionRow?.resolvedAt).toBeTruthy();
const activityRows = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, sourceIssueId));
expect(activityRows.find((row) => row.action === "issue.recovery_action_resolved")?.details).toMatchObject({
source: "source_revalidation",
trigger: "read_projection",
recoveryActionId: action.id,
});
});
it("keeps active recovery visible when a plain comment does not create a live path", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
await db
.update(issues)
.set({ assigneeAgentId: null, assigneeUserId: "board-user" })
.where(eq(issues.id, sourceIssueId));
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "issue_graph_liveness",
ownerType: "agent",
ownerAgentId: managerId,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:plain-comment",
evidence: { latestIssueStatus: "in_progress" },
nextAction: "Restore a live execution path.",
wakePolicy: { type: "manual" },
});
const app = createApp();
await request(app)
.post(`/api/issues/${sourceIssueId}/comments`)
.send({ body: "I am looking at this, but not changing the disposition." })
.expect(201);
expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toMatchObject({
id: action.id,
status: "active",
});
const detail = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200);
expect(detail.body.activeRecoveryAction).toMatchObject({ id: action.id });
});
it("folds stale recovery when a structured resume comment restores todo dispatch", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
await db
.update(issues)
.set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" })
.where(eq(issues.id, sourceIssueId));
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "issue_graph_liveness",
ownerType: "agent",
ownerAgentId: managerId,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:resume-comment",
evidence: { latestIssueStatus: "blocked" },
nextAction: "Restore a live execution path.",
wakePolicy: { type: "manual" },
});
const app = createApp();
await request(app)
.post(`/api/issues/${sourceIssueId}/comments`)
.send({ body: "Resume this now.", resume: true })
.expect(201);
const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
expect(sourceIssue?.status).toBe("todo");
const [actionRow] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.id, action.id));
expect(actionRow).toMatchObject({
status: "cancelled",
outcome: "cancelled",
resolutionNote: "Recovery action became stale because the source issue was manually moved from blocked to todo.",
});
expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toBeNull();
const activityRows = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, sourceIssueId));
expect(activityRows.find((row) => row.action === "issue.recovery_action_resolved")?.details).toMatchObject({
source: "source_revalidation",
trigger: "comment",
recoveryActionId: action.id,
});
});
it("rejects peer-agent source issue updates that would hide another owner's recovery action", async () => {
const { companyId, managerId, coderId, sourceIssueId } = await seedCompany();
await db
.update(issues)
.set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" })
.where(eq(issues.id, sourceIssueId));
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "issue_graph_liveness",
ownerType: "agent",
ownerAgentId: managerId,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:peer-status-update",
evidence: { latestIssueStatus: "blocked" },
nextAction: "Restore a live execution path.",
wakePolicy: { type: "manual" },
});
const app = createApp({
type: "agent",
agentId: coderId,
companyId,
runId: randomUUID(),
source: "agent_jwt",
});
await request(app)
.patch(`/api/issues/${sourceIssueId}`)
.send({ status: "todo" })
.expect(403);
const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
expect(sourceIssue?.status).toBe("blocked");
const [actionRow] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.id, action.id));
expect(actionRow).toMatchObject({
status: "active",
outcome: null,
resolvedAt: null,
});
});
it("rejects peer-agent recovery action resolution on a board-owned source issue", async () => {
const { companyId, managerId, coderId, sourceIssueId } = await seedCompany();
await db
.update(issues)
.set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" })
.where(eq(issues.id, sourceIssueId));
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "issue_graph_liveness",
ownerType: "agent",
ownerAgentId: managerId,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:peer-resolution",
evidence: { latestIssueStatus: "blocked" },
nextAction: "Restore a live execution path.",
wakePolicy: { type: "manual" },
});
const app = createApp({
type: "agent",
agentId: coderId,
companyId,
runId: randomUUID(),
source: "agent_jwt",
});
await request(app)
.post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`)
.send({
actionId: action.id,
outcome: "restored",
sourceIssueStatus: "done",
resolutionNote: "Peer agent should not be able to clear this recovery.",
})
.expect(403);
const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
expect(sourceIssue?.status).toBe("blocked");
const [actionRow] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.id, action.id));
expect(actionRow).toMatchObject({
status: "active",
outcome: null,
resolvedAt: null,
});
});
it("allows the named recovery owner to resolve a board-owned source recovery action", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
await db
.update(issues)
.set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" })
.where(eq(issues.id, sourceIssueId));
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "issue_graph_liveness",
ownerType: "agent",
ownerAgentId: managerId,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:owner-resolution",
evidence: { latestIssueStatus: "blocked" },
nextAction: "Restore a live execution path.",
wakePolicy: { type: "manual" },
});
const runId = randomUUID();
const app = createApp({
type: "agent",
agentId: managerId,
companyId,
runId,
source: "agent_jwt",
});
await seedHeartbeatRun({
companyId,
agentId: managerId,
runId,
issueId: sourceIssueId,
});
const resolved = await request(app)
.post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`)
.send({
actionId: action.id,
outcome: "restored",
sourceIssueStatus: "done",
resolutionNote: "Recovery owner verified the work was intentionally completed.",
})
.expect(200);
expect(resolved.body.issue).toMatchObject({
id: sourceIssueId,
status: "done",
activeRecoveryAction: null,
});
expect(resolved.body.recoveryAction).toMatchObject({
id: action.id,
status: "resolved",
outcome: "restored",
});
});
it("rejects blocked recovery resolution when the source issue has no first-class blockers", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
const recoveryActionSvc = issueRecoveryActionService(db);
@@ -58,6 +58,11 @@ function registerModuleMocks() {
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueThreadInteractionService: () => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
@@ -16,6 +16,7 @@ const mockInteractionService = vi.hoisted(() => ({
acceptSuggestedTasks: vi.fn(),
rejectInteraction: vi.fn(),
rejectSuggestedTasks: vi.fn(),
expireRequestConfirmationsSupersededByHistoricalComments: vi.fn(),
answerQuestions: vi.fn(),
cancelQuestions: vi.fn(),
}));
@@ -42,6 +43,12 @@ function registerModuleMocks() {
}),
accessService: () => ({
canUser: vi.fn(async () => true),
decide: vi.fn(async (input: { action?: string }) => ({
allowed: true,
action: input.action,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant.",
})),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
@@ -106,6 +113,7 @@ function createIssue(overrides: Record<string, unknown> = {}) {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "in_progress",
workMode: "standard",
priority: "medium",
projectId: null,
goalId: null,
@@ -155,6 +163,7 @@ describe.sequential("issue thread interaction routes", () => {
vi.clearAllMocks();
mockIssueService.getById.mockResolvedValue(createIssue());
mockInteractionService.listForIssue.mockResolvedValue([]);
mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments.mockResolvedValue([]);
mockInteractionService.create.mockResolvedValue({
id: "interaction-1",
companyId: "company-1",
@@ -287,6 +296,18 @@ describe.sequential("issue thread interaction routes", () => {
});
it("lists and creates board-authored interactions", async () => {
mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments.mockResolvedValueOnce([
{
id: "interaction-expired",
kind: "request_confirmation",
status: "expired",
result: {
version: 1,
outcome: "superseded_by_comment",
commentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
},
},
]);
mockInteractionService.listForIssue.mockResolvedValue([
{ id: "interaction-1", kind: "suggest_tasks", status: "pending" },
]);
@@ -297,6 +318,24 @@ describe.sequential("issue thread interaction routes", () => {
expect(listRes.body).toEqual([
{ id: "interaction-1", kind: "suggest_tasks", status: "pending" },
]);
expect(mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments).toHaveBeenCalledWith(
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.thread_interaction_expired",
details: expect.objectContaining({
interactionId: "interaction-expired",
interactionKind: "request_confirmation",
source: "issue.interactions.catchup_superseded_by_comment",
result: expect.objectContaining({
outcome: "superseded_by_comment",
commentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
}),
}),
}),
);
const createRes = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions")
@@ -481,6 +520,57 @@ describe.sequential("issue thread interaction routes", () => {
);
});
it("forces a fresh workspace-aware session when accepting a planning confirmation", async () => {
mockIssueService.getById.mockResolvedValueOnce(createIssue({ workMode: "planning" }));
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
interaction: {
id: "interaction-plan",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee_on_accept",
idempotencyKey: "confirmation:issue:plan:revision",
sourceCommentId: null,
sourceRunId: "run-plan",
payload: {
version: 1,
prompt: "Approve this plan?",
},
result: {
version: 1,
outcome: "accepted",
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
},
createdIssues: [],
});
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-plan/accept")
.send({});
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
reason: "issue_commented",
contextSnapshot: expect.objectContaining({
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
interactionId: "interaction-plan",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
forceFreshSession: true,
workspaceRefreshReason: "accepted_plan_confirmation",
}),
}),
);
});
it("wakes the returned agent when accepting an agent-authored confirmation from a board review assignee", async () => {
mockIssueService.getById.mockResolvedValueOnce(createIssue({
status: "in_review",
@@ -9,6 +9,7 @@ import {
documents,
goals,
heartbeatRuns,
issueComments,
issueDocuments,
instanceSettings,
issueRelations,
@@ -41,6 +42,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
afterEach(async () => {
await db.delete(issueThreadInteractions);
await db.delete(issueComments);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
await db.delete(documents);
@@ -57,6 +59,37 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
await tempDb?.cleanup();
});
async function seedConfirmationIssue(title = "Comment supersede") {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title,
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
return { companyId, goalId, issueId };
}
it("accepts suggested tasks by creating a rooted issue tree under the current issue", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
@@ -783,35 +816,10 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
});
});
it("expires supersedable request confirmations when a user comments", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
it("expires request confirmations opted into user-comment supersede after creation", async () => {
const { companyId, issueId } = await seedConfirmationIssue();
const commentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Comment supersede",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
@@ -831,6 +839,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
companyId,
}, {
id: commentId,
createdAt: new Date(new Date(created.createdAt).getTime() + 1_000),
authorUserId: "local-board",
}, {
userId: "local-board",
@@ -849,6 +858,160 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
});
});
it("keeps request confirmations pending unless user-comment supersede is explicitly enabled", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Comment supersede opt-out");
await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Proceed with the current draft?",
},
}, {
userId: "local-board",
});
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
id: issueId,
companyId,
}, {
id: randomUUID(),
createdAt: new Date(Date.now() + 1_000),
authorUserId: "local-board",
}, {
userId: "local-board",
});
expect(expired).toHaveLength(0);
const rows = await db.select().from(issueThreadInteractions);
expect(rows).toHaveLength(1);
expect(rows[0]?.status).toBe("pending");
});
it("does not supersede request confirmations for agent, system, or older user comments", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Comment supersede exclusions");
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Proceed with the current draft?",
supersedeOnUserComment: true,
},
}, {
userId: "local-board",
});
const createdAtMs = new Date(created.createdAt).getTime();
await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({
id: issueId,
companyId,
}, {
id: randomUUID(),
createdAt: new Date(createdAtMs + 1_000),
authorUserId: null,
}, {
agentId: randomUUID(),
})).resolves.toHaveLength(0);
await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({
id: issueId,
companyId,
}, {
id: randomUUID(),
createdAt: new Date(createdAtMs + 1_000),
authorUserId: null,
}, {})).resolves.toHaveLength(0);
await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({
id: issueId,
companyId,
}, {
id: randomUUID(),
createdAt: new Date(createdAtMs - 1_000),
authorUserId: "local-board",
}, {
userId: "local-board",
})).resolves.toHaveLength(0);
const rows = await db.select().from(issueThreadInteractions);
expect(rows).toHaveLength(1);
expect(rows[0]?.status).toBe("pending");
});
it("repairs historical request confirmations superseded by later user comments idempotently", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Historical comment supersede");
const commentId = randomUUID();
const createdAt = new Date("2026-05-18T12:00:00.000Z");
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Proceed with the current draft?",
supersedeOnUserComment: true,
},
}, {
userId: "local-board",
});
await db
.update(issueThreadInteractions)
.set({ createdAt, updatedAt: createdAt })
.where(eq(issueThreadInteractions.id, created.id));
await db.insert(issueComments).values({
id: randomUUID(),
companyId,
issueId,
authorType: "system",
body: "System-side progress note.",
createdAt: new Date("2026-05-18T12:00:30.000Z"),
updatedAt: new Date("2026-05-18T12:00:30.000Z"),
});
await db.insert(issueComments).values({
id: commentId,
companyId,
issueId,
authorUserId: "local-board",
authorType: "user",
body: "Please revise this first.",
createdAt: new Date("2026-05-18T12:01:00.000Z"),
updatedAt: new Date("2026-05-18T12:01:00.000Z"),
});
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({
id: issueId,
companyId,
});
expect(expired).toHaveLength(1);
expect(expired[0]).toMatchObject({
id: created.id,
status: "expired",
result: {
version: 1,
outcome: "superseded_by_comment",
commentId,
},
resolvedByAgentId: null,
resolvedByUserId: "local-board",
});
await expect(interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({
id: issueId,
companyId,
})).resolves.toEqual([]);
});
it("expires request confirmations when the watched issue document revision changes", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
@@ -12,6 +12,7 @@ const mockIssueService = vi.hoisted(() => ({
getRelationSummaries: vi.fn(),
listWakeableBlockedDependents: vi.fn(),
getWakeableParentAfterChildCompletion: vi.fn(),
getCurrentScheduledRetry: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
@@ -32,6 +33,12 @@ vi.mock("../services/index.js", () => ({
}),
accessService: () => ({
canUser: vi.fn(async () => true),
decide: vi.fn(async (input: { action?: string }) => ({
allowed: true,
action: input.action,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant.",
})),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
@@ -94,6 +101,12 @@ function registerModuleMocks() {
}),
accessService: () => ({
canUser: vi.fn(async () => true),
decide: vi.fn(async (input: { action?: string }) => ({
allowed: true,
action: input.action,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant.",
})),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
@@ -205,6 +218,7 @@ describe("issue update comment wakeups", () => {
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
mockIssueService.getCurrentScheduledRetry.mockResolvedValue(null);
});
it("includes the new comment in assignment wakes from issue updates", async () => {
@@ -119,6 +119,11 @@ function registerRouteMocks() {
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueThreadInteractionService: () => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
@@ -115,6 +115,11 @@ vi.mock("../services/index.js", () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueThreadInteractionService: () => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}),
issueReferenceService: () => mockIssueReferenceService,
issueService: () => mockIssueService,
logActivity: mockLogActivity,
+189
View File
@@ -380,6 +380,46 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
expect(result.map((issue) => issue.id)).toEqual([titleMatchId, descriptionMatchId]);
});
it("can page issues by most recently updated before priority", async () => {
const companyId = randomUUID();
const oldCriticalIssueId = randomUUID();
const recentMediumIssueId = randomUUID();
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: oldCriticalIssueId,
companyId,
title: "Old critical issue",
status: "todo",
priority: "critical",
updatedAt: new Date("2026-05-01T10:00:00.000Z"),
},
{
id: recentMediumIssueId,
companyId,
title: "Recent medium issue",
status: "todo",
priority: "medium",
updatedAt: new Date("2026-05-17T21:12:29.993Z"),
},
]);
const result = await svc.list(companyId, {
limit: 1,
sortField: "updated",
sortDir: "desc",
});
expect(result.map((issue) => issue.id)).toEqual([recentMediumIssueId]);
});
it("ranks comment matches ahead of description-only matches", async () => {
const companyId = randomUUID();
const commentMatchId = randomUUID();
@@ -733,6 +773,8 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
const normalIssueId = randomUUID();
const pluginVisibleIssueId = randomUUID();
const operationIssueId = randomUUID();
const typedOperationIssueId = randomUUID();
const legacyContentMachineOperationIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
@@ -786,12 +828,36 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
originKind: "plugin:paperclip.missions:operation",
originId: "mission-alpha:operation-1",
},
{
id: typedOperationIssueId,
companyId,
projectId,
title: "Typed plugin operation issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
originKind: "plugin:paperclip.missions:operation:evaluation",
originId: "mission-alpha:operation-2",
},
{
id: legacyContentMachineOperationIssueId,
companyId,
projectId,
title: "Legacy Content Machine operation issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
originKind: "plugin:paperclipai.content-machine:evaluation",
originId: "content-machine-operation-1",
},
]);
const defaultIssueIds = (await svc.list(companyId)).map((issue) => issue.id);
expect(defaultIssueIds).toContain(normalIssueId);
expect(defaultIssueIds).toContain(pluginVisibleIssueId);
expect(defaultIssueIds).not.toContain(operationIssueId);
expect(defaultIssueIds).not.toContain(typedOperationIssueId);
expect(defaultIssueIds).not.toContain(legacyContentMachineOperationIssueId);
const inboxIssueIds = (await svc.list(companyId, {
assigneeAgentId: agentId,
@@ -800,17 +866,28 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
})).map((issue) => issue.id);
expect(inboxIssueIds).toContain(normalIssueId);
expect(inboxIssueIds).not.toContain(operationIssueId);
expect(inboxIssueIds).not.toContain(typedOperationIssueId);
expect(inboxIssueIds).not.toContain(legacyContentMachineOperationIssueId);
await expect(svc.list(companyId, { originKind: "plugin:paperclip.missions:operation" }))
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
await expect(svc.list(companyId, { originId: "mission-alpha:operation-1" }))
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
await expect(svc.list(companyId, { originKindPrefix: "plugin:paperclip.missions:operation" }))
.resolves.toEqual(expect.arrayContaining([
expect.objectContaining({ id: operationIssueId }),
expect.objectContaining({ id: typedOperationIssueId }),
]));
const projectIssueIds = (await svc.list(companyId, { projectId })).map((issue) => issue.id);
expect(projectIssueIds).toContain(operationIssueId);
expect(projectIssueIds).toContain(typedOperationIssueId);
expect(projectIssueIds).toContain(legacyContentMachineOperationIssueId);
const advancedIssueIds = (await svc.list(companyId, { includePluginOperations: true })).map((issue) => issue.id);
expect(advancedIssueIds).toContain(operationIssueId);
expect(advancedIssueIds).toContain(typedOperationIssueId);
expect(advancedIssueIds).toContain(legacyContentMachineOperationIssueId);
});
it("excludes plugin operation issues from unread inbox counts", async () => {
@@ -1222,6 +1299,72 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
expect(comments[0]?.body).toBe("Comment should be visible");
});
it("lists user comments when a candidate attribution run log is missing", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const commentId = 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: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Comments issue with missing run log",
status: "todo",
priority: "medium",
});
await db.insert(heartbeatRuns).values({
id: randomUUID(),
companyId,
agentId,
contextSnapshot: { issueId },
createdAt: new Date("2026-05-12T22:58:00.000Z"),
startedAt: new Date("2026-05-12T22:58:00.000Z"),
finishedAt: new Date("2026-05-12T23:14:00.000Z"),
logStore: "local_file",
logRef: "missing/run-log.ndjson",
logBytes: 128,
});
await db.insert(issueComments).values({
id: commentId,
companyId,
issueId,
authorUserId: "user-1",
body: "Comment should still be visible",
createdAt: new Date("2026-05-12T23:00:00.000Z"),
updatedAt: new Date("2026-05-12T23:00:00.000Z"),
});
const comments = await svc.listComments(issueId, {
order: "desc",
limit: 50,
});
expect(comments.map((comment) => comment.id)).toEqual([commentId]);
expect(comments[0]?.body).toBe("Comment should still be visible");
expect(comments[0]?.metadata).toBeNull();
});
it("includes blockedBy summaries on list rows in one batched pass", async () => {
const companyId = randomUUID();
const blockerId = randomUUID();
@@ -2342,6 +2485,52 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
});
});
it("unblocks a source issue when a liveness escalation recovery issue is marked done", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const sourceIssueId = randomUUID();
const recoveryIssueId = randomUUID();
await db.insert(issues).values([
{
id: sourceIssueId,
companyId,
title: "Source issue",
status: "blocked",
priority: "medium",
},
{
id: recoveryIssueId,
companyId,
title: "Liveness escalation issue",
status: "in_progress",
priority: "high",
originKind: "harness_liveness_escalation",
originId: `harness_liveness:${companyId}:${sourceIssueId}:invalid_review_participant:none`,
},
]);
await svc.update(sourceIssueId, {
blockedByIssueIds: [recoveryIssueId],
});
await expect(svc.getRelationSummaries(sourceIssueId)).resolves.toMatchObject({
blockedBy: [expect.objectContaining({ id: recoveryIssueId })],
});
await svc.update(recoveryIssueId, {
status: "done",
});
await expect(svc.getRelationSummaries(sourceIssueId)).resolves.toMatchObject({
blockedBy: [],
});
});
it("rejects execution when unresolved blockers remain", async () => {
const companyId = randomUUID();
const assigneeAgentId = randomUUID();
@@ -0,0 +1,182 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { companies, createDb } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
import type { StorageService } from "../storage/types.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres multilingual issue route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("multilingual issue routes", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let app!: ReturnType<typeof createApp>;
let companyId!: string;
const title = "验证中文任务";
const description = [
"请用中文回复并保留上下文。",
"日本語: 次の手順を書いてください。",
"हिन्दी: कृपया स्थिति बताएं।",
].join("\n");
const firstReply = [
"结果: 中文响应保留。",
"日本語の返信も保持。",
"हिन्दी उत्तर भी सुरक्षित है।",
].join("\n");
const completionNote = [
"完成: 已验证中文。",
"日本語: 完了しました。",
"हिन्दी: सत्यापन पूरा हुआ।",
].join("\n");
const documentBody = [
"# QA notes",
"",
"- 中文: 可以创建、读取、搜索、评论。",
"- 日本語: ドキュメント本文を保持します。",
"- हिन्दी: दस्तावेज़ पाठ सुरक्षित रहता है।",
].join("\n");
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-multilingual-issues-");
db = createDb(tempDb.connectionString);
companyId = randomUUID();
app = createApp(companyId);
await db.insert(companies).values({
id: companyId,
name: "Multilingual tenant",
issuePrefix: "LNG",
requireBoardApprovalForNewAgents: false,
});
}, 20_000);
afterAll(async () => {
await tempDb?.cleanup();
});
function createStorage(): StorageService {
return {
provider: "local_disk",
putFile: vi.fn(async () => {
throw new Error("Unexpected storage.putFile call in multilingual issue route test");
}),
getObject: vi.fn(async () => {
throw new Error("Unexpected storage.getObject call in multilingual issue route test");
}),
headObject: vi.fn(async () => ({ exists: false })),
deleteObject: vi.fn(async () => undefined),
};
}
function createApp(companyId: string) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "cloud-user-1",
companyIds: [companyId],
memberships: [{ companyId, membershipRole: "owner", status: "active" }],
source: "cloud_tenant",
isInstanceAdmin: true,
};
next();
});
app.use("/api", issueRoutes(db, createStorage()));
app.use(errorHandler);
return app;
}
it("creates an issue with multilingual title and description", async () => {
const createRes = await request(app)
.post(`/api/companies/${companyId}/issues`)
.send({
title,
description,
status: "todo",
priority: "medium",
});
expect(createRes.status, JSON.stringify(createRes.body)).toBe(201);
expect(createRes.body).toMatchObject({
title,
description,
status: "todo",
priority: "medium",
identifier: "LNG-1",
});
});
it("reads the multilingual title and description unchanged", async () => {
const getRes = await request(app).get("/api/issues/LNG-1");
expect(getRes.status, JSON.stringify(getRes.body)).toBe(200);
expect(getRes.body.title).toBe(title);
expect(getRes.body.description).toBe(description);
});
it("finds the issue by Chinese search text", async () => {
const searchRes = await request(app).get(`/api/companies/${companyId}/issues`).query({ q: "中文" });
expect(searchRes.status, JSON.stringify(searchRes.body)).toBe(200);
expect(searchRes.body.map((issue: { identifier: string }) => issue.identifier)).toContain("LNG-1");
});
it("preserves multilingual comment bodies", async () => {
const commentRes = await request(app)
.post("/api/issues/LNG-1/comments")
.send({ body: firstReply });
expect(commentRes.status, JSON.stringify(commentRes.body)).toBe(201);
expect(commentRes.body.body).toBe(firstReply);
});
it("preserves multilingual document bodies", async () => {
const documentRes = await request(app)
.put("/api/issues/LNG-1/documents/qa-notes")
.send({
title: "Multilingual QA",
format: "markdown",
body: documentBody,
});
expect(documentRes.status, JSON.stringify(documentRes.body)).toBe(201);
expect(documentRes.body.body).toBe(documentBody);
});
it("preserves multilingual completion comments", async () => {
const completeRes = await request(app)
.patch("/api/issues/LNG-1")
.send({ status: "done", comment: completionNote });
expect(completeRes.status, JSON.stringify(completeRes.body)).toBe(200);
expect(completeRes.body.status).toBe("done");
expect(completeRes.body.comment.body).toBe(completionNote);
});
it("lists multilingual comments in write order", async () => {
const commentsRes = await request(app).get("/api/issues/LNG-1/comments").query({ order: "asc" });
expect(commentsRes.status, JSON.stringify(commentsRes.body)).toBe(200);
expect(commentsRes.body.map((comment: { body: string }) => comment.body)).toEqual([
firstReply,
completionNote,
]);
});
it("exposes multilingual issue text in heartbeat context", async () => {
const heartbeatContextRes = await request(app).get("/api/issues/LNG-1/heartbeat-context");
expect(heartbeatContextRes.status, JSON.stringify(heartbeatContextRes.body)).toBe(200);
expect(heartbeatContextRes.body.issue.title).toBe(title);
expect(heartbeatContextRes.body.issue.description).toBe(description);
expect(heartbeatContextRes.body.commentCursor.totalComments).toBe(2);
});
});
@@ -0,0 +1,348 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
assets,
companies,
companyMemberships,
createDb,
documents,
heartbeatRuns,
issueAttachments,
issueComments,
issueDocuments,
issues,
issueWorkProducts,
principalPermissionGrants,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
vi.hoisted(() => {
process.env.PAPERCLIP_HOME = "/tmp/paperclip-test-home";
process.env.PAPERCLIP_INSTANCE_ID = "vitest";
process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs";
process.env.PAPERCLIP_IN_WORKTREE = "false";
});
vi.mock("../services/issue-assignment-wakeup.js", () => ({
queueIssueAssignmentWakeup: vi.fn(),
}));
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
type Db = ReturnType<typeof createDb>;
function agentActor(companyId: string, agentId: string): Express.Request["actor"] {
return {
type: "agent",
agentId,
companyId,
runId: null,
source: "agent_jwt",
};
}
async function createApp(db: Db, actor: Express.Request["actor"]) {
process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs";
process.env.PAPERCLIP_IN_WORKTREE = "false";
const [{ activityRoutes }, { issueRoutes }] = await Promise.all([
import("../routes/activity.js"),
import("../routes/issues.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
app.use("/api", issueRoutes(db, {} as any));
app.use("/api", activityRoutes(db));
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(err.status ?? 500).json({ error: err.message ?? "Internal server error" });
});
return app;
}
async function seedCompany(db: Db, label: string) {
return db
.insert(companies)
.values({
name: `Permissions Boundary ${label}`,
issuePrefix: `PB${randomUUID().replace(/-/g, "").slice(0, 6).toUpperCase()}`,
})
.returning()
.then((rows) => rows[0]!);
}
async function seedAgent(
db: Db,
companyId: string,
input: { role?: string; permissions?: Record<string, unknown>; status?: "active" | "idle" } = {},
) {
return db
.insert(agents)
.values({
companyId,
name: `Agent ${randomUUID()}`,
role: input.role ?? "engineer",
status: input.status ?? "active",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: input.permissions ?? {},
})
.returning()
.then((rows) => rows[0]!);
}
describeEmbeddedPostgres("permissions upgrade visibility and route boundaries", () => {
let db!: Db;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-permissions-boundary-routes-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(issueAttachments);
await db.delete(assets);
await db.delete(issueDocuments);
await db.delete(documents);
await db.delete(issueWorkProducts);
await db.delete(issueComments);
await db.delete(activityLog);
await db.delete(principalPermissionGrants);
await db.delete(companyMemberships);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("keeps V1 private agent visibility from becoming issue, comment, document, attachment, activity, or work product privacy", async () => {
const company = await seedCompany(db, "Visibility");
const readerAgent = await seedAgent(db, company.id);
const privateTargetAgent = await seedAgent(db, company.id, {
permissions: {
authorizationPolicy: {
agentVisibility: {
mode: "private",
hiddenFromDefaultDirectory: true,
},
assignmentPolicy: { mode: "protected" },
protectedAgent: { requiresApproval: false },
managedBy: "permissions-extension",
},
},
});
const issue = await db
.insert(issues)
.values({
companyId: company.id,
identifier: `${company.issuePrefix}-1`,
title: "Visible work for a private target agent",
status: "todo",
priority: "medium",
assigneeAgentId: privateTargetAgent.id,
})
.returning()
.then((rows) => rows[0]!);
const comment = await db
.insert(issueComments)
.values({
companyId: company.id,
issueId: issue.id,
authorAgentId: privateTargetAgent.id,
body: "Private target agent status is still company-visible.",
})
.returning()
.then((rows) => rows[0]!);
const doc = await db
.insert(documents)
.values({
companyId: company.id,
title: "Plan",
latestBody: "Shared plan body",
createdByAgentId: privateTargetAgent.id,
updatedByAgentId: privateTargetAgent.id,
})
.returning()
.then((rows) => rows[0]!);
await db.insert(issueDocuments).values({
companyId: company.id,
issueId: issue.id,
documentId: doc.id,
key: "plan",
});
const asset = await db
.insert(assets)
.values({
companyId: company.id,
provider: "local_disk",
objectKey: `attachments/${randomUUID()}.txt`,
contentType: "text/plain",
byteSize: 12,
sha256: "abc123",
originalFilename: "note.txt",
createdByAgentId: privateTargetAgent.id,
})
.returning()
.then((rows) => rows[0]!);
await db.insert(issueAttachments).values({
companyId: company.id,
issueId: issue.id,
issueCommentId: comment.id,
assetId: asset.id,
});
await db.insert(issueWorkProducts).values({
companyId: company.id,
issueId: issue.id,
type: "url",
provider: "test",
title: "Preview",
url: "https://example.test/preview",
status: "ready",
});
await db.insert(activityLog).values({
companyId: company.id,
actorType: "agent",
actorId: privateTargetAgent.id,
agentId: privateTargetAgent.id,
action: "issue.updated",
entityType: "issue",
entityId: issue.id,
details: { source: "test" },
});
const app = await createApp(db, agentActor(company.id, readerAgent.id));
const [issueList, comments, docs, docDetail, attachments, activity, workProducts] = await Promise.all([
request(app).get(`/api/companies/${company.id}/issues`),
request(app).get(`/api/issues/${issue.id}/comments`),
request(app).get(`/api/issues/${issue.id}/documents`),
request(app).get(`/api/issues/${issue.id}/documents/plan`),
request(app).get(`/api/issues/${issue.id}/attachments`),
request(app).get(`/api/issues/${issue.id}/activity`),
request(app).get(`/api/issues/${issue.id}/work-products`),
]);
expect(issueList.status, JSON.stringify(issueList.body)).toBe(200);
expect(issueList.body.items ?? issueList.body).toEqual(
expect.arrayContaining([expect.objectContaining({ id: issue.id })]),
);
expect(comments.status, JSON.stringify(comments.body)).toBe(200);
expect(comments.body).toEqual(expect.arrayContaining([expect.objectContaining({ id: comment.id })]));
expect(docs.status, JSON.stringify(docs.body)).toBe(200);
expect(docs.body).toEqual(expect.arrayContaining([expect.objectContaining({ key: "plan" })]));
expect(docDetail.status, JSON.stringify(docDetail.body)).toBe(200);
expect(docDetail.body.body ?? docDetail.body.latestBody).toContain("Shared plan body");
expect(attachments.status, JSON.stringify(attachments.body)).toBe(200);
expect(attachments.body).toEqual(expect.arrayContaining([expect.objectContaining({ id: expect.any(String) })]));
expect(activity.status, JSON.stringify(activity.body)).toBe(200);
expect(activity.body).toEqual(expect.arrayContaining([expect.objectContaining({ action: "issue.updated" })]));
expect(workProducts.status, JSON.stringify(workProducts.body)).toBe(200);
expect(workProducts.body).toEqual(expect.arrayContaining([expect.objectContaining({ title: "Preview" })]));
});
it("denies cross-company issue reads before private-agent grant evaluation can matter", async () => {
const sourceCompany = await seedCompany(db, "Source");
const targetCompany = await seedCompany(db, "Target");
const sourceAgent = await seedAgent(db, sourceCompany.id);
const privateTargetAgent = await seedAgent(db, targetCompany.id, {
permissions: {
authorizationPolicy: {
agentVisibility: { mode: "private", hiddenFromDefaultDirectory: true },
assignmentPolicy: { mode: "company_default" },
protectedAgent: { requiresApproval: false },
},
},
});
const issue = await db
.insert(issues)
.values({
companyId: targetCompany.id,
title: "Other company work",
status: "todo",
priority: "medium",
assigneeAgentId: privateTargetAgent.id,
})
.returning()
.then((rows) => rows[0]!);
const res = await request(await createApp(db, agentActor(sourceCompany.id, sourceAgent.id)))
.get(`/api/issues/${issue.id}`);
expect(res.status).toBe(403);
expect(res.body.error).toContain("Agent key cannot access another company");
});
it("allows same-company route assignment after upgrade but keeps private target assignment grant constrained", async () => {
const company = await seedCompany(db, "Assignment");
const actorAgent = await seedAgent(db, company.id);
const openTargetAgent = await seedAgent(db, company.id);
const privateTargetAgent = await seedAgent(db, company.id, {
permissions: {
authorizationPolicy: {
agentVisibility: { mode: "private", hiddenFromDefaultDirectory: true },
assignmentPolicy: { mode: "company_default" },
protectedAgent: { requiresApproval: false },
managedBy: "permissions-extension",
},
},
});
const app = await createApp(db, agentActor(company.id, actorAgent.id));
const openAssignment = await request(app)
.post(`/api/companies/${company.id}/issues`)
.send({ title: "Assignable after upgrade", assigneeAgentId: openTargetAgent.id });
expect(openAssignment.status, JSON.stringify(openAssignment.body)).toBe(201);
const deniedPrivateAssignment = await request(app)
.post(`/api/companies/${company.id}/issues`)
.send({ title: "Private target needs scope", assigneeAgentId: privateTargetAgent.id });
expect(deniedPrivateAssignment.status).toBe(403);
expect(deniedPrivateAssignment.body.error).toContain("private");
await db.insert(companyMemberships).values({
companyId: company.id,
principalType: "agent",
principalId: actorAgent.id,
status: "active",
membershipRole: "member",
});
await db.insert(principalPermissionGrants).values({
companyId: company.id,
principalType: "agent",
principalId: actorAgent.id,
permissionKey: "tasks:assign_scope",
scope: { assigneeAgentIds: [privateTargetAgent.id] },
grantedByUserId: null,
});
const allowedPrivateAssignment = await request(app)
.post(`/api/companies/${company.id}/issues`)
.send({ title: "Private target has explicit scope", assigneeAgentId: privateTargetAgent.id });
expect(allowedPrivateAssignment.status, JSON.stringify(allowedPrivateAssignment.body)).toBe(201);
const otherPrivateTargetAgent = await seedAgent(db, company.id, {
permissions: privateTargetAgent.permissions as Record<string, unknown>,
});
const deniedOutsideScope = await request(app)
.post(`/api/companies/${company.id}/issues`)
.send({ title: "Different private target stays denied", assigneeAgentId: otherPrivateTargetAgent.id });
expect(deniedOutsideScope.status).toBe(403);
expect(deniedOutsideScope.body.error).toContain("private");
});
});
@@ -0,0 +1,322 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
companies,
companyMemberships,
createDb,
invites,
principalPermissionGrants,
} from "@paperclipai/db";
import { buildHostServices } from "../services/plugin-host-services.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const pluginId = "plugin-record-id";
function createEventBusStub() {
return {
forPlugin() {
return {
emit: vi.fn(),
subscribe: vi.fn(),
clear: vi.fn(),
};
},
} as any;
}
async function createCompany(db: ReturnType<typeof createDb>, prefix: string) {
return db
.insert(companies)
.values({
name: `${prefix} ${randomUUID()}`,
issuePrefix: `${prefix}${randomUUID().slice(0, 6).toUpperCase()}`,
})
.returning()
.then((rows) => rows[0]!);
}
describeEmbeddedPostgres("plugin access and authorization host services", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-access-authz-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(principalPermissionGrants);
await db.delete(invites);
await db.delete(agents);
await db.delete(companyMemberships);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("rejects grant writes for principals outside the requested company", async () => {
const targetCompany = await createCompany(db, "PAX");
const otherCompany = await createCompany(db, "PAY");
const otherAgent = await db
.insert(agents)
.values({
companyId: otherCompany.id,
name: "Other agent",
role: "engineer",
adapterType: "process",
adapterConfig: {},
permissions: {},
})
.returning()
.then((rows) => rows[0]!);
const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub());
await expect(
services.authorization.setGrants({
companyId: targetCompany.id,
principalType: "agent",
principalId: otherAgent.id,
grants: [{ permissionKey: "tasks:assign" }],
}),
).rejects.toThrow("Agent not found");
const rows = await db.select().from(principalPermissionGrants);
expect(rows).toEqual([]);
services.dispose();
});
it("redacts invite token hashes and sensitive defaults from plugin invite reads", async () => {
const company = await createCompany(db, "PAZ");
const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub());
const created = await services.access.createInvite({
companyId: company.id,
allowedJoinTypes: "human",
defaultsPayload: {
human: { role: "operator", apiKey: "secret-value" },
secret: "top-secret",
},
});
expect(created.token).toMatch(/^pcp_invite_/);
expect("tokenHash" in created).toBe(false);
expect(created.defaultsPayload).toMatchObject({
human: { role: "operator", apiKey: "***REDACTED***" },
secret: "***REDACTED***",
});
const listed = await services.access.listInvites({ companyId: company.id });
expect(listed.invites).toHaveLength(1);
expect("token" in listed.invites[0]!).toBe(false);
expect("tokenHash" in listed.invites[0]!).toBe(false);
services.dispose();
});
it("filters authorization audit entries by allow or deny decision details", async () => {
const company = await createCompany(db, "PAU");
const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub());
await db.insert(activityLog).values([
{
companyId: company.id,
actorType: "agent",
actorId: "agent-1",
action: "authorization.assignment_preview",
entityType: "issue",
entityId: "issue-1",
details: { decision: "allow", secret: "do-not-leak" },
createdAt: new Date("2026-01-02T00:00:00Z"),
},
{
companyId: company.id,
actorType: "agent",
actorId: "agent-1",
action: "authorization.assignment_preview",
entityType: "issue",
entityId: "issue-2",
details: { reason: "deny_scope" },
createdAt: new Date("2026-01-03T00:00:00Z"),
},
]);
const [allowed, denied] = await Promise.all([
services.authorization.searchAudit({
companyId: company.id,
action: "authorization.assignment_preview",
decision: "allow",
limit: 1,
}),
services.authorization.searchAudit({
companyId: company.id,
action: "authorization.assignment_preview",
decision: "deny",
}),
]);
expect(allowed).toHaveLength(1);
expect(allowed[0]!.entityId).toBe("issue-1");
expect(allowed[0]!.details).toMatchObject({ decision: "allow", secret: "***REDACTED***" });
expect(denied).toHaveLength(1);
expect(denied[0]!.entityId).toBe("issue-2");
services.dispose();
});
it("uses persisted agent policy for plugin assignment preview and explanation", async () => {
const company = await createCompany(db, "PAP");
const [actorAgent, targetAgent] = await db
.insert(agents)
.values([
{
companyId: company.id,
name: "Actor agent",
role: "engineer",
adapterType: "process",
adapterConfig: {},
permissions: {},
},
{
companyId: company.id,
name: "Protected target",
role: "engineer",
adapterType: "process",
adapterConfig: {},
permissions: {},
},
])
.returning();
await db.insert(companyMemberships).values({
companyId: company.id,
principalType: "agent",
principalId: actorAgent!.id,
status: "active",
membershipRole: "member",
});
const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub());
const updatedPolicy = await services.authorization.updatePolicy({
companyId: company.id,
resourceType: "agent",
resourceId: targetAgent!.id,
policy: {
assignmentPolicy: {
mode: "protected",
protectedAgentRequiresApproval: true,
},
protectedAgent: {
requiresApproval: true,
approvalReason: "Needs board approval",
},
managedBy: "permissions-extension",
},
});
const input = {
companyId: company.id,
actor: {
type: "agent" as const,
agentId: actorAgent!.id,
companyId: company.id,
source: "agent_key" as const,
},
target: { assigneeAgentId: targetAgent!.id },
};
const [policy, preview, explanation] = await Promise.all([
Promise.resolve(updatedPolicy),
services.authorization.previewAssignment(input),
services.authorization.explainAssignment(input),
]);
expect(policy.policy).toMatchObject({
protectedAgent: { requiresApproval: true },
});
expect(preview).toMatchObject({
allowed: false,
reason: "deny_policy_restricted",
});
expect(explanation).toMatchObject(preview);
const injectedBoardPreview = await services.authorization.previewAssignment({
companyId: company.id,
actor: {
type: "board",
userId: "operator",
companyIds: [company.id],
source: "local_implicit",
isInstanceAdmin: true,
} as any,
target: { assigneeAgentId: targetAgent!.id },
});
expect(injectedBoardPreview).toMatchObject({
allowed: false,
reason: "deny_policy_restricted",
});
services.dispose();
});
it("sanitizes plugin authorization policy updates and records audit activity", async () => {
const company = await createCompany(db, "PAS");
const targetAgent = await db
.insert(agents)
.values({
companyId: company.id,
name: "Policy target",
role: "engineer",
adapterType: "process",
adapterConfig: {},
permissions: {},
})
.returning()
.then((rows) => rows[0]!);
const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub());
const updatedPolicy = await services.authorization.updatePolicy({
companyId: company.id,
resourceType: "agent",
resourceId: targetAgent.id,
policy: {
assignmentPolicy: { mode: "protected" },
apiKey: "sk-test-secret",
nested: {
authorization: "Bearer should-not-persist",
safeLabel: "kept",
},
},
});
expect(updatedPolicy.policy).toMatchObject({
assignmentPolicy: { mode: "protected" },
apiKey: "***REDACTED***",
nested: {
authorization: "***REDACTED***",
safeLabel: "kept",
},
});
const rows = await db.select().from(activityLog);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
companyId: company.id,
actorType: "plugin",
actorId: pluginId,
action: "authorization.policy_updated_by_plugin",
entityType: "agent",
entityId: targetAgent.id,
});
expect(rows[0]!.details).toMatchObject({
hasPolicy: true,
sourcePluginId: pluginId,
sourcePluginKey: "permissions-extension",
});
expect(JSON.stringify(rows[0]!.details)).not.toContain("sk-test-secret");
expect(JSON.stringify(rows[0]!.details)).not.toContain("should-not-persist");
services.dispose();
});
});
+138 -1
View File
@@ -30,6 +30,7 @@ import { buildPluginWorkerEnv, pluginLoader } from "../services/plugin-loader.js
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const multiMigrationPluginKey = "paperclip.dbfixture";
const llmWikiPluginKey = "paperclipai.plugin-llm-wiki";
if (!embeddedPostgresSupport.supported) {
console.warn(
@@ -48,6 +49,63 @@ describe("plugin database SQL validation", () => {
).not.toThrow();
});
it("allows qualified index creation and namespace-scoped migration backfills", () => {
expect(() =>
validatePluginMigrationStatement(
"CREATE INDEX IF NOT EXISTS rows_issue_idx ON plugin_test.rows (issue_id)",
"plugin_test",
)
).not.toThrow();
expect(() =>
validatePluginMigrationStatement(
`
WITH source_rows AS (
SELECT id FROM plugin_test.rows
)
INSERT INTO plugin_test.row_copies (id)
SELECT id FROM source_rows
ON CONFLICT (id) DO NOTHING
`,
"plugin_test",
)
).not.toThrow();
expect(() =>
validatePluginMigrationStatement(
`
UPDATE plugin_test.rows r
SET copied_from_id = s.id
FROM plugin_test.source_rows s
WHERE s.id = r.id
`,
"plugin_test",
)
).not.toThrow();
});
it("keeps migration backfill writes scoped to the plugin namespace", () => {
expect(() =>
validatePluginMigrationStatement(
"CREATE TABLE rows (id uuid PRIMARY KEY, issue_id uuid REFERENCES public.issues(id))",
"plugin_test",
["issues"],
)
).toThrow(/fully qualified/i);
expect(() =>
validatePluginMigrationStatement(
"WITH source_rows AS (SELECT id FROM plugin_test.rows) INSERT INTO public.issues (id) SELECT id FROM source_rows",
"plugin_test",
["issues"],
)
).toThrow(/public/i);
expect(() =>
validatePluginMigrationStatement(
"UPDATE public.issues SET title = 'bad'",
"plugin_test",
["issues"],
)
).toThrow(/public/i);
});
it("rejects migrations that create public objects", () => {
expect(() =>
validatePluginMigrationStatement(
@@ -137,10 +195,11 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
}, 20_000);
afterEach(async () => {
for (const pluginKey of ["paperclip.dbtest", "paperclip.escape", "paperclip.refresh", multiMigrationPluginKey]) {
for (const pluginKey of ["paperclip.dbtest", "paperclip.escape", "paperclip.refresh", multiMigrationPluginKey, llmWikiPluginKey]) {
const namespace = derivePluginDatabaseNamespace(pluginKey);
await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${namespace}" CASCADE`));
}
await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${derivePluginDatabaseNamespace(llmWikiPluginKey, "llm_wiki")}" CASCADE`));
await db.delete(pluginMigrations);
await db.delete(pluginDatabaseNamespaces);
await db.delete(plugins);
@@ -164,6 +223,29 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
return packageRoot;
}
function llmWikiManifest(): PaperclipPluginManifestV1 {
return {
id: llmWikiPluginKey,
apiVersion: 1,
version: "0.1.0",
displayName: "LLM Wiki",
description: "Local-file LLM Wiki plugin.",
author: "Paperclip",
categories: ["automation", "ui"],
capabilities: [
"database.namespace.migrate",
"database.namespace.read",
"database.namespace.write",
],
entrypoints: { worker: "./dist/worker.js" },
database: {
namespaceSlug: "llm_wiki",
migrationsDir: "migrations",
coreReadTables: ["companies", "issues", "projects", "agents"],
},
};
}
async function createInstallablePluginPackage(
pluginManifest: PaperclipPluginManifestV1,
migrationSql: string,
@@ -252,6 +334,61 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
expect(migrations).toHaveLength(2);
});
it("applies the bundled LLM Wiki migrations through the production validator", async () => {
const pluginManifest = llmWikiManifest();
const repoRoot = path.basename(process.cwd()) === "server" ? path.resolve(process.cwd(), "..") : process.cwd();
const packageRoot = path.join(repoRoot, "packages", "plugins", "plugin-llm-wiki");
const namespace = derivePluginDatabaseNamespace(pluginManifest.id, pluginManifest.database?.namespaceSlug);
const pluginId = await installPluginRecord(pluginManifest);
await pluginDatabaseService(db).applyMigrations(pluginId, pluginManifest, packageRoot);
const migrations = await db
.select()
.from(pluginMigrations)
.where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.status, "applied")));
expect(migrations.map((migration) => migration.migrationKey)).toEqual([
"001_llm_wiki.sql",
"002_paperclip_distillation.sql",
"003_spaces.sql",
]);
const constraintRows = Array.from(
await db.execute(
sql<{ table_name: string; conname: string; columns: string[] }>`
SELECT t.relname AS table_name, c.conname, array_agg(a.attname ORDER BY constraint_columns.ordinality)::text[] AS columns
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN unnest(c.conkey) WITH ORDINALITY AS constraint_columns(attnum, ordinality) ON true
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = constraint_columns.attnum
WHERE c.connamespace = ${namespace}::regnamespace AND c.contype = 'u'
GROUP BY t.relname, c.conname
ORDER BY t.relname, c.conname
`,
) as Iterable<{ table_name: string; conname: string; columns: string[] }>,
);
const constraints = constraintRows.map((row) => row.conname);
const uniqueColumnSets = new Set(
constraintRows.map((row) => `${row.table_name}:${row.columns.join(",")}`),
);
expect(constraints).toEqual(
expect.arrayContaining([
"wiki_pages_company_wiki_space_path_key",
"distillation_cursors_company_wiki_space_scope_key",
"distillation_work_items_company_wiki_space_idempotency_key",
"page_bindings_company_wiki_space_page_path_key",
]),
);
expect(constraints).not.toContain("wiki_pages_company_id_wiki_id_path_key");
expect(constraints).not.toContain("paperclip_distillation_cursor_company_id_wiki_id_source_sco_key");
expect(constraints).not.toContain("paperclip_distillation_work_i_company_id_wiki_id_idempotenc_key");
expect(constraints).not.toContain("paperclip_page_bindings_company_id_wiki_id_page_path_key");
expect(uniqueColumnSets).not.toContain("wiki_pages:company_id,wiki_id,path");
expect(uniqueColumnSets).not.toContain("paperclip_distillation_cursors:company_id,wiki_id,source_scope,scope_key,source_kind");
expect(uniqueColumnSets).not.toContain("paperclip_distillation_work_items:company_id,wiki_id,idempotency_key");
expect(uniqueColumnSets).not.toContain("paperclip_page_bindings:company_id,wiki_id,page_path");
});
it("applies migrations once and allows whitelisted core joins at runtime", async () => {
const pluginManifest = manifest();
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from "vitest";
import { createHostClientHandlers } from "../../../packages/plugins/sdk/src/host-client-factory.js";
import { PLUGIN_RPC_ERROR_CODES } from "../../../packages/plugins/sdk/src/protocol.js";
describe("plugin execution workspace bridge", () => {
it("routes metadata reads through the host client when the capability is declared", async () => {
const get = vi.fn().mockResolvedValue({
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
path: "/tmp/workspace-1",
cwd: "/tmp/workspace-1",
repoUrl: null,
baseRef: "main",
branchName: "feature/workspace-1",
providerType: "git_worktree",
providerMetadata: null,
});
const handlers = createHostClientHandlers({
pluginId: "workspace-plugin",
capabilities: ["execution.workspaces.read"],
services: {
executionWorkspaces: { get },
} as any,
});
await expect(
handlers["executionWorkspaces.get"]({ workspaceId: "workspace-1", companyId: "company-1" }),
).resolves.toMatchObject({
id: "workspace-1",
cwd: "/tmp/workspace-1",
});
expect(get).toHaveBeenCalledWith({ workspaceId: "workspace-1", companyId: "company-1" });
});
it("rejects metadata reads when the plugin lacks execution.workspace read access", async () => {
const get = vi.fn();
const handlers = createHostClientHandlers({
pluginId: "workspace-plugin",
capabilities: [],
services: {
executionWorkspaces: { get },
} as any,
});
await expect(
handlers["executionWorkspaces.get"]({ workspaceId: "workspace-1", companyId: "company-1" }),
).rejects.toMatchObject({
code: PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED,
});
expect(get).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,129 @@
/**
* Regression test for PAP-9585.
*
* `restartWorker` is called by the dev file-watcher whenever a local-path
* plugin's source files change. Before PAP-9585 it only bounced the worker
* subprocess, which left newly added `migrations/*.sql` files unapplied the
* plugin schema would silently drift out of sync with worker code.
*
* The fix is for `restartWorker` to do a full deactivate + reactivate cycle
* via the plugin loader, which re-reads the manifest from disk and runs
* `applyMigrations` (idempotently) before starting the new worker.
*/
import { describe, expect, it, vi } from "vitest";
const pluginRecord = {
id: "plugin-1",
pluginKey: "example.plugin",
status: "ready",
manifestJson: { id: "example.plugin", capabilities: [] },
packageName: "@example/plugin",
version: "1.0.0",
packagePath: "/tmp/example-plugin",
};
const mockRegistry = vi.hoisted(() => ({
getById: vi.fn(),
getByKey: vi.fn(),
update: vi.fn(),
updateStatus: vi.fn(),
upsertConfig: vi.fn(),
getConfig: vi.fn(),
list: vi.fn(),
delete: vi.fn(),
}));
vi.mock("../services/plugin-registry.js", () => ({
pluginRegistryService: () => mockRegistry,
}));
import { pluginLifecycleManager } from "../services/plugin-lifecycle.js";
import type { PluginLoader } from "../services/plugin-loader.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
function makeWorkerManagerStub() {
const handle = {
restart: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
};
return {
handle,
workerManager: {
getWorker: vi.fn().mockReturnValue(handle),
isRunning: vi.fn().mockReturnValue(true),
startWorker: vi.fn().mockResolvedValue(undefined),
stopWorker: vi.fn().mockResolvedValue(undefined),
restartWorker: vi.fn().mockResolvedValue(undefined),
} as unknown as PluginWorkerManager,
};
}
describe("pluginLifecycleManager.restartWorker", () => {
it("does a full deactivate+reactivate cycle when the loader has runtime services", async () => {
mockRegistry.getById.mockResolvedValue(pluginRecord);
mockRegistry.updateStatus.mockResolvedValue(pluginRecord);
const { handle, workerManager } = makeWorkerManagerStub();
const loader: Partial<PluginLoader> = {
hasRuntimeServices: vi.fn().mockReturnValue(true) as PluginLoader["hasRuntimeServices"],
loadSingle: vi.fn().mockResolvedValue({
success: true,
plugin: pluginRecord,
registered: { worker: true, eventSubscriptions: 0, jobs: 0, webhooks: 0, tools: 0 },
}) as PluginLoader["loadSingle"],
unloadSingle: vi.fn().mockResolvedValue(undefined) as PluginLoader["unloadSingle"],
};
const lifecycle = pluginLifecycleManager(
{} as never,
{ loader: loader as PluginLoader, workerManager },
);
const stopped = vi.fn();
const started = vi.fn();
lifecycle.on("plugin.worker_stopped", stopped);
lifecycle.on("plugin.worker_started", started);
await lifecycle.restartWorker("plugin-1");
expect(loader.unloadSingle).toHaveBeenCalledWith("plugin-1", "example.plugin");
expect(loader.loadSingle).toHaveBeenCalledWith("plugin-1");
// The bare worker handle should NOT be bounced — the loader handles
// worker (re)start as part of activate.
expect(handle.restart).not.toHaveBeenCalled();
expect(stopped).not.toHaveBeenCalled();
expect(started).not.toHaveBeenCalled();
});
it("falls back to bouncing the worker handle when the loader has no runtime services", async () => {
mockRegistry.getById.mockResolvedValue(pluginRecord);
mockRegistry.updateStatus.mockResolvedValue(pluginRecord);
const { handle, workerManager } = makeWorkerManagerStub();
const loader: Partial<PluginLoader> = {
hasRuntimeServices: vi.fn().mockReturnValue(false) as PluginLoader["hasRuntimeServices"],
loadSingle: vi.fn() as PluginLoader["loadSingle"],
unloadSingle: vi.fn() as PluginLoader["unloadSingle"],
};
const lifecycle = pluginLifecycleManager(
{} as never,
{ loader: loader as PluginLoader, workerManager },
);
const stopped = vi.fn();
const started = vi.fn();
lifecycle.on("plugin.worker_stopped", stopped);
lifecycle.on("plugin.worker_started", started);
await lifecycle.restartWorker("plugin-1");
expect(loader.unloadSingle).not.toHaveBeenCalled();
expect(loader.loadSingle).not.toHaveBeenCalled();
expect(handle.restart).toHaveBeenCalledTimes(1);
expect(stopped).toHaveBeenCalledTimes(1);
expect(stopped).toHaveBeenCalledWith({ pluginId: "plugin-1", pluginKey: "example.plugin" });
expect(started).toHaveBeenCalledTimes(1);
expect(started).toHaveBeenCalledWith({ pluginId: "plugin-1", pluginKey: "example.plugin" });
});
});
@@ -219,6 +219,14 @@ describe("plugin local folders", () => {
expect(leftovers.filter((name) => name.includes(".paperclip-"))).toEqual([]);
});
it("creates missing nested parent directories for atomic writes", async () => {
const root = await makeRoot();
await writePluginLocalFolderTextAtomic(root, "cases/active/smoke/README.md", "hello");
await expect(readPluginLocalFolderText(root, "cases/active/smoke/README.md")).resolves.toBe("hello");
});
it("returns the real folder key after deleting a file", async () => {
const root = await makeRoot();
await fs.writeFile(path.join(root, "stale.md"), "delete me", "utf8");
@@ -11,6 +11,7 @@ import {
companies,
costEvents,
createDb,
executionWorkspaces,
heartbeatRuns,
issueRelations,
issues,
@@ -67,6 +68,7 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
await db.delete(agentWakeupRequests);
await db.delete(issueRelations);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(pluginManagedResources);
await db.delete(projects);
await db.delete(plugins);
@@ -107,6 +109,61 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
return root;
}
it("returns plugin-safe execution workspace metadata scoped to the company", async () => {
const { companyId } = await seedCompanyAndAgent();
const otherCompanyId = randomUUID();
const projectId = randomUUID();
const workspaceId = randomUUID();
await db.insert(companies).values({
id: otherCompanyId,
name: "Other",
issuePrefix: issuePrefix(otherCompanyId),
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspaces",
status: "in_progress",
});
await db.insert(executionWorkspaces).values({
id: workspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Feature workspace",
status: "active",
cwd: "/tmp/paperclip-feature",
repoUrl: "https://example.com/paperclip.git",
baseRef: "main",
branchName: "feature/workspace",
providerType: "git_worktree",
providerRef: "/tmp/paperclip-feature",
metadata: {
providerMetadata: { sandboxId: "sandbox-1" },
workspaceRealizationRequest: { hiddenInternal: true },
},
});
const services = buildHostServices(db, "plugin-record-id", "paperclip.workspace", createEventBusStub());
await expect(services.executionWorkspaces.get({ workspaceId, companyId })).resolves.toMatchObject({
id: workspaceId,
companyId,
projectId,
projectWorkspaceId: null,
path: "/tmp/paperclip-feature",
cwd: "/tmp/paperclip-feature",
repoUrl: "https://example.com/paperclip.git",
baseRef: "main",
branchName: "feature/workspace",
providerType: "git_worktree",
providerMetadata: { sandboxId: "sandbox-1" },
});
await expect(services.executionWorkspaces.get({ workspaceId, companyId: otherCompanyId })).resolves.toBeNull();
});
it("creates plugin-origin issues with full orchestration fields and audit activity", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const blockerIssueId = randomUUID();
@@ -42,6 +42,7 @@ async function createApp(
jobDeps?: unknown;
toolDeps?: unknown;
bridgeDeps?: unknown;
captureJsonContext?: (context: unknown, body: unknown) => void;
} = {},
) {
const [{ pluginRoutes }, { errorHandler }] = await Promise.all([
@@ -56,6 +57,16 @@ async function createApp(
const app = express();
app.use(express.json());
if (routeOverrides.captureJsonContext) {
app.use((_req, res, next) => {
const originalJson = res.json.bind(res);
res.json = ((body: unknown) => {
routeOverrides.captureJsonContext?.((res as any).__errorContext, body);
return originalJson(body);
}) as typeof res.json;
next();
});
}
app.use((req, _res, next) => {
req.actor = actor as typeof req.actor;
next();
@@ -103,6 +114,17 @@ function boardActor(overrides: Record<string, unknown> = {}) {
};
}
function agentActor(overrides: Record<string, unknown> = {}) {
return {
type: "agent",
agentId: agentA,
companyId: companyA,
runId: runA,
source: "agent_jwt",
...overrides,
};
}
function readyPlugin() {
mockRegistry.getById.mockResolvedValue({
id: pluginId,
@@ -602,6 +624,28 @@ describe.sequential("plugin tool and bridge authz", () => {
expect(call).not.toHaveBeenCalled();
});
it("forwards authorized bridge company scope to the plugin worker", async () => {
readyPlugin();
const call = vi.fn().mockResolvedValue({ ok: true });
const { app } = await createApp(boardActor(), {}, {
bridgeDeps: {
workerManager: { call },
},
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/data/health`)
.send({ companyId: companyA, params: { view: "compact" } });
expect(res.status).toBe(200);
expect(call).toHaveBeenCalledWith(pluginId, "getData", {
key: "health",
companyId: companyA,
params: { view: "compact" },
renderEnvironment: null,
});
});
it("allows omitted-company bridge calls for instance admins as global plugin actions", async () => {
readyPlugin();
const call = vi.fn().mockResolvedValue({ ok: true });
@@ -623,10 +667,194 @@ describe.sequential("plugin tool and bridge authz", () => {
expect(call).toHaveBeenCalledWith(pluginId, "performAction", {
key: "sync",
params: {},
actorContext: {
type: "user",
userId: "admin-1",
agentId: null,
runId: null,
companyId: null,
},
renderEnvironment: null,
});
});
it("passes authenticated actor context and overrides spoofed company scope for plugin actions", async () => {
readyPlugin();
const call = vi.fn().mockResolvedValue({ ok: true });
const { app } = await createApp(boardActor({ runId: runA }), {}, {
bridgeDeps: {
workerManager: { call },
},
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/actions/sync`)
.send({
companyId: companyA,
params: {
companyId: companyB,
reviewerUserId: "spoofed-user",
},
});
expect(res.status).toBe(200);
expect(call).toHaveBeenCalledWith(pluginId, "performAction", {
key: "sync",
params: {
companyId: companyA,
reviewerUserId: "spoofed-user",
},
actorContext: {
type: "user",
userId: "user-1",
agentId: null,
runId: runA,
companyId: companyA,
},
renderEnvironment: null,
});
});
it("uses null for board actor userId when no authenticated user id is present", async () => {
readyPlugin();
const call = vi.fn().mockResolvedValue({ ok: true });
const { app } = await createApp(boardActor({ userId: undefined }), {}, {
bridgeDeps: {
workerManager: { call },
},
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/actions/sync`)
.send({ companyId: companyA });
expect(res.status).toBe(200);
expect(call).toHaveBeenCalledWith(pluginId, "performAction", expect.objectContaining({
actorContext: expect.objectContaining({
type: "user",
userId: null,
companyId: companyA,
}),
}));
});
it("allows agent-scoped plugin actions with authenticated actor context", async () => {
readyPlugin();
const call = vi.fn().mockResolvedValue({ ok: true });
const { app } = await createApp(agentActor(), {}, {
bridgeDeps: {
workerManager: { call },
},
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/actions/sync`)
.send({
companyId: companyA,
params: {
companyId: companyB,
reviewerAgentId: "spoofed-agent",
},
});
expect(res.status).toBe(200);
expect(call).toHaveBeenCalledWith(pluginId, "performAction", {
key: "sync",
params: {
companyId: companyA,
reviewerAgentId: "spoofed-agent",
},
actorContext: {
type: "agent",
userId: null,
agentId: agentA,
runId: runA,
companyId: companyA,
},
renderEnvironment: null,
});
call.mockClear();
const legacyRes = await request(app)
.post(`/api/plugins/${pluginId}/bridge/action`)
.send({
key: "sync",
companyId: companyA,
params: {
companyId: companyB,
reviewerAgentId: "spoofed-agent",
},
});
expect(legacyRes.status).toBe(200);
expect(call).toHaveBeenCalledWith(pluginId, "performAction", {
key: "sync",
params: {
companyId: companyA,
reviewerAgentId: "spoofed-agent",
},
actorContext: {
type: "agent",
userId: null,
agentId: agentA,
runId: runA,
companyId: companyA,
},
renderEnvironment: null,
});
});
it("rejects agent plugin actions outside the authenticated company scope", async () => {
readyPlugin();
const call = vi.fn().mockResolvedValue({ ok: true });
const { app } = await createApp(agentActor(), {}, {
bridgeDeps: {
workerManager: { call },
},
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/actions/sync`)
.send({ companyId: companyB });
expect(res.status).toBe(403);
expect(call).not.toHaveBeenCalled();
});
it("attaches worker bridge errors to the HTTP logger context", async () => {
readyPlugin();
const call = vi.fn().mockRejectedValue(new Error("missing source_objects column"));
const captured: Array<{ context: any; body: unknown }> = [];
const { app } = await createApp(boardActor(), {}, {
bridgeDeps: {
workerManager: { call },
},
captureJsonContext: (context, body) => {
captured.push({ context, body });
},
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/data/source-objects`)
.send({ companyId: companyA });
expect(res.status).toBe(502);
expect(res.body).toMatchObject({
code: "UNKNOWN",
message: "missing source_objects column",
});
expect(captured.at(-1)?.context?.error).toMatchObject({
message: "missing source_objects column",
details: {
pluginId,
pluginKey: "paperclip.example",
bridgeMethod: "getData",
dataKey: "source-objects",
bridgeCode: "UNKNOWN",
},
});
});
it("rejects manual job triggers for non-admin board users", async () => {
const scheduler = { triggerJob: vi.fn() };
const jobStore = { getJobByIdForPlugin: vi.fn() };
@@ -3,6 +3,63 @@ import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
describe("plugin SDK test harness", () => {
it("returns scoped execution workspace metadata with the read capability", async () => {
const manifest: PaperclipPluginManifestV1 = {
id: "paperclip.test-execution-workspace-metadata",
apiVersion: 1,
version: "0.1.0",
displayName: "Execution Workspace Metadata",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["execution.workspaces.read"],
entrypoints: { worker: "./dist/worker.js" },
};
const harness = createTestHarness({ manifest });
harness.seed({
executionWorkspaces: [{
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "project-workspace-1",
path: "/tmp/paperclip-test",
cwd: "/tmp/paperclip-test",
repoUrl: "https://example.com/repo.git",
baseRef: "main",
branchName: "feature/test",
providerType: "git_worktree",
providerMetadata: { sandboxId: "sandbox-1" },
}],
});
await expect(harness.ctx.executionWorkspaces.get("workspace-1", "company-1")).resolves.toMatchObject({
id: "workspace-1",
cwd: "/tmp/paperclip-test",
branchName: "feature/test",
providerMetadata: { sandboxId: "sandbox-1" },
});
await expect(harness.ctx.executionWorkspaces.get("workspace-1", "company-2")).resolves.toBeNull();
});
it("requires execution.workspaces.read before returning workspace metadata", async () => {
const manifest: PaperclipPluginManifestV1 = {
id: "paperclip.test-missing-execution-workspace-read",
apiVersion: 1,
version: "0.1.0",
displayName: "Missing Workspace Read Capability",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: { worker: "./dist/worker.js" },
};
const harness = createTestHarness({ manifest });
await expect(harness.ctx.executionWorkspaces.get("workspace-1", "company-1")).rejects.toThrow(
"missing required capability 'execution.workspaces.read'",
);
});
it("requires skills.managed capability before resetting a missing declaration", async () => {
const manifest: PaperclipPluginManifestV1 = {
id: "paperclip.test-missing-managed-skill-capability",
@@ -25,4 +82,29 @@ describe("plugin SDK test harness", () => {
"missing required capability 'skills.managed'",
);
});
it("requires access and authorization capabilities for permission SDK calls", async () => {
const manifest: PaperclipPluginManifestV1 = {
id: "paperclip.test-missing-access-authz-capability",
apiVersion: 1,
version: "0.1.0",
displayName: "Missing Access Capability",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: { worker: "./dist/worker.js" },
};
const harness = createTestHarness({ manifest });
await expect(harness.ctx.access.members.list({ companyId: "company-1" })).rejects.toThrow(
"missing required capability 'access.members.read'",
);
await expect(harness.ctx.authorization.grants.list({ companyId: "company-1" })).rejects.toThrow(
"missing required capability 'authorization.grants.read'",
);
await expect(harness.ctx.authorization.audit.search({ companyId: "company-1" })).rejects.toThrow(
"missing required capability 'authorization.audit.read'",
);
});
});
@@ -3,7 +3,10 @@ import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import {
createHostClientHandlers,
JsonRpcCallError,
PLUGIN_RPC_ERROR_CODES,
type HostServices,
type HostToWorkerMethods,
} from "@paperclipai/plugin-sdk";
import {
@@ -14,6 +17,10 @@ import {
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
const DELAYED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-delayed.cjs");
const INVOCATION_SCOPE_WORKER_ENTRYPOINT = path.join(
FIXTURES_DIR,
"plugin-worker-invocation-scope.cjs",
);
const TERMINATED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-terminated.cjs");
const TEST_MANIFEST: PaperclipPluginManifestV1 = {
@@ -178,4 +185,236 @@ describe("plugin-worker-manager stderr failure context", () => {
await handle.stop().catch(() => undefined);
}
});
it("passes performAction invocation scope to nested worker host calls", async () => {
const companiesGet = vi.fn(async (
params: { companyId: string },
context?: { invocationScope?: { companyId?: string | null } | null },
) => ({
id: params.companyId,
scopedCompanyId: context?.invocationScope?.companyId ?? null,
}));
const handle = createPluginWorkerHandle("test.plugin", {
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
manifest: TEST_MANIFEST,
config: {},
instanceInfo: {
instanceId: "instance-1",
hostVersion: "1.0.0",
},
apiVersion: 1,
hostHandlers: {
"companies.get": companiesGet as never,
},
});
try {
await handle.start();
await expect(handle.call("performAction", {
key: "probe",
params: {
mode: "echo",
requestedCompanyId: "company-a",
},
actorContext: {
type: "agent",
userId: null,
agentId: "agent-1",
runId: "run-1",
companyId: "company-a",
},
renderEnvironment: null,
})).resolves.toEqual({
id: "company-a",
scopedCompanyId: "company-a",
});
expect(companiesGet).toHaveBeenCalledWith(
{ companyId: "company-a" },
{ invocationScope: { companyId: "company-a" } },
);
} finally {
await handle.stop().catch(() => undefined);
}
});
it("passes echoed invocation scope to worker-to-host handlers", async () => {
const companiesGet = vi.fn(async () => ({ id: "company-1" }));
const handle = createPluginWorkerHandle("test.plugin", {
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
manifest: TEST_MANIFEST,
config: {},
instanceInfo: {
instanceId: "instance-1",
hostVersion: "1.0.0",
},
apiVersion: 1,
hostHandlers: {
"companies.get": companiesGet,
},
});
try {
await handle.start();
await expect(handle.call("getData", {
key: "probe",
companyId: "company-1",
params: {
mode: "echo",
requestedCompanyId: "company-1",
},
} as HostToWorkerMethods["getData"][0])).resolves.toEqual({ id: "company-1" });
expect(companiesGet).toHaveBeenCalledWith(
{ companyId: "company-1" },
{ invocationScope: { companyId: "company-1" } },
);
} finally {
await handle.stop().catch(() => undefined);
}
});
it("rejects performAction nested host calls that omit the invocation id", async () => {
const handlers = createHostClientHandlers({
pluginId: "test.plugin",
capabilities: ["companies.read"],
services: {
companies: {
list: vi.fn(async () => []),
get: vi.fn(async (params: { companyId: string }) => ({ id: params.companyId })),
},
} as unknown as HostServices,
});
const handle = createPluginWorkerHandle("test.plugin", {
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
manifest: TEST_MANIFEST,
config: {},
instanceInfo: {
instanceId: "instance-1",
hostVersion: "1.0.0",
},
apiVersion: 1,
hostHandlers: handlers,
});
try {
await handle.start();
await expect(handle.call("performAction", {
key: "probe",
params: {
requestedCompanyId: "company-b",
},
actorContext: {
type: "agent",
userId: null,
agentId: "agent-1",
runId: "run-1",
companyId: "company-a",
},
renderEnvironment: null,
})).rejects.toMatchObject({
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
message: expect.stringContaining("unknown invocation scope"),
});
} finally {
await handle.stop().catch(() => undefined);
}
});
it("rejects nested worker host calls that forge an unknown invocation id", async () => {
const companiesGet = vi.fn(async (params: { companyId: string }) => ({ id: params.companyId }));
const handlers = createHostClientHandlers({
pluginId: "test.plugin",
capabilities: ["companies.read"],
services: {
companies: {
get: companiesGet,
},
} as unknown as HostServices,
});
const handle = createPluginWorkerHandle("test.plugin", {
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
manifest: TEST_MANIFEST,
config: {},
instanceInfo: {
instanceId: "instance-1",
hostVersion: "1.0.0",
},
apiVersion: 1,
hostHandlers: handlers,
});
try {
await handle.start();
await expect(handle.call("performAction", {
key: "probe",
params: {
mode: "unknown",
requestedCompanyId: "company-a",
},
actorContext: {
type: "agent",
userId: null,
agentId: "agent-1",
runId: "run-1",
companyId: "company-a",
},
renderEnvironment: null,
})).rejects.toMatchObject({
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
message: expect.stringContaining("unknown invocation scope"),
});
expect(companiesGet).not.toHaveBeenCalled();
} finally {
await handle.stop().catch(() => undefined);
}
});
it("rejects missing or unknown invocation ids while a company invocation is active", async () => {
const companiesGet = vi.fn(async () => ({ id: "company-2" }));
const hostHandlers = createHostClientHandlers({
pluginId: "test.plugin",
capabilities: ["companies.read"],
services: {
companies: {
get: companiesGet,
},
} as unknown as HostServices,
});
const handle = createPluginWorkerHandle("test.plugin", {
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
manifest: TEST_MANIFEST,
config: {},
instanceInfo: {
instanceId: "instance-1",
hostVersion: "1.0.0",
},
apiVersion: 1,
hostHandlers,
});
try {
await handle.start();
for (const mode of ["omit", "unknown"]) {
await expect(handle.call("getData", {
key: "probe",
companyId: "company-1",
params: {
mode,
requestedCompanyId: "company-2",
},
} as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
});
}
expect(companiesGet).not.toHaveBeenCalled();
} finally {
await handle.stop().catch(() => undefined);
}
});
});
@@ -0,0 +1,458 @@
// QA validation for [PAP-9522](/PAP/issues/PAP-9522). Drives the routine-secret
// chain end-to-end against a real embedded Postgres:
//
// 1. Routine env reaches the heartbeat runtime via `resolveExecutionRunAdapterConfig`
// using `secretsSvc.resolveEnvBindings` with a `consumerType: "routine"` context,
// even when the executing agent has zero direct bindings for that secret.
// 2. Precedence: agent < project < routine for a shared key.
// 3. `secret_access_events` records routine consumption but NEVER the resolved value.
// 4. Restoring an older revision re-syncs `company_secret_bindings` to the snapshot env.
// 5. Legacy fallback: a routine_run with null `routine_revision_id` still resolves
// the routine's current env (matches the explicit acceptance criterion).
// 6. Disabled / missing / cross-company secret bindings fail clearly without
// echoing the value.
import { randomUUID } from "node:crypto";
import { mkdirSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { eq, and } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
companies,
companySecretBindings,
companySecrets,
companySecretVersions,
createDb,
projects,
routineRuns,
routines,
secretAccessEvents,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { routineService } from "../services/routines.ts";
import { secretService } from "../services/secrets.ts";
import { resolveExecutionRunAdapterConfig } from "../services/heartbeat.ts";
const support = await getEmbeddedPostgresTestSupport();
const describeEmbedded = support.supported ? describe : describe.skip;
if (!support.supported) {
console.warn(`Skipping QA e2e on this host: ${support.reason ?? "embedded pg unsupported"}`);
}
describeEmbedded("PAP-9522 QA: routine secrets end-to-end", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const secretsTmpDir = path.join(os.tmpdir(), `paperclip-qa-routine-secrets-${randomUUID()}`);
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
beforeAll(async () => {
mkdirSync(secretsTmpDir, { recursive: true });
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key");
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-qa-routine-secrets-");
db = createDb(tempDb.connectionString);
}, 30_000);
afterEach(async () => {
await db.delete(secretAccessEvents);
await db.delete(companySecretBindings);
await db.delete(routineRuns);
await db.delete(routines);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
await db.delete(projects);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
if (previousKeyFile === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
else process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
rmSync(secretsTmpDir, { recursive: true, force: true });
});
async function seed() {
const companyId = randomUUID();
const executorAgentId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
await db.insert(companies).values({
id: companyId,
name: "QA Co",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
// Note: executor agent has NO secret bindings of its own — this is the
// whole point of routine env (the secret rides with the routine, not the agent).
await db.insert(agents).values({
id: executorAgentId,
companyId,
name: "Executor",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: { env: {} },
runtimeConfig: {},
permissions: {},
});
return { companyId, executorAgentId };
}
const ROUTINE_VALUE = "super-sekret-routine-value";
const PROJECT_VALUE = "project-overlay-value";
const AGENT_VALUE = "agent-base-value";
it("resolves routine env for an executing agent that has no direct binding, with routine winning precedence and zero value in access events", async () => {
const { companyId, executorAgentId } = await seed();
const secrets = secretService(db);
const routines = routineService(db, { heartbeat: { wakeup: async () => null } });
const secret = await secrets.create(companyId, {
name: `routine-api-${randomUUID()}`,
provider: "local_encrypted",
value: ROUTINE_VALUE,
});
const routine = await routines.create(
companyId,
{
projectId: null,
goalId: null,
parentIssueId: null,
title: "qa routine",
description: null,
assigneeAgentId: executorAgentId,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
env: {
SHARED: { type: "plain", value: "routine-overrides" },
ROUTINE_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" },
},
},
{},
);
// Verify binding is owned by the routine, not the executing agent.
const bindings = await db
.select()
.from(companySecretBindings)
.where(eq(companySecretBindings.targetId, routine.id));
expect(bindings).toMatchObject([
{ targetType: "routine", secretId: secret.id, configPath: "env.ROUTINE_API_KEY" },
]);
// Drive the real heartbeat resolution path with the routine env.
// issueId/heartbeatRunId left null because secret_access_events has FK
// constraints on both — populating them would require seeding issue and
// heartbeat_run rows just for FK validity. The routine consumer fields are
// what this test cares about.
const result = await resolveExecutionRunAdapterConfig({
companyId,
agentId: executorAgentId,
issueId: null,
heartbeatRunId: null,
projectId: null,
routineId: routine.id,
executionRunConfig: { env: { SHARED: AGENT_VALUE, AGENT_ONLY: AGENT_VALUE } },
projectEnv: { SHARED: { type: "plain", value: PROJECT_VALUE } },
routineEnv: routine.env,
secretsSvc: secrets,
});
expect(result.resolvedConfig.env).toMatchObject({
AGENT_ONLY: AGENT_VALUE,
SHARED: "routine-overrides", // routine beats project beats agent
ROUTINE_API_KEY: ROUTINE_VALUE,
});
expect(result.secretKeys.has("ROUTINE_API_KEY")).toBe(true);
expect(result.secretManifest.some((m) => m.envKey === "ROUTINE_API_KEY")).toBe(true);
// Manifest must not echo the resolved value.
expect(JSON.stringify(result.secretManifest)).not.toContain(ROUTINE_VALUE);
const events = await db
.select()
.from(secretAccessEvents)
.where(eq(secretAccessEvents.secretId, secret.id));
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
consumerType: "routine",
consumerId: routine.id,
actorType: "agent",
actorId: executorAgentId,
configPath: "env.ROUTINE_API_KEY",
outcome: "success",
});
// No serialized field of the access event row can contain the secret value.
expect(JSON.stringify(events[0])).not.toContain(ROUTINE_VALUE);
});
it("rejects routine env that references a secret from a different company", async () => {
const { companyId } = await seed();
const { companyId: otherCompanyId } = await seed();
const secrets = secretService(db);
const routines = routineService(db, { heartbeat: { wakeup: async () => null } });
const foreignSecret = await secrets.create(otherCompanyId, {
name: `foreign-${randomUUID()}`,
provider: "local_encrypted",
value: "cross-company-leak-bait",
});
await expect(
routines.create(
companyId,
{
projectId: null,
goalId: null,
parentIssueId: null,
title: "cross company",
description: null,
assigneeAgentId: null,
priority: "medium",
status: "paused",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
env: {
BAD: { type: "secret_ref", secretId: foreignSecret.id, version: "latest" },
},
},
{},
),
).rejects.toThrow(/same company/i);
});
it("surfaces a clear, value-free error when a routine secret is missing/deleted at resolution time", async () => {
const { companyId, executorAgentId } = await seed();
const secrets = secretService(db);
const routines = routineService(db, { heartbeat: { wakeup: async () => null } });
const secret = await secrets.create(companyId, {
name: `to-be-deleted-${randomUUID()}`,
provider: "local_encrypted",
value: "doomed-secret-value",
});
const routine = await routines.create(
companyId,
{
projectId: null,
goalId: null,
parentIssueId: null,
title: "doomed routine",
description: null,
assigneeAgentId: executorAgentId,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
env: {
DOOMED: { type: "secret_ref", secretId: secret.id, version: "latest" },
},
},
{},
);
// Hard delete the secret out from under the routine; the routine env now
// points at a vanished id.
await secrets.remove(secret.id);
let caught: unknown = null;
try {
await resolveExecutionRunAdapterConfig({
companyId,
agentId: executorAgentId,
issueId: null,
heartbeatRunId: null,
projectId: null,
routineId: routine.id,
executionRunConfig: { env: {} },
projectEnv: null,
routineEnv: routine.env,
secretsSvc: secrets,
});
} catch (error) {
caught = error;
}
expect(caught).toBeTruthy();
const message = String((caught as Error)?.message ?? caught);
expect(message).not.toContain("doomed-secret-value");
});
it("restoring an older revision re-syncs company_secret_bindings to the snapshot env", async () => {
const { companyId, executorAgentId } = await seed();
const secrets = secretService(db);
const routines = routineService(db, { heartbeat: { wakeup: async () => null } });
const secretA = await secrets.create(companyId, {
name: `a-${randomUUID()}`,
provider: "local_encrypted",
value: "val-a",
});
const secretB = await secrets.create(companyId, {
name: `b-${randomUUID()}`,
provider: "local_encrypted",
value: "val-b",
});
const routine = await routines.create(
companyId,
{
projectId: null,
goalId: null,
parentIssueId: null,
title: "restore routine",
description: null,
assigneeAgentId: executorAgentId,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
env: {
ALPHA: { type: "secret_ref", secretId: secretA.id, version: "latest" },
},
},
{},
);
const rev1Id = routine.latestRevisionId!;
await routines.update(
routine.id,
{
env: {
ALPHA: { type: "secret_ref", secretId: secretA.id, version: "latest" },
BETA: { type: "secret_ref", secretId: secretB.id, version: "latest" },
},
},
{},
);
let bindings = await db
.select()
.from(companySecretBindings)
.where(eq(companySecretBindings.targetId, routine.id));
expect(bindings.map((b) => b.configPath).sort()).toEqual(["env.ALPHA", "env.BETA"]);
await routines.restoreRevision(routine.id, rev1Id, {});
bindings = await db
.select()
.from(companySecretBindings)
.where(eq(companySecretBindings.targetId, routine.id));
expect(bindings.map((b) => b.configPath)).toEqual(["env.ALPHA"]);
expect(bindings[0]?.secretId).toBe(secretA.id);
});
it("legacy run with null routine_revision_id falls back to the routine's current env (still resolves)", async () => {
const { companyId, executorAgentId } = await seed();
const secrets = secretService(db);
const routines = routineService(db, { heartbeat: { wakeup: async () => null } });
const secret = await secrets.create(companyId, {
name: `legacy-${randomUUID()}`,
provider: "local_encrypted",
value: "legacy-value",
});
const routine = await routines.create(
companyId,
{
projectId: null,
goalId: null,
parentIssueId: null,
title: "legacy routine",
description: null,
assigneeAgentId: executorAgentId,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
env: {
LEGACY: { type: "secret_ref", secretId: secret.id, version: "latest" },
},
},
{},
);
// Simulate an old routine_run row (predating the migration) with no
// routine_revision_id. The fallback path in `getRoutineEnvForExecutionIssue`
// should still resolve to the routine's current env. Here we exercise the
// resolution layer directly with routine.env to mirror that behavior.
await db.insert(routineRuns).values({
id: randomUUID(),
companyId,
routineId: routine.id,
triggerId: null,
source: "manual",
status: "issue_created",
triggeredAt: new Date(),
completedAt: new Date(),
routineRevisionId: null,
});
const result = await resolveExecutionRunAdapterConfig({
companyId,
agentId: executorAgentId,
issueId: null,
heartbeatRunId: null,
projectId: null,
routineId: routine.id,
executionRunConfig: { env: {} },
projectEnv: null,
routineEnv: routine.env,
secretsSvc: secrets,
});
expect(result.resolvedConfig.env).toMatchObject({ LEGACY: "legacy-value" });
});
it("routines created with null env (no Secrets tab interaction) still resolve normally with empty env", async () => {
const { companyId, executorAgentId } = await seed();
const secrets = secretService(db);
const routines = routineService(db, { heartbeat: { wakeup: async () => null } });
const routine = await routines.create(
companyId,
{
projectId: null,
goalId: null,
parentIssueId: null,
title: "null env routine",
description: null,
assigneeAgentId: executorAgentId,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
},
{},
);
expect(routine.env ?? null).toBeNull();
const bindings = await db
.select()
.from(companySecretBindings)
.where(eq(companySecretBindings.targetId, routine.id));
expect(bindings).toHaveLength(0);
const result = await resolveExecutionRunAdapterConfig({
companyId,
agentId: executorAgentId,
issueId: null,
heartbeatRunId: null,
projectId: null,
routineId: routine.id,
executionRunConfig: { env: { AGENT_ONLY: "agent" } },
projectEnv: null,
routineEnv: null,
secretsSvc: secrets,
});
expect(result.resolvedConfig.env).toEqual({ AGENT_ONLY: "agent" });
expect(result.secretKeys.size).toBe(0);
});
});
+4
View File
@@ -70,7 +70,9 @@ describe("redaction", () => {
const input = [
"Authorization: Bearer live-bearer-token-value",
`payload {"apiKey":"json-secret-value"}`,
`paperclip {"PAPERCLIP_API_KEY":"paperclip-json-secret"}`,
`escaped {\\"apiKey\\":\\"escaped-json-secret\\"}`,
`export PAPERCLIP_API_KEY='paperclip-shell-secret'`,
`GITHUB_TOKEN=${githubToken}`,
`session=${jwt}`,
].join("\n");
@@ -80,7 +82,9 @@ describe("redaction", () => {
expect(result).toContain(REDACTED_EVENT_VALUE);
expect(result).not.toContain("live-bearer-token-value");
expect(result).not.toContain("json-secret-value");
expect(result).not.toContain("paperclip-json-secret");
expect(result).not.toContain("escaped-json-secret");
expect(result).not.toContain("paperclip-shell-secret");
expect(result).not.toContain(githubToken);
expect(result).not.toContain(jwt);
});
@@ -0,0 +1,218 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agentMemberships,
agents,
companies,
createDb,
projectMemberships,
projects,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { resourceMembershipRoutes } from "../routes/resource-memberships.js";
import { errorHandler } from "../middleware/index.js";
import { resourceMembershipService } from "../services/resource-memberships.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres resource membership tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
function boardActor(companyId: string, role: "admin" | "operator" | "viewer" = "viewer") {
return {
type: "board" as const,
userId: "user-1",
source: "session" as const,
isInstanceAdmin: false,
companyIds: [companyId],
memberships: [{ companyId, membershipRole: role, status: "active" }],
};
}
function createApp(db: ReturnType<typeof createDb>, actor: Express.Request["actor"]) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
app.use("/api", resourceMembershipRoutes(db));
app.use(errorHandler);
return app;
}
describeEmbeddedPostgres("resource membership routes", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-resource-memberships-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(projectMemberships);
await db.delete(agentMemberships);
await db.delete(projects);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seed() {
const companyId = randomUUID();
const otherCompanyId = randomUUID();
const projectId = randomUUID();
const otherProjectId = randomUUID();
const agentId = randomUUID();
const otherAgentId = randomUUID();
await db.insert(companies).values([
{
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
},
{
id: otherCompanyId,
name: "Other",
issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
},
]);
await db.insert(projects).values([
{ id: projectId, companyId, name: "Growth", status: "in_progress" },
{ id: otherProjectId, companyId: otherCompanyId, name: "Other", status: "in_progress" },
]);
await db.insert(agents).values([
{
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: otherAgentId,
companyId: otherCompanyId,
name: "OtherAgent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
return { companyId, otherAgentId, otherProjectId, projectId, agentId };
}
it("defaults missing membership rows to joined", async () => {
const { companyId } = await seed();
const app = createApp(db, boardActor(companyId));
const res = await request(app).get(`/api/companies/${companyId}/resource-memberships/me`);
expect(res.status).toBe(200);
expect(res.body).toEqual({
projectMemberships: {},
agentMemberships: {},
updatedAt: null,
});
});
it("allows viewer self-service mutations, logs changes, and keeps repeats idempotent", async () => {
const { companyId, projectId } = await seed();
const app = createApp(db, boardActor(companyId, "viewer"));
const first = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/projects/${projectId}`)
.send({ state: "left" });
const second = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/projects/${projectId}`)
.send({ state: "left" });
expect(first.status).toBe(200);
expect(first.body).toMatchObject({ resourceType: "project", resourceId: projectId, state: "left" });
expect(second.status).toBe(200);
const rows = await db.select().from(projectMemberships);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ companyId, projectId, userId: "user-1", state: "left" });
const activity = await db.select().from(activityLog);
expect(activity).toHaveLength(1);
expect(activity[0]).toMatchObject({
companyId,
actorType: "user",
actorId: "user-1",
action: "resource_membership.left",
entityType: "project",
entityId: projectId,
});
});
it("rejects agent API key actors", async () => {
const { companyId, agentId } = await seed();
const app = createApp(db, {
type: "agent",
agentId,
companyId,
source: "agent_key",
});
const res = await request(app).get(`/api/companies/${companyId}/resource-memberships/me`);
expect(res.status).toBe(403);
});
it("rejects cross-company target resources", async () => {
const { companyId, otherAgentId, otherProjectId } = await seed();
const app = createApp(db, boardActor(companyId));
const projectRes = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/projects/${otherProjectId}`)
.send({ state: "left" });
const agentRes = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/agents/${otherAgentId}`)
.send({ state: "left" });
expect(projectRes.status).toBe(404);
expect(agentRes.status).toBe(404);
await expect(db.select().from(projectMemberships)).resolves.toHaveLength(0);
await expect(db.select().from(agentMemberships)).resolves.toHaveLength(0);
});
it("denies direct service calls that try to mutate another user's membership", async () => {
const { companyId, projectId } = await seed();
const svc = resourceMembershipService(db);
await expect(
svc.updateProject({
companyId,
projectId,
userId: "other-user",
state: "left",
actor: boardActor(companyId),
}),
).rejects.toMatchObject({ status: 403 });
});
});
@@ -5,6 +5,7 @@ import {
activityLog,
agents,
companies,
companySecretBindings,
companySecrets,
companySecretVersions,
createDb,
@@ -19,6 +20,7 @@ import {
routineRuns,
routines,
routineTriggers,
secretAccessEvents,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
@@ -28,6 +30,7 @@ import { issueService } from "../services/issues.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
import * as providerRegistry from "../secrets/provider-registry.ts";
import { routineService } from "../services/routines.ts";
import { secretService } from "../services/secrets.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -57,6 +60,8 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
await db.delete(activityLog);
await db.delete(issueInboxArchives);
await db.delete(issueReadStates);
await db.delete(secretAccessEvents);
await db.delete(companySecretBindings);
await db.delete(routineRuns);
await db.delete(routineTriggers);
await db.delete(routines);
@@ -331,6 +336,89 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
expect(revisions[1]?.snapshot.routine.description).toBe("Run the frog routine");
});
it("stores routine env in revisions, syncs routine secret bindings, and stamps runs with the dispatch revision", async () => {
const { agentId, companyId, projectId, svc } = await seedFixture();
const secrets = secretService(db);
const secret = await secrets.create(companyId, {
name: `routine-api-${randomUUID()}`,
provider: "local_encrypted",
value: "secret-value",
});
const routine = await svc.create(
companyId,
{
projectId,
goalId: null,
parentIssueId: null,
title: "secret routine",
description: null,
assigneeAgentId: agentId,
priority: "medium",
status: "active",
concurrencyPolicy: "always_enqueue",
catchUpPolicy: "skip_missed",
env: {
ROUTINE_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" },
ROUTINE_PLAIN: { type: "plain", value: "plain-value" },
},
},
{},
);
const bindings = await db
.select()
.from(companySecretBindings)
.where(eq(companySecretBindings.targetId, routine.id));
expect(bindings).toMatchObject([
{
companyId,
secretId: secret.id,
targetType: "routine",
configPath: "env.ROUTINE_API_KEY",
},
]);
const [initialRevision] = await svc.listRevisions(routine.id);
expect(initialRevision?.snapshot.routine.env).toEqual(routine.env);
await db.delete(companySecretBindings).where(eq(companySecretBindings.targetId, routine.id));
const repaired = await svc.update(routine.id, { env: routine.env }, {});
expect(repaired).not.toBeNull();
const repairedBindings = await db
.select()
.from(companySecretBindings)
.where(eq(companySecretBindings.targetId, routine.id));
expect(repairedBindings).toMatchObject([
{
companyId,
secretId: secret.id,
targetType: "routine",
configPath: "env.ROUTINE_API_KEY",
},
]);
const currentRoutine = repaired ?? routine;
const runBefore = await svc.runRoutine(routine.id, { source: "manual" });
expect(runBefore.routineRevisionId).toBe(currentRoutine.latestRevisionId);
const updated = await svc.update(
routine.id,
{
env: {
ROUTINE_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" },
ROUTINE_PLAIN: { type: "plain", value: "changed" },
},
},
{},
);
expect(updated?.latestRevisionNumber).toBe(currentRoutine.latestRevisionNumber + 1);
const runAfter = await svc.runRoutine(routine.id, { source: "manual" });
expect(runAfter.routineRevisionId).toBe(updated?.latestRevisionId);
expect(runAfter.dispatchFingerprint).not.toBe(runBefore.dispatchFingerprint);
});
it("rejects stale routine baseRevisionId updates", async () => {
const { routine, svc } = await seedFixture();
const updated = await svc.update(routine.id, { description: "new description" }, {});
@@ -76,12 +76,11 @@ describe("run liveness continuations", () => {
continuationAttempt: 1,
maxContinuationAttempts: DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS,
instruction: "Take the first concrete action now.",
modelProfile: "cheap",
});
expect(decision.payload).not.toHaveProperty("modelProfile");
expect(decision.contextSnapshot).toMatchObject({
issueId,
wakeReason: RUN_LIVENESS_CONTINUATION_REASON,
modelProfile: "cheap",
livenessContinuationAttempt: 1,
livenessContinuationMaxAttempts: DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS,
livenessContinuationSourceRunId: runId,
@@ -89,6 +88,7 @@ describe("run liveness continuations", () => {
livenessContinuationReason: "Planned without acting",
livenessContinuationInstruction: "Take the first concrete action now.",
});
expect(decision.contextSnapshot).not.toHaveProperty("modelProfile");
});
it("enqueues the second empty_response continuation", () => {
+146
View File
@@ -9,10 +9,12 @@ const mockSecretService = vi.hoisted(() => ({
listProviders: vi.fn(),
checkProviders: vi.fn(),
listProviderConfigs: vi.fn(),
previewProviderConfigDiscovery: vi.fn(),
getProviderConfigById: vi.fn(),
createProviderConfig: vi.fn(),
updateProviderConfig: vi.fn(),
disableProviderConfig: vi.fn(),
removeProviderConfig: vi.fn(),
setDefaultProviderConfig: vi.fn(),
checkProviderConfigHealth: vi.fn(),
getById: vi.fn(),
@@ -117,6 +119,22 @@ describe("secret routes", () => {
expect(mockSecretService.listProviderConfigs).not.toHaveBeenCalled();
});
it("rejects provider vault discovery preview for non-board actors", async () => {
const res = await request(createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
}))
.post("/api/companies/company-1/secret-provider-configs/discovery/preview")
.send({
provider: "aws_secrets_manager",
config: { region: "us-east-1" },
});
expect(res.status).toBe(403);
expect(mockSecretService.previewProviderConfigDiscovery).not.toHaveBeenCalled();
});
it("rejects sensitive provider vault config fields", async () => {
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
provider: "aws_secrets_manager",
@@ -132,6 +150,92 @@ describe("secret routes", () => {
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
});
it("rejects sensitive provider vault discovery draft config fields", async () => {
const res = await request(createApp())
.post("/api/companies/company-1/secret-provider-configs/discovery/preview")
.send({
provider: "aws_secrets_manager",
config: {
region: "us-east-1",
secretAccessKey: "secret",
},
});
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/sensitive field/i);
expect(mockSecretService.previewProviderConfigDiscovery).not.toHaveBeenCalled();
});
it("previews provider vault discovery and logs only aggregate metadata", async () => {
mockSecretService.previewProviderConfigDiscovery.mockResolvedValue({
provider: "aws_secrets_manager",
nextToken: null,
sampledSecretCount: 2,
skippedForeignPaperclipSampleCount: 0,
candidates: [
{
provider: "aws_secrets_manager",
displayName: "AWS production",
config: {
region: "us-east-1",
namespace: "prod-use1",
secretNamePrefix: "paperclip",
environmentTag: "production",
ownerTag: "platform",
kmsKeyId: null,
},
sampleCount: 2,
samples: [
{ name: "paperclip/prod-use1/company-1/openai", hasKmsKey: false, tagKeys: ["environment"] },
],
signals: {
namespace: "prod-use1",
secretNamePrefix: "paperclip",
environmentTag: "production",
ownerTag: "platform",
kmsKeyId: null,
hasKmsKey: false,
sampleCount: 2,
paperclipManagedSampleCount: 0,
skippedForeignPaperclipSampleCount: 0,
},
warnings: [],
},
],
warnings: [],
});
const res = await request(createApp())
.post("/api/companies/company-1/secret-provider-configs/discovery/preview")
.send({
provider: "aws_secrets_manager",
config: { region: "us-east-1" },
query: "paperclip",
pageSize: 25,
});
expect(res.status).toBe(200);
expect(mockSecretService.previewProviderConfigDiscovery).toHaveBeenCalledWith("company-1", {
provider: "aws_secrets_manager",
config: { region: "us-east-1" },
query: "paperclip",
nextToken: undefined,
pageSize: 25,
});
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "secret_provider_config.discovery_previewed",
entityType: "secret_provider_config_discovery",
entityId: "company-1",
details: {
provider: "aws_secrets_manager",
candidateCount: 1,
sampledSecretCount: 2,
warningCount: 0,
},
}));
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("paperclip/prod-use1/company-1/openai");
});
it("rejects ready status for coming-soon provider vaults", async () => {
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
provider: "vault",
@@ -241,6 +345,48 @@ describe("secret routes", () => {
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("accessKey");
});
it("removes provider vault config locally without deleting remote provider data", async () => {
const createdAt = new Date("2026-05-06T00:00:00.000Z");
const providerConfig = {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
provider: "aws_secrets_manager",
displayName: "AWS prod",
status: "ready",
isDefault: false,
config: { region: "us-east-1" },
healthStatus: null,
healthCheckedAt: null,
healthMessage: null,
healthDetails: null,
disabledAt: null,
createdByAgentId: null,
createdByUserId: "user-1",
createdAt,
updatedAt: createdAt,
};
mockSecretService.getProviderConfigById.mockResolvedValue(providerConfig);
mockSecretService.removeProviderConfig.mockResolvedValue(providerConfig);
const res = await request(createApp()).delete(
"/api/secret-provider-configs/11111111-1111-4111-8111-111111111111",
);
expect(res.status).toBe(200);
expect(mockSecretService.removeProviderConfig).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
);
expect(mockSecretService.disableProviderConfig).not.toHaveBeenCalled();
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "secret_provider_config.removed",
details: {
provider: "aws_secrets_manager",
displayName: "AWS prod",
remoteDeleted: false,
},
}));
});
it("rejects remote import preview for non-board actors", async () => {
const res = await request(createApp({
type: "agent",
@@ -205,6 +205,116 @@ describeEmbeddedPostgres("secretService", () => {
expect(JSON.stringify(events)).not.toContain("runtime-secret");
});
it("resolves routine env secret refs through routine bindings and records value-free access metadata", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `routine-secret-${randomUUID()}`,
provider: "local_encrypted",
value: "routine-super-secret",
});
const env = {
ROUTINE_API_KEY: { type: "secret_ref" as const, secretId: secret.id, version: "latest" as const },
};
await svc.syncEnvBindingsForTarget(companyId, { targetType: "routine", targetId: "routine-1" }, env);
const resolved = await svc.resolveEnvBindings(companyId, env, {
consumerType: "routine",
consumerId: "routine-1",
actorType: "agent",
actorId: "agent-1",
});
expect(resolved.env.ROUTINE_API_KEY).toBe("routine-super-secret");
expect(resolved.manifest).toEqual([
expect.objectContaining({
configPath: "env.ROUTINE_API_KEY",
envKey: "ROUTINE_API_KEY",
secretId: secret.id,
outcome: "success",
}),
]);
const events = await svc.listAccessEvents(companyId, secret.id);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
companyId,
secretId: secret.id,
consumerType: "routine",
consumerId: "routine-1",
configPath: "env.ROUTINE_API_KEY",
actorType: "agent",
actorId: "agent-1",
outcome: "success",
});
expect(JSON.stringify(events)).not.toContain("routine-super-secret");
});
it("records stable redacted failure codes for routine env secret resolution", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `routine-failure-codes-${randomUUID()}`,
provider: "local_encrypted",
value: "routine-super-secret",
});
const env = {
ROUTINE_API_KEY: { type: "secret_ref" as const, secretId: secret.id, version: "latest" as const },
};
const context = {
consumerType: "routine" as const,
consumerId: "routine-1",
actorType: "agent" as const,
actorId: "agent-1",
};
await svc.syncEnvBindingsForTarget(companyId, { targetType: "routine", targetId: "routine-1" }, env);
await expect(
svc.resolveEnvBindings(companyId, env, { ...context, consumerId: "routine-2" }),
).rejects.toThrow(/not bound/i);
await db.update(companySecrets).set({ status: "disabled" }).where(eq(companySecrets.id, secret.id));
await expect(svc.resolveEnvBindings(companyId, env, context)).rejects.toThrow(/not active/i);
await db.update(companySecrets).set({ status: "active" }).where(eq(companySecrets.id, secret.id));
await expect(
svc.resolveSecretValue(companyId, secret.id, 999, {
...context,
configPath: "env.ROUTINE_API_KEY",
}),
).rejects.toThrow(/version not found/i);
await db
.update(companySecretVersions)
.set({ status: "disabled" })
.where(eq(companySecretVersions.secretId, secret.id));
await expect(svc.resolveEnvBindings(companyId, env, context)).rejects.toThrow(/version is not active/i);
await db
.update(companySecretVersions)
.set({ status: "current" })
.where(eq(companySecretVersions.secretId, secret.id));
vi.spyOn(localEncryptedProvider, "resolveVersion").mockRejectedValueOnce(
new Error("provider leaked value routine-super-secret"),
);
await expect(svc.resolveEnvBindings(companyId, env, context)).rejects.toThrow(/provider leaked value/i);
await db.update(companySecrets).set({ status: "deleted" }).where(eq(companySecrets.id, secret.id));
await expect(svc.resolveEnvBindings(companyId, env, context)).rejects.toThrow(/not found/i);
const events = await svc.listAccessEvents(companyId, secret.id);
expect(events.map((event) => event.errorCode).sort()).toEqual([
"binding_missing",
"provider_error",
"secret_deleted",
"secret_inactive",
"version_inactive",
"version_missing",
]);
expect(JSON.stringify(events)).not.toContain("routine-super-secret");
expect(JSON.stringify(events)).not.toContain("provider leaked value");
});
it("scopes env binding sync deletes to the env path prefix", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
@@ -382,6 +492,35 @@ describeEmbeddedPostgres("secretService", () => {
);
});
it("removes provider vault config locally without deleting remote AWS secrets", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const vault = await svc.createProviderConfig(companyId, {
provider: "aws_secrets_manager",
displayName: "AWS production",
config: { region: "us-east-1", namespace: "prod-use1" },
});
const secret = await svc.create(companyId, {
name: `external-${randomUUID()}`,
provider: "aws_secrets_manager",
providerConfigId: vault.id,
managedMode: "external_reference",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/external",
});
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
const removed = await svc.removeProviderConfig(vault.id);
expect(removed?.id).toBe(vault.id);
await expect(svc.getProviderConfigById(vault.id)).resolves.toBeNull();
const [persistedSecret] = await db
.select()
.from(companySecrets)
.where(eq(companySecrets.id, secret.id));
expect(persistedSecret?.providerConfigId).toBeNull();
expect(deleteSpy).not.toHaveBeenCalled();
});
it("hides soft-deleted secrets and allows name/key reuse", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
@@ -1097,6 +1236,111 @@ describeEmbeddedPostgres("secretService", () => {
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws");
});
it("previews AWS provider vault discovery from draft config without persisting a provider vault", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const discoverSpy = vi.spyOn(awsSecretsManagerProvider, "discoverProviderConfigs").mockResolvedValue({
provider: "aws_secrets_manager",
nextToken: null,
sampledSecretCount: 1,
skippedForeignPaperclipSampleCount: 0,
candidates: [
{
provider: "aws_secrets_manager",
displayName: "AWS production",
config: {
region: "us-east-1",
namespace: "prod-use1",
secretNamePrefix: "paperclip",
kmsKeyId: null,
ownerTag: "platform",
environmentTag: "production",
},
sampleCount: 1,
samples: [
{ name: "paperclip/prod-use1/company-1/openai", hasKmsKey: false, tagKeys: ["paperclip:environment"] },
],
signals: {
namespace: "prod-use1",
secretNamePrefix: "paperclip",
environmentTag: "production",
ownerTag: "platform",
kmsKeyId: null,
hasKmsKey: false,
sampleCount: 1,
paperclipManagedSampleCount: 0,
skippedForeignPaperclipSampleCount: 0,
},
warnings: [],
},
],
warnings: [],
});
const preview = await svc.previewProviderConfigDiscovery(companyId, {
provider: "aws_secrets_manager",
config: { region: "us-east-1" },
query: "openai",
pageSize: 25,
});
expect(discoverSpy).toHaveBeenCalledWith({
companyId,
providerConfig: {
id: `discovery-preview-${companyId}`,
provider: "aws_secrets_manager",
status: "ready",
config: { region: "us-east-1" },
},
query: "openai",
nextToken: undefined,
pageSize: 25,
});
expect(preview.candidates[0]?.config).toMatchObject({
region: "us-east-1",
namespace: "prod-use1",
});
expect(JSON.stringify(preview)).not.toContain("runtime-secret");
const persistedVaults = await db.select().from(companySecretProviderConfigs);
expect(persistedVaults).toHaveLength(0);
});
it("sanitizes AWS provider vault discovery errors before crossing the service boundary", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const rawProviderMessage =
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets";
vi.spyOn(awsSecretsManagerProvider, "discoverProviderConfigs").mockRejectedValueOnce(
new SecretProviderClientError({
code: "access_denied",
provider: "aws_secrets_manager",
operation: "discoverProviderConfigs",
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
rawMessage: rawProviderMessage,
}),
);
let thrown: unknown;
try {
await svc.previewProviderConfigDiscovery(companyId, {
provider: "aws_secrets_manager",
config: { region: "us-east-1" },
});
} catch (error) {
thrown = error;
}
expect(thrown).toMatchObject({
status: 403,
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
details: { code: "access_denied" },
});
expect(JSON.stringify(thrown)).not.toContain("arn:aws");
expect(JSON.stringify(thrown)).not.toContain("123456789012");
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws");
});
it("imports AWS remote references row-by-row without fetching plaintext", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
@@ -138,6 +138,10 @@ vi.mock("../realtime/live-events-ws.js", () => ({
}));
vi.mock("../services/index.js", () => ({
backfillPrincipalAccessCompatibility: vi.fn(async () => ({
agentMembershipsInserted: 0,
humanGrantsInserted: 0,
})),
feedbackService: feedbackServiceFactoryMock,
heartbeatService: vi.fn(() => ({
reapOrphanedRuns: vi.fn(async () => undefined),
@@ -162,6 +166,7 @@ vi.mock("../services/index.js", () => ({
},
})),
})),
reconcileCloudUpstreamRunsOnStartup: vi.fn(async () => ({ reconciled: 0 })),
reconcilePersistedRuntimeServicesOnStartup: vi.fn(async () => ({ reconciled: 0 })),
routineService: vi.fn(() => ({
tickScheduledTriggers: vi.fn(async () => ({ triggered: 0 })),
@@ -216,6 +221,35 @@ describe("startServer feedback export wiring", () => {
serverPort: 3210,
});
});
it("refuses authenticated public startup without an external database URL", async () => {
loadConfigMock.mockReturnValue(buildTestConfig({
deploymentExposure: "public",
authBaseUrlMode: "explicit",
authPublicBaseUrl: "https://tenant.example.com",
databaseMode: "embedded-postgres",
databaseUrl: undefined,
}));
await expect(startServer()).rejects.toThrow(
"authenticated public deployments require DATABASE_URL or config.database.connectionString",
);
expect(createDbMock).not.toHaveBeenCalled();
});
it("refuses authenticated public startup when DATABASE_URL is not a postgres URL", async () => {
loadConfigMock.mockReturnValue(buildTestConfig({
deploymentExposure: "public",
authBaseUrlMode: "explicit",
authPublicBaseUrl: "https://tenant.example.com",
databaseUrl: "secret://paperclip-cloud/stacks/alpha/database/runtime-url",
}));
await expect(startServer()).rejects.toThrow(
"authenticated public deployments require DATABASE_URL to be a postgres/postgresql connection string",
);
expect(createDbMock).not.toHaveBeenCalled();
});
});
describe("startServer authenticated auth origin setup", () => {
@@ -0,0 +1,49 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import express from "express";
import request from "supertest";
import { afterEach, describe, expect, it } from "vitest";
import { readBrandedStaticIndexHtml } from "../static-index-html.js";
describe("static SPA fallback HTML", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("serves the current index.html instead of reusing stale asset hashes", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-static-index-"));
tempDirs.push(tempDir);
const indexPath = path.join(tempDir, "index.html");
const app = express();
app.get(/.*/, (_req, res) => {
res
.status(200)
.set("Content-Type", "text/html")
.set("Cache-Control", "no-cache")
.end(readBrandedStaticIndexHtml(tempDir));
});
fs.writeFileSync(
indexPath,
'<html><body><script type="module" src="/assets/index-old.js"></script></body></html>',
"utf8",
);
await expect(request(app).get("/PAP/issues/PAP-9939")).resolves.toMatchObject({
text: expect.stringContaining("/assets/index-old.js"),
});
fs.writeFileSync(
indexPath,
'<html><body><script type="module" src="/assets/index-new.js"></script></body></html>',
"utf8",
);
const res = await request(app).get("/PAP/issues/PAP-9939");
expect(res.text).toContain("/assets/index-new.js");
expect(res.text).not.toContain("/assets/index-old.js");
});
});
@@ -210,6 +210,56 @@ describe("worktree config repair", () => {
expect(repairedConfig.database.embeddedPostgresPort).toBe(54331);
});
it("ignores stale migrated env paths when the dev runner resolved the local config", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-migrated-env-"));
const worktreeRoot = path.join(tempRoot, "PAP-9940-what-can-we-learn");
const paperclipDir = path.join(worktreeRoot, ".paperclip");
const configPath = path.join(paperclipDir, "config.json");
const envPath = path.join(paperclipDir, ".env");
const oldHome = "/old/home/.paperclip-worktrees";
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
await fs.mkdir(paperclipDir, { recursive: true });
await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(oldHome), null, 2) + "\n", "utf8");
await fs.writeFile(
envPath,
[
"# Paperclip environment variables",
"PAPERCLIP_HOME=/old/home/.paperclip-worktrees",
"PAPERCLIP_INSTANCE_ID=pap-9940-what-can-we-learn",
"PAPERCLIP_CONFIG=/old/home/paperclip/.paperclip/worktrees/PAP-9940-what-can-we-learn/.paperclip/config.json",
"PAPERCLIP_CONTEXT=/old/home/.paperclip-worktrees/context.json",
"PAPERCLIP_IN_WORKTREE=true",
"PAPERCLIP_WORKTREE_NAME=PAP-9940-what-can-we-learn",
"",
].join("\n"),
"utf8",
);
process.chdir(worktreeRoot);
process.env.PAPERCLIP_IN_WORKTREE = "true";
process.env.PAPERCLIP_CONFIG = configPath;
process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome;
delete process.env.PAPERCLIP_HOME;
delete process.env.PAPERCLIP_INSTANCE_ID;
delete process.env.PAPERCLIP_CONTEXT;
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
const repairedEnv = await fs.readFile(envPath, "utf8");
const instanceRoot = path.join(isolatedHome, "instances", "pap-9940-what-can-we-learn");
expect(result).toEqual({
repairedConfig: true,
repairedEnv: true,
});
expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db"));
expect(repairedConfig.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key"));
expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`);
expect(repairedEnv).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`);
expect(repairedEnv).not.toContain("/old/home");
});
it("does not persist transient runtime home overrides over repo-local worktree env", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-runtime-override-"));
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
@@ -508,6 +558,8 @@ describe("worktree config repair", () => {
process.env.PAPERCLIP_HOME = isolatedHome;
process.env.PAPERCLIP_INSTANCE_ID = "pap-878-create-a-mine-tab-in-inbox";
process.env.PAPERCLIP_CONFIG = configPath;
delete process.env.PORT;
delete process.env.DATABASE_URL;
maybePersistWorktreeRuntimePorts({
serverPort: 3103,
@@ -590,6 +642,8 @@ describe("worktree config repair", () => {
process.env.PAPERCLIP_HOME = isolatedHome;
process.env.PAPERCLIP_INSTANCE_ID = "pap-125-public-base-url";
process.env.PAPERCLIP_CONFIG = configPath;
delete process.env.PORT;
delete process.env.DATABASE_URL;
maybePersistWorktreeRuntimePorts({
serverPort: 3103,
@@ -8,6 +8,7 @@ export const BUILTIN_ADAPTER_TYPES = new Set([
"cursor_cloud",
"cursor",
"gemini_local",
"grok_local",
"openclaw_gateway",
"opencode_local",
"pi_local",
+33
View File
@@ -78,6 +78,17 @@ import {
models as geminiModels,
modelProfiles as geminiModelProfiles,
} from "@paperclipai/adapter-gemini-local";
import {
execute as grokExecute,
listGrokSkills,
syncGrokSkills,
testEnvironment as grokTestEnvironment,
sessionCodec as grokSessionCodec,
} from "@paperclipai/adapter-grok-local/server";
import {
agentConfigurationDoc as grokAgentConfigurationDoc,
models as grokModels,
} from "@paperclipai/adapter-grok-local";
import {
execute as openCodeExecute,
listOpenCodeSkills,
@@ -349,6 +360,27 @@ const geminiLocalAdapter: ServerAdapterModule = {
agentConfigurationDoc: geminiAgentConfigurationDoc,
};
const grokLocalAdapter: ServerAdapterModule = {
type: "grok_local",
execute: grokExecute,
testEnvironment: grokTestEnvironment,
listSkills: listGrokSkills,
syncSkills: syncGrokSkills,
sessionCodec: grokSessionCodec,
sessionManagement: getAdapterSessionManagement("grok_local") ?? undefined,
models: grokModels,
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: true,
getRuntimeCommandSpec: (config) => ({
command: readConfiguredCommand(config, "grok"),
detectCommand: readConfiguredCommand(config, "grok"),
installCommand: null,
}),
agentConfigurationDoc: grokAgentConfigurationDoc,
};
const openclawGatewayAdapter: ServerAdapterModule = {
type: "openclaw_gateway",
execute: openclawGatewayExecute,
@@ -486,6 +518,7 @@ function registerBuiltInAdapters() {
cursorCloudAdapter,
cursorLocalAdapter,
geminiLocalAdapter,
grokLocalAdapter,
openclawGatewayAdapter,
hermesLocalAdapter,
processAdapter,
+64 -18
View File
@@ -28,6 +28,7 @@ import { dashboardRoutes } from "./routes/dashboard.js";
import { userProfileRoutes } from "./routes/user-profiles.js";
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
import { resourceMembershipRoutes } from "./routes/resource-memberships.js";
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
import {
@@ -41,6 +42,7 @@ import { accessRoutes } from "./routes/access.js";
import { pluginRoutes } from "./routes/plugins.js";
import { adapterRoutes } from "./routes/adapters.js";
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
import { readBrandedStaticIndexHtml } from "./static-index-html.js";
import { applyUiBranding } from "./ui-branding.js";
import { logger } from "./middleware/logger.js";
import { DEFAULT_LOCAL_PLUGIN_DIR, pluginLoader } from "./services/plugin-loader.js";
@@ -59,6 +61,8 @@ import { pluginRegistryService } from "./services/plugin-registry.js";
import { createHostClientHandlers } from "@paperclipai/plugin-sdk";
import type { BetterAuthSessionResult } from "./auth/better-auth.js";
import { createCachedViteHtmlRenderer } from "./vite-html-renderer.js";
import { DEFAULT_JSON_BODY_LIMIT, PORTABLE_JSON_BODY_LIMIT } from "./http/body-limits.js";
import { COMPANY_IMPORT_API_PATH } from "./routes/company-import-paths.js";
type UiMode = "none" | "static" | "vite-dev";
const FEEDBACK_EXPORT_FLUSH_INTERVAL_MS = 5_000;
@@ -81,6 +85,12 @@ const VITE_DEV_STATIC_PATHS = new Set([
"/sw.js",
]);
export function isDatabaseConnectionUnavailableError(err: unknown): boolean {
const error = err as { code?: unknown; message?: unknown; cause?: unknown };
if (error?.code === "ECONNREFUSED") return true;
return Boolean(error?.cause && isDatabaseConnectionUnavailableError(error.cause));
}
export function resolveViteHmrPort(serverPort: number): number {
if (serverPort <= 55_535) {
return serverPort + 10_000;
@@ -88,6 +98,12 @@ export function resolveViteHmrPort(serverPort: number): number {
return Math.max(1_024, serverPort - 10_000);
}
export function resolveViteHmrHost(bindHost: string): string | undefined {
const normalized = bindHost.trim().toLowerCase();
if (normalized === "0.0.0.0" || normalized === "::") return undefined;
return bindHost;
}
export function shouldServeViteDevHtml(req: ExpressRequest): boolean {
const pathname = req.path;
if (VITE_DEV_STATIC_PATHS.has(pathname)) return false;
@@ -136,13 +152,17 @@ export async function createApp(
},
) {
const app = express();
const captureRawBody = (req: express.Request, _res: express.Response, buf: Buffer) => {
(req as unknown as { rawBody: Buffer }).rawBody = buf;
};
app.use(COMPANY_IMPORT_API_PATH, express.json({
limit: PORTABLE_JSON_BODY_LIMIT,
verify: captureRawBody,
}));
app.use(express.json({
// Company import/export payloads can inline full portable packages.
limit: "10mb",
verify: (req, _res, buf) => {
(req as unknown as { rawBody: Buffer }).rawBody = buf;
},
limit: DEFAULT_JSON_BODY_LIMIT,
verify: captureRawBody,
}));
app.use(httpLogger);
const privateHostnameGateEnabled = shouldEnablePrivateHostnameGuard({
@@ -209,6 +229,7 @@ export async function createApp(
api.use(userProfileRoutes(db));
api.use(sidebarBadgeRoutes(db));
api.use(sidebarPreferenceRoutes(db));
api.use(resourceMembershipRoutes(db));
api.use(inboxDismissalRoutes(db));
api.use(instanceSettingsRoutes(db));
if (opts.databaseBackupService) {
@@ -310,7 +331,6 @@ 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"));
// Hashed asset files (Vite emits them under /assets/<name>.<hash>.<ext>)
// never change once built, so they can be cached aggressively.
app.use(
@@ -350,7 +370,7 @@ export async function createApp(
.status(200)
.set("Content-Type", "text/html")
.set("Cache-Control", "no-cache")
.end(indexHtml);
.end(readBrandedStaticIndexHtml(uiDist));
});
} else {
console.warn("[paperclip] UI dist not found; running in API-only mode");
@@ -361,6 +381,7 @@ export async function createApp(
const uiRoot = path.resolve(__dirname, "../../ui");
const publicUiRoot = path.resolve(uiRoot, "public");
const hmrPort = resolveViteHmrPort(opts.serverPort);
const hmrHost = resolveViteHmrHost(opts.bindHost);
const { createServer: createViteServer } = await import("vite");
const vite = await createViteServer({
root: uiRoot,
@@ -368,7 +389,7 @@ export async function createApp(
server: {
middlewareMode: true,
hmr: {
host: opts.bindHost,
...(hmrHost ? { host: hmrHost } : {}),
port: hmrPort,
clientPort: hmrPort,
},
@@ -404,18 +425,37 @@ export async function createApp(
jobCoordinator.start();
scheduler.start();
const feedbackExportTimer = opts.feedbackExportService
let feedbackExportShuttingDown = false;
let feedbackExportTimer: ReturnType<typeof setInterval> | null = null;
const disableFeedbackExportFlushes = () => {
feedbackExportShuttingDown = true;
if (feedbackExportTimer) {
clearInterval(feedbackExportTimer);
feedbackExportTimer = null;
}
};
const flushPendingFeedbackExports = async () => {
if (feedbackExportShuttingDown) return;
try {
await opts.feedbackExportService?.flushPendingFeedbackTraces();
} catch (err) {
if (isDatabaseConnectionUnavailableError(err)) {
disableFeedbackExportFlushes();
logger.warn({ err }, "Disabling pending feedback export flushes because the database is unavailable");
return;
}
logger.error({ err }, "Failed to flush pending feedback exports");
}
};
feedbackExportTimer = opts.feedbackExportService
? setInterval(() => {
void opts.feedbackExportService?.flushPendingFeedbackTraces().catch((err) => {
logger.error({ err }, "Failed to flush pending feedback exports");
});
void flushPendingFeedbackExports();
}, FEEDBACK_EXPORT_FLUSH_INTERVAL_MS)
: null;
feedbackExportTimer?.unref?.();
if (opts.feedbackExportService) {
void opts.feedbackExportService.flushPendingFeedbackTraces().catch((err) => {
logger.error({ err }, "Failed to flush pending feedback exports");
});
void flushPendingFeedbackExports();
}
void toolDispatcher.initialize().catch((err) => {
logger.error({ err }, "Failed to initialize plugin tool dispatcher");
@@ -434,13 +474,19 @@ export async function createApp(
}).catch((err) => {
logger.error({ err }, "Failed to load ready plugins on startup");
});
process.once("exit", () => {
if (feedbackExportTimer) clearInterval(feedbackExportTimer);
let appServicesShutdown = false;
const shutdownAppServices = () => {
if (appServicesShutdown) return;
appServicesShutdown = true;
disableFeedbackExportFlushes();
devWatcher?.close();
viteHtmlRenderer?.dispose();
hostServiceCleanup.disposeAll();
hostServiceCleanup.teardown();
});
};
app.locals.paperclipShutdown = shutdownAppServices;
process.once("exit", shutdownAppServices);
process.once("beforeExit", () => {
void flushPluginLogBuffer();
});
+31 -3
View File
@@ -44,6 +44,28 @@ export function buildBetterAuthAdvancedOptions(input: { disableSecureCookies: bo
};
}
export function shouldDisableSecureAuthCookies(input: {
deploymentMode: Config["deploymentMode"];
deploymentExposure?: Config["deploymentExposure"];
authBaseUrlMode: Config["authBaseUrlMode"];
authPublicBaseUrl: string | undefined;
publicUrl?: string | undefined;
}): boolean {
const publicUrl = (
input.publicUrl?.trim() ||
(input.authBaseUrlMode === "explicit" ? input.authPublicBaseUrl?.trim() : "")
);
if (publicUrl) return publicUrl.startsWith("http://");
return (
input.deploymentMode === "authenticated" &&
(
(input.deploymentExposure === "private" && input.authBaseUrlMode === "auto") ||
input.deploymentExposure === undefined
)
);
}
function headersFromNodeHeaders(rawHeaders: IncomingHttpHeaders): Headers {
const headers = new Headers();
for (const [key, raw] of Object.entries(rawHeaders)) {
@@ -92,6 +114,7 @@ export function deriveAuthTrustedOrigins(config: Config, opts?: { listenPort?: n
export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins: string[]): BetterAuthInstance {
const baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined;
const publicUrl = process.env.PAPERCLIP_PUBLIC_URL?.trim() || baseUrl;
const secret = process.env.BETTER_AUTH_SECRET ?? process.env.PAPERCLIP_AGENT_JWT_SECRET;
if (!secret) {
throw new Error(
@@ -99,8 +122,13 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins:
"For local development, set BETTER_AUTH_SECRET=paperclip-dev-secret in your .env file.",
);
}
const publicUrl = process.env.PAPERCLIP_PUBLIC_URL ?? baseUrl;
const isHttpOnly = publicUrl ? publicUrl.startsWith("http://") : false;
const disableSecureCookies = shouldDisableSecureAuthCookies({
deploymentMode: config.deploymentMode,
deploymentExposure: config.deploymentExposure,
authBaseUrlMode: config.authBaseUrlMode,
authPublicBaseUrl: config.authPublicBaseUrl,
publicUrl,
});
const authConfig = {
baseURL: baseUrl,
@@ -120,7 +148,7 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins:
requireEmailVerification: false,
disableSignUp: config.authDisableSignUp,
},
advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies: isHttpOnly }),
advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies }),
};
if (!baseUrl) {
+12
View File
@@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { companies, companyMemberships, instanceUserRoles } from "@paperclipai/db";
import type { DeploymentMode } from "@paperclipai/shared";
import { ensureHumanRoleDefaultGrants } from "./services/principal-access-compatibility.js";
const LOCAL_BOARD_USER_ID = "local-board";
const CLAIM_TTL_MS = 1000 * 60 * 60 * 24;
@@ -89,6 +90,7 @@ export async function claimBoardOwnership(
const status = getChallengeStatus(opts.token, opts.code);
if (status !== "available") return { status };
const claimedCompanyIds: string[] = [];
await db.transaction(async (tx) => {
const existingTargetAdmin = await tx
.select({ id: instanceUserRoles.id })
@@ -108,6 +110,7 @@ export async function claimBoardOwnership(
const allCompanies = await tx.select({ id: companies.id }).from(companies);
for (const company of allCompanies) {
claimedCompanyIds.push(company.id);
const existing = await tx
.select({ id: companyMemberships.id, status: companyMemberships.status })
.from(companyMemberships)
@@ -140,6 +143,15 @@ export async function claimBoardOwnership(
}
});
for (const companyId of claimedCompanyIds) {
await ensureHumanRoleDefaultGrants(db, {
companyId,
principalId: opts.userId,
membershipRole: "owner",
grantedByUserId: opts.userId,
});
}
if (activeChallenge && activeChallenge.token === opts.token) {
activeChallenge.claimedAt = new Date();
activeChallenge.claimedByUserId = opts.userId;
+45 -1
View File
@@ -1,4 +1,5 @@
import { existsSync, lstatSync, readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
function parseEnvFile(contents: string): Record<string, string> {
@@ -55,6 +56,45 @@ export function resolveWorktreeEnvFilePath(rootDir: string): string {
return path.resolve(rootDir, ".paperclip", ".env");
}
function expandHomePrefix(value: string): string {
if (value === "~") return os.homedir();
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
return value;
}
function resolveHomeAwarePath(value: string): string {
return path.resolve(expandHomePrefix(value));
}
function resolveDefaultWorktreeHome(env: NodeJS.ProcessEnv): string {
return path.resolve(expandHomePrefix(env.PAPERCLIP_WORKTREES_DIR?.trim() || "~/.paperclip-worktrees"));
}
function repairStaleMigratedWorktreeEnvEntries(
rootDir: string,
entries: Record<string, string>,
env: NodeJS.ProcessEnv,
): Record<string, string> {
const localConfigPath = path.resolve(rootDir, ".paperclip", "config.json");
const configuredPath = entries.PAPERCLIP_CONFIG?.trim();
if (!configuredPath) return entries;
const resolvedConfiguredPath = resolveHomeAwarePath(configuredPath);
const staleConfigPath =
resolvedConfiguredPath !== localConfigPath &&
!existsSync(resolvedConfiguredPath) &&
existsSync(localConfigPath);
if (!staleConfigPath) return entries;
const homeDir = resolveDefaultWorktreeHome(env);
return {
...entries,
PAPERCLIP_HOME: homeDir,
PAPERCLIP_CONFIG: localConfigPath,
PAPERCLIP_CONTEXT: path.resolve(homeDir, "context.json"),
};
}
export function bootstrapDevRunnerWorktreeEnv(
rootDir: string,
env: NodeJS.ProcessEnv = process.env,
@@ -74,7 +114,11 @@ export function bootstrapDevRunnerWorktreeEnv(
};
}
const entries = parseEnvFile(readFileSync(envPath, "utf8"));
const entries = repairStaleMigratedWorktreeEnvEntries(
rootDir,
parseEnvFile(readFileSync(envPath, "utf8")),
env,
);
for (const [key, value] of Object.entries(entries)) {
if (typeof env[key] === "string" && env[key]!.trim().length > 0) continue;
env[key] = value;
+27 -1
View File
@@ -1,4 +1,5 @@
import { existsSync, readFileSync, statSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
const MAX_PERSISTED_DEV_SERVER_STATUS_BYTES = 64 * 1024;
@@ -25,6 +26,31 @@ export type DevServerHealthStatus = {
lastRestartAt: string | null;
};
export type DevServerRestartRequest = {
requestedAt: string;
reason: "manual_restart_now";
};
export function getDevServerRestartRequestFilePath(
env: NodeJS.ProcessEnv = process.env,
): string | null {
const statusFilePath = env.PAPERCLIP_DEV_SERVER_STATUS_FILE?.trim();
if (!statusFilePath) return null;
return path.join(path.dirname(statusFilePath), "dev-server-restart-request.json");
}
export function writeDevServerRestartRequest(
request: DevServerRestartRequest,
env: NodeJS.ProcessEnv = process.env,
): boolean {
const filePath = getDevServerRestartRequestFilePath(env);
if (!filePath) return false;
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(request, null, 2)}\n`, "utf8");
return true;
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
+3
View File
@@ -0,0 +1,3 @@
export const DEFAULT_JSON_BODY_LIMIT = "10mb";
export const PORTABLE_JSON_BODY_LIMIT = "64mb";
export const PORTABLE_JSON_BODY_LIMIT_BYTES = 64 * 1024 * 1024;
+50
View File
@@ -15,6 +15,7 @@ import {
inspectMigrations,
applyPendingMigrations,
createEmbeddedPostgresLogBuffer,
prepareEmbeddedPostgresNativeRuntime,
reconcilePendingMigrationHistory,
formatDatabaseBackupResult,
runDatabaseBackup,
@@ -30,8 +31,10 @@ import { logger } from "./middleware/logger.js";
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
import {
feedbackService,
backfillPrincipalAccessCompatibility,
heartbeatService,
instanceSettingsService,
reconcileCloudUpstreamRunsOnStartup,
reconcilePersistedRuntimeServicesOnStartup,
routineService,
} from "./services/index.js";
@@ -187,6 +190,31 @@ export async function startServer(): Promise<StartedServer> {
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
}
function isPostgresConnectionString(connectionString: string): boolean {
try {
const parsed = new URL(connectionString);
return parsed.protocol === "postgres:" || parsed.protocol === "postgresql:";
} catch {
return false;
}
}
function assertCloudDatabaseContract(): void {
if (config.deploymentMode !== "authenticated" || config.deploymentExposure !== "public") {
return;
}
if (!config.databaseUrl) {
throw new Error(
"authenticated public deployments require DATABASE_URL or config.database.connectionString; refusing embedded PostgreSQL fallback",
);
}
if (!isPostgresConnectionString(config.databaseUrl)) {
throw new Error(
"authenticated public deployments require DATABASE_URL to be a postgres/postgresql connection string",
);
}
}
function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined {
if (!rawUrl) return undefined;
try {
@@ -270,6 +298,7 @@ export async function startServer(): Promise<StartedServer> {
let startupDbInfo:
| { mode: "external-postgres"; connectionString: string }
| { mode: "embedded-postgres"; dataDir: string; port: number };
assertCloudDatabaseContract();
if (config.databaseUrl) {
const migrationUrl = config.databaseMigrationUrl ?? config.databaseUrl;
migrationSummary = await ensureMigrations(migrationUrl, "PostgreSQL");
@@ -290,6 +319,7 @@ export async function startServer(): Promise<StartedServer> {
"Embedded PostgreSQL mode requires dependency `embedded-postgres`. Reinstall dependencies (without omitting required packages), or set DATABASE_URL for external Postgres.",
);
}
await prepareEmbeddedPostgresNativeRuntime();
const dataDir = resolve(config.embeddedPostgresDataDir);
const configuredPort = config.embeddedPostgresPort;
@@ -486,6 +516,10 @@ export async function startServer(): Promise<StartedServer> {
if (config.deploymentMode === "local_trusted") {
await ensureLocalTrustedBoardPrincipal(db as any);
}
const accessBackfill = await backfillPrincipalAccessCompatibility(db as any);
if (accessBackfill.agentMembershipsInserted > 0 || accessBackfill.humanGrantsInserted > 0) {
logger.info(accessBackfill, "Backfilled principal access compatibility records");
}
if (config.deploymentMode === "authenticated") {
const {
createBetterAuthHandler,
@@ -668,6 +702,19 @@ export async function startServer(): Promise<StartedServer> {
.catch((err) => {
logger.error({ err }, "startup reconciliation of persisted runtime services failed");
});
void reconcileCloudUpstreamRunsOnStartup(db as any)
.then((result) => {
if (result.reconciled > 0) {
logger.warn(
{ reconciled: result.reconciled },
"reconciled cloud upstream runs from a previous server process",
);
}
})
.catch((err) => {
logger.error({ err }, "startup reconciliation of cloud upstream runs failed");
});
if (config.heartbeatSchedulerEnabled) {
const heartbeat = heartbeatService(db as any, { pluginWorkerManager });
@@ -878,6 +925,9 @@ export async function startServer(): Promise<StartedServer> {
await telemetryClient.flush();
}
const appShutdown = (app as { locals?: { paperclipShutdown?: () => void } }).locals?.paperclipShutdown;
appShutdown?.();
if (embeddedPostgres && embeddedPostgresStartedByThisProcess) {
logger.info({ signal }, "Stopping embedded PostgreSQL");
try {
@@ -56,9 +56,14 @@ export function boardMutationGuard(): RequestHandler {
return;
}
// Local-trusted mode and board bearer keys are not browser-session requests.
// Local-trusted mode, board bearer keys, and trusted Cloud tenant calls are
// not browser-session requests.
// In these modes, origin/referer headers can be absent; do not block those mutations.
if (req.actor.source === "local_implicit" || req.actor.source === "board_key") {
if (
req.actor.source === "local_implicit"
|| req.actor.source === "board_key"
|| req.actor.source === "cloud_tenant"
) {
next();
return;
}
+11 -1
View File
@@ -3,6 +3,7 @@ import { ZodError } from "zod";
import { HttpError } from "../errors.js";
import { trackErrorHandlerCrash } from "@paperclipai/shared/telemetry";
import { getTelemetryClient } from "../telemetry.js";
import { COMPANY_IMPORT_API_PATH } from "../routes/company-import-paths.js";
export interface ErrorContext {
error: { message: string; stack?: string; name?: string; details?: unknown; raw?: unknown };
@@ -74,5 +75,14 @@ export function errorHandler(
const tc = getTelemetryClient();
if (tc) trackErrorHandlerCrash(tc, { errorCode: rootError.name });
res.status(500).json({ error: "Internal server error" });
res.status(500).json({
error: "Internal server error",
...(shouldExposeTrustedCloudTenantImportError(req) ? { message: rootError.message } : {}),
});
}
function shouldExposeTrustedCloudTenantImportError(req: Request) {
return req.actor?.source === "cloud_tenant"
&& req.method === "POST"
&& req.originalUrl.split("?")[0] === COMPANY_IMPORT_API_PATH;
}

Some files were not shown because too many files have changed in this diff Show More